Vibecode Airmail
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Airmail, create a configurable Apple email client with actions, snooze, and send later. The hard boundary is native apps, provider quirks, automation, push, and years of compatibility work, plus mail infrastructure, integrations, and client polish.
You are building a lean indie version of Airmail. 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 ===== # Airmail · indie build A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a desktop client with keychain access and a real webview to sandbox | | Store | SQLite, every message row keyed on mailbox, UIDVALIDITY and UID | the pair is the identity the protocol actually guarantees | | Protocol | IMAP for reading with SMTP for sending, OAuth where the provider requires it | you are a client of a mailbox somebody else operates | | Credentials | The operating system keychain, never the database and never a config file | a database file copied to a backup should not contain a password | | Rendering | A sandboxed webview with no network and no scripting, remote content blocked by default | HTML mail is untrusted input written by strangers | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **The Rust toolchain and Node.js 22** · free - Why: Tauri needs both, and keychain access plus the IMAP client sit on the Rust side. - Get it: rustup.rs and nodejs.org. - Verify: cargo --version and node --version print, and a Tauri dev build launches - [ ] **A real IMAP and SMTP mailbox you can break** · free to a few pounds a month - Why: Every failure mode here is a server behaviour, and testing against a mailbox you depend on means the first renumbering test is performed on your own mail. - Get it: A secondary account at any provider, or a self-hosted mailbox with a few thousand messages in it. - Verify: An IMAP login lists mailboxes and an SMTP send succeeds - [ ] **OAuth client credentials where your provider requires them** (optional) · free - Why: Several large providers no longer accept a password for IMAP, and their consent configuration also decides how often your refresh token expires. - Get it: Create an OAuth client in the provider's console with the mail scopes it documents. - Verify: A consent round trip returns a refresh token and an IMAP login using it succeeds - [ ] **A scriptable IMAP server for tests** · free - Why: The UIDVALIDITY discard path, the interrupted resume and the renumbering case are impossible to test reliably against a real provider. - Get it: Any test IMAP server you can drive from the suite, or a small fake implementing the commands you use. - Verify: A test can force a UIDVALIDITY change and the client discards its cache - [ ] **A mailbox of at least fifty thousand messages** · free - Why: Sync time, search time and threading behaviour are the claims this build makes, and none of them are credible at a thousand messages. - Get it: An archive you already have, imported into the test mailbox. - Verify: A full sync completes and both times are recorded 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: - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. - native apps, provider quirks, automation, push, and years of compatibility work - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Airmail, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Airmail 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 desktop mail client that keeps a local copy of your mailbox and can throw it away and rebuild it without losing anything you wrote to replace Airmail. 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 Two rules, and both are about not losing things. Your local store is a cache and the server is the truth. IMAP identifies a message by the pair of the mailbox's UIDVALIDITY and the message's UID, and the specification is precise about it: a UID must not change during a session and should not change between sessions, UIDs are assigned strictly ascending within a mailbox, an expunged UID must not be reused under the same UIDVALIDITY, and a server that cannot keep UIDs stable must give the mailbox a higher UIDVALIDITY than before. So a changed UIDVALIDITY means every cached UID for that mailbox is meaningless and the correct response is to discard and resynchronise, never to merge. Key every local row on the pair, not on the UID. Second, never lose something the user wrote. Drafts are persisted locally before they are ever sent, on a timer and on every focus change, and a crash mid-compose loses at most the last few seconds. Deleting the local cache must never delete a draft that has not reached the server. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with React and TypeScript · a desktop client with keychain access and a real webview to sandbox - Store: SQLite, every message row keyed on mailbox, UIDVALIDITY and UID · the pair is the identity the protocol actually guarantees - Protocol: IMAP for reading with SMTP for sending, OAuth where the provider requires it · you are a client of a mailbox somebody else operates - Credentials: The operating system keychain, never the database and never a config file · a database file copied to a backup should not contain a password - Rendering: A sandboxed webview with no network and no scripting, remote content blocked by default · HTML mail is untrusted input written by strangers ### Data model (create this before Phase 1) - accounts(id, address, imap_host, imap_port, smtp_host, smtp_port, auth_kind, credential_ref, state) - mailboxes(account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is part of the identity of everything below - messages(account_id, mailbox, uidvalidity, uid, message_id_header, thread_id, internal_date, flags, envelope_json, body_ref) · primary key includes uidvalidity - bodies(sha256, path, bytes) · the raw message on disk, stored once, so a reindex never needs a refetch - drafts(id, account_id, local_updated_at, subject, to_json, body, attachments_json, server_ref) · written locally before anything is sent - outbox(draft_id, state, send_after, attempts, smtp_response, sent_at) · state is queued, sending, sent or failed - snoozes(account_id, mailbox, uidvalidity, uid, wake_at, moved_to) · a snooze is a move on the server plus a wake time held here ### Phase 1 · A cache that renumbering cannot corrupt Build: Connect one account, list mailboxes, and synchronise messages in descending UID order so the newest arrive first. Store the raw message on disk keyed by hash and the metadata in SQLite, keyed on mailbox, UIDVALIDITY and UID. On connect, compare the stored UIDVALIDITY: if it differs, drop every cached row for that mailbox and resynchronise from scratch. Trap: The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. Done when: Changing the stored UIDVALIDITY for a mailbox and reconnecting drops the cache and resynchronises, tested against a fake server that renumbers. A synchronisation interrupted at any point resumes without refetching messages already stored, tested by killing it mid-run. Every message row is keyed on the mailbox and UIDVALIDITY pair, enforced by the primary key. Raw messages are stored once by hash and a second copy of the same message adds no bytes. Do not build yet: No threading, search, rendering, composing or sending. ### Phase 2 · Threads and search over what you already have Build: Thread on the Message-ID, In-Reply-To and References headers, falling back to nothing rather than to subject matching. Build a local full-text index over headers and extracted text bodies. Support searching by sender, date range, mailbox, attachment presence and free text, all offline. Trap: Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. Done when: Two unrelated messages sharing a subject are not threaded, tested with a crafted pair. A reply chain with intact References headers threads in the right order including a missing middle message. Search returns results with the network disconnected, asserted by running the suite offline. Search over at least fifty thousand messages returns within a time you have measured and stated in the README. Do not build yet: No rendering, composing, sending or snoozing yet. ### Phase 3 · Rendering mail that would rather you did not Build: Render HTML in a sandboxed webview with scripting disabled, no network permission, and a content policy that blocks every remote origin. Rewrite remote images to placeholders with a per-message and per-sender allow action. Strip anything that can navigate or submit. Show the real destination of every link, and warn when the link text is an address that differs from it. Trap: Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. Done when: Rendering a message containing remote images, a remote stylesheet, a remote font and a background image produces zero outbound requests, asserted by a network monitor in the test. Scripting is disabled in the render surface and a message containing a script produces no execution, tested. Allowing remote content is per message or per sender and is recorded. A link whose visible text differs from its destination is flagged in the interface. Do not build yet: No composing, sending, snoozing or export yet. ### Phase 4 · Composing, drafts and an outbox that admits what it is Build: Persist a draft locally on a timer and on every focus change before any network use. Build multipart messages with attachments and correct headers. Send through SMTP and append the sent message to the server's sent mailbox. Implement send-later as a local queue, and state plainly in the interface that a queued message goes out when the application next runs. Trap: Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. Done when: Killing the application mid-compose loses at most the last autosave interval, tested by killing the process. A sent message is appended to the server's sent mailbox and appears there after a resynchronisation. A queued send-later message is visible in the outbox with wording stating it needs the app running, verified in the interface. Clearing the local cache leaves unsent drafts and the outbox intact, tested. Do not build yet: No snoozing, keychain migration or export yet. ### Phase 5 · Snooze, keychain, export and the reset button Build: Implement snooze as a real move to a server mailbox plus a local wake time, so the message is somewhere sensible even if this app never runs again. Keep credentials only in the operating system keychain. Export any mailbox as mbox and the whole account as maildir. Provide a cache reset that rebuilds from the server, and a diagnostics page with protocol logs that redact credentials and message contents. Trap: A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. Done when: A snoozed message is visible in a named mailbox from a different mail client, checked directly. Credentials exist only in the keychain, and a scan of the database, config files and logs for a canary password finds nothing. Cache reset deletes and rebuilds the local store while drafts, outbox and snooze wake times survive, tested. Export produces a valid mbox file that another client imports, and the diagnostics log shows protocol commands with credentials and bodies redacted. Do not build yet: Nothing after this. ### Out of scope (and why) - Running a mail server, which is deliverability and abuse handling as a full-time occupation rather than a feature. - Push notifications on every platform, which needs a hosted service holding a connection to your mailbox on your behalf. - Shared team inboxes and assignment workflows, which add identity, permissions and a server you operate. - Provider-scale spam filtering, which depends on seeing a volume of mail that a single-user client never will. - Automatic rules that delete mail, because a client whose cache is a truth claim should not be the thing removing messages. ### README must contain - That the local store is a cache keyed on mailbox, UIDVALIDITY and UID, and that a changed UIDVALIDITY discards and resynchronises - That threading uses the reference headers only, and why subject threading is not offered - That rendering blocks every remote origin, not only images, and that the test asserts zero outbound requests - That send-later and snooze wake times need the application to be running, and that a snooze is a real move on the server - That credentials live only in the operating system keychain, with the measured search and sync times on a mailbox of at least fifty thousand messages ===== AGENTS.md ===== # Agent instructions · Airmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, every message row keyed on mailbox, UIDVALIDITY and UID, IMAP for reading with SMTP for sending, OAuth where the provider requires it, The operating system keychain, never the database and never a config file, A sandboxed webview with no network and no scripting, remote content blocked by default. 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 - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ===== BUILD_PLAN.md ===== # Build plan · Airmail A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 renumbering cannot corrupt One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly. ### Steps 1. Put UIDVALIDITY in the primary key This is the whole defence against a renumbered mailbox. Files: `src/db/schema.sql`, `src-tauri/src/imap/sync.rs` 2. Test the discard path against a renumbering fake An untested recovery path is a rumour. Files: `tests/uidvalidity.test.ts` 3. Store raw bodies by hash, metadata in the database So a schema change is a reindex rather than a refetch. Files: `src-tauri/src/store/bodies.rs` ### Done when - [ ] A changed UIDVALIDITY drops and resynchronises the mailbox - [ ] An interrupted sync resumes without refetching - [ ] Message rows are keyed on the mailbox and UIDVALIDITY pair - [ ] Duplicate raw messages are stored once ### Watch out - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. ## Phase 2 · Threads and search over what you already have Conversations group correctly offline and search is fast on a real mailbox. ### Steps 1. Thread on reference headers only Subject threading is why old clients show hundred-message threads. Files: `src/threading/build.ts`, `tests/threading.test.ts` 2. Index headers and extracted text locally Offline search is most of why a local cache exists. Files: `src/db/schema.sql`, `src/search/index.ts` 3. Measure search on a real-sized mailbox Fifty messages proves nothing about fifty thousand. Files: `tests/search-perf.test.ts` ### Done when - [ ] Same-subject strangers are not threaded - [ ] A chain with a missing middle still threads in order - [ ] Search works with the network disconnected - [ ] Search time at fifty thousand messages is measured and stated ### Watch out - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. ## Phase 3 · Rendering mail that would rather you did not An HTML message cannot run code, load a tracker, or reach the network without your say-so. ### Steps 1. Block every remote origin with a content policy Not just images. A font request is the same signal. Files: `src/render/sandbox.ts`, `tests/no-network.test.ts` 2. Disable scripting and navigation in the render surface Mail is untrusted input written by people who want things. Files: `src-tauri/tauri.conf.json`, `src/render/sandbox.ts` 3. Flag links whose text disagrees with their target The oldest trick, and still the most effective. Files: `src/render/links.ts` ### Done when - [ ] A message with remote references makes zero requests - [ ] Scripting in a message never executes - [ ] Allowing remote content is per message or sender and recorded - [ ] Mismatched link text is flagged ### Watch out - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. ## Phase 4 · Composing, drafts and an outbox that admits what it is Nothing you type is lost, and send-later is described honestly. ### Steps 1. Autosave drafts before anything else The one loss a mail client is never forgiven for. Files: `src/compose/autosave.ts`, `tests/draft-crash.test.ts` 2. Append sent mail to the server's sent mailbox Otherwise your phone has no record of what you sent. Files: `src-tauri/src/imap/append.rs` 3. Word send-later honestly in the interface A local queue is not a scheduled send, and saying so costs nothing. Files: `src/compose/schedule.tsx` ### Done when - [ ] A crash mid-compose loses at most one autosave interval - [ ] Sent messages appear in the server's sent mailbox - [ ] The outbox states that queued sends need the app running - [ ] Clearing the cache leaves drafts and outbox intact ### Watch out - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ## Not in this build - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. ## After v1, if you want it - A second account, which the schema already supports and the sync loop mostly does - Extension-style custom actions on a message, once the rendering sandbox has been stable for a while - Condensed resynchronisation using the server's modification sequence, where the server offers it ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite cache and the raw message store. Deletable and rebuildable. DATA_DIR=~/.local/share/airmail # Required. Drafts and the outbox. Deliberately outside the cache reset path. DRAFTS_DIR=~/.local/share/airmail/drafts # Required. Per account. Port and TLS mode alongside it. IMAP_HOST=imap.example.com # Required. Per account. Used only when the outbox drains. SMTP_HOST=smtp.example.com # Required. Either password or oauth. Decides which credential the keychain holds. AUTH_KIND=oauth # Optional. Only when the provider requires OAuth for IMAP. OAUTH_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Optional · secret. Only when the provider requires OAuth. Never in the repository. OAUTH_CLIENT_SECRET=redacted # Required. Draft autosave interval. It is also the maximum you can lose in a crash. AUTOSAVE_SECONDS=3
You are building a lean indie version of Airmail. 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 ===== # Airmail · indie build A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a desktop client with keychain access and a real webview to sandbox | | Store | SQLite, every message row keyed on mailbox, UIDVALIDITY and UID | the pair is the identity the protocol actually guarantees | | Protocol | IMAP for reading with SMTP for sending, OAuth where the provider requires it | you are a client of a mailbox somebody else operates | | Credentials | The operating system keychain, never the database and never a config file | a database file copied to a backup should not contain a password | | Rendering | A sandboxed webview with no network and no scripting, remote content blocked by default | HTML mail is untrusted input written by strangers | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **The Rust toolchain and Node.js 22** · free - Why: Tauri needs both, and keychain access plus the IMAP client sit on the Rust side. - Get it: rustup.rs and nodejs.org. - Verify: cargo --version and node --version print, and a Tauri dev build launches - [ ] **A real IMAP and SMTP mailbox you can break** · free to a few pounds a month - Why: Every failure mode here is a server behaviour, and testing against a mailbox you depend on means the first renumbering test is performed on your own mail. - Get it: A secondary account at any provider, or a self-hosted mailbox with a few thousand messages in it. - Verify: An IMAP login lists mailboxes and an SMTP send succeeds - [ ] **OAuth client credentials where your provider requires them** (optional) · free - Why: Several large providers no longer accept a password for IMAP, and their consent configuration also decides how often your refresh token expires. - Get it: Create an OAuth client in the provider's console with the mail scopes it documents. - Verify: A consent round trip returns a refresh token and an IMAP login using it succeeds - [ ] **A scriptable IMAP server for tests** · free - Why: The UIDVALIDITY discard path, the interrupted resume and the renumbering case are impossible to test reliably against a real provider. - Get it: Any test IMAP server you can drive from the suite, or a small fake implementing the commands you use. - Verify: A test can force a UIDVALIDITY change and the client discards its cache - [ ] **A mailbox of at least fifty thousand messages** · free - Why: Sync time, search time and threading behaviour are the claims this build makes, and none of them are credible at a thousand messages. - Get it: An archive you already have, imported into the test mailbox. - Verify: A full sync completes and both times are recorded 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: - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. - native apps, provider quirks, automation, push, and years of compatibility work - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Airmail, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Airmail 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 desktop mail client that keeps a local copy of your mailbox and can throw it away and rebuild it without losing anything you wrote to replace Airmail. 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 Two rules, and both are about not losing things. Your local store is a cache and the server is the truth. IMAP identifies a message by the pair of the mailbox's UIDVALIDITY and the message's UID, and the specification is precise about it: a UID must not change during a session and should not change between sessions, UIDs are assigned strictly ascending within a mailbox, an expunged UID must not be reused under the same UIDVALIDITY, and a server that cannot keep UIDs stable must give the mailbox a higher UIDVALIDITY than before. So a changed UIDVALIDITY means every cached UID for that mailbox is meaningless and the correct response is to discard and resynchronise, never to merge. Key every local row on the pair, not on the UID. Second, never lose something the user wrote. Drafts are persisted locally before they are ever sent, on a timer and on every focus change, and a crash mid-compose loses at most the last few seconds. Deleting the local cache must never delete a draft that has not reached the server. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with React and TypeScript · a desktop client with keychain access and a real webview to sandbox - Store: SQLite, every message row keyed on mailbox, UIDVALIDITY and UID · the pair is the identity the protocol actually guarantees - Protocol: IMAP for reading with SMTP for sending, OAuth where the provider requires it · you are a client of a mailbox somebody else operates - Credentials: The operating system keychain, never the database and never a config file · a database file copied to a backup should not contain a password - Rendering: A sandboxed webview with no network and no scripting, remote content blocked by default · HTML mail is untrusted input written by strangers ### Data model (create this before Phase 1) - accounts(id, address, imap_host, imap_port, smtp_host, smtp_port, auth_kind, credential_ref, state) - mailboxes(account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is part of the identity of everything below - messages(account_id, mailbox, uidvalidity, uid, message_id_header, thread_id, internal_date, flags, envelope_json, body_ref) · primary key includes uidvalidity - bodies(sha256, path, bytes) · the raw message on disk, stored once, so a reindex never needs a refetch - drafts(id, account_id, local_updated_at, subject, to_json, body, attachments_json, server_ref) · written locally before anything is sent - outbox(draft_id, state, send_after, attempts, smtp_response, sent_at) · state is queued, sending, sent or failed - snoozes(account_id, mailbox, uidvalidity, uid, wake_at, moved_to) · a snooze is a move on the server plus a wake time held here ### Phase 1 · A cache that renumbering cannot corrupt Build: Connect one account, list mailboxes, and synchronise messages in descending UID order so the newest arrive first. Store the raw message on disk keyed by hash and the metadata in SQLite, keyed on mailbox, UIDVALIDITY and UID. On connect, compare the stored UIDVALIDITY: if it differs, drop every cached row for that mailbox and resynchronise from scratch. Trap: The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. Done when: Changing the stored UIDVALIDITY for a mailbox and reconnecting drops the cache and resynchronises, tested against a fake server that renumbers. A synchronisation interrupted at any point resumes without refetching messages already stored, tested by killing it mid-run. Every message row is keyed on the mailbox and UIDVALIDITY pair, enforced by the primary key. Raw messages are stored once by hash and a second copy of the same message adds no bytes. Do not build yet: No threading, search, rendering, composing or sending. ### Phase 2 · Threads and search over what you already have Build: Thread on the Message-ID, In-Reply-To and References headers, falling back to nothing rather than to subject matching. Build a local full-text index over headers and extracted text bodies. Support searching by sender, date range, mailbox, attachment presence and free text, all offline. Trap: Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. Done when: Two unrelated messages sharing a subject are not threaded, tested with a crafted pair. A reply chain with intact References headers threads in the right order including a missing middle message. Search returns results with the network disconnected, asserted by running the suite offline. Search over at least fifty thousand messages returns within a time you have measured and stated in the README. Do not build yet: No rendering, composing, sending or snoozing yet. ### Phase 3 · Rendering mail that would rather you did not Build: Render HTML in a sandboxed webview with scripting disabled, no network permission, and a content policy that blocks every remote origin. Rewrite remote images to placeholders with a per-message and per-sender allow action. Strip anything that can navigate or submit. Show the real destination of every link, and warn when the link text is an address that differs from it. Trap: Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. Done when: Rendering a message containing remote images, a remote stylesheet, a remote font and a background image produces zero outbound requests, asserted by a network monitor in the test. Scripting is disabled in the render surface and a message containing a script produces no execution, tested. Allowing remote content is per message or per sender and is recorded. A link whose visible text differs from its destination is flagged in the interface. Do not build yet: No composing, sending, snoozing or export yet. ### Phase 4 · Composing, drafts and an outbox that admits what it is Build: Persist a draft locally on a timer and on every focus change before any network use. Build multipart messages with attachments and correct headers. Send through SMTP and append the sent message to the server's sent mailbox. Implement send-later as a local queue, and state plainly in the interface that a queued message goes out when the application next runs. Trap: Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. Done when: Killing the application mid-compose loses at most the last autosave interval, tested by killing the process. A sent message is appended to the server's sent mailbox and appears there after a resynchronisation. A queued send-later message is visible in the outbox with wording stating it needs the app running, verified in the interface. Clearing the local cache leaves unsent drafts and the outbox intact, tested. Do not build yet: No snoozing, keychain migration or export yet. ### Phase 5 · Snooze, keychain, export and the reset button Build: Implement snooze as a real move to a server mailbox plus a local wake time, so the message is somewhere sensible even if this app never runs again. Keep credentials only in the operating system keychain. Export any mailbox as mbox and the whole account as maildir. Provide a cache reset that rebuilds from the server, and a diagnostics page with protocol logs that redact credentials and message contents. Trap: A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. Done when: A snoozed message is visible in a named mailbox from a different mail client, checked directly. Credentials exist only in the keychain, and a scan of the database, config files and logs for a canary password finds nothing. Cache reset deletes and rebuilds the local store while drafts, outbox and snooze wake times survive, tested. Export produces a valid mbox file that another client imports, and the diagnostics log shows protocol commands with credentials and bodies redacted. Do not build yet: Nothing after this. ### Out of scope (and why) - Running a mail server, which is deliverability and abuse handling as a full-time occupation rather than a feature. - Push notifications on every platform, which needs a hosted service holding a connection to your mailbox on your behalf. - Shared team inboxes and assignment workflows, which add identity, permissions and a server you operate. - Provider-scale spam filtering, which depends on seeing a volume of mail that a single-user client never will. - Automatic rules that delete mail, because a client whose cache is a truth claim should not be the thing removing messages. ### README must contain - That the local store is a cache keyed on mailbox, UIDVALIDITY and UID, and that a changed UIDVALIDITY discards and resynchronises - That threading uses the reference headers only, and why subject threading is not offered - That rendering blocks every remote origin, not only images, and that the test asserts zero outbound requests - That send-later and snooze wake times need the application to be running, and that a snooze is a real move on the server - That credentials live only in the operating system keychain, with the measured search and sync times on a mailbox of at least fifty thousand messages ===== AGENTS.md ===== # Agent instructions · Airmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, every message row keyed on mailbox, UIDVALIDITY and UID, IMAP for reading with SMTP for sending, OAuth where the provider requires it, The operating system keychain, never the database and never a config file, A sandboxed webview with no network and no scripting, remote content blocked by default. 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 - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ===== BUILD_PLAN.md ===== # Build plan · Airmail A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 renumbering cannot corrupt One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly. ### Steps 1. Put UIDVALIDITY in the primary key This is the whole defence against a renumbered mailbox. Files: `src/db/schema.sql`, `src-tauri/src/imap/sync.rs` 2. Test the discard path against a renumbering fake An untested recovery path is a rumour. Files: `tests/uidvalidity.test.ts` 3. Store raw bodies by hash, metadata in the database So a schema change is a reindex rather than a refetch. Files: `src-tauri/src/store/bodies.rs` ### Done when - [ ] A changed UIDVALIDITY drops and resynchronises the mailbox - [ ] An interrupted sync resumes without refetching - [ ] Message rows are keyed on the mailbox and UIDVALIDITY pair - [ ] Duplicate raw messages are stored once ### Watch out - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. ## Phase 2 · Threads and search over what you already have Conversations group correctly offline and search is fast on a real mailbox. ### Steps 1. Thread on reference headers only Subject threading is why old clients show hundred-message threads. Files: `src/threading/build.ts`, `tests/threading.test.ts` 2. Index headers and extracted text locally Offline search is most of why a local cache exists. Files: `src/db/schema.sql`, `src/search/index.ts` 3. Measure search on a real-sized mailbox Fifty messages proves nothing about fifty thousand. Files: `tests/search-perf.test.ts` ### Done when - [ ] Same-subject strangers are not threaded - [ ] A chain with a missing middle still threads in order - [ ] Search works with the network disconnected - [ ] Search time at fifty thousand messages is measured and stated ### Watch out - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. ## Phase 3 · Rendering mail that would rather you did not An HTML message cannot run code, load a tracker, or reach the network without your say-so. ### Steps 1. Block every remote origin with a content policy Not just images. A font request is the same signal. Files: `src/render/sandbox.ts`, `tests/no-network.test.ts` 2. Disable scripting and navigation in the render surface Mail is untrusted input written by people who want things. Files: `src-tauri/tauri.conf.json`, `src/render/sandbox.ts` 3. Flag links whose text disagrees with their target The oldest trick, and still the most effective. Files: `src/render/links.ts` ### Done when - [ ] A message with remote references makes zero requests - [ ] Scripting in a message never executes - [ ] Allowing remote content is per message or sender and recorded - [ ] Mismatched link text is flagged ### Watch out - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. ## Phase 4 · Composing, drafts and an outbox that admits what it is Nothing you type is lost, and send-later is described honestly. ### Steps 1. Autosave drafts before anything else The one loss a mail client is never forgiven for. Files: `src/compose/autosave.ts`, `tests/draft-crash.test.ts` 2. Append sent mail to the server's sent mailbox Otherwise your phone has no record of what you sent. Files: `src-tauri/src/imap/append.rs` 3. Word send-later honestly in the interface A local queue is not a scheduled send, and saying so costs nothing. Files: `src/compose/schedule.tsx` ### Done when - [ ] A crash mid-compose loses at most one autosave interval - [ ] Sent messages appear in the server's sent mailbox - [ ] The outbox states that queued sends need the app running - [ ] Clearing the cache leaves drafts and outbox intact ### Watch out - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ## Not in this build - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. ## After v1, if you want it - A second account, which the schema already supports and the sync loop mostly does - Extension-style custom actions on a message, once the rendering sandbox has been stable for a while - Condensed resynchronisation using the server's modification sequence, where the server offers it ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite cache and the raw message store. Deletable and rebuildable. DATA_DIR=~/.local/share/airmail # Required. Drafts and the outbox. Deliberately outside the cache reset path. DRAFTS_DIR=~/.local/share/airmail/drafts # Required. Per account. Port and TLS mode alongside it. IMAP_HOST=imap.example.com # Required. Per account. Used only when the outbox drains. SMTP_HOST=smtp.example.com # Required. Either password or oauth. Decides which credential the keychain holds. AUTH_KIND=oauth # Optional. Only when the provider requires OAuth for IMAP. OAUTH_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Optional · secret. Only when the provider requires OAuth. Never in the repository. OAUTH_CLIENT_SECRET=redacted # Required. Draft autosave interval. It is also the maximum you can lose in a crash. AUTOSAVE_SECONDS=3
You are building a production product version of Airmail. 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 ===== # Airmail · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Airmail, create a configurable Apple email client with actions, snooze, and send later. The hard boundary is native apps, provider quirks, automation, push, and years of compatibility work, plus mail infrastructure, integrations, and client polish. ## Product outcome A mail client that survives a server renumbering a mailbox, threads conversations correctly on a decade of mail, renders hostile HTML without a single outbound request, keeps drafts through a crash, and lets you export everything and walk away. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - existing IMAP and SMTP mailbox - OAuth client credentials where required - desktop keychain access - local disk space ## Explicit non-goals for v1 - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. - native apps, provider quirks, automation, push, and years of compatibility work - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search ## Success criteria - A changed UIDVALIDITY is confirmed to discard and resynchronise - Same-subject strangers are confirmed not threaded - A message with remote references is confirmed to make zero requests - A crash mid-compose is confirmed to lose at most one autosave interval - No credential is confirmed to appear outside the keychain ===== BRIEF.md ===== # Build brief · Airmail 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 desktop mail client that keeps a local copy of your mailbox and can throw it away and rebuild it without losing anything you wrote to replace Airmail. 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 Two rules, and both are about not losing things. Your local store is a cache and the server is the truth. IMAP identifies a message by the pair of the mailbox's UIDVALIDITY and the message's UID, and the specification is precise about it: a UID must not change during a session and should not change between sessions, UIDs are assigned strictly ascending within a mailbox, an expunged UID must not be reused under the same UIDVALIDITY, and a server that cannot keep UIDs stable must give the mailbox a higher UIDVALIDITY than before. So a changed UIDVALIDITY means every cached UID for that mailbox is meaningless and the correct response is to discard and resynchronise, never to merge. Key every local row on the pair, not on the UID. Second, never lose something the user wrote. Drafts are persisted locally before they are ever sent, on a timer and on every focus change, and a crash mid-compose loses at most the last few seconds. Deleting the local cache must never delete a draft that has not reached the server. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with React and TypeScript · a desktop client with keychain access and a real webview to sandbox - Store: SQLite, every message row keyed on mailbox, UIDVALIDITY and UID · the pair is the identity the protocol actually guarantees - Protocol: IMAP for reading with SMTP for sending, OAuth where the provider requires it · you are a client of a mailbox somebody else operates - Credentials: The operating system keychain, never the database and never a config file · a database file copied to a backup should not contain a password - Rendering: A sandboxed webview with no network and no scripting, remote content blocked by default · HTML mail is untrusted input written by strangers ### Data model (create this before Phase 1) - accounts(id, address, imap_host, imap_port, smtp_host, smtp_port, auth_kind, credential_ref, state) - mailboxes(account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is part of the identity of everything below - messages(account_id, mailbox, uidvalidity, uid, message_id_header, thread_id, internal_date, flags, envelope_json, body_ref) · primary key includes uidvalidity - bodies(sha256, path, bytes) · the raw message on disk, stored once, so a reindex never needs a refetch - drafts(id, account_id, local_updated_at, subject, to_json, body, attachments_json, server_ref) · written locally before anything is sent - outbox(draft_id, state, send_after, attempts, smtp_response, sent_at) · state is queued, sending, sent or failed - snoozes(account_id, mailbox, uidvalidity, uid, wake_at, moved_to) · a snooze is a move on the server plus a wake time held here ### Phase 1 · A cache that renumbering cannot corrupt Build: Connect one account, list mailboxes, and synchronise messages in descending UID order so the newest arrive first. Store the raw message on disk keyed by hash and the metadata in SQLite, keyed on mailbox, UIDVALIDITY and UID. On connect, compare the stored UIDVALIDITY: if it differs, drop every cached row for that mailbox and resynchronise from scratch. Trap: The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. Done when: Changing the stored UIDVALIDITY for a mailbox and reconnecting drops the cache and resynchronises, tested against a fake server that renumbers. A synchronisation interrupted at any point resumes without refetching messages already stored, tested by killing it mid-run. Every message row is keyed on the mailbox and UIDVALIDITY pair, enforced by the primary key. Raw messages are stored once by hash and a second copy of the same message adds no bytes. Do not build yet: No threading, search, rendering, composing or sending. ### Phase 2 · Threads and search over what you already have Build: Thread on the Message-ID, In-Reply-To and References headers, falling back to nothing rather than to subject matching. Build a local full-text index over headers and extracted text bodies. Support searching by sender, date range, mailbox, attachment presence and free text, all offline. Trap: Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. Done when: Two unrelated messages sharing a subject are not threaded, tested with a crafted pair. A reply chain with intact References headers threads in the right order including a missing middle message. Search returns results with the network disconnected, asserted by running the suite offline. Search over at least fifty thousand messages returns within a time you have measured and stated in the README. Do not build yet: No rendering, composing, sending or snoozing yet. ### Phase 3 · Rendering mail that would rather you did not Build: Render HTML in a sandboxed webview with scripting disabled, no network permission, and a content policy that blocks every remote origin. Rewrite remote images to placeholders with a per-message and per-sender allow action. Strip anything that can navigate or submit. Show the real destination of every link, and warn when the link text is an address that differs from it. Trap: Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. Done when: Rendering a message containing remote images, a remote stylesheet, a remote font and a background image produces zero outbound requests, asserted by a network monitor in the test. Scripting is disabled in the render surface and a message containing a script produces no execution, tested. Allowing remote content is per message or per sender and is recorded. A link whose visible text differs from its destination is flagged in the interface. Do not build yet: No composing, sending, snoozing or export yet. ### Phase 4 · Composing, drafts and an outbox that admits what it is Build: Persist a draft locally on a timer and on every focus change before any network use. Build multipart messages with attachments and correct headers. Send through SMTP and append the sent message to the server's sent mailbox. Implement send-later as a local queue, and state plainly in the interface that a queued message goes out when the application next runs. Trap: Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. Done when: Killing the application mid-compose loses at most the last autosave interval, tested by killing the process. A sent message is appended to the server's sent mailbox and appears there after a resynchronisation. A queued send-later message is visible in the outbox with wording stating it needs the app running, verified in the interface. Clearing the local cache leaves unsent drafts and the outbox intact, tested. Do not build yet: No snoozing, keychain migration or export yet. ### Phase 5 · Snooze, keychain, export and the reset button Build: Implement snooze as a real move to a server mailbox plus a local wake time, so the message is somewhere sensible even if this app never runs again. Keep credentials only in the operating system keychain. Export any mailbox as mbox and the whole account as maildir. Provide a cache reset that rebuilds from the server, and a diagnostics page with protocol logs that redact credentials and message contents. Trap: A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. Done when: A snoozed message is visible in a named mailbox from a different mail client, checked directly. Credentials exist only in the keychain, and a scan of the database, config files and logs for a canary password finds nothing. Cache reset deletes and rebuilds the local store while drafts, outbox and snooze wake times survive, tested. Export produces a valid mbox file that another client imports, and the diagnostics log shows protocol commands with credentials and bodies redacted. Do not build yet: Nothing after this. ### Out of scope (and why) - Running a mail server, which is deliverability and abuse handling as a full-time occupation rather than a feature. - Push notifications on every platform, which needs a hosted service holding a connection to your mailbox on your behalf. - Shared team inboxes and assignment workflows, which add identity, permissions and a server you operate. - Provider-scale spam filtering, which depends on seeing a volume of mail that a single-user client never will. - Automatic rules that delete mail, because a client whose cache is a truth claim should not be the thing removing messages. ### README must contain - That the local store is a cache keyed on mailbox, UIDVALIDITY and UID, and that a changed UIDVALIDITY discards and resynchronises - That threading uses the reference headers only, and why subject threading is not offered - That rendering blocks every remote origin, not only images, and that the test asserts zero outbound requests - That send-later and snooze wake times need the application to be running, and that a snooze is a real move on the server - That credentials live only in the operating system keychain, with the measured search and sync times on a mailbox of at least fifty thousand messages ===== ARCHITECTURE.md ===== # Architecture · Airmail ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a desktop client with keychain access and a real webview to sandbox | | Store | SQLite, every message row keyed on mailbox, UIDVALIDITY and UID | the pair is the identity the protocol actually guarantees | | Protocol | IMAP for reading with SMTP for sending, OAuth where the provider requires it | you are a client of a mailbox somebody else operates | | Credentials | The operating system keychain, never the database and never a config file | a database file copied to a backup should not contain a password | | Rendering | A sandboxed webview with no network and no scripting, remote content blocked by default | HTML mail is untrusted input written by strangers | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | UIDVALIDITY comparison, resume and the discard path. | Any IMAP library, provided the pair stays the key. | | Threading | Reference-header grouping. | Any algorithm, provided subject matching is never reintroduced. | | Render | The sandbox and the remote-origin block. | Any renderer, provided the zero-request test still passes. | | Compose | Autosave, multipart building and the outbox. | Any editor, provided autosave happens before any network use. | | Credentials | Keychain storage and redaction. | Any platform keychain. Never the database, whatever the convenience. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The SQLite cache and the raw message store. Deletable and rebuildable. - `DRAFTS_DIR` · required · Drafts and the outbox. Deliberately outside the cache reset path. - `IMAP_HOST` · required · Per account. Port and TLS mode alongside it. - `SMTP_HOST` · required · Per account. Used only when the outbox drains. - `AUTH_KIND` · required · Either password or oauth. Decides which credential the keychain holds. - `OAUTH_CLIENT_ID` · optional · Only when the provider requires OAuth for IMAP. - `OAUTH_CLIENT_SECRET` · optional, secret · Only when the provider requires OAuth. Never in the repository. - `AUTOSAVE_SECONDS` · required · Draft autosave interval. It is also the maximum you can lose in a crash. ## 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 · Airmail product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, every message row keyed on mailbox, UIDVALIDITY and UID, IMAP for reading with SMTP for sending, OAuth where the provider requires it, The operating system keychain, never the database and never a config file, A sandboxed webview with no network and no scripting, remote content blocked by default. - 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 - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. - A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. ===== MILESTONES.md ===== # Delivery milestones · Airmail Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A cache that renumbering cannot corrupt One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly. ### Steps 1. Put UIDVALIDITY in the primary key This is the whole defence against a renumbered mailbox. Files: `src/db/schema.sql`, `src-tauri/src/imap/sync.rs` 2. Test the discard path against a renumbering fake An untested recovery path is a rumour. Files: `tests/uidvalidity.test.ts` 3. Store raw bodies by hash, metadata in the database So a schema change is a reindex rather than a refetch. Files: `src-tauri/src/store/bodies.rs` ### Done when - [ ] A changed UIDVALIDITY drops and resynchronises the mailbox - [ ] An interrupted sync resumes without refetching - [ ] Message rows are keyed on the mailbox and UIDVALIDITY pair - [ ] Duplicate raw messages are stored once ### Watch out - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. ## M2 · Threads and search over what you already have Conversations group correctly offline and search is fast on a real mailbox. ### Steps 1. Thread on reference headers only Subject threading is why old clients show hundred-message threads. Files: `src/threading/build.ts`, `tests/threading.test.ts` 2. Index headers and extracted text locally Offline search is most of why a local cache exists. Files: `src/db/schema.sql`, `src/search/index.ts` 3. Measure search on a real-sized mailbox Fifty messages proves nothing about fifty thousand. Files: `tests/search-perf.test.ts` ### Done when - [ ] Same-subject strangers are not threaded - [ ] A chain with a missing middle still threads in order - [ ] Search works with the network disconnected - [ ] Search time at fifty thousand messages is measured and stated ### Watch out - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. ## M3 · Rendering mail that would rather you did not An HTML message cannot run code, load a tracker, or reach the network without your say-so. ### Steps 1. Block every remote origin with a content policy Not just images. A font request is the same signal. Files: `src/render/sandbox.ts`, `tests/no-network.test.ts` 2. Disable scripting and navigation in the render surface Mail is untrusted input written by people who want things. Files: `src-tauri/tauri.conf.json`, `src/render/sandbox.ts` 3. Flag links whose text disagrees with their target The oldest trick, and still the most effective. Files: `src/render/links.ts` ### Done when - [ ] A message with remote references makes zero requests - [ ] Scripting in a message never executes - [ ] Allowing remote content is per message or sender and recorded - [ ] Mismatched link text is flagged ### Watch out - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. ## M4 · Composing, drafts and an outbox that admits what it is Nothing you type is lost, and send-later is described honestly. ### Steps 1. Autosave drafts before anything else The one loss a mail client is never forgiven for. Files: `src/compose/autosave.ts`, `tests/draft-crash.test.ts` 2. Append sent mail to the server's sent mailbox Otherwise your phone has no record of what you sent. Files: `src-tauri/src/imap/append.rs` 3. Word send-later honestly in the interface A local queue is not a scheduled send, and saying so costs nothing. Files: `src/compose/schedule.tsx` ### Done when - [ ] A crash mid-compose loses at most one autosave interval - [ ] Sent messages appear in the server's sent mailbox - [ ] The outbox states that queued sends need the app running - [ ] Clearing the cache leaves drafts and outbox intact ### Watch out - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ## M5 · Snooze, keychain, export and the reset button (production only) State that matters lives where it survives, and you can leave with everything. ### Steps 1. Make snooze a server-side move So the message exists somewhere real without this app. Files: `src-tauri/src/imap/snooze.rs`, `tests/snooze.test.ts` 2. Keep credentials in the keychain and scan for leaks A canary password in the test suite, scanned everywhere. Files: `src-tauri/src/keychain.rs`, `tests/no-secret-leak.test.ts` 3. Export to mbox and maildir, and verify elsewhere An export no other client can read is not an exit. Files: `src/export/mbox.ts` ### Done when - [ ] A snoozed message is visible from another client - [ ] No credential appears outside the keychain - [ ] Cache reset preserves drafts, outbox and snoozes - [ ] Exported mbox imports into another client ### Watch out - A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. ===== OPERATIONS.md ===== # Operations · Airmail ## Backup The cache does not need backing up because it rebuilds from the server, but drafts, the outbox and snooze wake times do, since none of them exist anywhere else until they are sent or moved. Back up that directory separately and say so in the README. ## Restore Restore the drafts directory, then let the cache rebuild from the server on first run. A restored cache from an older backup is worse than no cache, because its UIDVALIDITY values may already be stale. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failures per account, the age of the last successful sync per mailbox, outbox items that have been queued for longer than expected, and authentication failures. A silently failing sync looks exactly like a quiet week. ## Incident checklist Flags applied to the wrong message almost always mean UIDVALIDITY was ignored somewhere, and the mailboxes table shows what value was stored. A missing message is usually a snooze, which is why the snooze is a real server move rather than a local flag. 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 changed UIDVALIDITY is confirmed to discard and resynchronise - [ ] Same-subject strangers are confirmed not threaded - [ ] A message with remote references is confirmed to make zero requests - [ ] A crash mid-compose is confirmed to lose at most one autosave interval - [ ] No credential is confirmed to appear outside the keychain ## Launch constraint Do not market omitted Airmail 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 SQLite cache and the raw message store. Deletable and rebuildable. DATA_DIR=~/.local/share/airmail # Required. Drafts and the outbox. Deliberately outside the cache reset path. DRAFTS_DIR=~/.local/share/airmail/drafts # Required. Per account. Port and TLS mode alongside it. IMAP_HOST=imap.example.com # Required. Per account. Used only when the outbox drains. SMTP_HOST=smtp.example.com # Required. Either password or oauth. Decides which credential the keychain holds. AUTH_KIND=oauth # Optional. Only when the provider requires OAuth for IMAP. OAUTH_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Optional · secret. Only when the provider requires OAuth. Never in the repository. OAUTH_CLIENT_SECRET=redacted # Required. Draft autosave interval. It is also the maximum you can lose in a crash. AUTOSAVE_SECONDS=3
# Airmail · indie build A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a desktop client with keychain access and a real webview to sandbox | | Store | SQLite, every message row keyed on mailbox, UIDVALIDITY and UID | the pair is the identity the protocol actually guarantees | | Protocol | IMAP for reading with SMTP for sending, OAuth where the provider requires it | you are a client of a mailbox somebody else operates | | Credentials | The operating system keychain, never the database and never a config file | a database file copied to a backup should not contain a password | | Rendering | A sandboxed webview with no network and no scripting, remote content blocked by default | HTML mail is untrusted input written by strangers | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **The Rust toolchain and Node.js 22** · free - Why: Tauri needs both, and keychain access plus the IMAP client sit on the Rust side. - Get it: rustup.rs and nodejs.org. - Verify: cargo --version and node --version print, and a Tauri dev build launches - [ ] **A real IMAP and SMTP mailbox you can break** · free to a few pounds a month - Why: Every failure mode here is a server behaviour, and testing against a mailbox you depend on means the first renumbering test is performed on your own mail. - Get it: A secondary account at any provider, or a self-hosted mailbox with a few thousand messages in it. - Verify: An IMAP login lists mailboxes and an SMTP send succeeds - [ ] **OAuth client credentials where your provider requires them** (optional) · free - Why: Several large providers no longer accept a password for IMAP, and their consent configuration also decides how often your refresh token expires. - Get it: Create an OAuth client in the provider's console with the mail scopes it documents. - Verify: A consent round trip returns a refresh token and an IMAP login using it succeeds - [ ] **A scriptable IMAP server for tests** · free - Why: The UIDVALIDITY discard path, the interrupted resume and the renumbering case are impossible to test reliably against a real provider. - Get it: Any test IMAP server you can drive from the suite, or a small fake implementing the commands you use. - Verify: A test can force a UIDVALIDITY change and the client discards its cache - [ ] **A mailbox of at least fifty thousand messages** · free - Why: Sync time, search time and threading behaviour are the claims this build makes, and none of them are credible at a thousand messages. - Get it: An archive you already have, imported into the test mailbox. - Verify: A full sync completes and both times are recorded 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: - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. - native apps, provider quirks, automation, push, and years of compatibility work - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Airmail, and the README should say so rather than pretend.
# Build brief · Airmail 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 desktop mail client that keeps a local copy of your mailbox and can throw it away and rebuild it without losing anything you wrote to replace Airmail. 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 Two rules, and both are about not losing things. Your local store is a cache and the server is the truth. IMAP identifies a message by the pair of the mailbox's UIDVALIDITY and the message's UID, and the specification is precise about it: a UID must not change during a session and should not change between sessions, UIDs are assigned strictly ascending within a mailbox, an expunged UID must not be reused under the same UIDVALIDITY, and a server that cannot keep UIDs stable must give the mailbox a higher UIDVALIDITY than before. So a changed UIDVALIDITY means every cached UID for that mailbox is meaningless and the correct response is to discard and resynchronise, never to merge. Key every local row on the pair, not on the UID. Second, never lose something the user wrote. Drafts are persisted locally before they are ever sent, on a timer and on every focus change, and a crash mid-compose loses at most the last few seconds. Deleting the local cache must never delete a draft that has not reached the server. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with React and TypeScript · a desktop client with keychain access and a real webview to sandbox - Store: SQLite, every message row keyed on mailbox, UIDVALIDITY and UID · the pair is the identity the protocol actually guarantees - Protocol: IMAP for reading with SMTP for sending, OAuth where the provider requires it · you are a client of a mailbox somebody else operates - Credentials: The operating system keychain, never the database and never a config file · a database file copied to a backup should not contain a password - Rendering: A sandboxed webview with no network and no scripting, remote content blocked by default · HTML mail is untrusted input written by strangers ### Data model (create this before Phase 1) - accounts(id, address, imap_host, imap_port, smtp_host, smtp_port, auth_kind, credential_ref, state) - mailboxes(account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is part of the identity of everything below - messages(account_id, mailbox, uidvalidity, uid, message_id_header, thread_id, internal_date, flags, envelope_json, body_ref) · primary key includes uidvalidity - bodies(sha256, path, bytes) · the raw message on disk, stored once, so a reindex never needs a refetch - drafts(id, account_id, local_updated_at, subject, to_json, body, attachments_json, server_ref) · written locally before anything is sent - outbox(draft_id, state, send_after, attempts, smtp_response, sent_at) · state is queued, sending, sent or failed - snoozes(account_id, mailbox, uidvalidity, uid, wake_at, moved_to) · a snooze is a move on the server plus a wake time held here ### Phase 1 · A cache that renumbering cannot corrupt Build: Connect one account, list mailboxes, and synchronise messages in descending UID order so the newest arrive first. Store the raw message on disk keyed by hash and the metadata in SQLite, keyed on mailbox, UIDVALIDITY and UID. On connect, compare the stored UIDVALIDITY: if it differs, drop every cached row for that mailbox and resynchronise from scratch. Trap: The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. Done when: Changing the stored UIDVALIDITY for a mailbox and reconnecting drops the cache and resynchronises, tested against a fake server that renumbers. A synchronisation interrupted at any point resumes without refetching messages already stored, tested by killing it mid-run. Every message row is keyed on the mailbox and UIDVALIDITY pair, enforced by the primary key. Raw messages are stored once by hash and a second copy of the same message adds no bytes. Do not build yet: No threading, search, rendering, composing or sending. ### Phase 2 · Threads and search over what you already have Build: Thread on the Message-ID, In-Reply-To and References headers, falling back to nothing rather than to subject matching. Build a local full-text index over headers and extracted text bodies. Support searching by sender, date range, mailbox, attachment presence and free text, all offline. Trap: Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. Done when: Two unrelated messages sharing a subject are not threaded, tested with a crafted pair. A reply chain with intact References headers threads in the right order including a missing middle message. Search returns results with the network disconnected, asserted by running the suite offline. Search over at least fifty thousand messages returns within a time you have measured and stated in the README. Do not build yet: No rendering, composing, sending or snoozing yet. ### Phase 3 · Rendering mail that would rather you did not Build: Render HTML in a sandboxed webview with scripting disabled, no network permission, and a content policy that blocks every remote origin. Rewrite remote images to placeholders with a per-message and per-sender allow action. Strip anything that can navigate or submit. Show the real destination of every link, and warn when the link text is an address that differs from it. Trap: Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. Done when: Rendering a message containing remote images, a remote stylesheet, a remote font and a background image produces zero outbound requests, asserted by a network monitor in the test. Scripting is disabled in the render surface and a message containing a script produces no execution, tested. Allowing remote content is per message or per sender and is recorded. A link whose visible text differs from its destination is flagged in the interface. Do not build yet: No composing, sending, snoozing or export yet. ### Phase 4 · Composing, drafts and an outbox that admits what it is Build: Persist a draft locally on a timer and on every focus change before any network use. Build multipart messages with attachments and correct headers. Send through SMTP and append the sent message to the server's sent mailbox. Implement send-later as a local queue, and state plainly in the interface that a queued message goes out when the application next runs. Trap: Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. Done when: Killing the application mid-compose loses at most the last autosave interval, tested by killing the process. A sent message is appended to the server's sent mailbox and appears there after a resynchronisation. A queued send-later message is visible in the outbox with wording stating it needs the app running, verified in the interface. Clearing the local cache leaves unsent drafts and the outbox intact, tested. Do not build yet: No snoozing, keychain migration or export yet. ### Phase 5 · Snooze, keychain, export and the reset button Build: Implement snooze as a real move to a server mailbox plus a local wake time, so the message is somewhere sensible even if this app never runs again. Keep credentials only in the operating system keychain. Export any mailbox as mbox and the whole account as maildir. Provide a cache reset that rebuilds from the server, and a diagnostics page with protocol logs that redact credentials and message contents. Trap: A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. Done when: A snoozed message is visible in a named mailbox from a different mail client, checked directly. Credentials exist only in the keychain, and a scan of the database, config files and logs for a canary password finds nothing. Cache reset deletes and rebuilds the local store while drafts, outbox and snooze wake times survive, tested. Export produces a valid mbox file that another client imports, and the diagnostics log shows protocol commands with credentials and bodies redacted. Do not build yet: Nothing after this. ### Out of scope (and why) - Running a mail server, which is deliverability and abuse handling as a full-time occupation rather than a feature. - Push notifications on every platform, which needs a hosted service holding a connection to your mailbox on your behalf. - Shared team inboxes and assignment workflows, which add identity, permissions and a server you operate. - Provider-scale spam filtering, which depends on seeing a volume of mail that a single-user client never will. - Automatic rules that delete mail, because a client whose cache is a truth claim should not be the thing removing messages. ### README must contain - That the local store is a cache keyed on mailbox, UIDVALIDITY and UID, and that a changed UIDVALIDITY discards and resynchronises - That threading uses the reference headers only, and why subject threading is not offered - That rendering blocks every remote origin, not only images, and that the test asserts zero outbound requests - That send-later and snooze wake times need the application to be running, and that a snooze is a real move on the server - That credentials live only in the operating system keychain, with the measured search and sync times on a mailbox of at least fifty thousand messages
# Agent instructions · Airmail indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, every message row keyed on mailbox, UIDVALIDITY and UID, IMAP for reading with SMTP for sending, OAuth where the provider requires it, The operating system keychain, never the database and never a config file, A sandboxed webview with no network and no scripting, remote content blocked by default. 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 - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox.
# Build plan · Airmail A desktop mail client whose hard problems are resynchronising correctly when the server renumbers a mailbox, rendering hostile HTML without letting it phone home, and being honest that snooze and send-later only happen while something is running. 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 renumbering cannot corrupt One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly. ### Steps 1. Put UIDVALIDITY in the primary key This is the whole defence against a renumbered mailbox. Files: `src/db/schema.sql`, `src-tauri/src/imap/sync.rs` 2. Test the discard path against a renumbering fake An untested recovery path is a rumour. Files: `tests/uidvalidity.test.ts` 3. Store raw bodies by hash, metadata in the database So a schema change is a reindex rather than a refetch. Files: `src-tauri/src/store/bodies.rs` ### Done when - [ ] A changed UIDVALIDITY drops and resynchronises the mailbox - [ ] An interrupted sync resumes without refetching - [ ] Message rows are keyed on the mailbox and UIDVALIDITY pair - [ ] Duplicate raw messages are stored once ### Watch out - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. ## Phase 2 · Threads and search over what you already have Conversations group correctly offline and search is fast on a real mailbox. ### Steps 1. Thread on reference headers only Subject threading is why old clients show hundred-message threads. Files: `src/threading/build.ts`, `tests/threading.test.ts` 2. Index headers and extracted text locally Offline search is most of why a local cache exists. Files: `src/db/schema.sql`, `src/search/index.ts` 3. Measure search on a real-sized mailbox Fifty messages proves nothing about fifty thousand. Files: `tests/search-perf.test.ts` ### Done when - [ ] Same-subject strangers are not threaded - [ ] A chain with a missing middle still threads in order - [ ] Search works with the network disconnected - [ ] Search time at fifty thousand messages is measured and stated ### Watch out - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. ## Phase 3 · Rendering mail that would rather you did not An HTML message cannot run code, load a tracker, or reach the network without your say-so. ### Steps 1. Block every remote origin with a content policy Not just images. A font request is the same signal. Files: `src/render/sandbox.ts`, `tests/no-network.test.ts` 2. Disable scripting and navigation in the render surface Mail is untrusted input written by people who want things. Files: `src-tauri/tauri.conf.json`, `src/render/sandbox.ts` 3. Flag links whose text disagrees with their target The oldest trick, and still the most effective. Files: `src/render/links.ts` ### Done when - [ ] A message with remote references makes zero requests - [ ] Scripting in a message never executes - [ ] Allowing remote content is per message or sender and recorded - [ ] Mismatched link text is flagged ### Watch out - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. ## Phase 4 · Composing, drafts and an outbox that admits what it is Nothing you type is lost, and send-later is described honestly. ### Steps 1. Autosave drafts before anything else The one loss a mail client is never forgiven for. Files: `src/compose/autosave.ts`, `tests/draft-crash.test.ts` 2. Append sent mail to the server's sent mailbox Otherwise your phone has no record of what you sent. Files: `src-tauri/src/imap/append.rs` 3. Word send-later honestly in the interface A local queue is not a scheduled send, and saying so costs nothing. Files: `src/compose/schedule.tsx` ### Done when - [ ] A crash mid-compose loses at most one autosave interval - [ ] Sent messages appear in the server's sent mailbox - [ ] The outbox states that queued sends need the app running - [ ] Clearing the cache leaves drafts and outbox intact ### Watch out - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ## Not in this build - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. ## After v1, if you want it - A second account, which the schema already supports and the sync loop mostly does - Extension-style custom actions on a message, once the rendering sandbox has been stable for a while - Condensed resynchronisation using the server's modification sequence, where the server offers it
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite cache and the raw message store. Deletable and rebuildable. DATA_DIR=~/.local/share/airmail # Required. Drafts and the outbox. Deliberately outside the cache reset path. DRAFTS_DIR=~/.local/share/airmail/drafts # Required. Per account. Port and TLS mode alongside it. IMAP_HOST=imap.example.com # Required. Per account. Used only when the outbox drains. SMTP_HOST=smtp.example.com # Required. Either password or oauth. Decides which credential the keychain holds. AUTH_KIND=oauth # Optional. Only when the provider requires OAuth for IMAP. OAUTH_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Optional · secret. Only when the provider requires OAuth. Never in the repository. OAUTH_CLIENT_SECRET=redacted # Required. Draft autosave interval. It is also the maximum you can lose in a crash. AUTOSAVE_SECONDS=3
# Airmail · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Airmail, create a configurable Apple email client with actions, snooze, and send later. The hard boundary is native apps, provider quirks, automation, push, and years of compatibility work, plus mail infrastructure, integrations, and client polish. ## Product outcome A mail client that survives a server renumbering a mailbox, threads conversations correctly on a decade of mail, renders hostile HTML without a single outbound request, keeps drafts through a crash, and lets you export everything and walk away. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - existing IMAP and SMTP mailbox - OAuth client credentials where required - desktop keychain access - local disk space ## Explicit non-goals for v1 - Running a mail server. - Push notifications on every platform. - Shared team inboxes. - Rules that delete mail automatically. - native apps, provider quirks, automation, push, and years of compatibility work - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search ## Success criteria - A changed UIDVALIDITY is confirmed to discard and resynchronise - Same-subject strangers are confirmed not threaded - A message with remote references is confirmed to make zero requests - A crash mid-compose is confirmed to lose at most one autosave interval - No credential is confirmed to appear outside the keychain
# Build brief · Airmail 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 desktop mail client that keeps a local copy of your mailbox and can throw it away and rebuild it without losing anything you wrote to replace Airmail. 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 Two rules, and both are about not losing things. Your local store is a cache and the server is the truth. IMAP identifies a message by the pair of the mailbox's UIDVALIDITY and the message's UID, and the specification is precise about it: a UID must not change during a session and should not change between sessions, UIDs are assigned strictly ascending within a mailbox, an expunged UID must not be reused under the same UIDVALIDITY, and a server that cannot keep UIDs stable must give the mailbox a higher UIDVALIDITY than before. So a changed UIDVALIDITY means every cached UID for that mailbox is meaningless and the correct response is to discard and resynchronise, never to merge. Key every local row on the pair, not on the UID. Second, never lose something the user wrote. Drafts are persisted locally before they are ever sent, on a timer and on every focus change, and a crash mid-compose loses at most the last few seconds. Deleting the local cache must never delete a draft that has not reached the server. ### Stack (fixed, do not substitute) - Shell: Tauri 2 with React and TypeScript · a desktop client with keychain access and a real webview to sandbox - Store: SQLite, every message row keyed on mailbox, UIDVALIDITY and UID · the pair is the identity the protocol actually guarantees - Protocol: IMAP for reading with SMTP for sending, OAuth where the provider requires it · you are a client of a mailbox somebody else operates - Credentials: The operating system keychain, never the database and never a config file · a database file copied to a backup should not contain a password - Rendering: A sandboxed webview with no network and no scripting, remote content blocked by default · HTML mail is untrusted input written by strangers ### Data model (create this before Phase 1) - accounts(id, address, imap_host, imap_port, smtp_host, smtp_port, auth_kind, credential_ref, state) - mailboxes(account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is part of the identity of everything below - messages(account_id, mailbox, uidvalidity, uid, message_id_header, thread_id, internal_date, flags, envelope_json, body_ref) · primary key includes uidvalidity - bodies(sha256, path, bytes) · the raw message on disk, stored once, so a reindex never needs a refetch - drafts(id, account_id, local_updated_at, subject, to_json, body, attachments_json, server_ref) · written locally before anything is sent - outbox(draft_id, state, send_after, attempts, smtp_response, sent_at) · state is queued, sending, sent or failed - snoozes(account_id, mailbox, uidvalidity, uid, wake_at, moved_to) · a snooze is a move on the server plus a wake time held here ### Phase 1 · A cache that renumbering cannot corrupt Build: Connect one account, list mailboxes, and synchronise messages in descending UID order so the newest arrive first. Store the raw message on disk keyed by hash and the metadata in SQLite, keyed on mailbox, UIDVALIDITY and UID. On connect, compare the stored UIDVALIDITY: if it differs, drop every cached row for that mailbox and resynchronise from scratch. Trap: The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. Done when: Changing the stored UIDVALIDITY for a mailbox and reconnecting drops the cache and resynchronises, tested against a fake server that renumbers. A synchronisation interrupted at any point resumes without refetching messages already stored, tested by killing it mid-run. Every message row is keyed on the mailbox and UIDVALIDITY pair, enforced by the primary key. Raw messages are stored once by hash and a second copy of the same message adds no bytes. Do not build yet: No threading, search, rendering, composing or sending. ### Phase 2 · Threads and search over what you already have Build: Thread on the Message-ID, In-Reply-To and References headers, falling back to nothing rather than to subject matching. Build a local full-text index over headers and extracted text bodies. Support searching by sender, date range, mailbox, attachment presence and free text, all offline. Trap: Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. Done when: Two unrelated messages sharing a subject are not threaded, tested with a crafted pair. A reply chain with intact References headers threads in the right order including a missing middle message. Search returns results with the network disconnected, asserted by running the suite offline. Search over at least fifty thousand messages returns within a time you have measured and stated in the README. Do not build yet: No rendering, composing, sending or snoozing yet. ### Phase 3 · Rendering mail that would rather you did not Build: Render HTML in a sandboxed webview with scripting disabled, no network permission, and a content policy that blocks every remote origin. Rewrite remote images to placeholders with a per-message and per-sender allow action. Strip anything that can navigate or submit. Show the real destination of every link, and warn when the link text is an address that differs from it. Trap: Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. Done when: Rendering a message containing remote images, a remote stylesheet, a remote font and a background image produces zero outbound requests, asserted by a network monitor in the test. Scripting is disabled in the render surface and a message containing a script produces no execution, tested. Allowing remote content is per message or per sender and is recorded. A link whose visible text differs from its destination is flagged in the interface. Do not build yet: No composing, sending, snoozing or export yet. ### Phase 4 · Composing, drafts and an outbox that admits what it is Build: Persist a draft locally on a timer and on every focus change before any network use. Build multipart messages with attachments and correct headers. Send through SMTP and append the sent message to the server's sent mailbox. Implement send-later as a local queue, and state plainly in the interface that a queued message goes out when the application next runs. Trap: Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. Done when: Killing the application mid-compose loses at most the last autosave interval, tested by killing the process. A sent message is appended to the server's sent mailbox and appears there after a resynchronisation. A queued send-later message is visible in the outbox with wording stating it needs the app running, verified in the interface. Clearing the local cache leaves unsent drafts and the outbox intact, tested. Do not build yet: No snoozing, keychain migration or export yet. ### Phase 5 · Snooze, keychain, export and the reset button Build: Implement snooze as a real move to a server mailbox plus a local wake time, so the message is somewhere sensible even if this app never runs again. Keep credentials only in the operating system keychain. Export any mailbox as mbox and the whole account as maildir. Provide a cache reset that rebuilds from the server, and a diagnostics page with protocol logs that redact credentials and message contents. Trap: A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back. Done when: A snoozed message is visible in a named mailbox from a different mail client, checked directly. Credentials exist only in the keychain, and a scan of the database, config files and logs for a canary password finds nothing. Cache reset deletes and rebuilds the local store while drafts, outbox and snooze wake times survive, tested. Export produces a valid mbox file that another client imports, and the diagnostics log shows protocol commands with credentials and bodies redacted. Do not build yet: Nothing after this. ### Out of scope (and why) - Running a mail server, which is deliverability and abuse handling as a full-time occupation rather than a feature. - Push notifications on every platform, which needs a hosted service holding a connection to your mailbox on your behalf. - Shared team inboxes and assignment workflows, which add identity, permissions and a server you operate. - Provider-scale spam filtering, which depends on seeing a volume of mail that a single-user client never will. - Automatic rules that delete mail, because a client whose cache is a truth claim should not be the thing removing messages. ### README must contain - That the local store is a cache keyed on mailbox, UIDVALIDITY and UID, and that a changed UIDVALIDITY discards and resynchronises - That threading uses the reference headers only, and why subject threading is not offered - That rendering blocks every remote origin, not only images, and that the test asserts zero outbound requests - That send-later and snooze wake times need the application to be running, and that a snooze is a real move on the server - That credentials live only in the operating system keychain, with the measured search and sync times on a mailbox of at least fifty thousand messages
# Architecture · Airmail ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a desktop client with keychain access and a real webview to sandbox | | Store | SQLite, every message row keyed on mailbox, UIDVALIDITY and UID | the pair is the identity the protocol actually guarantees | | Protocol | IMAP for reading with SMTP for sending, OAuth where the provider requires it | you are a client of a mailbox somebody else operates | | Credentials | The operating system keychain, never the database and never a config file | a database file copied to a backup should not contain a password | | Rendering | A sandboxed webview with no network and no scripting, remote content blocked by default | HTML mail is untrusted input written by strangers | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | UIDVALIDITY comparison, resume and the discard path. | Any IMAP library, provided the pair stays the key. | | Threading | Reference-header grouping. | Any algorithm, provided subject matching is never reintroduced. | | Render | The sandbox and the remote-origin block. | Any renderer, provided the zero-request test still passes. | | Compose | Autosave, multipart building and the outbox. | Any editor, provided autosave happens before any network use. | | Credentials | Keychain storage and redaction. | Any platform keychain. Never the database, whatever the convenience. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The SQLite cache and the raw message store. Deletable and rebuildable. - `DRAFTS_DIR` · required · Drafts and the outbox. Deliberately outside the cache reset path. - `IMAP_HOST` · required · Per account. Port and TLS mode alongside it. - `SMTP_HOST` · required · Per account. Used only when the outbox drains. - `AUTH_KIND` · required · Either password or oauth. Decides which credential the keychain holds. - `OAUTH_CLIENT_ID` · optional · Only when the provider requires OAuth for IMAP. - `OAUTH_CLIENT_SECRET` · optional, secret · Only when the provider requires OAuth. Never in the repository. - `AUTOSAVE_SECONDS` · required · Draft autosave interval. It is also the maximum you can lose in a crash. ## 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 · Airmail product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, every message row keyed on mailbox, UIDVALIDITY and UID, IMAP for reading with SMTP for sending, OAuth where the provider requires it, The operating system keychain, never the database and never a config file, A sandboxed webview with no network and no scripting, remote content blocked by default. - 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 - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. - A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back.
# Delivery milestones · Airmail Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A cache that renumbering cannot corrupt One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly. ### Steps 1. Put UIDVALIDITY in the primary key This is the whole defence against a renumbered mailbox. Files: `src/db/schema.sql`, `src-tauri/src/imap/sync.rs` 2. Test the discard path against a renumbering fake An untested recovery path is a rumour. Files: `tests/uidvalidity.test.ts` 3. Store raw bodies by hash, metadata in the database So a schema change is a reindex rather than a refetch. Files: `src-tauri/src/store/bodies.rs` ### Done when - [ ] A changed UIDVALIDITY drops and resynchronises the mailbox - [ ] An interrupted sync resumes without refetching - [ ] Message rows are keyed on the mailbox and UIDVALIDITY pair - [ ] Duplicate raw messages are stored once ### Watch out - The tempting shortcut is to key messages on UID alone, which works perfectly until a server renumbers a mailbox and then every cached row silently points at a different message, so flags apply to the wrong mail and a delete removes something the user wanted. Put UIDVALIDITY in the primary key, compare it on every connect, and make the discard path a tested code path rather than one you hope never runs. ## M2 · Threads and search over what you already have Conversations group correctly offline and search is fast on a real mailbox. ### Steps 1. Thread on reference headers only Subject threading is why old clients show hundred-message threads. Files: `src/threading/build.ts`, `tests/threading.test.ts` 2. Index headers and extracted text locally Offline search is most of why a local cache exists. Files: `src/db/schema.sql`, `src/search/index.ts` 3. Measure search on a real-sized mailbox Fifty messages proves nothing about fifty thousand. Files: `tests/search-perf.test.ts` ### Done when - [ ] Same-subject strangers are not threaded - [ ] A chain with a missing middle still threads in order - [ ] Search works with the network disconnected - [ ] Search time at fifty thousand messages is measured and stated ### Watch out - Threading by subject line glues together every message anyone ever titled 'Re: quick question', including messages from strangers, and on a mailbox with ten years of history it produces threads with hundreds of unrelated messages. Thread on the reference headers only, and when they are absent leave the message on its own, which is honest and rarely wrong. ## M3 · Rendering mail that would rather you did not An HTML message cannot run code, load a tracker, or reach the network without your say-so. ### Steps 1. Block every remote origin with a content policy Not just images. A font request is the same signal. Files: `src/render/sandbox.ts`, `tests/no-network.test.ts` 2. Disable scripting and navigation in the render surface Mail is untrusted input written by people who want things. Files: `src-tauri/tauri.conf.json`, `src/render/sandbox.ts` 3. Flag links whose text disagrees with their target The oldest trick, and still the most effective. Files: `src/render/links.ts` ### Done when - [ ] A message with remote references makes zero requests - [ ] Scripting in a message never executes - [ ] Allowing remote content is per message or sender and recorded - [ ] Mismatched link text is flagged ### Watch out - Blocking images but letting stylesheets, fonts or background images load leaves the tracking pixel intact under a different name, since any remote fetch carries the same signal that a message was opened at a given time from a given address. Block every remote origin with a content policy rather than filtering image elements, and assert in a test that rendering a message with remote references makes no outbound request at all. ## M4 · Composing, drafts and an outbox that admits what it is Nothing you type is lost, and send-later is described honestly. ### Steps 1. Autosave drafts before anything else The one loss a mail client is never forgiven for. Files: `src/compose/autosave.ts`, `tests/draft-crash.test.ts` 2. Append sent mail to the server's sent mailbox Otherwise your phone has no record of what you sent. Files: `src-tauri/src/imap/append.rs` 3. Word send-later honestly in the interface A local queue is not a scheduled send, and saying so costs nothing. Files: `src/compose/schedule.tsx` ### Done when - [ ] A crash mid-compose loses at most one autosave interval - [ ] Sent messages appear in the server's sent mailbox - [ ] The outbox states that queued sends need the app running - [ ] Clearing the cache leaves drafts and outbox intact ### Watch out - Describing send-later as scheduling implies the message leaves at the chosen time whatever your machine is doing, and the first time someone closes the laptop before a timed send they discover it did not, usually about an important message. Say in the compose window that the message is held locally and goes out when the app is next running, and show the queued item with that wording in the outbox. ## M5 · Snooze, keychain, export and the reset button (production only) State that matters lives where it survives, and you can leave with everything. ### Steps 1. Make snooze a server-side move So the message exists somewhere real without this app. Files: `src-tauri/src/imap/snooze.rs`, `tests/snooze.test.ts` 2. Keep credentials in the keychain and scan for leaks A canary password in the test suite, scanned everywhere. Files: `src-tauri/src/keychain.rs`, `tests/no-secret-leak.test.ts` 3. Export to mbox and maildir, and verify elsewhere An export no other client can read is not an exit. Files: `src/export/mbox.ts` ### Done when - [ ] A snoozed message is visible from another client - [ ] No credential appears outside the keychain - [ ] Cache reset preserves drafts, outbox and snoozes - [ ] Exported mbox imports into another client ### Watch out - A snooze held only as a local flag that hides the message means that if this app is uninstalled, or simply not opened for a fortnight, the message is neither in the inbox nor anywhere the user would look, and it is gone as far as they are concerned. Move it to a named mailbox on the server so every other client can see it, and treat the local wake time as the thing that moves it back.
# Operations · Airmail ## Backup The cache does not need backing up because it rebuilds from the server, but drafts, the outbox and snooze wake times do, since none of them exist anywhere else until they are sent or moved. Back up that directory separately and say so in the README. ## Restore Restore the drafts directory, then let the cache rebuild from the server on first run. A restored cache from an older backup is worse than no cache, because its UIDVALIDITY values may already be stale. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failures per account, the age of the last successful sync per mailbox, outbox items that have been queued for longer than expected, and authentication failures. A silently failing sync looks exactly like a quiet week. ## Incident checklist Flags applied to the wrong message almost always mean UIDVALIDITY was ignored somewhere, and the mailboxes table shows what value was stored. A missing message is usually a snooze, which is why the snooze is a real server move rather than a local flag. 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 changed UIDVALIDITY is confirmed to discard and resynchronise - [ ] Same-subject strangers are confirmed not threaded - [ ] A message with remote references is confirmed to make zero requests - [ ] A crash mid-compose is confirmed to lose at most one autosave interval - [ ] No credential is confirmed to appear outside the keychain ## Launch constraint Do not market omitted Airmail 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 SQLite cache and the raw message store. Deletable and rebuildable. DATA_DIR=~/.local/share/airmail # Required. Drafts and the outbox. Deliberately outside the cache reset path. DRAFTS_DIR=~/.local/share/airmail/drafts # Required. Per account. Port and TLS mode alongside it. IMAP_HOST=imap.example.com # Required. Per account. Used only when the outbox drains. SMTP_HOST=smtp.example.com # Required. Either password or oauth. Decides which credential the keychain holds. AUTH_KIND=oauth # Optional. Only when the provider requires OAuth for IMAP. OAUTH_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Optional · secret. Only when the provider requires OAuth. Never in the repository. OAUTH_CLIENT_SECRET=redacted # Required. Draft autosave interval. It is also the maximum you can lose in a crash. AUTOSAVE_SECONDS=3
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Airmail because an email client can be rebuilt; operating a trusted mail service with excellent deliverability cannot. The recurring cost buys IMAP quirks, OAuth, MIME, threading, search, attachments, offline sync, notifications, secure storage, and provider changes, not just the visible interface.
xnative apps, provider quirks, automation, push, and years of compatibility work
xmail hosting and deliverability
xpush on every platform
xadvanced team collaboration
xprovider-specific AI and search
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Airmail →· no votes, no pay-to-list · just what's real
Airmail pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 1 email account; basic inbox management only |
| airmail pro | $7.99/user | $3.33/user | Multiple accounts; subscription shared across Apple devices on the same Apple ID |
free tier1 email account; basic inbox management
billingmonthly + annual; auto-renews through the Apple App Store unless canceled at least 24 hours before renewal
hidden costsStorefront and promotional pricing varies. A separate AI Composer in-app purchase is listed in some storefronts and is not reliably priced on the US product page.
verified 2026-08-12 · source ↗
Vibecode Airmail
Kinda. The core of Airmail is buildable in a weekend with the prompt on this page, but there are real gaps: native apps, provider quirks, automation, push, and years of compatibility work, mail hosting and deliverability. Read the honest list above before committing.
How much does Airmail cost?
Airmail costs about $7.99/month (Airmail Pro, checked 2026-08-12), which is $95.88 per year.
What do I lose by replacing Airmail?
Honestly: native apps, provider quirks, automation, push, and years of compatibility work; mail hosting and deliverability; push on every platform; advanced team collaboration; provider-specific AI and search. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Airmail?
Yes: Apple Mail (The client already on the Mac has send-later, reminders and swipe actions; customization stops where Apple says.) Edison Mail (Custom swipes, snooze and scheduled sending without a subscription; optional shopping-data research pays the bill.) Spark Free (Snooze, scheduled sends and configurable actions for free; read receipts and team features still collect rent.) All 3 curated free alternatives are at vibecodeit.com/airmail/alternatives. The prompt is for when you want it exactly your way.