Files
margin-mail/docs/architecture.md
T

11 KiB

Architecture

Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. The same stack as margin and Margin Calendar, so OAuth, token sealing, the build and bundle setup, the overlay title bar and the phone chrome carry over rather than being invented again. The conventions are the calendar's, in ../margin-caledar/docs/conventions.md, and apply here unchanged.

The split is strict. Rust owns authentication, every byte to and from a mail provider, the local mirror, the sync loop, MIME parsing, HTML sanitising, the portable state database, the journal and the backup store. TypeScript owns rendering and interaction. The frontend never talks to a provider, which keeps the content security policy locked to ipc: as in the siblings.

The facts about the Gmail API that this design rests on are in research/gmail-api.md, checked against Google's pages on 3 September 2026.

Two databases, one boundary

There are two SQLite databases per account and the boundary between them is the product.

The mirror is a copy of the mailbox: messages, threads, labels, bodies, attachment metadata, an FTS5 index, the provider's sync cursor, and an outbox. It is derived from the provider and can be thrown away and rebuilt. Its keys are the provider's ids.

The state database is everything the user decided: sender rules, pile membership, snoozes, notes, renames, merges, clips, ignore flags, notification opt-ins, contact notes. It is never derived, it is never sent to the provider, and its keys are portable: a thread is identified by the RFC Message-ID of its earliest message (the thread key), a message by its own Message-ID, a sender by their address or domain. Provider ids appear in the state database nowhere. When the user moves to another provider and the same mail arrives through IMAP with the same Message-IDs, every decision reattaches.

A view is the mirror joined to the state. The Inbox is "threads whose sender rule says Inbox, or that carry a reply to a thread we are in, minus piles, minus snoozes, minus archived", grouped by seen state. The join is computed in Rust and served to the frontend as a flat, ordered list of thread summaries; the frontend never sees a label id or a rule.

The provider trait

trait Provider {
    fn authenticate(...)                 // interactive, returns a sealed credential
    fn full_list(...)                    // ids and thread ids, newest first, paged
    fn changes_since(cursor)             // the change log, or NeedsFullSync
    fn fetch_headers(ids)                // batched metadata
    fn fetch_body(id)                    // raw RFC 2822 bytes
    fn fetch_attachment(id, part)        // bytes
    fn set_flags(ids, seen, starred, archived, trashed, spam)
    fn labels() / apply_label / remove_label
    fn send(raw, thread_hint)            // returns the provider's id and thread id
    fn drafts(): create / update / delete
    fn search(query, page)               // provider-side search, ids only
    fn settings(): aliases, signature
}

Gmail implements it with the REST API. IMAP and SMTP will implement it next with folders mapped onto the flag set and labels onto X-GM-LABELS or keywords, and JMAP after that with its own change log. The trait is shaped by what every provider can do, and everything the trait cannot do is done in the state database instead, which is why the state database exists.

Gmail specifics that live only inside the Gmail implementation: history.list as the change log and its 404 recovery, the 6,000 units per minute per user budget, format=metadata with a fixed header list for hydration, format=raw for bodies, batch requests of 25 to 50, truncated exponential backoff on 429, threading rules on send, CATEGORY_* labels read as a hint for the suggestion function, and the People API for contact autocomplete.

Authentication and distribution

Loopback OAuth with PKCE from the calendar, unchanged: the consent page opens in the system browser, never in a webview the app owns, and the code lands on 127.0.0.1. Refresh tokens are sealed with XChaCha20-Poly1305 in the app data directory as the calendar does, for the same reasons (no keyring on Android, code-signature churn on macOS, no Secret Service on minimal Linux). Scopes: gmail.modify, gmail.settings.basic, contacts.other.readonly, contacts.readonly, and calendar.events for RSVP. No mail.google.com until IMAP is real.

Every Gmail scope that reads mail is restricted. The plan is the one in the research: push the consent screen to production unverified for the friends release (100 lifetime users, no weekly re-login), file restricted-scope verification at once with the statement that there is no server and all Google user data stays on the device, ask in writing whether the security assessment applies, and budget for it anyway. Bring-your-own OAuth client stays as an escape hatch in settings for the technical.

Sync

Polling, no push. users.watch needs a Pub/Sub subscription, which needs a server or an IAM grant no desktop app should hold, and a relay is the thing that might drag the app into an annual security assessment. history.list costs 2 units; polling every 12 seconds in the foreground and 60 seconds in the background is a rounding error against the budget.

