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