Files
margin-mail/docs/plan.md
T
pj eb59cb5f8d build the app, and ship it on Linux and Windows as well as macOS
The client itself: the sync engine and mirror, the Gmail and IMAP providers,
the screener, the three boxes and two piles, compose and send, the sanitiser
and tracker stripping, contacts, clips, backup, notifications and the guide.

The pipeline that ships it. Three runners now build in parallel and the
publish gate wants all four platform keys in latest.json before a release
leaves draft. Linux gets two more packages Tauri does not build: a flatpak
repackaged from the deb against GNOME 48, and a Nix package that relinks the
deb against nixpkgs so it runs as a native Wayland client rather than through
Xwayland. CI runs the Rust suite on all three desktops rather than on Linux
alone, and rebuilds the flatpak manifest on every push to main.

Two things that were only ever exercised on macOS and were wrong everywhere
else. The menu bar was built by adjusting the submenus macOS is given, so on
a platform whose default menu has no File or View the new File menu landed
after Edit, Check for Updates landed nowhere, and the three places and the
reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and
libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri
already emits.

The updater key exists now, so releases can sign their artifacts.
2026-09-06 12:15:30 +05:30

13 KiB

Plan

How the app gets built, in the order it gets built, and what it is built out of. The product is specified in design.md and features.md; this document is about the work rather than the result, and it exists so that somebody picking the repository up in six months can see why the pieces landed in the order they did.

Work is cut into milestones, and a milestone into work packages. A package is a unit somebody can finish: it owns a set of files, it ends with something that runs, and it never edits another package's files. The package names below are the ones the source comments already use, so a placeholder that says "the contract lands in F3" means the work package named here.

The milestones

M0, foundations. F1 is the scaffold: the Tauri crate, the Vite front end, the icons, the justfile and CI. F2 is the design system, which is src/styles/tokens.css and the primitives that sit on it, reviewed through the Kit page. F3 is the contracts: src-tauri/src/dto.rs and its mirror src/ipc.ts, frozen before anything implements them. F4 is the documentation, this file among it. None of M0 sends a byte to Google, and that is the point: the shape is settled while it is still cheap to change.

M1, read. The milestone that proves the two hard things. R1 is authentication and accounts: the loopback flow, the sealed token, the granted scopes. R2 is the Gmail client behind the Provider trait, with the quota arithmetic and the backoff in it. R3 is the mirror and the sync loop, including the storage window and eviction. R4 is the message pipeline: the MIME parse, the sanitiser, the tracker stripper. R5 is the shell, which is the header, the list column, the reading pane and the keyboard. R6 is connect and onboarding, the first thing a new user meets and the last thing built in this milestone, because it cannot be designed honestly until the sync it narrates exists. At the end of M1 the app reads mail and does nothing else.

M2, triage. T1 is flags and undo: seen, star, archive, trash, spam, each optimistic and each reversible. T2 is selection and bulk actions. T3 is search, local over the window with the provider as the second pass. T4 is labels. At the end of M2 it is a fast Gmail client and nothing more, which is worth having in the hands of one user for a week before the next milestone changes what it is.

M3, places. P1 is the state database and its journal, the schema that everything after this depends on. P2 is routing: sender rules, the suggestion function, the overrides. P3 is the Screener and the first-run pass. P4 is the Feed and the Paper Trail. P5 is contacts, the contact card and autocomplete. This is the milestone where it stops being a Gmail client.

M4, piles. L1 is Reply later, Set aside and Focus & Reply. L2 is snooze with lazy evaluation. L3 is notes, rename and merge. L4 is the rest of what the state database makes possible: clips, All files, ignore, per-thread notifications and unsubscribe.

M5, writing. W1 is the editor and drafts. W2 is the send pipeline: the outbox, the undo delay, attachments, threading headers. W3 is instant intro, remind me if no reply, and calendar invites including the re-authorization that RSVP needs.