Initial sync is the expensive part after the May 2026 quota change: messages.get is 20 units, so hydration runs at about 300 messages a minute per account. A 20,000 message mailbox takes about an hour of background work; a 100,000 message mailbox most of a working day. The order is newest first, the app is usable as soon as the first page lands, and a thin bar in the account chip says how far back the mirror reaches. Bodies are fetched on open and prefetched for the last 90 days when idle. Attachments are fetched on open and cached with a size cap. The mirror is the whole mailbox by decision; a setting caps the age for people who want less on disk.

Every write is optimistic: it lands in the mirror, renders, and is pushed behind. Consecutive flag changes are coalesced into batchModify. Offline writes queue in the outbox and drain on reconnect, with the thread showing "Waiting to send" until a send goes.

Seen, starred, archived, trashed and spam are provider flags and go through the trait. Nothing in the piles, the Screener, snoozes or notes ever touches the provider, so a user who screens out two hundred senders makes zero API calls.

The state journal and the backup store

The state database is written through an append-only journal: every change is an event with a device id, a per-device sequence number, a wall-clock timestamp, a kind, a portable key, and a payload. The tables are a materialised view of the journal. This is what makes roaming possible without a server and what makes the backup meaningful.

A backup store is a trait with three operations: put a blob at a name, get a blob by name, list names under a prefix. Two implementations ship: Google Drive's app-data folder, which every Gmail user already has and which margin's backup already uses, and Cloudflare R2 through S3 credentials for people who run their own. Each device uploads its own journal segments under its device id and downloads every other device's. Merging is last-writer-wins per key by timestamp, which is correct for every kind of state here (a pile toggle, a note, a rule), and a device that has been offline for a month simply replays what it missed.

Everything uploaded is encrypted on the device with XChaCha20-Poly1305 under a key that is generated on first backup, stored sealed like the tokens, and shown once as a recovery phrase. Drive and R2 hold ciphertext and names; neither can read a note or a rule. The recovery phrase is the only way to attach a second device or restore after a lost one, and the settings panel says so in one sentence.

Version one on macOS alone does not need the merge. The journal shape is there from the first commit so that the iPhone can join without a migration.

Security

Message bodies are parsed from raw RFC 2822 with a real MIME parser (mail-parser), never from the provider's pre-parsed payload beyond headers, because encoded words, parameter continuations and legacy charsets appear daily. HTML is sanitised in Rust before it reaches the webview: scripts, forms, event handlers, <meta> refreshes, external stylesheets, javascript: and data: navigation are removed; cid: references are rewritten to a local resource scheme; every remote <img> is replaced with a placeholder and its source recorded. The body renders in an iframe with a strict CSP inside the app's webview so the message can never touch the app.

Tracker stripping happens in the same pass: images with a known tracking host (a maintained list, shipped with the app and updated with it), images of one pixel or hidden by style, and images whose URL carries a recipient token are removed and counted, and the vendor is named in the banner. When the user asks to show images, Rust fetches them without cookies or referrer and serves them from cache; the user's IP is exposed to the image host at that moment and only then, which the privacy setting says plainly. Outgoing mail never contains a tracker and the app never requests a read receipt. Links are rewritten on click to drop known tracking parameters, with a setting to turn that off.

Refresh tokens and the backup key are sealed, never in SQLite. The mirror and the state database are files in the app data directory and inherit the OS's disk encryption; encrypting them again would cost search and buy nothing on a device that is already locked, and the export path is the answer for anyone who wants their mail in a form they control.

Platforms

macOS: overlay title bar with the traffic lights on the header's centre line, closing the window hides it and Cmd-Q quits, all from the calendar. iOS second: the same code with the phone chrome from the calendar's data-phone and data-touch scheme, overlays as bottom sheets, the OAuth flow through ASWebAuthenticationSession, and background app refresh used only to run the snooze evaluation and a short sync. Linux afterwards: no traffic lights, closing quits, deb and AppImage.

Order of work

The sync engine and the reading pane are the two hard things and neither proves the other, so the first milestone is one account, authentication, the mirror, and a read-only Inbox with a sanitised, tracker-stripped reading pane. Everything the app is for depends on those being right.

Second, triage on the mirror: seen, archive, star, trash, spam, selection, the keyboard, the palette, local search. At this point it is a fast Gmail client and nothing more.

Third, the state database and the four places: sender rules, the suggestion function, the Screener, the first-run pass, Feed and Paper Trail. This is the milestone where it stops being a Gmail client.

Fourth, the piles and their friends: Reply later, Set aside, Focus & Reply, snooze with lazy evaluation, notes, rename, merge, clips, All files, ignore, per-thread notifications, the contact card.

Fifth, writing: reply, compose, drafts, the outbox with undo send, attachments, remind me if no reply, instant intro, calendar RSVP.

Sixth, more than one account, the unified view, settings, export, and the backup store with the journal behind it.

Then the iPhone, then Linux, then the IMAP provider, in that order.