Vibecode Spike
track this build4 phases, 12 steps, beginner friendly0%The visible conversational email loop is buildable, but a credible replacement needs more than the first screen. Spike earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Spike. 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 ===== # Spike · indie build A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. Estimated effort: **multi-day**. 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 | A desktop application with a local database and exactly one account | one account is a tool, several accounts is a product | | Protocol | IMAP synced by UID, using only the extensions the server advertises | assuming an extension is present is how sync breaks on the second provider | | Store | SQLite holding a cache of what the server has, rebuildable from empty | the store is a convenience and the server is the truth | | Rendering | Sanitised HTML in a sandboxed view with remote content off until asked | a mail body is untrusted input that wants to phone home | | Sending | The provider's own submission service with the credential you already hold | running your own sender means owning deliverability, which you cannot | | Secrets | The operating system keychain, never the database and never a log | a token in a SQLite file is a token in a backup | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with a desktop shell** · free - Why: Runs the application and the local database. - Get it: nodejs.org, plus the desktop framework of your choice. - Verify: A window opens and the local database file is created - [ ] **One mail account you own, with IMAP access** · free - Why: Everything in this build is tested against a real server's behaviour, and providers differ in which extensions they advertise. - Get it: Enable IMAP and create an application password or an OAuth client of your own. - Verify: A client can log in, list folders and read the folder validity value - [ ] **A throwaway mailbox you can break** · free - Why: Validity changes, expunges and double sends have to be tested by actually doing them, and not on the account holding your bank mail. - Get it: A second account on the same provider, seeded with a few hundred messages including a long list thread. - Verify: You can expunge and resync it without caring about the outcome - [ ] **A corpus of awkward mail** · free - Why: Character sets, nested MIME, inline replies and tracking pixels are the actual work, and you cannot write the tests without examples. - Get it: Export a varied folder to maildir, or collect public mailing list archives. - Verify: The corpus includes a legacy charset, a nested multipart, an inline reply and a remote image - [ ] **Your scope, retention and export format** · free - Why: How much mail is cached, what the subject fallback window is, and which export format you commit to, are all things tests assert. - Get it: Write down the sync window, the threading fallback rule and the export format. - Verify: All three appear in the configuration and in the README ## 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: - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. - sender reputation, deliverability, and suppression handling - provider-grade deliverability - push reliability across devices - team routing and shared inbox rules - abuse response and security operations If one of those is essential to you, that is the reason to keep paying for Spike, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Spike 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 mail reader that shows a thread as a conversation, over one account you already own to replace Spike. 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. ### Rule zero Your local store is a cache and the server holds the mail. Nothing is deleted or expunged on the server that the person did not ask for, a full resync must be able to rebuild the cache from nothing, and no sync bug is ever allowed to be destructive. Mail is the account recovery path for everything else somebody owns. Be honest about the half you cannot own. Send through the provider's own submission service, never a mail server of your own, because deliverability is a reputation built over years and a new sending host lands in spam. And plan for a personal tool: Google requires apps asking for restricted scopes to pass a security assessment by its empanelled assessors and be reverified at least every twelve months, with an explicit exception where you are the only user or the few users are known to you personally (checked), so this is your own client, your own account, and an unverified consent screen you click through knowingly. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and exactly one account · one account is a tool, several accounts is a product - Protocol: IMAP synced by UID, using only the extensions the server advertises · assuming an extension is present is how sync breaks on the second provider - Store: SQLite holding a cache of what the server has, rebuildable from empty · the store is a convenience and the server is the truth - Rendering: Sanitised HTML in a sandboxed view with remote content off until asked · a mail body is untrusted input that wants to phone home - Sending: The provider's own submission service with the credential you already hold · running your own sender means owning deliverability, which you cannot - Secrets: The operating system keychain, never the database and never a log · a token in a SQLite file is a token in a backup ### Data model (create this before Phase 1) - accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human ### Phase 1 · A cache that knows when it has gone stale Build: Store each folder's UID validity value beside every cached message. On opening a folder, compare it: the IMAP specification says that when unique identifiers from an earlier session fail to persist, the validity value must be greater than the one used before (checked), so a change means every cached UID for that folder is meaningless and the folder cache is discarded and refetched. Fetch changes by UID range, never by sequence number, which the same specification says is reassigned when a message is expunged. Trap: Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. Done when: Changing the folder's validity value causes the cached messages for that folder to be discarded and refetched, tested against a server where you can force it. Expunging a message from another client does not corrupt any cached body, tested by expunging and resyncing. Sync uses UID ranges and no code path indexes by sequence number, verified by review. A full resync from an empty database reproduces the same message set as the server. Do not build yet: No threading, rendering, conversational view or sending. ### Phase 2 · Threads assembled from headers Build: Thread on the message identifier, in-reply-to and references headers, walking the references chain to find a root. Fall back to normalised subject only when no identifier links exist, and only within a time window and among overlapping participants. Handle a missing root, a message quoted back with a mangled identifier, and a reply arriving before its parent. Trap: Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. Done when: Two unrelated conversations sharing a subject stay separate, tested with a crafted mailbox. A reply that arrives before its parent joins the correct thread once the parent appears, tested. A message whose references chain is broken still attaches by in-reply-to where present. Thread membership is stable across a full resync, compared before and after. Do not build yet: No rendering, conversational layout or sending yet. ### Phase 3 · Rendering mail that is not on your side Build: Parse MIME properly, decoding transfer encodings and character sets rather than assuming UTF-8. Sanitise HTML to an allowlist of elements and attributes, strip scripts, event handlers, forms and embedded frames, and render in a view with no access to the application. Block remote content by default with a per-message and per-sender option to load it. Attachments are written to a quarantine directory and never opened automatically. Trap: Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. Done when: A message containing a script, an event handler and a form renders with none of them, tested against a crafted corpus. No network request leaves the application when a message with remote images is opened, verified by capturing traffic. A message in a legacy character set renders correctly rather than as replacement characters. An attachment is never executed or previewed by an external handler without an explicit action. Do not build yet: No conversational layout, sending or export yet. ### Phase 4 · The conversational view, with the original always one click away Build: Lay a thread out as a sequence of messages with the quoted history collapsed and signatures folded, showing participants and the arrival time of each. Detect quoted text by structure rather than by looking for a line that starts with an angle bracket. Always offer the full original body and the raw source. Keep read and flag state in step with the server both ways. Trap: Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. Done when: A reply written inline inside the quoted text displays its content rather than an empty bubble, tested with real examples. Collapsed history expands and the raw source is reachable for every message. Marking read in the application is reflected on the server and a change made elsewhere appears after a sync, both tested. A thread of two hundred messages renders without the interface stalling, measured. Do not build yet: No sending, token refresh or export yet. ### Phase 5 · Sending once, staying authenticated, and being able to leave Build: Queue an outgoing message locally, mark it as sending in its own transaction before touching the network, and never retry a send whose outcome is unknown without first checking the sent folder for its identifier. Refresh tokens ahead of expiry and surface an authentication failure as a visible state rather than a silent sync that stops. Export the cache as maildir or mbox with attachments intact, and document that the credential lives in the keychain. Trap: Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. Done when: A kill between submission and recording leaves one delivered message and a queued row that resolves by looking in the sent folder, tested with a fake submission service. An expired token puts the account into a visible failed state within one sync cycle rather than failing quietly, tested by revoking it. Export produces a maildir that another client opens with attachments intact. No credential appears in the database, in an export or in a log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own outbound mail server, since deliverability is a reputation earned over years and a fresh sending host is a spam folder. - Several accounts with unified search, which multiplies every sync edge case by the number of providers and their differing extension support. - Team features such as shared inboxes, assignment and internal notes, which need a server, a permission model and other people's mail. - Push notifications across devices, which need a service you would have to run, keep running and hold credentials for. ### README must contain - That the local store is a cache, is rebuildable from empty, and that nothing is ever deleted on the server without an explicit action - That a changed folder validity value discards that folder's cache, and why sequence numbers are never used - That remote content is blocked by default because loading it is a read receipt, and that sending goes through the provider's submission service rather than a mail server of your own - That restricted scopes require a security assessment and annual reverification unless the app is used only by you or people you know, so this stays a personal client - Where the credential is stored, and how to export everything as maildir before you stop using this ===== AGENTS.md ===== # Agent instructions · Spike indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and exactly one account, IMAP synced by UID, using only the extensions the server advertises, SQLite holding a cache of what the server has, rebuildable from empty, Sanitised HTML in a sandboxed view with remote content off until asked, The provider's own submission service with the credential you already hold, The operating system keychain, never the database and never a log. 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 - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ===== BUILD_PLAN.md ===== # Build plan · Spike A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. 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 cache that knows when it has gone stale Sync by UID, and throw the folder away the moment the server says its numbering changed. ### Steps 1. Store validity per folder and compare on open It is two columns and it is the difference between a cache and a corruption. Files: `src/sync/folders.ts`, `tests/uidvalidity.test.ts` 2. Fetch by UID, never by sequence number Sequence numbers are reassigned under you during the session. Files: `src/sync/fetch.ts` 3. Prove a full resync from empty If the cache can always be rebuilt, no sync bug can be fatal. Files: `tests/resync.test.ts` ### Done when - [ ] A validity change discards and refetches the folder - [ ] An external expunge corrupts no cached body - [ ] No code path uses sequence numbers - [ ] A resync from empty matches the server ### Watch out - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. ## Phase 2 · Threads assembled from headers A conversation is built from message identifiers, with subject matching as a last resort. ### Steps 1. Walk references before anything else The headers exist precisely so you do not have to guess. Files: `src/threading.ts`, `tests/threading.test.ts` 2. Gate the subject fallback A time window and shared participants, or not at all. Files: `src/threading.ts` 3. Assert stability across a resync Threads that reshuffle on every sync are threads nobody trusts. Files: `tests/threading.test.ts` ### Done when - [ ] Same-subject conversations stay separate - [ ] Out-of-order replies join correctly - [ ] A broken references chain still threads by in-reply-to - [ ] Threads are stable across a full resync ### Watch out - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. ## Phase 3 · Rendering mail that is not on your side A message body cannot run anything, and opening it tells nobody. ### Steps 1. Decode MIME and character sets properly Mail from 2004 is still mail and still arrives. Files: `src/mime.ts`, `tests/charset.test.ts` 2. Sanitise to an allowlist and sandbox the view A denylist is a list of the attacks you thought of. Files: `src/sanitise.ts`, `tests/sanitise.test.ts` 3. Block remote content and show what was blocked Then let the person load it deliberately, per message or per sender. Files: `src/render.tsx`, `tests/no-beacon.test.ts` ### Done when - [ ] Scripts, handlers and forms are stripped - [ ] Opening a message emits no network request - [ ] Legacy character sets render correctly - [ ] Attachments never open themselves ### Watch out - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. ## Phase 4 · The conversational view, with the original always one click away A thread reads like a chat without hiding anything the mail actually said. ### Steps 1. Collapse quoted history, never remove it Expand automatically when the remainder is too short to be the message. Files: `src/quoting.ts`, `tests/quoting.test.ts` 2. Keep flags in step in both directions A reader that lies about what is unread is worse than a list. Files: `src/sync/flags.ts` 3. Measure a very long thread Mailing lists exist and they are where naive rendering dies. Files: `tests/perf.test.ts` ### Done when - [ ] Inline replies inside quotes are shown - [ ] Raw source is reachable for every message - [ ] Flags stay in step in both directions - [ ] A two hundred message thread renders promptly ### Watch out - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ## Not in this build - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. ## After v1, if you want it - Server-side search where the provider offers it, falling back to the local index - Rules that file or flag on arrival, running locally over the same threading model - A second account, once a full resync of the first has matched the server for a month ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The single account. Adding a second is a different project. ACCOUNT_ADDRESS=you@example.com # Required. Always over TLS. Extensions are discovered, not assumed. IMAP_HOST=imap.example.com:993 # Required. The provider's submission service. Never a mail server of your own. SUBMISSION_HOST=smtp.example.com:587 # Optional. Your own client, used where the provider requires OAuth rather than an app password. OAUTH_CLIENT_ID=your-own-client-id # Optional · secret. Kept in the keychain. The environment holds a reference, not the value. OAUTH_CLIENT_SECRET=redacted # Required. The cache. Rebuildable from empty, which is the point. DATABASE_PATH=~/.local/share/mailreader/cache.db # Required. How far back to cache. Older mail is fetched on demand. SYNC_WINDOW_DAYS=365 # Required. Off by default. Loading it is a read receipt for the sender. ALLOW_REMOTE_CONTENT=false
You are building a lean indie version of Spike. 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 ===== # Spike · indie build A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. Estimated effort: **multi-day**. 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 | A desktop application with a local database and exactly one account | one account is a tool, several accounts is a product | | Protocol | IMAP synced by UID, using only the extensions the server advertises | assuming an extension is present is how sync breaks on the second provider | | Store | SQLite holding a cache of what the server has, rebuildable from empty | the store is a convenience and the server is the truth | | Rendering | Sanitised HTML in a sandboxed view with remote content off until asked | a mail body is untrusted input that wants to phone home | | Sending | The provider's own submission service with the credential you already hold | running your own sender means owning deliverability, which you cannot | | Secrets | The operating system keychain, never the database and never a log | a token in a SQLite file is a token in a backup | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with a desktop shell** · free - Why: Runs the application and the local database. - Get it: nodejs.org, plus the desktop framework of your choice. - Verify: A window opens and the local database file is created - [ ] **One mail account you own, with IMAP access** · free - Why: Everything in this build is tested against a real server's behaviour, and providers differ in which extensions they advertise. - Get it: Enable IMAP and create an application password or an OAuth client of your own. - Verify: A client can log in, list folders and read the folder validity value - [ ] **A throwaway mailbox you can break** · free - Why: Validity changes, expunges and double sends have to be tested by actually doing them, and not on the account holding your bank mail. - Get it: A second account on the same provider, seeded with a few hundred messages including a long list thread. - Verify: You can expunge and resync it without caring about the outcome - [ ] **A corpus of awkward mail** · free - Why: Character sets, nested MIME, inline replies and tracking pixels are the actual work, and you cannot write the tests without examples. - Get it: Export a varied folder to maildir, or collect public mailing list archives. - Verify: The corpus includes a legacy charset, a nested multipart, an inline reply and a remote image - [ ] **Your scope, retention and export format** · free - Why: How much mail is cached, what the subject fallback window is, and which export format you commit to, are all things tests assert. - Get it: Write down the sync window, the threading fallback rule and the export format. - Verify: All three appear in the configuration and in the README ## 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: - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. - sender reputation, deliverability, and suppression handling - provider-grade deliverability - push reliability across devices - team routing and shared inbox rules - abuse response and security operations If one of those is essential to you, that is the reason to keep paying for Spike, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Spike 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 mail reader that shows a thread as a conversation, over one account you already own to replace Spike. 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. ### Rule zero Your local store is a cache and the server holds the mail. Nothing is deleted or expunged on the server that the person did not ask for, a full resync must be able to rebuild the cache from nothing, and no sync bug is ever allowed to be destructive. Mail is the account recovery path for everything else somebody owns. Be honest about the half you cannot own. Send through the provider's own submission service, never a mail server of your own, because deliverability is a reputation built over years and a new sending host lands in spam. And plan for a personal tool: Google requires apps asking for restricted scopes to pass a security assessment by its empanelled assessors and be reverified at least every twelve months, with an explicit exception where you are the only user or the few users are known to you personally (checked), so this is your own client, your own account, and an unverified consent screen you click through knowingly. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and exactly one account · one account is a tool, several accounts is a product - Protocol: IMAP synced by UID, using only the extensions the server advertises · assuming an extension is present is how sync breaks on the second provider - Store: SQLite holding a cache of what the server has, rebuildable from empty · the store is a convenience and the server is the truth - Rendering: Sanitised HTML in a sandboxed view with remote content off until asked · a mail body is untrusted input that wants to phone home - Sending: The provider's own submission service with the credential you already hold · running your own sender means owning deliverability, which you cannot - Secrets: The operating system keychain, never the database and never a log · a token in a SQLite file is a token in a backup ### Data model (create this before Phase 1) - accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human ### Phase 1 · A cache that knows when it has gone stale Build: Store each folder's UID validity value beside every cached message. On opening a folder, compare it: the IMAP specification says that when unique identifiers from an earlier session fail to persist, the validity value must be greater than the one used before (checked), so a change means every cached UID for that folder is meaningless and the folder cache is discarded and refetched. Fetch changes by UID range, never by sequence number, which the same specification says is reassigned when a message is expunged. Trap: Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. Done when: Changing the folder's validity value causes the cached messages for that folder to be discarded and refetched, tested against a server where you can force it. Expunging a message from another client does not corrupt any cached body, tested by expunging and resyncing. Sync uses UID ranges and no code path indexes by sequence number, verified by review. A full resync from an empty database reproduces the same message set as the server. Do not build yet: No threading, rendering, conversational view or sending. ### Phase 2 · Threads assembled from headers Build: Thread on the message identifier, in-reply-to and references headers, walking the references chain to find a root. Fall back to normalised subject only when no identifier links exist, and only within a time window and among overlapping participants. Handle a missing root, a message quoted back with a mangled identifier, and a reply arriving before its parent. Trap: Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. Done when: Two unrelated conversations sharing a subject stay separate, tested with a crafted mailbox. A reply that arrives before its parent joins the correct thread once the parent appears, tested. A message whose references chain is broken still attaches by in-reply-to where present. Thread membership is stable across a full resync, compared before and after. Do not build yet: No rendering, conversational layout or sending yet. ### Phase 3 · Rendering mail that is not on your side Build: Parse MIME properly, decoding transfer encodings and character sets rather than assuming UTF-8. Sanitise HTML to an allowlist of elements and attributes, strip scripts, event handlers, forms and embedded frames, and render in a view with no access to the application. Block remote content by default with a per-message and per-sender option to load it. Attachments are written to a quarantine directory and never opened automatically. Trap: Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. Done when: A message containing a script, an event handler and a form renders with none of them, tested against a crafted corpus. No network request leaves the application when a message with remote images is opened, verified by capturing traffic. A message in a legacy character set renders correctly rather than as replacement characters. An attachment is never executed or previewed by an external handler without an explicit action. Do not build yet: No conversational layout, sending or export yet. ### Phase 4 · The conversational view, with the original always one click away Build: Lay a thread out as a sequence of messages with the quoted history collapsed and signatures folded, showing participants and the arrival time of each. Detect quoted text by structure rather than by looking for a line that starts with an angle bracket. Always offer the full original body and the raw source. Keep read and flag state in step with the server both ways. Trap: Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. Done when: A reply written inline inside the quoted text displays its content rather than an empty bubble, tested with real examples. Collapsed history expands and the raw source is reachable for every message. Marking read in the application is reflected on the server and a change made elsewhere appears after a sync, both tested. A thread of two hundred messages renders without the interface stalling, measured. Do not build yet: No sending, token refresh or export yet. ### Phase 5 · Sending once, staying authenticated, and being able to leave Build: Queue an outgoing message locally, mark it as sending in its own transaction before touching the network, and never retry a send whose outcome is unknown without first checking the sent folder for its identifier. Refresh tokens ahead of expiry and surface an authentication failure as a visible state rather than a silent sync that stops. Export the cache as maildir or mbox with attachments intact, and document that the credential lives in the keychain. Trap: Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. Done when: A kill between submission and recording leaves one delivered message and a queued row that resolves by looking in the sent folder, tested with a fake submission service. An expired token puts the account into a visible failed state within one sync cycle rather than failing quietly, tested by revoking it. Export produces a maildir that another client opens with attachments intact. No credential appears in the database, in an export or in a log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own outbound mail server, since deliverability is a reputation earned over years and a fresh sending host is a spam folder. - Several accounts with unified search, which multiplies every sync edge case by the number of providers and their differing extension support. - Team features such as shared inboxes, assignment and internal notes, which need a server, a permission model and other people's mail. - Push notifications across devices, which need a service you would have to run, keep running and hold credentials for. ### README must contain - That the local store is a cache, is rebuildable from empty, and that nothing is ever deleted on the server without an explicit action - That a changed folder validity value discards that folder's cache, and why sequence numbers are never used - That remote content is blocked by default because loading it is a read receipt, and that sending goes through the provider's submission service rather than a mail server of your own - That restricted scopes require a security assessment and annual reverification unless the app is used only by you or people you know, so this stays a personal client - Where the credential is stored, and how to export everything as maildir before you stop using this ===== AGENTS.md ===== # Agent instructions · Spike indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and exactly one account, IMAP synced by UID, using only the extensions the server advertises, SQLite holding a cache of what the server has, rebuildable from empty, Sanitised HTML in a sandboxed view with remote content off until asked, The provider's own submission service with the credential you already hold, The operating system keychain, never the database and never a log. 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 - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ===== BUILD_PLAN.md ===== # Build plan · Spike A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. 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 cache that knows when it has gone stale Sync by UID, and throw the folder away the moment the server says its numbering changed. ### Steps 1. Store validity per folder and compare on open It is two columns and it is the difference between a cache and a corruption. Files: `src/sync/folders.ts`, `tests/uidvalidity.test.ts` 2. Fetch by UID, never by sequence number Sequence numbers are reassigned under you during the session. Files: `src/sync/fetch.ts` 3. Prove a full resync from empty If the cache can always be rebuilt, no sync bug can be fatal. Files: `tests/resync.test.ts` ### Done when - [ ] A validity change discards and refetches the folder - [ ] An external expunge corrupts no cached body - [ ] No code path uses sequence numbers - [ ] A resync from empty matches the server ### Watch out - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. ## Phase 2 · Threads assembled from headers A conversation is built from message identifiers, with subject matching as a last resort. ### Steps 1. Walk references before anything else The headers exist precisely so you do not have to guess. Files: `src/threading.ts`, `tests/threading.test.ts` 2. Gate the subject fallback A time window and shared participants, or not at all. Files: `src/threading.ts` 3. Assert stability across a resync Threads that reshuffle on every sync are threads nobody trusts. Files: `tests/threading.test.ts` ### Done when - [ ] Same-subject conversations stay separate - [ ] Out-of-order replies join correctly - [ ] A broken references chain still threads by in-reply-to - [ ] Threads are stable across a full resync ### Watch out - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. ## Phase 3 · Rendering mail that is not on your side A message body cannot run anything, and opening it tells nobody. ### Steps 1. Decode MIME and character sets properly Mail from 2004 is still mail and still arrives. Files: `src/mime.ts`, `tests/charset.test.ts` 2. Sanitise to an allowlist and sandbox the view A denylist is a list of the attacks you thought of. Files: `src/sanitise.ts`, `tests/sanitise.test.ts` 3. Block remote content and show what was blocked Then let the person load it deliberately, per message or per sender. Files: `src/render.tsx`, `tests/no-beacon.test.ts` ### Done when - [ ] Scripts, handlers and forms are stripped - [ ] Opening a message emits no network request - [ ] Legacy character sets render correctly - [ ] Attachments never open themselves ### Watch out - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. ## Phase 4 · The conversational view, with the original always one click away A thread reads like a chat without hiding anything the mail actually said. ### Steps 1. Collapse quoted history, never remove it Expand automatically when the remainder is too short to be the message. Files: `src/quoting.ts`, `tests/quoting.test.ts` 2. Keep flags in step in both directions A reader that lies about what is unread is worse than a list. Files: `src/sync/flags.ts` 3. Measure a very long thread Mailing lists exist and they are where naive rendering dies. Files: `tests/perf.test.ts` ### Done when - [ ] Inline replies inside quotes are shown - [ ] Raw source is reachable for every message - [ ] Flags stay in step in both directions - [ ] A two hundred message thread renders promptly ### Watch out - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ## Not in this build - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. ## After v1, if you want it - Server-side search where the provider offers it, falling back to the local index - Rules that file or flag on arrival, running locally over the same threading model - A second account, once a full resync of the first has matched the server for a month ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The single account. Adding a second is a different project. ACCOUNT_ADDRESS=you@example.com # Required. Always over TLS. Extensions are discovered, not assumed. IMAP_HOST=imap.example.com:993 # Required. The provider's submission service. Never a mail server of your own. SUBMISSION_HOST=smtp.example.com:587 # Optional. Your own client, used where the provider requires OAuth rather than an app password. OAUTH_CLIENT_ID=your-own-client-id # Optional · secret. Kept in the keychain. The environment holds a reference, not the value. OAUTH_CLIENT_SECRET=redacted # Required. The cache. Rebuildable from empty, which is the point. DATABASE_PATH=~/.local/share/mailreader/cache.db # Required. How far back to cache. Older mail is fetched on demand. SYNC_WINDOW_DAYS=365 # Required. Off by default. Loading it is a read receipt for the sender. ALLOW_REMOTE_CONTENT=false
You are building a production product version of Spike. 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 ===== # Spike · product brief ## Problem The visible conversational email loop is buildable, but a credible replacement needs more than the first screen. Spike earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A mail reader that can always rebuild itself from the server, threads conversations from the headers that exist for that purpose, renders hostile HTML without telling anyone you opened it, and sends a reply exactly once. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. - sender reputation, deliverability, and suppression handling - provider-grade deliverability - push reliability across devices - team routing and shared inbox rules - abuse response and security operations ## Success criteria - A validity change is confirmed to discard and refetch the folder - Same-subject conversations are confirmed to stay separate - Opening a message is confirmed to emit no network request - A kill mid-send is confirmed to deliver exactly one message - An export is confirmed to open in another mail client ===== BRIEF.md ===== # Build brief · Spike 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 mail reader that shows a thread as a conversation, over one account you already own to replace Spike. 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. ### Rule zero Your local store is a cache and the server holds the mail. Nothing is deleted or expunged on the server that the person did not ask for, a full resync must be able to rebuild the cache from nothing, and no sync bug is ever allowed to be destructive. Mail is the account recovery path for everything else somebody owns. Be honest about the half you cannot own. Send through the provider's own submission service, never a mail server of your own, because deliverability is a reputation built over years and a new sending host lands in spam. And plan for a personal tool: Google requires apps asking for restricted scopes to pass a security assessment by its empanelled assessors and be reverified at least every twelve months, with an explicit exception where you are the only user or the few users are known to you personally (checked), so this is your own client, your own account, and an unverified consent screen you click through knowingly. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and exactly one account · one account is a tool, several accounts is a product - Protocol: IMAP synced by UID, using only the extensions the server advertises · assuming an extension is present is how sync breaks on the second provider - Store: SQLite holding a cache of what the server has, rebuildable from empty · the store is a convenience and the server is the truth - Rendering: Sanitised HTML in a sandboxed view with remote content off until asked · a mail body is untrusted input that wants to phone home - Sending: The provider's own submission service with the credential you already hold · running your own sender means owning deliverability, which you cannot - Secrets: The operating system keychain, never the database and never a log · a token in a SQLite file is a token in a backup ### Data model (create this before Phase 1) - accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human ### Phase 1 · A cache that knows when it has gone stale Build: Store each folder's UID validity value beside every cached message. On opening a folder, compare it: the IMAP specification says that when unique identifiers from an earlier session fail to persist, the validity value must be greater than the one used before (checked), so a change means every cached UID for that folder is meaningless and the folder cache is discarded and refetched. Fetch changes by UID range, never by sequence number, which the same specification says is reassigned when a message is expunged. Trap: Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. Done when: Changing the folder's validity value causes the cached messages for that folder to be discarded and refetched, tested against a server where you can force it. Expunging a message from another client does not corrupt any cached body, tested by expunging and resyncing. Sync uses UID ranges and no code path indexes by sequence number, verified by review. A full resync from an empty database reproduces the same message set as the server. Do not build yet: No threading, rendering, conversational view or sending. ### Phase 2 · Threads assembled from headers Build: Thread on the message identifier, in-reply-to and references headers, walking the references chain to find a root. Fall back to normalised subject only when no identifier links exist, and only within a time window and among overlapping participants. Handle a missing root, a message quoted back with a mangled identifier, and a reply arriving before its parent. Trap: Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. Done when: Two unrelated conversations sharing a subject stay separate, tested with a crafted mailbox. A reply that arrives before its parent joins the correct thread once the parent appears, tested. A message whose references chain is broken still attaches by in-reply-to where present. Thread membership is stable across a full resync, compared before and after. Do not build yet: No rendering, conversational layout or sending yet. ### Phase 3 · Rendering mail that is not on your side Build: Parse MIME properly, decoding transfer encodings and character sets rather than assuming UTF-8. Sanitise HTML to an allowlist of elements and attributes, strip scripts, event handlers, forms and embedded frames, and render in a view with no access to the application. Block remote content by default with a per-message and per-sender option to load it. Attachments are written to a quarantine directory and never opened automatically. Trap: Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. Done when: A message containing a script, an event handler and a form renders with none of them, tested against a crafted corpus. No network request leaves the application when a message with remote images is opened, verified by capturing traffic. A message in a legacy character set renders correctly rather than as replacement characters. An attachment is never executed or previewed by an external handler without an explicit action. Do not build yet: No conversational layout, sending or export yet. ### Phase 4 · The conversational view, with the original always one click away Build: Lay a thread out as a sequence of messages with the quoted history collapsed and signatures folded, showing participants and the arrival time of each. Detect quoted text by structure rather than by looking for a line that starts with an angle bracket. Always offer the full original body and the raw source. Keep read and flag state in step with the server both ways. Trap: Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. Done when: A reply written inline inside the quoted text displays its content rather than an empty bubble, tested with real examples. Collapsed history expands and the raw source is reachable for every message. Marking read in the application is reflected on the server and a change made elsewhere appears after a sync, both tested. A thread of two hundred messages renders without the interface stalling, measured. Do not build yet: No sending, token refresh or export yet. ### Phase 5 · Sending once, staying authenticated, and being able to leave Build: Queue an outgoing message locally, mark it as sending in its own transaction before touching the network, and never retry a send whose outcome is unknown without first checking the sent folder for its identifier. Refresh tokens ahead of expiry and surface an authentication failure as a visible state rather than a silent sync that stops. Export the cache as maildir or mbox with attachments intact, and document that the credential lives in the keychain. Trap: Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. Done when: A kill between submission and recording leaves one delivered message and a queued row that resolves by looking in the sent folder, tested with a fake submission service. An expired token puts the account into a visible failed state within one sync cycle rather than failing quietly, tested by revoking it. Export produces a maildir that another client opens with attachments intact. No credential appears in the database, in an export or in a log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own outbound mail server, since deliverability is a reputation earned over years and a fresh sending host is a spam folder. - Several accounts with unified search, which multiplies every sync edge case by the number of providers and their differing extension support. - Team features such as shared inboxes, assignment and internal notes, which need a server, a permission model and other people's mail. - Push notifications across devices, which need a service you would have to run, keep running and hold credentials for. ### README must contain - That the local store is a cache, is rebuildable from empty, and that nothing is ever deleted on the server without an explicit action - That a changed folder validity value discards that folder's cache, and why sequence numbers are never used - That remote content is blocked by default because loading it is a read receipt, and that sending goes through the provider's submission service rather than a mail server of your own - That restricted scopes require a security assessment and annual reverification unless the app is used only by you or people you know, so this stays a personal client - Where the credential is stored, and how to export everything as maildir before you stop using this ===== ARCHITECTURE.md ===== # Architecture · Spike ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application with a local database and exactly one account | one account is a tool, several accounts is a product | | Protocol | IMAP synced by UID, using only the extensions the server advertises | assuming an extension is present is how sync breaks on the second provider | | Store | SQLite holding a cache of what the server has, rebuildable from empty | the store is a convenience and the server is the truth | | Rendering | Sanitised HTML in a sandboxed view with remote content off until asked | a mail body is untrusted input that wants to phone home | | Sending | The provider's own submission service with the credential you already hold | running your own sender means owning deliverability, which you cannot | | Secrets | The operating system keychain, never the database and never a log | a token in a SQLite file is a token in a backup | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Folder validity, UID ranges, flag propagation. | Any IMAP library, provided sequence numbers stay unused. | | Threading | Reference walking and the gated subject fallback. | Any algorithm, provided same-subject conversations stay separate. | | Rendering | MIME decoding, sanitising, the sandboxed view. | Any sanitiser with an allowlist. Never a denylist. | | Outbox | The send state machine and sent-folder reconciliation. | Not swappable for retry-on-error, which delivers twice. | | Export | Maildir or mbox with attachments intact. | Any standard format another client can open, tested by opening it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `ACCOUNT_ADDRESS` · required · The single account. Adding a second is a different project. - `IMAP_HOST` · required · Always over TLS. Extensions are discovered, not assumed. - `SUBMISSION_HOST` · required · The provider's submission service. Never a mail server of your own. - `OAUTH_CLIENT_ID` · optional · Your own client, used where the provider requires OAuth rather than an app password. - `OAUTH_CLIENT_SECRET` · optional, secret · Kept in the keychain. The environment holds a reference, not the value. - `DATABASE_PATH` · required · The cache. Rebuildable from empty, which is the point. - `SYNC_WINDOW_DAYS` · required · How far back to cache. Older mail is fetched on demand. - `ALLOW_REMOTE_CONTENT` · required · Off by default. Loading it is a read receipt for the sender. ## 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 · Spike product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application with a local database and exactly one account, IMAP synced by UID, using only the extensions the server advertises, SQLite holding a cache of what the server has, rebuildable from empty, Sanitised HTML in a sandboxed view with remote content off until asked, The provider's own submission service with the credential you already hold, The operating system keychain, never the database and never a log. - 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 - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. - Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. ===== MILESTONES.md ===== # Delivery milestones · Spike Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A cache that knows when it has gone stale Sync by UID, and throw the folder away the moment the server says its numbering changed. ### Steps 1. Store validity per folder and compare on open It is two columns and it is the difference between a cache and a corruption. Files: `src/sync/folders.ts`, `tests/uidvalidity.test.ts` 2. Fetch by UID, never by sequence number Sequence numbers are reassigned under you during the session. Files: `src/sync/fetch.ts` 3. Prove a full resync from empty If the cache can always be rebuilt, no sync bug can be fatal. Files: `tests/resync.test.ts` ### Done when - [ ] A validity change discards and refetches the folder - [ ] An external expunge corrupts no cached body - [ ] No code path uses sequence numbers - [ ] A resync from empty matches the server ### Watch out - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. ## M2 · Threads assembled from headers A conversation is built from message identifiers, with subject matching as a last resort. ### Steps 1. Walk references before anything else The headers exist precisely so you do not have to guess. Files: `src/threading.ts`, `tests/threading.test.ts` 2. Gate the subject fallback A time window and shared participants, or not at all. Files: `src/threading.ts` 3. Assert stability across a resync Threads that reshuffle on every sync are threads nobody trusts. Files: `tests/threading.test.ts` ### Done when - [ ] Same-subject conversations stay separate - [ ] Out-of-order replies join correctly - [ ] A broken references chain still threads by in-reply-to - [ ] Threads are stable across a full resync ### Watch out - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. ## M3 · Rendering mail that is not on your side A message body cannot run anything, and opening it tells nobody. ### Steps 1. Decode MIME and character sets properly Mail from 2004 is still mail and still arrives. Files: `src/mime.ts`, `tests/charset.test.ts` 2. Sanitise to an allowlist and sandbox the view A denylist is a list of the attacks you thought of. Files: `src/sanitise.ts`, `tests/sanitise.test.ts` 3. Block remote content and show what was blocked Then let the person load it deliberately, per message or per sender. Files: `src/render.tsx`, `tests/no-beacon.test.ts` ### Done when - [ ] Scripts, handlers and forms are stripped - [ ] Opening a message emits no network request - [ ] Legacy character sets render correctly - [ ] Attachments never open themselves ### Watch out - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. ## M4 · The conversational view, with the original always one click away A thread reads like a chat without hiding anything the mail actually said. ### Steps 1. Collapse quoted history, never remove it Expand automatically when the remainder is too short to be the message. Files: `src/quoting.ts`, `tests/quoting.test.ts` 2. Keep flags in step in both directions A reader that lies about what is unread is worse than a list. Files: `src/sync/flags.ts` 3. Measure a very long thread Mailing lists exist and they are where naive rendering dies. Files: `tests/perf.test.ts` ### Done when - [ ] Inline replies inside quotes are shown - [ ] Raw source is reachable for every message - [ ] Flags stay in step in both directions - [ ] A two hundred message thread renders promptly ### Watch out - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ## M5 · Sending once, staying authenticated, and being able to leave (production only) A reply goes out exactly once, a dead token is obvious, and the mail can walk out in a standard format. ### Steps 1. Lock the outbox row before the network call The same protection a scheduler needs, for the same reason. Files: `src/send.ts`, `tests/exactly-once.test.ts` 2. Make an auth failure a visible state A client that silently stops syncing looks like a quiet week. Files: `src/auth.ts` 3. Export to maildir and test it elsewhere Open it in another client before calling the export done. Files: `src/export.ts`, `tests/export.test.ts` ### Done when - [ ] A kill mid-send delivers exactly one message - [ ] A revoked token produces a visible failed state - [ ] The export opens in another mail client - [ ] No credential reaches the database or a log ### Watch out - Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. ===== OPERATIONS.md ===== # Operations · Spike ## Backup The cache is rebuildable, so the things worth backing up are the account configuration, the keychain entry and any local drafts that have never reached the server. Back up an export rather than the database if you want your own copy of the mail. ## Restore Restore the configuration, unlock the credential, and let a full resync rebuild the cache from the server. If a resync produces a different message set from the server, that is the bug this design exists to make visible. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Account state, the age of the last successful sync per folder, outbox rows that have been sending for too long, and the count of messages whose bodies failed to parse. ## Incident checklist The dangerous incident is silent divergence: a stale folder, a broken token, or a thread assembled wrongly. The validity check, the visible account state and the resync comparison are the three things that turn each of those from invisible into obvious. 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 - [ ] A validity change is confirmed to discard and refetch the folder - [ ] Same-subject conversations are confirmed to stay separate - [ ] Opening a message is confirmed to emit no network request - [ ] A kill mid-send is confirmed to deliver exactly one message - [ ] An export is confirmed to open in another mail client ## Launch constraint Do not market omitted Spike 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. The single account. Adding a second is a different project. ACCOUNT_ADDRESS=you@example.com # Required. Always over TLS. Extensions are discovered, not assumed. IMAP_HOST=imap.example.com:993 # Required. The provider's submission service. Never a mail server of your own. SUBMISSION_HOST=smtp.example.com:587 # Optional. Your own client, used where the provider requires OAuth rather than an app password. OAUTH_CLIENT_ID=your-own-client-id # Optional · secret. Kept in the keychain. The environment holds a reference, not the value. OAUTH_CLIENT_SECRET=redacted # Required. The cache. Rebuildable from empty, which is the point. DATABASE_PATH=~/.local/share/mailreader/cache.db # Required. How far back to cache. Older mail is fetched on demand. SYNC_WINDOW_DAYS=365 # Required. Off by default. Loading it is a read receipt for the sender. ALLOW_REMOTE_CONTENT=false
# Spike · indie build A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. Estimated effort: **multi-day**. 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 | A desktop application with a local database and exactly one account | one account is a tool, several accounts is a product | | Protocol | IMAP synced by UID, using only the extensions the server advertises | assuming an extension is present is how sync breaks on the second provider | | Store | SQLite holding a cache of what the server has, rebuildable from empty | the store is a convenience and the server is the truth | | Rendering | Sanitised HTML in a sandboxed view with remote content off until asked | a mail body is untrusted input that wants to phone home | | Sending | The provider's own submission service with the credential you already hold | running your own sender means owning deliverability, which you cannot | | Secrets | The operating system keychain, never the database and never a log | a token in a SQLite file is a token in a backup | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer with a desktop shell** · free - Why: Runs the application and the local database. - Get it: nodejs.org, plus the desktop framework of your choice. - Verify: A window opens and the local database file is created - [ ] **One mail account you own, with IMAP access** · free - Why: Everything in this build is tested against a real server's behaviour, and providers differ in which extensions they advertise. - Get it: Enable IMAP and create an application password or an OAuth client of your own. - Verify: A client can log in, list folders and read the folder validity value - [ ] **A throwaway mailbox you can break** · free - Why: Validity changes, expunges and double sends have to be tested by actually doing them, and not on the account holding your bank mail. - Get it: A second account on the same provider, seeded with a few hundred messages including a long list thread. - Verify: You can expunge and resync it without caring about the outcome - [ ] **A corpus of awkward mail** · free - Why: Character sets, nested MIME, inline replies and tracking pixels are the actual work, and you cannot write the tests without examples. - Get it: Export a varied folder to maildir, or collect public mailing list archives. - Verify: The corpus includes a legacy charset, a nested multipart, an inline reply and a remote image - [ ] **Your scope, retention and export format** · free - Why: How much mail is cached, what the subject fallback window is, and which export format you commit to, are all things tests assert. - Get it: Write down the sync window, the threading fallback rule and the export format. - Verify: All three appear in the configuration and in the README ## 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: - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. - sender reputation, deliverability, and suppression handling - provider-grade deliverability - push reliability across devices - team routing and shared inbox rules - abuse response and security operations If one of those is essential to you, that is the reason to keep paying for Spike, and the README should say so rather than pretend.
# Build brief · Spike 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 mail reader that shows a thread as a conversation, over one account you already own to replace Spike. 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. ### Rule zero Your local store is a cache and the server holds the mail. Nothing is deleted or expunged on the server that the person did not ask for, a full resync must be able to rebuild the cache from nothing, and no sync bug is ever allowed to be destructive. Mail is the account recovery path for everything else somebody owns. Be honest about the half you cannot own. Send through the provider's own submission service, never a mail server of your own, because deliverability is a reputation built over years and a new sending host lands in spam. And plan for a personal tool: Google requires apps asking for restricted scopes to pass a security assessment by its empanelled assessors and be reverified at least every twelve months, with an explicit exception where you are the only user or the few users are known to you personally (checked), so this is your own client, your own account, and an unverified consent screen you click through knowingly. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and exactly one account · one account is a tool, several accounts is a product - Protocol: IMAP synced by UID, using only the extensions the server advertises · assuming an extension is present is how sync breaks on the second provider - Store: SQLite holding a cache of what the server has, rebuildable from empty · the store is a convenience and the server is the truth - Rendering: Sanitised HTML in a sandboxed view with remote content off until asked · a mail body is untrusted input that wants to phone home - Sending: The provider's own submission service with the credential you already hold · running your own sender means owning deliverability, which you cannot - Secrets: The operating system keychain, never the database and never a log · a token in a SQLite file is a token in a backup ### Data model (create this before Phase 1) - accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human ### Phase 1 · A cache that knows when it has gone stale Build: Store each folder's UID validity value beside every cached message. On opening a folder, compare it: the IMAP specification says that when unique identifiers from an earlier session fail to persist, the validity value must be greater than the one used before (checked), so a change means every cached UID for that folder is meaningless and the folder cache is discarded and refetched. Fetch changes by UID range, never by sequence number, which the same specification says is reassigned when a message is expunged. Trap: Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. Done when: Changing the folder's validity value causes the cached messages for that folder to be discarded and refetched, tested against a server where you can force it. Expunging a message from another client does not corrupt any cached body, tested by expunging and resyncing. Sync uses UID ranges and no code path indexes by sequence number, verified by review. A full resync from an empty database reproduces the same message set as the server. Do not build yet: No threading, rendering, conversational view or sending. ### Phase 2 · Threads assembled from headers Build: Thread on the message identifier, in-reply-to and references headers, walking the references chain to find a root. Fall back to normalised subject only when no identifier links exist, and only within a time window and among overlapping participants. Handle a missing root, a message quoted back with a mangled identifier, and a reply arriving before its parent. Trap: Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. Done when: Two unrelated conversations sharing a subject stay separate, tested with a crafted mailbox. A reply that arrives before its parent joins the correct thread once the parent appears, tested. A message whose references chain is broken still attaches by in-reply-to where present. Thread membership is stable across a full resync, compared before and after. Do not build yet: No rendering, conversational layout or sending yet. ### Phase 3 · Rendering mail that is not on your side Build: Parse MIME properly, decoding transfer encodings and character sets rather than assuming UTF-8. Sanitise HTML to an allowlist of elements and attributes, strip scripts, event handlers, forms and embedded frames, and render in a view with no access to the application. Block remote content by default with a per-message and per-sender option to load it. Attachments are written to a quarantine directory and never opened automatically. Trap: Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. Done when: A message containing a script, an event handler and a form renders with none of them, tested against a crafted corpus. No network request leaves the application when a message with remote images is opened, verified by capturing traffic. A message in a legacy character set renders correctly rather than as replacement characters. An attachment is never executed or previewed by an external handler without an explicit action. Do not build yet: No conversational layout, sending or export yet. ### Phase 4 · The conversational view, with the original always one click away Build: Lay a thread out as a sequence of messages with the quoted history collapsed and signatures folded, showing participants and the arrival time of each. Detect quoted text by structure rather than by looking for a line that starts with an angle bracket. Always offer the full original body and the raw source. Keep read and flag state in step with the server both ways. Trap: Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. Done when: A reply written inline inside the quoted text displays its content rather than an empty bubble, tested with real examples. Collapsed history expands and the raw source is reachable for every message. Marking read in the application is reflected on the server and a change made elsewhere appears after a sync, both tested. A thread of two hundred messages renders without the interface stalling, measured. Do not build yet: No sending, token refresh or export yet. ### Phase 5 · Sending once, staying authenticated, and being able to leave Build: Queue an outgoing message locally, mark it as sending in its own transaction before touching the network, and never retry a send whose outcome is unknown without first checking the sent folder for its identifier. Refresh tokens ahead of expiry and surface an authentication failure as a visible state rather than a silent sync that stops. Export the cache as maildir or mbox with attachments intact, and document that the credential lives in the keychain. Trap: Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. Done when: A kill between submission and recording leaves one delivered message and a queued row that resolves by looking in the sent folder, tested with a fake submission service. An expired token puts the account into a visible failed state within one sync cycle rather than failing quietly, tested by revoking it. Export produces a maildir that another client opens with attachments intact. No credential appears in the database, in an export or in a log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own outbound mail server, since deliverability is a reputation earned over years and a fresh sending host is a spam folder. - Several accounts with unified search, which multiplies every sync edge case by the number of providers and their differing extension support. - Team features such as shared inboxes, assignment and internal notes, which need a server, a permission model and other people's mail. - Push notifications across devices, which need a service you would have to run, keep running and hold credentials for. ### README must contain - That the local store is a cache, is rebuildable from empty, and that nothing is ever deleted on the server without an explicit action - That a changed folder validity value discards that folder's cache, and why sequence numbers are never used - That remote content is blocked by default because loading it is a read receipt, and that sending goes through the provider's submission service rather than a mail server of your own - That restricted scopes require a security assessment and annual reverification unless the app is used only by you or people you know, so this stays a personal client - Where the credential is stored, and how to export everything as maildir before you stop using this
# Agent instructions · Spike indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and exactly one account, IMAP synced by UID, using only the extensions the server advertises, SQLite holding a cache of what the server has, rebuildable from empty, Sanitised HTML in a sandboxed view with remote content off until asked, The provider's own submission service with the credential you already hold, The operating system keychain, never the database and never a log. 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 - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable.
# Build plan · Spike A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it. 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 cache that knows when it has gone stale Sync by UID, and throw the folder away the moment the server says its numbering changed. ### Steps 1. Store validity per folder and compare on open It is two columns and it is the difference between a cache and a corruption. Files: `src/sync/folders.ts`, `tests/uidvalidity.test.ts` 2. Fetch by UID, never by sequence number Sequence numbers are reassigned under you during the session. Files: `src/sync/fetch.ts` 3. Prove a full resync from empty If the cache can always be rebuilt, no sync bug can be fatal. Files: `tests/resync.test.ts` ### Done when - [ ] A validity change discards and refetches the folder - [ ] An external expunge corrupts no cached body - [ ] No code path uses sequence numbers - [ ] A resync from empty matches the server ### Watch out - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. ## Phase 2 · Threads assembled from headers A conversation is built from message identifiers, with subject matching as a last resort. ### Steps 1. Walk references before anything else The headers exist precisely so you do not have to guess. Files: `src/threading.ts`, `tests/threading.test.ts` 2. Gate the subject fallback A time window and shared participants, or not at all. Files: `src/threading.ts` 3. Assert stability across a resync Threads that reshuffle on every sync are threads nobody trusts. Files: `tests/threading.test.ts` ### Done when - [ ] Same-subject conversations stay separate - [ ] Out-of-order replies join correctly - [ ] A broken references chain still threads by in-reply-to - [ ] Threads are stable across a full resync ### Watch out - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. ## Phase 3 · Rendering mail that is not on your side A message body cannot run anything, and opening it tells nobody. ### Steps 1. Decode MIME and character sets properly Mail from 2004 is still mail and still arrives. Files: `src/mime.ts`, `tests/charset.test.ts` 2. Sanitise to an allowlist and sandbox the view A denylist is a list of the attacks you thought of. Files: `src/sanitise.ts`, `tests/sanitise.test.ts` 3. Block remote content and show what was blocked Then let the person load it deliberately, per message or per sender. Files: `src/render.tsx`, `tests/no-beacon.test.ts` ### Done when - [ ] Scripts, handlers and forms are stripped - [ ] Opening a message emits no network request - [ ] Legacy character sets render correctly - [ ] Attachments never open themselves ### Watch out - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. ## Phase 4 · The conversational view, with the original always one click away A thread reads like a chat without hiding anything the mail actually said. ### Steps 1. Collapse quoted history, never remove it Expand automatically when the remainder is too short to be the message. Files: `src/quoting.ts`, `tests/quoting.test.ts` 2. Keep flags in step in both directions A reader that lies about what is unread is worse than a list. Files: `src/sync/flags.ts` 3. Measure a very long thread Mailing lists exist and they are where naive rendering dies. Files: `tests/perf.test.ts` ### Done when - [ ] Inline replies inside quotes are shown - [ ] Raw source is reachable for every message - [ ] Flags stay in step in both directions - [ ] A two hundred message thread renders promptly ### Watch out - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ## Not in this build - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. ## After v1, if you want it - Server-side search where the provider offers it, falling back to the local index - Rules that file or flag on arrival, running locally over the same threading model - A second account, once a full resync of the first has matched the server for a month
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The single account. Adding a second is a different project. ACCOUNT_ADDRESS=you@example.com # Required. Always over TLS. Extensions are discovered, not assumed. IMAP_HOST=imap.example.com:993 # Required. The provider's submission service. Never a mail server of your own. SUBMISSION_HOST=smtp.example.com:587 # Optional. Your own client, used where the provider requires OAuth rather than an app password. OAUTH_CLIENT_ID=your-own-client-id # Optional · secret. Kept in the keychain. The environment holds a reference, not the value. OAUTH_CLIENT_SECRET=redacted # Required. The cache. Rebuildable from empty, which is the point. DATABASE_PATH=~/.local/share/mailreader/cache.db # Required. How far back to cache. Older mail is fetched on demand. SYNC_WINDOW_DAYS=365 # Required. Off by default. Loading it is a read receipt for the sender. ALLOW_REMOTE_CONTENT=false
# Spike · product brief ## Problem The visible conversational email loop is buildable, but a credible replacement needs more than the first screen. Spike earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A mail reader that can always rebuild itself from the server, threads conversations from the headers that exist for that purpose, renders hostile HTML without telling anyone you opened it, and sends a reply exactly once. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - Your own outbound mail server. - Several accounts and unified search. - Shared inboxes and team routing. - Chat, meetings and shared notes. - sender reputation, deliverability, and suppression handling - provider-grade deliverability - push reliability across devices - team routing and shared inbox rules - abuse response and security operations ## Success criteria - A validity change is confirmed to discard and refetch the folder - Same-subject conversations are confirmed to stay separate - Opening a message is confirmed to emit no network request - A kill mid-send is confirmed to deliver exactly one message - An export is confirmed to open in another mail client
# Build brief · Spike 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 mail reader that shows a thread as a conversation, over one account you already own to replace Spike. 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. ### Rule zero Your local store is a cache and the server holds the mail. Nothing is deleted or expunged on the server that the person did not ask for, a full resync must be able to rebuild the cache from nothing, and no sync bug is ever allowed to be destructive. Mail is the account recovery path for everything else somebody owns. Be honest about the half you cannot own. Send through the provider's own submission service, never a mail server of your own, because deliverability is a reputation built over years and a new sending host lands in spam. And plan for a personal tool: Google requires apps asking for restricted scopes to pass a security assessment by its empanelled assessors and be reverified at least every twelve months, with an explicit exception where you are the only user or the few users are known to you personally (checked), so this is your own client, your own account, and an unverified consent screen you click through knowingly. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and exactly one account · one account is a tool, several accounts is a product - Protocol: IMAP synced by UID, using only the extensions the server advertises · assuming an extension is present is how sync breaks on the second provider - Store: SQLite holding a cache of what the server has, rebuildable from empty · the store is a convenience and the server is the truth - Rendering: Sanitised HTML in a sandboxed view with remote content off until asked · a mail body is untrusted input that wants to phone home - Sending: The provider's own submission service with the credential you already hold · running your own sender means owning deliverability, which you cannot - Secrets: The operating system keychain, never the database and never a log · a token in a SQLite file is a token in a backup ### Data model (create this before Phase 1) - accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human ### Phase 1 · A cache that knows when it has gone stale Build: Store each folder's UID validity value beside every cached message. On opening a folder, compare it: the IMAP specification says that when unique identifiers from an earlier session fail to persist, the validity value must be greater than the one used before (checked), so a change means every cached UID for that folder is meaningless and the folder cache is discarded and refetched. Fetch changes by UID range, never by sequence number, which the same specification says is reassigned when a message is expunged. Trap: Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. Done when: Changing the folder's validity value causes the cached messages for that folder to be discarded and refetched, tested against a server where you can force it. Expunging a message from another client does not corrupt any cached body, tested by expunging and resyncing. Sync uses UID ranges and no code path indexes by sequence number, verified by review. A full resync from an empty database reproduces the same message set as the server. Do not build yet: No threading, rendering, conversational view or sending. ### Phase 2 · Threads assembled from headers Build: Thread on the message identifier, in-reply-to and references headers, walking the references chain to find a root. Fall back to normalised subject only when no identifier links exist, and only within a time window and among overlapping participants. Handle a missing root, a message quoted back with a mangled identifier, and a reply arriving before its parent. Trap: Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. Done when: Two unrelated conversations sharing a subject stay separate, tested with a crafted mailbox. A reply that arrives before its parent joins the correct thread once the parent appears, tested. A message whose references chain is broken still attaches by in-reply-to where present. Thread membership is stable across a full resync, compared before and after. Do not build yet: No rendering, conversational layout or sending yet. ### Phase 3 · Rendering mail that is not on your side Build: Parse MIME properly, decoding transfer encodings and character sets rather than assuming UTF-8. Sanitise HTML to an allowlist of elements and attributes, strip scripts, event handlers, forms and embedded frames, and render in a view with no access to the application. Block remote content by default with a per-message and per-sender option to load it. Attachments are written to a quarantine directory and never opened automatically. Trap: Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. Done when: A message containing a script, an event handler and a form renders with none of them, tested against a crafted corpus. No network request leaves the application when a message with remote images is opened, verified by capturing traffic. A message in a legacy character set renders correctly rather than as replacement characters. An attachment is never executed or previewed by an external handler without an explicit action. Do not build yet: No conversational layout, sending or export yet. ### Phase 4 · The conversational view, with the original always one click away Build: Lay a thread out as a sequence of messages with the quoted history collapsed and signatures folded, showing participants and the arrival time of each. Detect quoted text by structure rather than by looking for a line that starts with an angle bracket. Always offer the full original body and the raw source. Keep read and flag state in step with the server both ways. Trap: Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. Done when: A reply written inline inside the quoted text displays its content rather than an empty bubble, tested with real examples. Collapsed history expands and the raw source is reachable for every message. Marking read in the application is reflected on the server and a change made elsewhere appears after a sync, both tested. A thread of two hundred messages renders without the interface stalling, measured. Do not build yet: No sending, token refresh or export yet. ### Phase 5 · Sending once, staying authenticated, and being able to leave Build: Queue an outgoing message locally, mark it as sending in its own transaction before touching the network, and never retry a send whose outcome is unknown without first checking the sent folder for its identifier. Refresh tokens ahead of expiry and surface an authentication failure as a visible state rather than a silent sync that stops. Export the cache as maildir or mbox with attachments intact, and document that the credential lives in the keychain. Trap: Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again. Done when: A kill between submission and recording leaves one delivered message and a queued row that resolves by looking in the sent folder, tested with a fake submission service. An expired token puts the account into a visible failed state within one sync cycle rather than failing quietly, tested by revoking it. Export produces a maildir that another client opens with attachments intact. No credential appears in the database, in an export or in a log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own outbound mail server, since deliverability is a reputation earned over years and a fresh sending host is a spam folder. - Several accounts with unified search, which multiplies every sync edge case by the number of providers and their differing extension support. - Team features such as shared inboxes, assignment and internal notes, which need a server, a permission model and other people's mail. - Push notifications across devices, which need a service you would have to run, keep running and hold credentials for. ### README must contain - That the local store is a cache, is rebuildable from empty, and that nothing is ever deleted on the server without an explicit action - That a changed folder validity value discards that folder's cache, and why sequence numbers are never used - That remote content is blocked by default because loading it is a read receipt, and that sending goes through the provider's submission service rather than a mail server of your own - That restricted scopes require a security assessment and annual reverification unless the app is used only by you or people you know, so this stays a personal client - Where the credential is stored, and how to export everything as maildir before you stop using this
# Architecture · Spike ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application with a local database and exactly one account | one account is a tool, several accounts is a product | | Protocol | IMAP synced by UID, using only the extensions the server advertises | assuming an extension is present is how sync breaks on the second provider | | Store | SQLite holding a cache of what the server has, rebuildable from empty | the store is a convenience and the server is the truth | | Rendering | Sanitised HTML in a sandboxed view with remote content off until asked | a mail body is untrusted input that wants to phone home | | Sending | The provider's own submission service with the credential you already hold | running your own sender means owning deliverability, which you cannot | | Secrets | The operating system keychain, never the database and never a log | a token in a SQLite file is a token in a backup | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Folder validity, UID ranges, flag propagation. | Any IMAP library, provided sequence numbers stay unused. | | Threading | Reference walking and the gated subject fallback. | Any algorithm, provided same-subject conversations stay separate. | | Rendering | MIME decoding, sanitising, the sandboxed view. | Any sanitiser with an allowlist. Never a denylist. | | Outbox | The send state machine and sent-folder reconciliation. | Not swappable for retry-on-error, which delivers twice. | | Export | Maildir or mbox with attachments intact. | Any standard format another client can open, tested by opening it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `ACCOUNT_ADDRESS` · required · The single account. Adding a second is a different project. - `IMAP_HOST` · required · Always over TLS. Extensions are discovered, not assumed. - `SUBMISSION_HOST` · required · The provider's submission service. Never a mail server of your own. - `OAUTH_CLIENT_ID` · optional · Your own client, used where the provider requires OAuth rather than an app password. - `OAUTH_CLIENT_SECRET` · optional, secret · Kept in the keychain. The environment holds a reference, not the value. - `DATABASE_PATH` · required · The cache. Rebuildable from empty, which is the point. - `SYNC_WINDOW_DAYS` · required · How far back to cache. Older mail is fetched on demand. - `ALLOW_REMOTE_CONTENT` · required · Off by default. Loading it is a read receipt for the sender. ## 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 · Spike product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application with a local database and exactly one account, IMAP synced by UID, using only the extensions the server advertises, SQLite holding a cache of what the server has, rebuildable from empty, Sanitised HTML in a sandboxed view with remote content off until asked, The provider's own submission service with the credential you already hold, The operating system keychain, never the database and never a log. - 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 - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. - Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again.
# Delivery milestones · Spike Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A cache that knows when it has gone stale Sync by UID, and throw the folder away the moment the server says its numbering changed. ### Steps 1. Store validity per folder and compare on open It is two columns and it is the difference between a cache and a corruption. Files: `src/sync/folders.ts`, `tests/uidvalidity.test.ts` 2. Fetch by UID, never by sequence number Sequence numbers are reassigned under you during the session. Files: `src/sync/fetch.ts` 3. Prove a full resync from empty If the cache can always be rebuilt, no sync bug can be fatal. Files: `tests/resync.test.ts` ### Done when - [ ] A validity change discards and refetches the folder - [ ] An external expunge corrupts no cached body - [ ] No code path uses sequence numbers - [ ] A resync from empty matches the server ### Watch out - Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it. ## M2 · Threads assembled from headers A conversation is built from message identifiers, with subject matching as a last resort. ### Steps 1. Walk references before anything else The headers exist precisely so you do not have to guess. Files: `src/threading.ts`, `tests/threading.test.ts` 2. Gate the subject fallback A time window and shared participants, or not at all. Files: `src/threading.ts` 3. Assert stability across a resync Threads that reshuffle on every sync are threads nobody trusts. Files: `tests/threading.test.ts` ### Done when - [ ] Same-subject conversations stay separate - [ ] Out-of-order replies join correctly - [ ] A broken references chain still threads by in-reply-to - [ ] Threads are stable across a full resync ### Watch out - Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants. ## M3 · Rendering mail that is not on your side A message body cannot run anything, and opening it tells nobody. ### Steps 1. Decode MIME and character sets properly Mail from 2004 is still mail and still arrives. Files: `src/mime.ts`, `tests/charset.test.ts` 2. Sanitise to an allowlist and sandbox the view A denylist is a list of the attacks you thought of. Files: `src/sanitise.ts`, `tests/sanitise.test.ts` 3. Block remote content and show what was blocked Then let the person load it deliberately, per message or per sender. Files: `src/render.tsx`, `tests/no-beacon.test.ts` ### Done when - [ ] Scripts, handlers and forms are stripped - [ ] Opening a message emits no network request - [ ] Legacy character sets render correctly - [ ] Attachments never open themselves ### Watch out - Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked. ## M4 · The conversational view, with the original always one click away A thread reads like a chat without hiding anything the mail actually said. ### Steps 1. Collapse quoted history, never remove it Expand automatically when the remainder is too short to be the message. Files: `src/quoting.ts`, `tests/quoting.test.ts` 2. Keep flags in step in both directions A reader that lies about what is unread is worse than a list. Files: `src/sync/flags.ts` 3. Measure a very long thread Mailing lists exist and they are where naive rendering dies. Files: `tests/perf.test.ts` ### Done when - [ ] Inline replies inside quotes are shown - [ ] Raw source is reachable for every message - [ ] Flags stay in step in both directions - [ ] A two hundred message thread renders promptly ### Watch out - Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable. ## M5 · Sending once, staying authenticated, and being able to leave (production only) A reply goes out exactly once, a dead token is obvious, and the mail can walk out in a standard format. ### Steps 1. Lock the outbox row before the network call The same protection a scheduler needs, for the same reason. Files: `src/send.ts`, `tests/exactly-once.test.ts` 2. Make an auth failure a visible state A client that silently stops syncing looks like a quiet week. Files: `src/auth.ts` 3. Export to maildir and test it elsewhere Open it in another client before calling the export done. Files: `src/export.ts`, `tests/export.test.ts` ### Done when - [ ] A kill mid-send delivers exactly one message - [ ] A revoked token produces a visible failed state - [ ] The export opens in another mail client - [ ] No credential reaches the database or a log ### Watch out - Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again.
# Operations · Spike ## Backup The cache is rebuildable, so the things worth backing up are the account configuration, the keychain entry and any local drafts that have never reached the server. Back up an export rather than the database if you want your own copy of the mail. ## Restore Restore the configuration, unlock the credential, and let a full resync rebuild the cache from the server. If a resync produces a different message set from the server, that is the bug this design exists to make visible. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Account state, the age of the last successful sync per folder, outbox rows that have been sending for too long, and the count of messages whose bodies failed to parse. ## Incident checklist The dangerous incident is silent divergence: a stale folder, a broken token, or a thread assembled wrongly. The validity check, the visible account state and the resync comparison are the three things that turn each of those from invisible into obvious. 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 - [ ] A validity change is confirmed to discard and refetch the folder - [ ] Same-subject conversations are confirmed to stay separate - [ ] Opening a message is confirmed to emit no network request - [ ] A kill mid-send is confirmed to deliver exactly one message - [ ] An export is confirmed to open in another mail client ## Launch constraint Do not market omitted Spike 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. The single account. Adding a second is a different project. ACCOUNT_ADDRESS=you@example.com # Required. Always over TLS. Extensions are discovered, not assumed. IMAP_HOST=imap.example.com:993 # Required. The provider's submission service. Never a mail server of your own. SUBMISSION_HOST=smtp.example.com:587 # Optional. Your own client, used where the provider requires OAuth rather than an app password. OAUTH_CLIENT_ID=your-own-client-id # Optional · secret. Kept in the keychain. The environment holds a reference, not the value. OAUTH_CLIENT_SECRET=redacted # Required. The cache. Rebuildable from empty, which is the point. DATABASE_PATH=~/.local/share/mailreader/cache.db # Required. How far back to cache. Older mail is fetched on demand. SYNC_WINDOW_DAYS=365 # Required. Off by default. Loading it is a read receipt for the sender. ALLOW_REMOTE_CONTENT=false
$ choose a build depth, inspect the files, then open the complete pack in your agent
Spike: Email is infrastructure. Customers pay to avoid lost messages, broken auth, spam problems, and the reputational cost of a flaky inbox.
xsender reputation, deliverability, and suppression handling
xprovider-grade deliverability
xpush reliability across devices
xteam routing and shared inbox rules
xabuse response and security operations
Spike pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| teamspace starter | $0/workspace | $0/workspace | Up to 3 members, 1 teamspace, free email address, 60 days search, 15 GB storage/member, 15 MB file uploads, 10 AI queries. |
| teamspace team | — | $4/user | 100 GB storage/member, 100 MB file uploads, unlimited search history, shared inbox; annual plan includes custom domain free for 1 year. |
| teamspace business | — | $8/user | 1 TB storage/member, 500 MB file uploads, unlimited search history, 50 video participants; SAML listed as coming soon. |
| email app free | $0/user | $0/user | 1 email address, 1 GB storage, 30 MB file uploads, 60 days search, 1:1 calls, 10 AI queries. |
| email app pro | — | $6/user | Up to 3 email addresses, 5 participants, 5 GB storage, 100 MB file uploads, unlimited search. |
| email app ultimate | — | $12/user | Unlimited email addresses, 10 participants, 20 GB storage, 1 GB file uploads. |
free tierTeamspace Starter: 3 members, 1 teamspace, 60-day search, 15 GB/member, 15 MB uploads, 10 AI queries; Email Free: 1 email address, 1 GB storage, 30 MB uploads, 60-day search, 10 AI queries.
billingannual public pricing only; subscriptions auto-renew until canceled
hidden costsMonthly USD prices were not exposed on the official page; Teamspace storage/file limits differ from Email App limits.
verified 2026-08-10 · source ↗
Vibecode Spike
Kinda. The core of Spike is buildable in a weekend with the prompt on this page, but there are real gaps: sender reputation, deliverability, and suppression handling, provider-grade deliverability. Read the honest list above before committing.
How much does Spike cost?
Spike's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Spike?
Honestly: sender reputation, deliverability, and suppression handling; provider-grade deliverability; push reliability across devices; team routing and shared inbox rules; abuse response and security operations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Spike?
Yes: Mailspring (Open-source desktop mail client and useful IMAP prior art.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.