M6, accounts, settings and backup. A1 is more than one account and the unified view. A2 is the settings screen, specified in settings.md. A3 is the backup store, the encryption and the recovery phrase, with the journal merge behind it.

M7, ship. S1 is the release pipeline: signing, notarisation, the updater. S2 is the verification materials Google's restricted scope review wants, which is a privacy policy that is true, a demo video, and a written justification for each scope. Then the platforms in order: S3 the iPhone, S4 Linux, S5 the IMAP provider. They are last because each one is a second copy of a problem already solved once, and solving it twice before it is solved once is how a project stalls.

What comes from the siblings

Very little here is new, and that is deliberate. Margin Mail sits beside margin and Margin Calendar on disk and takes from both.

From the calendar: the OAuth loopback flow with PKCE and its Google-specific handling, the sealed token store, the deep-link path that mobile needs instead of a loopback listener, the overlay title bar and the macOS window behaviour, the data-phone and data-touch scheme with the boot script that sets them before first paint, the escape-layer stack, the palette, the zustand store idiom, the release workflow's shape, and the build.rs trick that embeds google-credentials.json with the example file as a fallback.

From margin: the HTTP half of gdrive.rs, which is folder lookup, upload and download against Drive's v3 API; the trick of putting heavy synchronous work behind #[tauri::command(async)]; and margin-shared, which is a real dependency rather than a copy. The tokens, the icon strings and the font catalogue come from that package through a relative path in package.json, which is why CI checks out both repositories side by side.

From neither: the mirror, the state database, the journal, the sanitiser and everything to do with mail. Those are this repository's own work.

The libraries

Every version below was checked against crates.io and npm on 3 September 2026.

On the Rust side, mail-parser 0.11 with full_encoding parses bodies from the raw RFC 2822 bytes; the feature is not optional, because the charsets it adds are the ones that still turn up in real mail every day. mail-builder 0.5 builds outgoing MIME. css-inline 0.21 folds the editor's stylesheet into the markup before the message is built, so a client that drops <style> still renders what was written. ammonia 4.1 is the sanitiser, and two of its hooks do the work that matters: attribute_filter rewrites img src, which is where tracker stripping and cid: substitution happen, and filter_style_properties narrows inline CSS to an allowlist of properties that can never take a url(), which closes the last route a message has to fetch something.

rusqlite 0.40 with bundled, so FTS5 is compiled in rather than depending on what the platform's libsqlite3 happened to be built with, and so several accounts sharing one process share one predictable SQLite. reqwest 0.13 with gzip, json and http2: Gmail's JSON compresses by an order of magnitude and hydration is thousands of responses, and a batch of fifty shares a connection with the poll loop. Note that its TLS feature is now called rustls rather than rustls-tls; the old name is a build error, not a warning. chacha20poly1305 0.11 seals tokens and backup segments, argon2 0.6 derives the backup key from the recovery phrase slowly enough to matter, bip39 2.2 produces the phrase, and chrono and fontdb do the obvious.

Two deliberate omissions. There is no oauth2 crate: the calendar's hand-rolled flow is already tested against Google's actual behaviour, including the parts that do not match the spec, and replacing working code with a dependency that has to be taught the same lessons is not a trade. There is no tokio-rusqlite: it pins an older rusqlite than the one above, and the synchronous command trick makes it unnecessary anyway.

On the front end, @tiptap/react 3.31 with StarterKit is the editor, as in margin. react-virtuoso 4.18 renders the grouped list, because a mailbox list is long, its rows are two heights, and it has group headers, which is exactly the case hand-rolled virtualisation gets wrong. Beyond those, React, zustand and the Tauri API, and nothing else.

One consequence worth writing down. Message bodies render in an iframe with srcdoc, which inherits the app's content security policy rather than escaping it, so the frame cannot fetch anything: inline cid: images are rewritten to data: URIs by the sanitiser, and a remote image the user has chosen to allow is fetched by Rust, without cookies or referrer, and handed to the frame the same way. Every byte a message displays has been through Rust first.

