Vibecode Mailient
track this build4 phases, 12 steps, beginner friendly0%The core trick here, pull mail, classify it, summarize threads, draft replies, is genuinely a weekend of work now that an LLM does the hard part. An agent can wire IMAP plus SQLite plus a cheap web UI and have working triage before dinner. The gaps are all the unglamorous parts: Gmail and Outlook OAuth, push sync instead of polling, threading edge cases, and the fact that you will read this thing on a phone and your build has no app. Nothing here is protected by data or network, so a personal version keeps most of the value if you accept that you are now the ops team for your own mail pipeline. Product details and pricing are thin from the outside, so treat the specifics as unverified.
You are building a lean indie version of Mailient. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mailient · indie build A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify, server-rendered HTML, bound to the loopback address | this is one person's mailbox and it should not be reachable from the network at all | | Mail | IMAP for reading with SMTP for sending, using an application password | the hosted providers' OAuth route is a verification process rather than a library call | | Storage | SQLite for rows with message bodies and raw headers written to disk | a mailbox mirror grows quickly and the rows are the part you actually query | | Model | One chat endpoint, called once per thread, with the redacted input recorded | it is the only thing that leaves the machine, so it is the only thing that needs an audit trail | | Sending | Drafts are stored, never queued, and a click is the only thing that can send one | an email tool that sends on its own is a different and considerably worse product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the sync worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A mailbox with IMAP and SMTP access and an application password** · free with most providers - Why: This build reads and sends over the protocols directly, and a provider that has turned them off makes the whole design impossible before you start. - Get it: Enable two-factor authentication and create an application password in the provider's security settings. - Verify: A client connects, lists the inbox, and sends one message to yourself - [ ] **An API key for a chat model that returns structured output** · pay per call; a busy personal inbox is small change with the ceiling in place - Why: Classification and drafting both need validated JSON, and this is also the only component that costs money or sees your mail. - Get it: Create a key and set a provider-side spend limit in addition to the ceiling in this build. - Verify: One thread classifies into the schema without a repair attempt - [ ] **A machine that stays awake** · free to a few pounds a month - Why: A sync that runs only while a laptop is open is a sync that misses the overnight mail, which is exactly the mail triage is for. - Get it: A small always-on box on your own network, or a laptop you genuinely never close. - Verify: Mail received overnight is present in the morning - [ ] **The redaction list and the byte budget, decided before the first call** · free - Why: Deciding what leaves the machine after you have already sent a month of mail to a provider is not a decision, it is a discovery. - Get it: Write the patterns and the byte budget into configuration and review the rendered preview for ten real threads. - Verify: The preview for ten threads contains nothing you did not intend to send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. - A mobile app and real push notifications, which is where most email actually gets read - Managed OAuth with Google and Microsoft, including the review process and token refresh pain - Someone else absorbing provider API changes, rate limits and broken threading - Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior - Any sending reputation work if the product handles outbound at scale If one of those is essential to you, that is the reason to keep paying for Mailient, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailient The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local inbox triage tool that classifies, summarises and drafts, and sends nothing without a click to replace Mailient. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify, server-rendered HTML, bound to the loopback address · this is one person's mailbox and it should not be reachable from the network at all - Mail: IMAP for reading with SMTP for sending, using an application password · the hosted providers' OAuth route is a verification process rather than a library call - Storage: SQLite for rows with message bodies and raw headers written to disk · a mailbox mirror grows quickly and the rows are the part you actually query - Model: One chat endpoint, called once per thread, with the redacted input recorded · it is the only thing that leaves the machine, so it is the only thing that needs an audit trail - Sending: Drafts are stored, never queued, and a click is the only thing that can send one · an email tool that sends on its own is a different and considerably worse product ### Data model (create this before Phase 1) - accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf. ### Phase 1 · A sync that survives the night Build: Connect over IMAP, fetch from the stored high-water mark, and store headers and body to disk with the row in SQLite. Hold an idle connection for new mail and restart it on a timer well inside the interval the protocol extension recommends. Record the mailbox identity marker and force a full resync if it changes. Make every step resumable so a kill at any point loses nothing but progress. Trap: An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. Done when: The idle connection is terminated and re-issued on a timer strictly under the interval named in the specification, asserted by a test with a fake clock. Killing the process mid-fetch and restarting loses no message and creates no duplicate, tested by interrupting at three different points. A changed mailbox identity marker triggers a full resync rather than resuming from a stale high-water mark, tested. A connection with no server traffic for longer than the idle interval is torn down and rebuilt, tested. Do not build yet: No model calls, no classification, no drafts yet. ### Phase 2 · What leaves the machine, written down Build: Convert HTML to text, drop the quoted reply trail, strip attachments entirely, apply the redaction patterns, and truncate to a configured byte budget. Before the call, write a model_calls row holding the byte count, a hash of the exact payload, and the list of redactions applied. Show the user the payload for any thread on request, rendered from the same function the sender uses. Trap: Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. Done when: A thread can be previewed exactly as it would be sent, rendered by the same function the sender calls, checked by a test that compares the two byte for byte. Attachments never appear in a payload, only filenames and sizes, tested with a message carrying one. Redaction patterns are applied and listed in the stored row, tested with a fixture containing a pattern that must be removed. A payload exceeding the byte budget is truncated at a boundary and the truncation is recorded rather than hidden. Do not build yet: No classification, no rules engine, no drafts yet. ### Phase 3 · Rules first, model second Build: Apply rules by sender, domain and subject substring in priority order before any model call. A thread matched by a rule is classified locally and never sent. Cache model results by thread and message count so re-opening a thread is free and a new message invalidates it. Show, per thread, which rule or which model call produced the category. Trap: Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. Done when: A thread matching a rule produces no model call, tested by counting calls against a stub. A new message on a cached thread invalidates the cache and triggers exactly one new call, tested. Every classified thread shows its provenance as either a named rule or a model call identifier, checked in the interface test. Rules apply in priority order and the order is deterministic for equal priorities, tested. Do not build yet: No drafting, no sending, no cost ceiling yet. ### Phase 4 · Drafts that thread, and only send when clicked Build: Generate a draft only for the categories that warrant one, validated against a schema. Build the reply headers from the original: the in-reply-to value is the message identifier of the message being replied to, and the references chain is the original's chain with that identifier appended. Store the draft. Send only from a handler reached by an explicit click, and record who clicked and when. Trap: Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. Done when: A generated reply carries the correct in-reply-to value and a references chain ending in it, asserted against a fixture with a three-message history. A reply sent to a real mail client appears inside the original conversation, verified by hand once and recorded in the README. No code path sets sent_at outside the click handler, verified by review and by a test that greps for assignments. A draft whose model output failed validation is absent rather than partial, tested. Do not build yet: No cost ceiling, no export, no operations work yet. ### Phase 5 · What it costs, what it keeps, and why it stays personal Build: Track model spend per month and stop classifying at the ceiling, leaving rules, search and manual triage working. Delete mirrored bodies older than a configured window while keeping the rows and summaries. Export threads, classifications, model call records and drafts as newline-delimited JSON. Document why this is loopback-only and what publishing it would require. Trap: Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. Done when: Reaching the monthly ceiling stops classification with a clear message and leaves rules, search and manual triage fully working, tested by moving the counter. Bodies older than the retention window are deleted while thread rows, summaries and model call records survive, tested. The server refuses to bind to anything other than the loopback address unless an explicit override is set, and the override prints a warning, tested. Export round-trips into an empty database and the thread count and classifications match. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted provider sign-in, because the mail-reading scopes are restricted, publishing an application using them needs verification, and storing that data on a server brings a security assessment with it. - A mobile application and push notifications, which is where most mail is actually read and is the honest reason the paid product still wins. - Multiple accounts, which changes the sync state machine, the rules model and the sending identity all at once. - Anything that sends without a click, including scheduled replies and out-of-office automation, because the one guarantee this build offers is that it cannot send on its own. ### README must contain - That polling and idle are not push, and what the idle restart interval is set to and why - That message text leaves the machine for the model, what is stripped first, and how to preview the exact payload for any thread - An estimate of tokens and cost per hundred messages, and what happens when the monthly ceiling is reached - That nothing can send without a click, and where the single code path that sends lives - What publishing this as a hosted tool would require, so nobody starts that by accident ===== AGENTS.md ===== # Agent instructions · Mailient indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, server-rendered HTML, bound to the loopback address, IMAP for reading with SMTP for sending, using an application password, SQLite for rows with message bodies and raw headers written to disk, One chat endpoint, called once per thread, with the redacted input recorded, Drafts are stored, never queued, and a click is the only thing that can send one. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ===== BUILD_PLAN.md ===== # Build plan · Mailient A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A sync that survives the night Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset. ### Steps 1. Re-issue idle on a timer inside the recommended interval Otherwise the sync stops without an error anybody sees. Files: `src/mail/idle.ts`, `tests/idle-timer.test.ts` 2. Make the fetch loop resumable at every point Interrupt it three ways in the test, not once. Files: `src/mail/sync.ts`, `tests/resume.test.ts` 3. Force a resync when the mailbox identity changes A stale high-water mark against a reset mailbox means silent gaps. Files: `src/mail/identity.ts` ### Done when - [ ] Idle is re-issued inside the recommended interval - [ ] Interrupting the fetch loses and duplicates nothing - [ ] A changed mailbox identity forces a full resync - [ ] A silent connection is rebuilt rather than trusted ### Watch out - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. ## Phase 2 · What leaves the machine, written down Every model call has a stored record of exactly what was sent, what was stripped, and what it cost. ### Steps 1. One function builds the payload and the preview Two functions drift, and the preview stops being true. Files: `src/model/payload.ts`, `tests/payload-preview.test.ts` 2. Cut the quoted trail and drop attachments Most of a reply chain is somebody else's mail. Files: `src/model/strip.ts`, `tests/strip.test.ts` 3. Record bytes, hash and redactions before calling Written first, so a crash still leaves the record. Files: `src/model/call.ts` ### Done when - [ ] Preview and payload are byte-identical - [ ] Attachments never reach the payload - [ ] Redactions are applied and listed in the record - [ ] Truncation is recorded, not silent ### Watch out - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. ## Phase 3 · Rules first, model second Cheap deterministic rules classify most of the mailbox before anything is sent anywhere. ### Steps 1. Apply rules before any call is considered The cheapest classification is the one you wrote yourself. Files: `src/triage/rules.ts`, `tests/rules-first.test.ts` 2. Key the cache on thread and message count A thread is not the same thread after a reply arrives. Files: `src/triage/cache.ts`, `tests/cache-invalidation.test.ts` 3. Show provenance on every classification A wrong category should be traceable in one click. Files: `src/app/threads/[id].tsx` ### Done when - [ ] A rule match produces no model call - [ ] A new message invalidates the cached classification - [ ] Every thread shows rule or call provenance - [ ] Rule priority is deterministic ### Watch out - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. ## Phase 4 · Drafts that thread, and only send when clicked A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically. ### Steps 1. Build reply headers from the original message The subject line is not what threads a conversation. Files: `src/mail/reply-headers.ts`, `tests/threading.test.ts` 2. Validate model output before storing a draft A half-parsed draft is worse than no draft. Files: `src/model/draft.ts`, `tests/draft-schema.test.ts` 3. Keep sending behind one click handler One code path, and a test that no other path exists. Files: `src/routes/send.ts`, `tests/no-auto-send.test.ts` ### Done when - [ ] Reply headers thread correctly against a fixture - [ ] A real client shows the reply in the conversation - [ ] Only the click handler can set sent_at - [ ] A failed validation stores no draft ### Watch out - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ## Not in this build - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. ## After v1, if you want it - Full-text search over the mirrored bodies, which is cheap while they are still on disk - A weekly report of what the rules caught versus what the model caught, which is how you write better rules - A per-sender byte budget, so the newsletters that arrive as enormous HTML stop costing what they cost ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reading mail. Port and TLS settings alongside it. IMAP_HOST=imap.example.com # Required. The single mailbox this instance mirrors. IMAP_USER=me@example.com # Required · secret. An application password, never your account password, and never logged. IMAP_PASS=redacted-app-password # Required. Sending, only ever from the click handler. SMTP_HOST=smtp.example.com # Required · secret. Classification and drafting. Set a provider-side limit too. LLM_API_KEY=sk-...redacted # Required. Classification stops here. Rules, search and manual triage keep working. LLM_MONTHLY_CEILING=4.00 # Required. Maximum bytes of thread text sent per call. Truncation is recorded. PAYLOAD_BYTE_BUDGET=12000 # Required. Mirrored bodies are deleted after this; threads, summaries and call records survive. BODY_RETENTION_DAYS=90
You are building a lean indie version of Mailient. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mailient · indie build A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify, server-rendered HTML, bound to the loopback address | this is one person's mailbox and it should not be reachable from the network at all | | Mail | IMAP for reading with SMTP for sending, using an application password | the hosted providers' OAuth route is a verification process rather than a library call | | Storage | SQLite for rows with message bodies and raw headers written to disk | a mailbox mirror grows quickly and the rows are the part you actually query | | Model | One chat endpoint, called once per thread, with the redacted input recorded | it is the only thing that leaves the machine, so it is the only thing that needs an audit trail | | Sending | Drafts are stored, never queued, and a click is the only thing that can send one | an email tool that sends on its own is a different and considerably worse product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the sync worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A mailbox with IMAP and SMTP access and an application password** · free with most providers - Why: This build reads and sends over the protocols directly, and a provider that has turned them off makes the whole design impossible before you start. - Get it: Enable two-factor authentication and create an application password in the provider's security settings. - Verify: A client connects, lists the inbox, and sends one message to yourself - [ ] **An API key for a chat model that returns structured output** · pay per call; a busy personal inbox is small change with the ceiling in place - Why: Classification and drafting both need validated JSON, and this is also the only component that costs money or sees your mail. - Get it: Create a key and set a provider-side spend limit in addition to the ceiling in this build. - Verify: One thread classifies into the schema without a repair attempt - [ ] **A machine that stays awake** · free to a few pounds a month - Why: A sync that runs only while a laptop is open is a sync that misses the overnight mail, which is exactly the mail triage is for. - Get it: A small always-on box on your own network, or a laptop you genuinely never close. - Verify: Mail received overnight is present in the morning - [ ] **The redaction list and the byte budget, decided before the first call** · free - Why: Deciding what leaves the machine after you have already sent a month of mail to a provider is not a decision, it is a discovery. - Get it: Write the patterns and the byte budget into configuration and review the rendered preview for ten real threads. - Verify: The preview for ten threads contains nothing you did not intend to send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. - A mobile app and real push notifications, which is where most email actually gets read - Managed OAuth with Google and Microsoft, including the review process and token refresh pain - Someone else absorbing provider API changes, rate limits and broken threading - Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior - Any sending reputation work if the product handles outbound at scale If one of those is essential to you, that is the reason to keep paying for Mailient, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailient The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local inbox triage tool that classifies, summarises and drafts, and sends nothing without a click to replace Mailient. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify, server-rendered HTML, bound to the loopback address · this is one person's mailbox and it should not be reachable from the network at all - Mail: IMAP for reading with SMTP for sending, using an application password · the hosted providers' OAuth route is a verification process rather than a library call - Storage: SQLite for rows with message bodies and raw headers written to disk · a mailbox mirror grows quickly and the rows are the part you actually query - Model: One chat endpoint, called once per thread, with the redacted input recorded · it is the only thing that leaves the machine, so it is the only thing that needs an audit trail - Sending: Drafts are stored, never queued, and a click is the only thing that can send one · an email tool that sends on its own is a different and considerably worse product ### Data model (create this before Phase 1) - accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf. ### Phase 1 · A sync that survives the night Build: Connect over IMAP, fetch from the stored high-water mark, and store headers and body to disk with the row in SQLite. Hold an idle connection for new mail and restart it on a timer well inside the interval the protocol extension recommends. Record the mailbox identity marker and force a full resync if it changes. Make every step resumable so a kill at any point loses nothing but progress. Trap: An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. Done when: The idle connection is terminated and re-issued on a timer strictly under the interval named in the specification, asserted by a test with a fake clock. Killing the process mid-fetch and restarting loses no message and creates no duplicate, tested by interrupting at three different points. A changed mailbox identity marker triggers a full resync rather than resuming from a stale high-water mark, tested. A connection with no server traffic for longer than the idle interval is torn down and rebuilt, tested. Do not build yet: No model calls, no classification, no drafts yet. ### Phase 2 · What leaves the machine, written down Build: Convert HTML to text, drop the quoted reply trail, strip attachments entirely, apply the redaction patterns, and truncate to a configured byte budget. Before the call, write a model_calls row holding the byte count, a hash of the exact payload, and the list of redactions applied. Show the user the payload for any thread on request, rendered from the same function the sender uses. Trap: Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. Done when: A thread can be previewed exactly as it would be sent, rendered by the same function the sender calls, checked by a test that compares the two byte for byte. Attachments never appear in a payload, only filenames and sizes, tested with a message carrying one. Redaction patterns are applied and listed in the stored row, tested with a fixture containing a pattern that must be removed. A payload exceeding the byte budget is truncated at a boundary and the truncation is recorded rather than hidden. Do not build yet: No classification, no rules engine, no drafts yet. ### Phase 3 · Rules first, model second Build: Apply rules by sender, domain and subject substring in priority order before any model call. A thread matched by a rule is classified locally and never sent. Cache model results by thread and message count so re-opening a thread is free and a new message invalidates it. Show, per thread, which rule or which model call produced the category. Trap: Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. Done when: A thread matching a rule produces no model call, tested by counting calls against a stub. A new message on a cached thread invalidates the cache and triggers exactly one new call, tested. Every classified thread shows its provenance as either a named rule or a model call identifier, checked in the interface test. Rules apply in priority order and the order is deterministic for equal priorities, tested. Do not build yet: No drafting, no sending, no cost ceiling yet. ### Phase 4 · Drafts that thread, and only send when clicked Build: Generate a draft only for the categories that warrant one, validated against a schema. Build the reply headers from the original: the in-reply-to value is the message identifier of the message being replied to, and the references chain is the original's chain with that identifier appended. Store the draft. Send only from a handler reached by an explicit click, and record who clicked and when. Trap: Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. Done when: A generated reply carries the correct in-reply-to value and a references chain ending in it, asserted against a fixture with a three-message history. A reply sent to a real mail client appears inside the original conversation, verified by hand once and recorded in the README. No code path sets sent_at outside the click handler, verified by review and by a test that greps for assignments. A draft whose model output failed validation is absent rather than partial, tested. Do not build yet: No cost ceiling, no export, no operations work yet. ### Phase 5 · What it costs, what it keeps, and why it stays personal Build: Track model spend per month and stop classifying at the ceiling, leaving rules, search and manual triage working. Delete mirrored bodies older than a configured window while keeping the rows and summaries. Export threads, classifications, model call records and drafts as newline-delimited JSON. Document why this is loopback-only and what publishing it would require. Trap: Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. Done when: Reaching the monthly ceiling stops classification with a clear message and leaves rules, search and manual triage fully working, tested by moving the counter. Bodies older than the retention window are deleted while thread rows, summaries and model call records survive, tested. The server refuses to bind to anything other than the loopback address unless an explicit override is set, and the override prints a warning, tested. Export round-trips into an empty database and the thread count and classifications match. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted provider sign-in, because the mail-reading scopes are restricted, publishing an application using them needs verification, and storing that data on a server brings a security assessment with it. - A mobile application and push notifications, which is where most mail is actually read and is the honest reason the paid product still wins. - Multiple accounts, which changes the sync state machine, the rules model and the sending identity all at once. - Anything that sends without a click, including scheduled replies and out-of-office automation, because the one guarantee this build offers is that it cannot send on its own. ### README must contain - That polling and idle are not push, and what the idle restart interval is set to and why - That message text leaves the machine for the model, what is stripped first, and how to preview the exact payload for any thread - An estimate of tokens and cost per hundred messages, and what happens when the monthly ceiling is reached - That nothing can send without a click, and where the single code path that sends lives - What publishing this as a hosted tool would require, so nobody starts that by accident ===== AGENTS.md ===== # Agent instructions · Mailient indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, server-rendered HTML, bound to the loopback address, IMAP for reading with SMTP for sending, using an application password, SQLite for rows with message bodies and raw headers written to disk, One chat endpoint, called once per thread, with the redacted input recorded, Drafts are stored, never queued, and a click is the only thing that can send one. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ===== BUILD_PLAN.md ===== # Build plan · Mailient A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A sync that survives the night Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset. ### Steps 1. Re-issue idle on a timer inside the recommended interval Otherwise the sync stops without an error anybody sees. Files: `src/mail/idle.ts`, `tests/idle-timer.test.ts` 2. Make the fetch loop resumable at every point Interrupt it three ways in the test, not once. Files: `src/mail/sync.ts`, `tests/resume.test.ts` 3. Force a resync when the mailbox identity changes A stale high-water mark against a reset mailbox means silent gaps. Files: `src/mail/identity.ts` ### Done when - [ ] Idle is re-issued inside the recommended interval - [ ] Interrupting the fetch loses and duplicates nothing - [ ] A changed mailbox identity forces a full resync - [ ] A silent connection is rebuilt rather than trusted ### Watch out - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. ## Phase 2 · What leaves the machine, written down Every model call has a stored record of exactly what was sent, what was stripped, and what it cost. ### Steps 1. One function builds the payload and the preview Two functions drift, and the preview stops being true. Files: `src/model/payload.ts`, `tests/payload-preview.test.ts` 2. Cut the quoted trail and drop attachments Most of a reply chain is somebody else's mail. Files: `src/model/strip.ts`, `tests/strip.test.ts` 3. Record bytes, hash and redactions before calling Written first, so a crash still leaves the record. Files: `src/model/call.ts` ### Done when - [ ] Preview and payload are byte-identical - [ ] Attachments never reach the payload - [ ] Redactions are applied and listed in the record - [ ] Truncation is recorded, not silent ### Watch out - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. ## Phase 3 · Rules first, model second Cheap deterministic rules classify most of the mailbox before anything is sent anywhere. ### Steps 1. Apply rules before any call is considered The cheapest classification is the one you wrote yourself. Files: `src/triage/rules.ts`, `tests/rules-first.test.ts` 2. Key the cache on thread and message count A thread is not the same thread after a reply arrives. Files: `src/triage/cache.ts`, `tests/cache-invalidation.test.ts` 3. Show provenance on every classification A wrong category should be traceable in one click. Files: `src/app/threads/[id].tsx` ### Done when - [ ] A rule match produces no model call - [ ] A new message invalidates the cached classification - [ ] Every thread shows rule or call provenance - [ ] Rule priority is deterministic ### Watch out - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. ## Phase 4 · Drafts that thread, and only send when clicked A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically. ### Steps 1. Build reply headers from the original message The subject line is not what threads a conversation. Files: `src/mail/reply-headers.ts`, `tests/threading.test.ts` 2. Validate model output before storing a draft A half-parsed draft is worse than no draft. Files: `src/model/draft.ts`, `tests/draft-schema.test.ts` 3. Keep sending behind one click handler One code path, and a test that no other path exists. Files: `src/routes/send.ts`, `tests/no-auto-send.test.ts` ### Done when - [ ] Reply headers thread correctly against a fixture - [ ] A real client shows the reply in the conversation - [ ] Only the click handler can set sent_at - [ ] A failed validation stores no draft ### Watch out - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ## Not in this build - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. ## After v1, if you want it - Full-text search over the mirrored bodies, which is cheap while they are still on disk - A weekly report of what the rules caught versus what the model caught, which is how you write better rules - A per-sender byte budget, so the newsletters that arrive as enormous HTML stop costing what they cost ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reading mail. Port and TLS settings alongside it. IMAP_HOST=imap.example.com # Required. The single mailbox this instance mirrors. IMAP_USER=me@example.com # Required · secret. An application password, never your account password, and never logged. IMAP_PASS=redacted-app-password # Required. Sending, only ever from the click handler. SMTP_HOST=smtp.example.com # Required · secret. Classification and drafting. Set a provider-side limit too. LLM_API_KEY=sk-...redacted # Required. Classification stops here. Rules, search and manual triage keep working. LLM_MONTHLY_CEILING=4.00 # Required. Maximum bytes of thread text sent per call. Truncation is recorded. PAYLOAD_BYTE_BUDGET=12000 # Required. Mirrored bodies are deleted after this; threads, summaries and call records survive. BODY_RETENTION_DAYS=90
You are building a production product version of Mailient. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Mailient · product brief ## Problem The core trick here, pull mail, classify it, summarize threads, draft replies, is genuinely a weekend of work now that an LLM does the hard part. An agent can wire IMAP plus SQLite plus a cheap web UI and have working triage before dinner. The gaps are all the unglamorous parts: Gmail and Outlook OAuth, push sync instead of polling, threading edge cases, and the fact that you will read this thing on a phone and your build has no app. Nothing here is protected by data or network, so a personal version keeps most of the value if you accept that you are now the ops team for your own mail pipeline. Product details and pricing are thin from the outside, so treat the specifics as unverified. ## Product outcome A triage tool for one mailbox that keeps syncing overnight because the idle connection is restarted on a timer, that can show you the exact bytes it sent to a model for any thread, that drafts replies which thread properly, and that has no code path capable of sending without a click. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An email account with IMAP/SMTP access and an app password, or patience for OAuth - An LLM API key, and acceptance that your mail text goes to that provider - A machine or small VPS that stays awake to poll - Basic comfort debugging MIME, threading and character encodings ## Explicit non-goals for v1 - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. - A mobile app and real push notifications, which is where most email actually gets read - Managed OAuth with Google and Microsoft, including the review process and token refresh pain - Someone else absorbing provider API changes, rate limits and broken threading - Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior - Any sending reputation work if the product handles outbound at scale ## Success criteria - Idle is confirmed re-issued inside the recommended interval - The preview is confirmed byte-identical to the payload - A new message is confirmed to invalidate the cached classification - Reply headers are confirmed to thread in a real client - No path other than the click handler is confirmed able to send ===== BRIEF.md ===== # Build brief · Mailient The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local inbox triage tool that classifies, summarises and drafts, and sends nothing without a click to replace Mailient. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify, server-rendered HTML, bound to the loopback address · this is one person's mailbox and it should not be reachable from the network at all - Mail: IMAP for reading with SMTP for sending, using an application password · the hosted providers' OAuth route is a verification process rather than a library call - Storage: SQLite for rows with message bodies and raw headers written to disk · a mailbox mirror grows quickly and the rows are the part you actually query - Model: One chat endpoint, called once per thread, with the redacted input recorded · it is the only thing that leaves the machine, so it is the only thing that needs an audit trail - Sending: Drafts are stored, never queued, and a click is the only thing that can send one · an email tool that sends on its own is a different and considerably worse product ### Data model (create this before Phase 1) - accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf. ### Phase 1 · A sync that survives the night Build: Connect over IMAP, fetch from the stored high-water mark, and store headers and body to disk with the row in SQLite. Hold an idle connection for new mail and restart it on a timer well inside the interval the protocol extension recommends. Record the mailbox identity marker and force a full resync if it changes. Make every step resumable so a kill at any point loses nothing but progress. Trap: An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. Done when: The idle connection is terminated and re-issued on a timer strictly under the interval named in the specification, asserted by a test with a fake clock. Killing the process mid-fetch and restarting loses no message and creates no duplicate, tested by interrupting at three different points. A changed mailbox identity marker triggers a full resync rather than resuming from a stale high-water mark, tested. A connection with no server traffic for longer than the idle interval is torn down and rebuilt, tested. Do not build yet: No model calls, no classification, no drafts yet. ### Phase 2 · What leaves the machine, written down Build: Convert HTML to text, drop the quoted reply trail, strip attachments entirely, apply the redaction patterns, and truncate to a configured byte budget. Before the call, write a model_calls row holding the byte count, a hash of the exact payload, and the list of redactions applied. Show the user the payload for any thread on request, rendered from the same function the sender uses. Trap: Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. Done when: A thread can be previewed exactly as it would be sent, rendered by the same function the sender calls, checked by a test that compares the two byte for byte. Attachments never appear in a payload, only filenames and sizes, tested with a message carrying one. Redaction patterns are applied and listed in the stored row, tested with a fixture containing a pattern that must be removed. A payload exceeding the byte budget is truncated at a boundary and the truncation is recorded rather than hidden. Do not build yet: No classification, no rules engine, no drafts yet. ### Phase 3 · Rules first, model second Build: Apply rules by sender, domain and subject substring in priority order before any model call. A thread matched by a rule is classified locally and never sent. Cache model results by thread and message count so re-opening a thread is free and a new message invalidates it. Show, per thread, which rule or which model call produced the category. Trap: Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. Done when: A thread matching a rule produces no model call, tested by counting calls against a stub. A new message on a cached thread invalidates the cache and triggers exactly one new call, tested. Every classified thread shows its provenance as either a named rule or a model call identifier, checked in the interface test. Rules apply in priority order and the order is deterministic for equal priorities, tested. Do not build yet: No drafting, no sending, no cost ceiling yet. ### Phase 4 · Drafts that thread, and only send when clicked Build: Generate a draft only for the categories that warrant one, validated against a schema. Build the reply headers from the original: the in-reply-to value is the message identifier of the message being replied to, and the references chain is the original's chain with that identifier appended. Store the draft. Send only from a handler reached by an explicit click, and record who clicked and when. Trap: Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. Done when: A generated reply carries the correct in-reply-to value and a references chain ending in it, asserted against a fixture with a three-message history. A reply sent to a real mail client appears inside the original conversation, verified by hand once and recorded in the README. No code path sets sent_at outside the click handler, verified by review and by a test that greps for assignments. A draft whose model output failed validation is absent rather than partial, tested. Do not build yet: No cost ceiling, no export, no operations work yet. ### Phase 5 · What it costs, what it keeps, and why it stays personal Build: Track model spend per month and stop classifying at the ceiling, leaving rules, search and manual triage working. Delete mirrored bodies older than a configured window while keeping the rows and summaries. Export threads, classifications, model call records and drafts as newline-delimited JSON. Document why this is loopback-only and what publishing it would require. Trap: Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. Done when: Reaching the monthly ceiling stops classification with a clear message and leaves rules, search and manual triage fully working, tested by moving the counter. Bodies older than the retention window are deleted while thread rows, summaries and model call records survive, tested. The server refuses to bind to anything other than the loopback address unless an explicit override is set, and the override prints a warning, tested. Export round-trips into an empty database and the thread count and classifications match. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted provider sign-in, because the mail-reading scopes are restricted, publishing an application using them needs verification, and storing that data on a server brings a security assessment with it. - A mobile application and push notifications, which is where most mail is actually read and is the honest reason the paid product still wins. - Multiple accounts, which changes the sync state machine, the rules model and the sending identity all at once. - Anything that sends without a click, including scheduled replies and out-of-office automation, because the one guarantee this build offers is that it cannot send on its own. ### README must contain - That polling and idle are not push, and what the idle restart interval is set to and why - That message text leaves the machine for the model, what is stripped first, and how to preview the exact payload for any thread - An estimate of tokens and cost per hundred messages, and what happens when the monthly ceiling is reached - That nothing can send without a click, and where the single code path that sends lives - What publishing this as a hosted tool would require, so nobody starts that by accident ===== ARCHITECTURE.md ===== # Architecture · Mailient ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify, server-rendered HTML, bound to the loopback address | this is one person's mailbox and it should not be reachable from the network at all | | Mail | IMAP for reading with SMTP for sending, using an application password | the hosted providers' OAuth route is a verification process rather than a library call | | Storage | SQLite for rows with message bodies and raw headers written to disk | a mailbox mirror grows quickly and the rows are the part you actually query | | Model | One chat endpoint, called once per thread, with the redacted input recorded | it is the only thing that leaves the machine, so it is the only thing that needs an audit trail | | Sending | Drafts are stored, never queued, and a click is the only thing that can send one | an email tool that sends on its own is a different and considerably worse product | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | IMAP connection, idle restarts, resumable fetch. | Any client library, provided idle is re-issued inside the recommended interval. | | Payload | Stripping, redaction, truncation and the preview. | Not swappable for two functions, because the preview must be the payload. | | Triage | Rules, cache keys and provenance. | Any classifier, provided rules run first and provenance is displayed. | | Drafts | Schema validation and reply headers. | Any generator, provided the headers come from the original message. | | Send | The single click handler. | Nothing. One path, and a test asserting there is only one. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `IMAP_HOST` · required · Reading mail. Port and TLS settings alongside it. - `IMAP_USER` · required · The single mailbox this instance mirrors. - `IMAP_PASS` · required, secret · An application password, never your account password, and never logged. - `SMTP_HOST` · required · Sending, only ever from the click handler. - `LLM_API_KEY` · required, secret · Classification and drafting. Set a provider-side limit too. - `LLM_MONTHLY_CEILING` · required · Classification stops here. Rules, search and manual triage keep working. - `PAYLOAD_BYTE_BUDGET` · required · Maximum bytes of thread text sent per call. Truncation is recorded. - `BODY_RETENTION_DAYS` · required · Mirrored bodies are deleted after this; threads, summaries and call records survive. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Mailient product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify, server-rendered HTML, bound to the loopback address, IMAP for reading with SMTP for sending, using an application password, SQLite for rows with message bodies and raw headers written to disk, One chat endpoint, called once per thread, with the redacted input recorded, Drafts are stored, never queued, and a click is the only thing that can send one. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. - Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. ===== MILESTONES.md ===== # Delivery milestones · Mailient Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A sync that survives the night Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset. ### Steps 1. Re-issue idle on a timer inside the recommended interval Otherwise the sync stops without an error anybody sees. Files: `src/mail/idle.ts`, `tests/idle-timer.test.ts` 2. Make the fetch loop resumable at every point Interrupt it three ways in the test, not once. Files: `src/mail/sync.ts`, `tests/resume.test.ts` 3. Force a resync when the mailbox identity changes A stale high-water mark against a reset mailbox means silent gaps. Files: `src/mail/identity.ts` ### Done when - [ ] Idle is re-issued inside the recommended interval - [ ] Interrupting the fetch loses and duplicates nothing - [ ] A changed mailbox identity forces a full resync - [ ] A silent connection is rebuilt rather than trusted ### Watch out - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. ## M2 · What leaves the machine, written down Every model call has a stored record of exactly what was sent, what was stripped, and what it cost. ### Steps 1. One function builds the payload and the preview Two functions drift, and the preview stops being true. Files: `src/model/payload.ts`, `tests/payload-preview.test.ts` 2. Cut the quoted trail and drop attachments Most of a reply chain is somebody else's mail. Files: `src/model/strip.ts`, `tests/strip.test.ts` 3. Record bytes, hash and redactions before calling Written first, so a crash still leaves the record. Files: `src/model/call.ts` ### Done when - [ ] Preview and payload are byte-identical - [ ] Attachments never reach the payload - [ ] Redactions are applied and listed in the record - [ ] Truncation is recorded, not silent ### Watch out - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. ## M3 · Rules first, model second Cheap deterministic rules classify most of the mailbox before anything is sent anywhere. ### Steps 1. Apply rules before any call is considered The cheapest classification is the one you wrote yourself. Files: `src/triage/rules.ts`, `tests/rules-first.test.ts` 2. Key the cache on thread and message count A thread is not the same thread after a reply arrives. Files: `src/triage/cache.ts`, `tests/cache-invalidation.test.ts` 3. Show provenance on every classification A wrong category should be traceable in one click. Files: `src/app/threads/[id].tsx` ### Done when - [ ] A rule match produces no model call - [ ] A new message invalidates the cached classification - [ ] Every thread shows rule or call provenance - [ ] Rule priority is deterministic ### Watch out - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. ## M4 · Drafts that thread, and only send when clicked A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically. ### Steps 1. Build reply headers from the original message The subject line is not what threads a conversation. Files: `src/mail/reply-headers.ts`, `tests/threading.test.ts` 2. Validate model output before storing a draft A half-parsed draft is worse than no draft. Files: `src/model/draft.ts`, `tests/draft-schema.test.ts` 3. Keep sending behind one click handler One code path, and a test that no other path exists. Files: `src/routes/send.ts`, `tests/no-auto-send.test.ts` ### Done when - [ ] Reply headers thread correctly against a fixture - [ ] A real client shows the reply in the conversation - [ ] Only the click handler can set sent_at - [ ] A failed validation stores no draft ### Watch out - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ## M5 · What it costs, what it keeps, and why it stays personal (production only) Spending has a ceiling, the mirror has a retention policy, and the reasons this stays a single-user local tool are written down. ### Steps 1. Cap the monthly spend and degrade to rules The tool should get dumber, not stop. Files: `src/model/cost.ts`, `tests/cost-ceiling.test.ts` 2. Expire bodies, keep threads and records The summary stays useful long after the body stops mattering. Files: `src/jobs/retention.ts` 3. Refuse to bind beyond loopback by default This is a mailbox, not a service. Files: `src/server.ts`, `tests/bind-loopback.test.ts` ### Done when - [ ] The ceiling stops classification and leaves triage working - [ ] Body retention preserves threads and records - [ ] Binding beyond loopback needs an explicit override - [ ] Export and reimport preserve threads and classifications ### Watch out - Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. ===== OPERATIONS.md ===== # Operations · Mailient ## Backup SQLite nightly plus the redaction configuration. Mirrored bodies are not worth backing up because the mail server still has them, and saying so keeps the backup small enough to run every night. ## Restore Restore the database, confirm the high-water mark and mailbox identity marker, then start the sync and watch that it resumes rather than refetching the mailbox. A resync after restore is a symptom, not a feature. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Time since the last message arrived, idle restarts per hour, model spend against the ceiling, and the proportion of threads classified by rules rather than by the model. The first of these is the one that catches a dead sync, which is the failure this build is most prone to. ## Incident checklist The two incidents are a sync that stopped silently and a payload that contained something you did not want sent. The first is caught by the last-message age, the second is investigated from model_calls, which is why the bytes, the hash and the redaction list are stored before the call rather than after it. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Idle is confirmed re-issued inside the recommended interval - [ ] The preview is confirmed byte-identical to the payload - [ ] A new message is confirmed to invalidate the cached classification - [ ] Reply headers are confirmed to thread in a real client - [ ] No path other than the click handler is confirmed able to send ## Launch constraint Do not market omitted Mailient capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reading mail. Port and TLS settings alongside it. IMAP_HOST=imap.example.com # Required. The single mailbox this instance mirrors. IMAP_USER=me@example.com # Required · secret. An application password, never your account password, and never logged. IMAP_PASS=redacted-app-password # Required. Sending, only ever from the click handler. SMTP_HOST=smtp.example.com # Required · secret. Classification and drafting. Set a provider-side limit too. LLM_API_KEY=sk-...redacted # Required. Classification stops here. Rules, search and manual triage keep working. LLM_MONTHLY_CEILING=4.00 # Required. Maximum bytes of thread text sent per call. Truncation is recorded. PAYLOAD_BYTE_BUDGET=12000 # Required. Mirrored bodies are deleted after this; threads, summaries and call records survive. BODY_RETENTION_DAYS=90
# Mailient · indie build A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify, server-rendered HTML, bound to the loopback address | this is one person's mailbox and it should not be reachable from the network at all | | Mail | IMAP for reading with SMTP for sending, using an application password | the hosted providers' OAuth route is a verification process rather than a library call | | Storage | SQLite for rows with message bodies and raw headers written to disk | a mailbox mirror grows quickly and the rows are the part you actually query | | Model | One chat endpoint, called once per thread, with the redacted input recorded | it is the only thing that leaves the machine, so it is the only thing that needs an audit trail | | Sending | Drafts are stored, never queued, and a click is the only thing that can send one | an email tool that sends on its own is a different and considerably worse product | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the sync worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A mailbox with IMAP and SMTP access and an application password** · free with most providers - Why: This build reads and sends over the protocols directly, and a provider that has turned them off makes the whole design impossible before you start. - Get it: Enable two-factor authentication and create an application password in the provider's security settings. - Verify: A client connects, lists the inbox, and sends one message to yourself - [ ] **An API key for a chat model that returns structured output** · pay per call; a busy personal inbox is small change with the ceiling in place - Why: Classification and drafting both need validated JSON, and this is also the only component that costs money or sees your mail. - Get it: Create a key and set a provider-side spend limit in addition to the ceiling in this build. - Verify: One thread classifies into the schema without a repair attempt - [ ] **A machine that stays awake** · free to a few pounds a month - Why: A sync that runs only while a laptop is open is a sync that misses the overnight mail, which is exactly the mail triage is for. - Get it: A small always-on box on your own network, or a laptop you genuinely never close. - Verify: Mail received overnight is present in the morning - [ ] **The redaction list and the byte budget, decided before the first call** · free - Why: Deciding what leaves the machine after you have already sent a month of mail to a provider is not a decision, it is a discovery. - Get it: Write the patterns and the byte budget into configuration and review the rendered preview for ten real threads. - Verify: The preview for ten threads contains nothing you did not intend to send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. - A mobile app and real push notifications, which is where most email actually gets read - Managed OAuth with Google and Microsoft, including the review process and token refresh pain - Someone else absorbing provider API changes, rate limits and broken threading - Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior - Any sending reputation work if the product handles outbound at scale If one of those is essential to you, that is the reason to keep paying for Mailient, and the README should say so rather than pretend.
# Build brief · Mailient The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local inbox triage tool that classifies, summarises and drafts, and sends nothing without a click to replace Mailient. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify, server-rendered HTML, bound to the loopback address · this is one person's mailbox and it should not be reachable from the network at all - Mail: IMAP for reading with SMTP for sending, using an application password · the hosted providers' OAuth route is a verification process rather than a library call - Storage: SQLite for rows with message bodies and raw headers written to disk · a mailbox mirror grows quickly and the rows are the part you actually query - Model: One chat endpoint, called once per thread, with the redacted input recorded · it is the only thing that leaves the machine, so it is the only thing that needs an audit trail - Sending: Drafts are stored, never queued, and a click is the only thing that can send one · an email tool that sends on its own is a different and considerably worse product ### Data model (create this before Phase 1) - accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf. ### Phase 1 · A sync that survives the night Build: Connect over IMAP, fetch from the stored high-water mark, and store headers and body to disk with the row in SQLite. Hold an idle connection for new mail and restart it on a timer well inside the interval the protocol extension recommends. Record the mailbox identity marker and force a full resync if it changes. Make every step resumable so a kill at any point loses nothing but progress. Trap: An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. Done when: The idle connection is terminated and re-issued on a timer strictly under the interval named in the specification, asserted by a test with a fake clock. Killing the process mid-fetch and restarting loses no message and creates no duplicate, tested by interrupting at three different points. A changed mailbox identity marker triggers a full resync rather than resuming from a stale high-water mark, tested. A connection with no server traffic for longer than the idle interval is torn down and rebuilt, tested. Do not build yet: No model calls, no classification, no drafts yet. ### Phase 2 · What leaves the machine, written down Build: Convert HTML to text, drop the quoted reply trail, strip attachments entirely, apply the redaction patterns, and truncate to a configured byte budget. Before the call, write a model_calls row holding the byte count, a hash of the exact payload, and the list of redactions applied. Show the user the payload for any thread on request, rendered from the same function the sender uses. Trap: Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. Done when: A thread can be previewed exactly as it would be sent, rendered by the same function the sender calls, checked by a test that compares the two byte for byte. Attachments never appear in a payload, only filenames and sizes, tested with a message carrying one. Redaction patterns are applied and listed in the stored row, tested with a fixture containing a pattern that must be removed. A payload exceeding the byte budget is truncated at a boundary and the truncation is recorded rather than hidden. Do not build yet: No classification, no rules engine, no drafts yet. ### Phase 3 · Rules first, model second Build: Apply rules by sender, domain and subject substring in priority order before any model call. A thread matched by a rule is classified locally and never sent. Cache model results by thread and message count so re-opening a thread is free and a new message invalidates it. Show, per thread, which rule or which model call produced the category. Trap: Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. Done when: A thread matching a rule produces no model call, tested by counting calls against a stub. A new message on a cached thread invalidates the cache and triggers exactly one new call, tested. Every classified thread shows its provenance as either a named rule or a model call identifier, checked in the interface test. Rules apply in priority order and the order is deterministic for equal priorities, tested. Do not build yet: No drafting, no sending, no cost ceiling yet. ### Phase 4 · Drafts that thread, and only send when clicked Build: Generate a draft only for the categories that warrant one, validated against a schema. Build the reply headers from the original: the in-reply-to value is the message identifier of the message being replied to, and the references chain is the original's chain with that identifier appended. Store the draft. Send only from a handler reached by an explicit click, and record who clicked and when. Trap: Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. Done when: A generated reply carries the correct in-reply-to value and a references chain ending in it, asserted against a fixture with a three-message history. A reply sent to a real mail client appears inside the original conversation, verified by hand once and recorded in the README. No code path sets sent_at outside the click handler, verified by review and by a test that greps for assignments. A draft whose model output failed validation is absent rather than partial, tested. Do not build yet: No cost ceiling, no export, no operations work yet. ### Phase 5 · What it costs, what it keeps, and why it stays personal Build: Track model spend per month and stop classifying at the ceiling, leaving rules, search and manual triage working. Delete mirrored bodies older than a configured window while keeping the rows and summaries. Export threads, classifications, model call records and drafts as newline-delimited JSON. Document why this is loopback-only and what publishing it would require. Trap: Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. Done when: Reaching the monthly ceiling stops classification with a clear message and leaves rules, search and manual triage fully working, tested by moving the counter. Bodies older than the retention window are deleted while thread rows, summaries and model call records survive, tested. The server refuses to bind to anything other than the loopback address unless an explicit override is set, and the override prints a warning, tested. Export round-trips into an empty database and the thread count and classifications match. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted provider sign-in, because the mail-reading scopes are restricted, publishing an application using them needs verification, and storing that data on a server brings a security assessment with it. - A mobile application and push notifications, which is where most mail is actually read and is the honest reason the paid product still wins. - Multiple accounts, which changes the sync state machine, the rules model and the sending identity all at once. - Anything that sends without a click, including scheduled replies and out-of-office automation, because the one guarantee this build offers is that it cannot send on its own. ### README must contain - That polling and idle are not push, and what the idle restart interval is set to and why - That message text leaves the machine for the model, what is stripped first, and how to preview the exact payload for any thread - An estimate of tokens and cost per hundred messages, and what happens when the monthly ceiling is reached - That nothing can send without a click, and where the single code path that sends lives - What publishing this as a hosted tool would require, so nobody starts that by accident
# Agent instructions · Mailient indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, server-rendered HTML, bound to the loopback address, IMAP for reading with SMTP for sending, using an application password, SQLite for rows with message bodies and raw headers written to disk, One chat endpoint, called once per thread, with the redacted input recorded, Drafts are stored, never queued, and a click is the only thing that can send one. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code.
# Build plan · Mailient A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A sync that survives the night Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset. ### Steps 1. Re-issue idle on a timer inside the recommended interval Otherwise the sync stops without an error anybody sees. Files: `src/mail/idle.ts`, `tests/idle-timer.test.ts` 2. Make the fetch loop resumable at every point Interrupt it three ways in the test, not once. Files: `src/mail/sync.ts`, `tests/resume.test.ts` 3. Force a resync when the mailbox identity changes A stale high-water mark against a reset mailbox means silent gaps. Files: `src/mail/identity.ts` ### Done when - [ ] Idle is re-issued inside the recommended interval - [ ] Interrupting the fetch loses and duplicates nothing - [ ] A changed mailbox identity forces a full resync - [ ] A silent connection is rebuilt rather than trusted ### Watch out - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. ## Phase 2 · What leaves the machine, written down Every model call has a stored record of exactly what was sent, what was stripped, and what it cost. ### Steps 1. One function builds the payload and the preview Two functions drift, and the preview stops being true. Files: `src/model/payload.ts`, `tests/payload-preview.test.ts` 2. Cut the quoted trail and drop attachments Most of a reply chain is somebody else's mail. Files: `src/model/strip.ts`, `tests/strip.test.ts` 3. Record bytes, hash and redactions before calling Written first, so a crash still leaves the record. Files: `src/model/call.ts` ### Done when - [ ] Preview and payload are byte-identical - [ ] Attachments never reach the payload - [ ] Redactions are applied and listed in the record - [ ] Truncation is recorded, not silent ### Watch out - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. ## Phase 3 · Rules first, model second Cheap deterministic rules classify most of the mailbox before anything is sent anywhere. ### Steps 1. Apply rules before any call is considered The cheapest classification is the one you wrote yourself. Files: `src/triage/rules.ts`, `tests/rules-first.test.ts` 2. Key the cache on thread and message count A thread is not the same thread after a reply arrives. Files: `src/triage/cache.ts`, `tests/cache-invalidation.test.ts` 3. Show provenance on every classification A wrong category should be traceable in one click. Files: `src/app/threads/[id].tsx` ### Done when - [ ] A rule match produces no model call - [ ] A new message invalidates the cached classification - [ ] Every thread shows rule or call provenance - [ ] Rule priority is deterministic ### Watch out - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. ## Phase 4 · Drafts that thread, and only send when clicked A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically. ### Steps 1. Build reply headers from the original message The subject line is not what threads a conversation. Files: `src/mail/reply-headers.ts`, `tests/threading.test.ts` 2. Validate model output before storing a draft A half-parsed draft is worse than no draft. Files: `src/model/draft.ts`, `tests/draft-schema.test.ts` 3. Keep sending behind one click handler One code path, and a test that no other path exists. Files: `src/routes/send.ts`, `tests/no-auto-send.test.ts` ### Done when - [ ] Reply headers thread correctly against a fixture - [ ] A real client shows the reply in the conversation - [ ] Only the click handler can set sent_at - [ ] A failed validation stores no draft ### Watch out - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ## Not in this build - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. ## After v1, if you want it - Full-text search over the mirrored bodies, which is cheap while they are still on disk - A weekly report of what the rules caught versus what the model caught, which is how you write better rules - A per-sender byte budget, so the newsletters that arrive as enormous HTML stop costing what they cost
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reading mail. Port and TLS settings alongside it. IMAP_HOST=imap.example.com # Required. The single mailbox this instance mirrors. IMAP_USER=me@example.com # Required · secret. An application password, never your account password, and never logged. IMAP_PASS=redacted-app-password # Required. Sending, only ever from the click handler. SMTP_HOST=smtp.example.com # Required · secret. Classification and drafting. Set a provider-side limit too. LLM_API_KEY=sk-...redacted # Required. Classification stops here. Rules, search and manual triage keep working. LLM_MONTHLY_CEILING=4.00 # Required. Maximum bytes of thread text sent per call. Truncation is recorded. PAYLOAD_BYTE_BUDGET=12000 # Required. Mirrored bodies are deleted after this; threads, summaries and call records survive. BODY_RETENTION_DAYS=90
# Mailient · product brief ## Problem The core trick here, pull mail, classify it, summarize threads, draft replies, is genuinely a weekend of work now that an LLM does the hard part. An agent can wire IMAP plus SQLite plus a cheap web UI and have working triage before dinner. The gaps are all the unglamorous parts: Gmail and Outlook OAuth, push sync instead of polling, threading edge cases, and the fact that you will read this thing on a phone and your build has no app. Nothing here is protected by data or network, so a personal version keeps most of the value if you accept that you are now the ops team for your own mail pipeline. Product details and pricing are thin from the outside, so treat the specifics as unverified. ## Product outcome A triage tool for one mailbox that keeps syncing overnight because the idle connection is restarted on a timer, that can show you the exact bytes it sent to a model for any thread, that drafts replies which thread properly, and that has no code path capable of sending without a click. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An email account with IMAP/SMTP access and an app password, or patience for OAuth - An LLM API key, and acceptance that your mail text goes to that provider - A machine or small VPS that stays awake to poll - Basic comfort debugging MIME, threading and character encodings ## Explicit non-goals for v1 - Hosted provider sign-in and the verification that comes with it. - A mobile application and push notifications. - Multiple accounts. - Anything that sends without a click. - A mobile app and real push notifications, which is where most email actually gets read - Managed OAuth with Google and Microsoft, including the review process and token refresh pain - Someone else absorbing provider API changes, rate limits and broken threading - Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior - Any sending reputation work if the product handles outbound at scale ## Success criteria - Idle is confirmed re-issued inside the recommended interval - The preview is confirmed byte-identical to the payload - A new message is confirmed to invalidate the cached classification - Reply headers are confirmed to thread in a real client - No path other than the click handler is confirmed able to send
# Build brief · Mailient The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local inbox triage tool that classifies, summarises and drafts, and sends nothing without a click to replace Mailient. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify, server-rendered HTML, bound to the loopback address · this is one person's mailbox and it should not be reachable from the network at all - Mail: IMAP for reading with SMTP for sending, using an application password · the hosted providers' OAuth route is a verification process rather than a library call - Storage: SQLite for rows with message bodies and raw headers written to disk · a mailbox mirror grows quickly and the rows are the part you actually query - Model: One chat endpoint, called once per thread, with the redacted input recorded · it is the only thing that leaves the machine, so it is the only thing that needs an audit trail - Sending: Drafts are stored, never queued, and a click is the only thing that can send one · an email tool that sends on its own is a different and considerably worse product ### Data model (create this before Phase 1) - accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf. ### Phase 1 · A sync that survives the night Build: Connect over IMAP, fetch from the stored high-water mark, and store headers and body to disk with the row in SQLite. Hold an idle connection for new mail and restart it on a timer well inside the interval the protocol extension recommends. Record the mailbox identity marker and force a full resync if it changes. Make every step resumable so a kill at any point loses nothing but progress. Trap: An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. Done when: The idle connection is terminated and re-issued on a timer strictly under the interval named in the specification, asserted by a test with a fake clock. Killing the process mid-fetch and restarting loses no message and creates no duplicate, tested by interrupting at three different points. A changed mailbox identity marker triggers a full resync rather than resuming from a stale high-water mark, tested. A connection with no server traffic for longer than the idle interval is torn down and rebuilt, tested. Do not build yet: No model calls, no classification, no drafts yet. ### Phase 2 · What leaves the machine, written down Build: Convert HTML to text, drop the quoted reply trail, strip attachments entirely, apply the redaction patterns, and truncate to a configured byte budget. Before the call, write a model_calls row holding the byte count, a hash of the exact payload, and the list of redactions applied. Show the user the payload for any thread on request, rendered from the same function the sender uses. Trap: Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. Done when: A thread can be previewed exactly as it would be sent, rendered by the same function the sender calls, checked by a test that compares the two byte for byte. Attachments never appear in a payload, only filenames and sizes, tested with a message carrying one. Redaction patterns are applied and listed in the stored row, tested with a fixture containing a pattern that must be removed. A payload exceeding the byte budget is truncated at a boundary and the truncation is recorded rather than hidden. Do not build yet: No classification, no rules engine, no drafts yet. ### Phase 3 · Rules first, model second Build: Apply rules by sender, domain and subject substring in priority order before any model call. A thread matched by a rule is classified locally and never sent. Cache model results by thread and message count so re-opening a thread is free and a new message invalidates it. Show, per thread, which rule or which model call produced the category. Trap: Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. Done when: A thread matching a rule produces no model call, tested by counting calls against a stub. A new message on a cached thread invalidates the cache and triggers exactly one new call, tested. Every classified thread shows its provenance as either a named rule or a model call identifier, checked in the interface test. Rules apply in priority order and the order is deterministic for equal priorities, tested. Do not build yet: No drafting, no sending, no cost ceiling yet. ### Phase 4 · Drafts that thread, and only send when clicked Build: Generate a draft only for the categories that warrant one, validated against a schema. Build the reply headers from the original: the in-reply-to value is the message identifier of the message being replied to, and the references chain is the original's chain with that identifier appended. Store the draft. Send only from a handler reached by an explicit click, and record who clicked and when. Trap: Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. Done when: A generated reply carries the correct in-reply-to value and a references chain ending in it, asserted against a fixture with a three-message history. A reply sent to a real mail client appears inside the original conversation, verified by hand once and recorded in the README. No code path sets sent_at outside the click handler, verified by review and by a test that greps for assignments. A draft whose model output failed validation is absent rather than partial, tested. Do not build yet: No cost ceiling, no export, no operations work yet. ### Phase 5 · What it costs, what it keeps, and why it stays personal Build: Track model spend per month and stop classifying at the ceiling, leaving rules, search and manual triage working. Delete mirrored bodies older than a configured window while keeping the rows and summaries. Export threads, classifications, model call records and drafts as newline-delimited JSON. Document why this is loopback-only and what publishing it would require. Trap: Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost. Done when: Reaching the monthly ceiling stops classification with a clear message and leaves rules, search and manual triage fully working, tested by moving the counter. Bodies older than the retention window are deleted while thread rows, summaries and model call records survive, tested. The server refuses to bind to anything other than the loopback address unless an explicit override is set, and the override prints a warning, tested. Export round-trips into an empty database and the thread count and classifications match. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted provider sign-in, because the mail-reading scopes are restricted, publishing an application using them needs verification, and storing that data on a server brings a security assessment with it. - A mobile application and push notifications, which is where most mail is actually read and is the honest reason the paid product still wins. - Multiple accounts, which changes the sync state machine, the rules model and the sending identity all at once. - Anything that sends without a click, including scheduled replies and out-of-office automation, because the one guarantee this build offers is that it cannot send on its own. ### README must contain - That polling and idle are not push, and what the idle restart interval is set to and why - That message text leaves the machine for the model, what is stripped first, and how to preview the exact payload for any thread - An estimate of tokens and cost per hundred messages, and what happens when the monthly ceiling is reached - That nothing can send without a click, and where the single code path that sends lives - What publishing this as a hosted tool would require, so nobody starts that by accident
# Architecture · Mailient ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify, server-rendered HTML, bound to the loopback address | this is one person's mailbox and it should not be reachable from the network at all | | Mail | IMAP for reading with SMTP for sending, using an application password | the hosted providers' OAuth route is a verification process rather than a library call | | Storage | SQLite for rows with message bodies and raw headers written to disk | a mailbox mirror grows quickly and the rows are the part you actually query | | Model | One chat endpoint, called once per thread, with the redacted input recorded | it is the only thing that leaves the machine, so it is the only thing that needs an audit trail | | Sending | Drafts are stored, never queued, and a click is the only thing that can send one | an email tool that sends on its own is a different and considerably worse product | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | IMAP connection, idle restarts, resumable fetch. | Any client library, provided idle is re-issued inside the recommended interval. | | Payload | Stripping, redaction, truncation and the preview. | Not swappable for two functions, because the preview must be the payload. | | Triage | Rules, cache keys and provenance. | Any classifier, provided rules run first and provenance is displayed. | | Drafts | Schema validation and reply headers. | Any generator, provided the headers come from the original message. | | Send | The single click handler. | Nothing. One path, and a test asserting there is only one. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `IMAP_HOST` · required · Reading mail. Port and TLS settings alongside it. - `IMAP_USER` · required · The single mailbox this instance mirrors. - `IMAP_PASS` · required, secret · An application password, never your account password, and never logged. - `SMTP_HOST` · required · Sending, only ever from the click handler. - `LLM_API_KEY` · required, secret · Classification and drafting. Set a provider-side limit too. - `LLM_MONTHLY_CEILING` · required · Classification stops here. Rules, search and manual triage keep working. - `PAYLOAD_BYTE_BUDGET` · required · Maximum bytes of thread text sent per call. Truncation is recorded. - `BODY_RETENTION_DAYS` · required · Mirrored bodies are deleted after this; threads, summaries and call records survive. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Mailient product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify, server-rendered HTML, bound to the loopback address, IMAP for reading with SMTP for sending, using an application password, SQLite for rows with message bodies and raw headers written to disk, One chat endpoint, called once per thread, with the redacted input recorded, Drafts are stored, never queued, and a click is the only thing that can send one. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. - Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost.
# Delivery milestones · Mailient Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A sync that survives the night Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset. ### Steps 1. Re-issue idle on a timer inside the recommended interval Otherwise the sync stops without an error anybody sees. Files: `src/mail/idle.ts`, `tests/idle-timer.test.ts` 2. Make the fetch loop resumable at every point Interrupt it three ways in the test, not once. Files: `src/mail/sync.ts`, `tests/resume.test.ts` 3. Force a resync when the mailbox identity changes A stale high-water mark against a reset mailbox means silent gaps. Files: `src/mail/identity.ts` ### Done when - [ ] Idle is re-issued inside the recommended interval - [ ] Interrupting the fetch loses and duplicates nothing - [ ] A changed mailbox identity forces a full resync - [ ] A silent connection is rebuilt rather than trusted ### Watch out - An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle. ## M2 · What leaves the machine, written down Every model call has a stored record of exactly what was sent, what was stripped, and what it cost. ### Steps 1. One function builds the payload and the preview Two functions drift, and the preview stops being true. Files: `src/model/payload.ts`, `tests/payload-preview.test.ts` 2. Cut the quoted trail and drop attachments Most of a reply chain is somebody else's mail. Files: `src/model/strip.ts`, `tests/strip.test.ts` 3. Record bytes, hash and redactions before calling Written first, so a crash still leaves the record. Files: `src/model/call.ts` ### Done when - [ ] Preview and payload are byte-identical - [ ] Attachments never reach the payload - [ ] Redactions are applied and listed in the record - [ ] Truncation is recorded, not silent ### Watch out - Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them. ## M3 · Rules first, model second Cheap deterministic rules classify most of the mailbox before anything is sent anywhere. ### Steps 1. Apply rules before any call is considered The cheapest classification is the one you wrote yourself. Files: `src/triage/rules.ts`, `tests/rules-first.test.ts` 2. Key the cache on thread and message count A thread is not the same thread after a reply arrives. Files: `src/triage/cache.ts`, `tests/cache-invalidation.test.ts` 3. Show provenance on every classification A wrong category should be traceable in one click. Files: `src/app/threads/[id].tsx` ### Done when - [ ] A rule match produces no model call - [ ] A new message invalidates the cached classification - [ ] Every thread shows rule or call provenance - [ ] Rule priority is deterministic ### Watch out - Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable. ## M4 · Drafts that thread, and only send when clicked A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically. ### Steps 1. Build reply headers from the original message The subject line is not what threads a conversation. Files: `src/mail/reply-headers.ts`, `tests/threading.test.ts` 2. Validate model output before storing a draft A half-parsed draft is worse than no draft. Files: `src/model/draft.ts`, `tests/draft-schema.test.ts` 3. Keep sending behind one click handler One code path, and a test that no other path exists. Files: `src/routes/send.ts`, `tests/no-auto-send.test.ts` ### Done when - [ ] Reply headers thread correctly against a fixture - [ ] A real client shows the reply in the conversation - [ ] Only the click handler can set sent_at - [ ] A failed validation stores no draft ### Watch out - Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code. ## M5 · What it costs, what it keeps, and why it stays personal (production only) Spending has a ceiling, the mirror has a retention policy, and the reasons this stays a single-user local tool are written down. ### Steps 1. Cap the monthly spend and degrade to rules The tool should get dumber, not stop. Files: `src/model/cost.ts`, `tests/cost-ceiling.test.ts` 2. Expire bodies, keep threads and records The summary stays useful long after the body stops mattering. Files: `src/jobs/retention.ts` 3. Refuse to bind beyond loopback by default This is a mailbox, not a service. Files: `src/server.ts`, `tests/bind-loopback.test.ts` ### Done when - [ ] The ceiling stops classification and leaves triage working - [ ] Body retention preserves threads and records - [ ] Binding beyond loopback needs an explicit override - [ ] Export and reimport preserve threads and classifications ### Watch out - Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost.
# Operations · Mailient ## Backup SQLite nightly plus the redaction configuration. Mirrored bodies are not worth backing up because the mail server still has them, and saying so keeps the backup small enough to run every night. ## Restore Restore the database, confirm the high-water mark and mailbox identity marker, then start the sync and watch that it resumes rather than refetching the mailbox. A resync after restore is a symptom, not a feature. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Time since the last message arrived, idle restarts per hour, model spend against the ceiling, and the proportion of threads classified by rules rather than by the model. The first of these is the one that catches a dead sync, which is the failure this build is most prone to. ## Incident checklist The two incidents are a sync that stopped silently and a payload that contained something you did not want sent. The first is caught by the last-message age, the second is investigated from model_calls, which is why the bytes, the hash and the redaction list are stored before the call rather than after it. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Idle is confirmed re-issued inside the recommended interval - [ ] The preview is confirmed byte-identical to the payload - [ ] A new message is confirmed to invalidate the cached classification - [ ] Reply headers are confirmed to thread in a real client - [ ] No path other than the click handler is confirmed able to send ## Launch constraint Do not market omitted Mailient capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reading mail. Port and TLS settings alongside it. IMAP_HOST=imap.example.com # Required. The single mailbox this instance mirrors. IMAP_USER=me@example.com # Required · secret. An application password, never your account password, and never logged. IMAP_PASS=redacted-app-password # Required. Sending, only ever from the click handler. SMTP_HOST=smtp.example.com # Required · secret. Classification and drafting. Set a provider-side limit too. LLM_API_KEY=sk-...redacted # Required. Classification stops here. Rules, search and manual triage keep working. LLM_MONTHLY_CEILING=4.00 # Required. Maximum bytes of thread text sent per call. Truncation is recorded. PAYLOAD_BYTE_BUDGET=12000 # Required. Mirrored bodies are deleted after this; threads, summaries and call records survive. BODY_RETENTION_DAYS=90
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because email is the one app that has to work at 7am on a phone with two bars of signal, and a self-hosted poller on a home server does not clear that bar. People pay to skip OAuth verification, token refresh, IMAP quirks and the slow realization that their triage cron died four days ago and they missed an invoice. The AI part is commodity; the boring reliability around it is what the subscription actually buys.
xA mobile app and real push notifications, which is where most email actually gets read
xManaged OAuth with Google and Microsoft, including the review process and token refresh pain
xSomeone else absorbing provider API changes, rate limits and broken threading
xPolish: keyboard shortcuts, fast search, snooze, undo send, offline behavior
xAny sending reputation work if the product handles outbound at scale
Nothing worth pointing at. That's why the prompt exists.
Vibecode Mailient
Kinda. The core of Mailient is buildable in a weekend with the prompt on this page, but there are real gaps: A mobile app and real push notifications, which is where most email actually gets read, Managed OAuth with Google and Microsoft, including the review process and token refresh pain. Read the honest list above before committing.
How much does Mailient cost?
Mailient costs about $29/month (Monthly, checked 2026-08-18), which is $348 per year.
What do I lose by replacing Mailient?
Honestly: A mobile app and real push notifications, which is where most email actually gets read; Managed OAuth with Google and Microsoft, including the review process and token refresh pain; Someone else absorbing provider API changes, rate limits and broken threading; Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior; Any sending reputation work if the product handles outbound at scale. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Mailient?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.