Build Airmail

KINDAreplaces $7.99/mosaves $95.88/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

ShellTauri 2 with React and TypeScriptStoreSQLite, every message row keyed on mailbox, UIDVALIDITY and UIDProtocolIMAP for reading with SMTP for sending, OAuth where the provider requires itCredentialsThe operating system keychain, never the database and never a config fileRenderingA sandboxed webview with no network and no scripting, remote content blocked by default

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Tauri needs both, and keychain access plus the IMAP client sit on the Rust side.

    Get it rustup.rs and nodejs.org. open ↗

    Verify cargo --version and node --version print, and a Tauri dev build launches

  2. 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

  3. API keyfree

    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

  4. installfree

    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

  5. have readyfree

    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

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- 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

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATA_DIRrequired~/.local/share/airmailThe SQLite cache and the raw message store. Deletable and rebuildable.
DRAFTS_DIRrequired~/.local/share/airmail/draftsDrafts and the outbox. Deliberately outside the cache reset path.
IMAP_HOSTrequiredimap.example.comPer account. Port and TLS mode alongside it.
SMTP_HOSTrequiredsmtp.example.comPer account. Used only when the outbox drains.
AUTH_KINDrequiredoauthEither password or oauth. Decides which credential the keychain holds.
OAUTH_CLIENT_IDoptional1234567890-abcdef.apps.googleusercontent.comOnly when the provider requires OAuth for IMAP.
OAUTH_CLIENT_SECRETsecretoptionalredactedOnly when the provider requires OAuth. Never in the repository.
AUTOSAVE_SECONDSrequired3Draft autosave interval. It is also the maximum you can lose in a crash.

The build, in order

  1. A cache that renumbering cannot corrupt

    One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly.

    1. This is the whole defence against a renumbered mailbox.

      Files src/db/schema.sqlsrc-tauri/src/imap/sync.rs

    2. An untested recovery path is a rumour.

      Files tests/uidvalidity.test.ts

    3. So a schema change is a reindex rather than a refetch.

      Files src-tauri/src/store/bodies.rs

    done when · tick each as it passes
    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.
  2. Threads and search over what you already have

    Conversations group correctly offline and search is fast on a real mailbox.

    1. Subject threading is why old clients show hundred-message threads.

      Files src/threading/build.tstests/threading.test.ts

    2. Offline search is most of why a local cache exists.

      Files src/db/schema.sqlsrc/search/index.ts

    3. Fifty messages proves nothing about fifty thousand.

      Files tests/search-perf.test.ts

    done when · tick each as it passes
    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.
  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.

    1. Not just images. A font request is the same signal.

      Files src/render/sandbox.tstests/no-network.test.ts

    2. Mail is untrusted input written by people who want things.

      Files src-tauri/tauri.conf.jsonsrc/render/sandbox.ts

    3. The oldest trick, and still the most effective.

      Files src/render/links.ts

    done when · tick each as it passes
    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.
  4. Composing, drafts and an outbox that admits what it is

    Nothing you type is lost, and send-later is described honestly.

    1. The one loss a mail client is never forgiven for.

      Files src/compose/autosave.tstests/draft-crash.test.ts

    2. Otherwise your phone has no record of what you sent.

      Files src-tauri/src/imap/append.rs

    3. A local queue is not a scheduled send, and saying so costs nothing.

      Files src/compose/schedule.tsx

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more email.