The sandbox attribute is allow-same-origin rather than empty, and the reason is worth keeping. An empty sandbox gives the frame an opaque origin, and a document the parent cannot reach is a document the parent cannot measure: there is then no way to size the frame to its content, so every message carries its own scrollbar, and no way to catch a click on a link, so nothing opens. Keeping allow-same-origin leaves every other restriction in place, forms and top navigation included, and scripts are still blocked three times over: the sandbox disables them without allow-scripts, the content security policy is script-src 'self' which stops inline scripts and inline event handlers whatever the origin, and the sanitiser removed them before either got a say.

The shape of the repository

The front end is src. Screens live in src/screens, the primitives they are built from in src/ui, one zustand store per domain in src/store, the typed IPC wrappers in src/api, and the stylesheets in src/styles, where tokens.css is the only file allowed to hold a raw colour. src/ipc.ts is the frontend half of the contract and src/screens/Kit.tsx is the page that renders every primitive in every state, which is how a restyle gets reviewed.

The backend is src-tauri/src. dto.rs is the other half of the contract and is frozen once M0 ends. lib.rs holds the app setup, the menu and emit_store_changed. The Google client, the mirror, the state database and the sync loop each get a module, and the Gmail specifics stay inside the Gmail one so that the second provider has somewhere to be.

Documentation is docs, with the mockups under docs/mockups: HTML sources in src beside a shared mail.css, rendered to PNGs by docs/mockups/render.sh, which pulls the fonts from the margin repository and Chromium from the calendar's node_modules so nothing binary is vendored here. The prose gate is scripts/docs-check.mjs. Local builds and installs are the justfile; CI and releases are the two workflows in .github/workflows.

How the work is verified

Four gates, all of them runnable on a laptop before anything is handed over.

just test runs the Vitest suites and cargo test. Both must be green; there are no retries anywhere in this repository, because a test that passes on the second attempt is lying about something.

just test-ui runs Playwright against the real UI, with src/ipc.ts routed to the dev fixture so no browser ever talks to Google. The viewport is pinned to 1440 by 900, the same size the mockups were rendered at, because half of what the suite asserts is geometry: the 420 px list column, the row height, whether the piles are on screen. It also writes screenshots as it goes, starting with the Kit page in both palettes under screenshots/, so a visual regression shows up as a diff rather than as a complaint two weeks later, and it greps the stylesheets for a hex literal outside the token layer, which is the one rule a reviewer will not reliably catch by eye.

just docs is the prose gate: no em dashes, no directory trees, no relative link that goes nowhere.

pnpm build is tsc then Vite, so the typecheck and the bundle are one step, and pnpm fonts:check catches the vendored font copy under public/fonts drifting from the margin-shared package. CI runs all of it, checking out this repository and margin side by side because the shared package is reached by a relative path.

Beyond the gates, the mockups in ui.md are the target rather than an impression of it. A screen is finished when it looks like its render, not when it looks reasonable.

What was learned from reading Mailspring

Mailspring is GPL-3.0 and was read, not copied. Five things in it are worth having and are in this design because of it. Bodies live in their own table with a fetched_at column, prefetched inside a window and evicted outside it, which is where the storage window in architecture.md comes from. The engine hands the UI a typed delta stream rather than telling it to refetch, which here is the store-changed event with a scope in it, so a note landing does not make the list reload its bodies. The provider's thread id and the References threading are kept as separate columns rather than collapsed into a hash of the headers, because they disagree often enough to matter. An account that fails to sync repeatedly is paused by a circuit breaker instead of being retried into a rate limit. And SQLite gets a busy timeout, because several accounts share one process and one connection.

The list of what to avoid is just as useful. Nothing may depend on the app running at a particular wall-clock time, which is why snooze is evaluated lazily. No metadata is held in a cloud service. No tracking pixels, no rewritten links, and no contact lookup that sends a correspondent's address to a server to find out who they are. And no header that never expires: everything cached has a rule for when it goes.