Build Airmail
KINDAreplaces $7.99/mosaves $95.88/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - accountfree 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATA_DIR | required | ~/.local/share/airmail | The SQLite cache and the raw message store. Deletable and rebuildable. |
DRAFTS_DIR | required | ~/.local/share/airmail/drafts | Drafts and the outbox. Deliberately outside the cache reset path. |
IMAP_HOST | required | imap.example.com | Per account. Port and TLS mode alongside it. |
SMTP_HOST | required | smtp.example.com | Per account. Used only when the outbox drains. |
AUTH_KIND | required | oauth | Either password or oauth. Decides which credential the keychain holds. |
OAUTH_CLIENT_ID | optional | 1234567890-abcdef.apps.googleusercontent.com | Only when the provider requires OAuth for IMAP. |
OAUTH_CLIENT_SECRETsecret | optional | redacted | Only when the provider requires OAuth. Never in the repository. |
AUTOSAVE_SECONDS | required | 3 | Draft autosave interval. It is also the maximum you can lose in a crash. |
The build, in order
A cache that renumbering cannot corrupt
One mailbox synchronises, resumes after an interruption, and handles a UIDVALIDITY change correctly.
This is the whole defence against a renumbered mailbox.
Files
src/db/schema.sqlsrc-tauri/src/imap/sync.rsAn untested recovery path is a rumour.
Files
tests/uidvalidity.test.tsSo a schema change is a reindex rather than a refetch.
Files
src-tauri/src/store/bodies.rs
done when · tick each as it passeswatch 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.
Threads and search over what you already have
Conversations group correctly offline and search is fast on a real mailbox.
Subject threading is why old clients show hundred-message threads.
Files
src/threading/build.tstests/threading.test.tsOffline search is most of why a local cache exists.
Files
src/db/schema.sqlsrc/search/index.tsFifty messages proves nothing about fifty thousand.
Files
tests/search-perf.test.ts
done when · tick each as it passeswatch 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.
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.
Not just images. A font request is the same signal.
Files
src/render/sandbox.tstests/no-network.test.tsMail is untrusted input written by people who want things.
Files
src-tauri/tauri.conf.jsonsrc/render/sandbox.tsThe oldest trick, and still the most effective.
Files
src/render/links.ts
done when · tick each as it passeswatch 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.
Composing, drafts and an outbox that admits what it is
Nothing you type is lost, and send-later is described honestly.
The one loss a mail client is never forgiven for.
Files
src/compose/autosave.tstests/draft-crash.test.tsOtherwise your phone has no record of what you sent.
Files
src-tauri/src/imap/append.rsA local queue is not a scheduled send, and saying so costs nothing.
Files
src/compose/schedule.tsx
done when · tick each as it passeswatch 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.
Snooze, keychain, export and the reset buttonproduct builder
State that matters lives where it survives, and you can leave with everything.
So the message exists somewhere real without this app.
Files
src-tauri/src/imap/snooze.rstests/snooze.test.tsA canary password in the test suite, scanned everywhere.
Files
src-tauri/src/keychain.rstests/no-secret-leak.test.tsAn export no other client can read is not an exit.
Files
src/export/mbox.ts
done when · tick each as it passeswatch 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.
That is the whole plan for Airmail. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.