# Accounts, OAuth, secrets, HTTP and backup The plan for `margin-google`, `margin-secrets` and `margin-http`, what happens to the two backup designs, and why the two sync engines stay where they are. Every claim carries a file and a line, read off disk on 2026-09-06. The audit behind it is [.research/rust-google-sync.md](.research/rust-google-sync.md); you should not need to open it. Margin Docs is absent throughout: its `src-tauri/Cargo.toml` has no `reqwest`, no `chacha20poly1305` and no Google anything, so it consumes none of these three crates. ## The two OAuth implementations are one implementation Margin Mail's `google/auth.rs` says so in its first four lines: ported from Margin Calendar's `google/auth.rs`, which took it from margin's `gdrive.rs`. It is a copy with edits, and the copy is measurable. Counts ignore each file's `#[cfg(test)]` module; a calendar line counts as present when the identical line exists in Mail's file. | File | Calendar | Mail | Calendar lines verbatim in Mail | |---|---|---|---| | `src-tauri/src/google/auth.rs` | 898 | 1,145 | 815 (91%) | | `src-tauri/src/google/browser.rs` | 420 | 419 | 416 (99%) | | `src-tauri/src/google/secrets.rs` | 262 | 265 | 243 (93%) | | `src-tauri/build.rs` | 29 | 29 | 29, `diff` prints nothing | 1,503 of the calendar's 1,609 non-test lines exist unchanged in Margin Mail. `browser.rs`, the whole iOS `SFSafariViewController` and Android Custom Tab consent surface, differs in four lines and all four are comment prose naming which app the sheet sits over. On top of that, `margin`'s `gdrive.rs` is 953 lines of which roughly 330 are the same flow written independently and earlier: the same PKCE (gdrive.rs:159-169), the same loopback listener (gdrive.rs:199-275, inline in one function rather than four testable pieces), the same token endpoint calls (gdrive.rs:318-359). Six things genuinely differ, and those six are the shared crate's entire configuration surface. 1. **Scopes.** The calendar has one string, `pub const SCOPES` (calendar auth.rs:28). Mail has a six-entry `BASE_SCOPES` (mail auth.rs:29-35), a `REQUIRED_SCOPE` an account is refused for (auth.rs:41), `scope_string` for extras (auth.rs:274), and `granted_scopes` / `missing_required` reading what Google granted back off the token response (auth.rs:292, 300); the calendar's `TokenResponse` has no `scope` field. Parameters: `base_scopes` and `required_scopes`, slices, the second allowed to be empty. 2. **Re-consent.** Mail has `grant()` (auth.rs:725) because Google offers installed apps no incremental authorization: picking up `calendar.events` to answer an invite costs the whole consent screen again. No parameter; the calendar never calls it. 3. **`login_hint` and `prompt`.** Mail's `auth_url` takes a hint and switches `prompt` between `consent` and `select_account consent` (auth.rs:607-639); the calendar always sends `select_account consent` (calendar auth.rs:519-530). No parameter; `Option` on `connect`. 4. **Revocation.** The calendar's `revoke` returns nothing and its result is discarded (calendar auth.rs:479); Mail keeps it and reports that the account went from this device but Google could not be reached (mail auth.rs:584, 1043). No parameter; the crate returns the outcome. 5. **Where the account record lands.** A SQLite row through `store::write::upsert_account` (calendar store/write.rs:168-181), against `accounts.json` through `crate::accounts::upsert`, because in Mail every account owns its own database and the account list must be readable before any of them is open (mail accounts.rs:1-15). Parameter: a trait object, the only one needing real design. 6. **HTTP body building.** Mail hand-rolls `form_body` over `url::form_urlencoded` (auth.rs:502-511) where the calendar uses `.form()`. See step 6: a mistake, not a difference. Two smaller ones belong to `margin-secrets`: the `SERVICE` and `KEY_CONTEXT` constants (calendar secrets.rs:41, 45; mail secrets.rs:42, 46), and a `reference()` helper the calendar needs for its `accounts.keychain_ref` column (calendar secrets.rs:60-62) which Mail dropped. ## Defects found on the way **1. margin keeps a Google refresh token in plaintext.** `BackupState.refresh_token` is a plain field (margin gdrive.rs:68), serialised with `serde_json::to_string_pretty` and written by `save_state` (gdrive.rs:153-157) through `crate::project::atomic_write` (project.rs:13-29), which sets no file mode, into `backup.json` (gdrive.rs:138-140). Same OAuth client and same grant that the other two apps seal. The weakest store in the suite sets the suite's real security level, so sealing anything is worth nothing until this is fixed. **Fix before the refactor**: it is small against today's code, and it is the one finding here that is live exposure rather than untidiness. **2. Margin Mail will not compile for mobile.** `src-tauri/src/lib.rs:307` calls `listen_for_redirects(handle)` under `#[cfg(mobile)]`, and `grep -rn "fn listen_for_redirects"` over the whole repository returns nothing. The call site was copied from the calendar and the definition was not; the calendar has it at `margin-caledar/src-tauri/src/lib.rs:150-172`. Desktop builds are unaffected, and the plugin is already registered (mail Cargo.toml:24, lib.rs:260), so the only thing missing is the twenty-line function. **Fix before the refactor**: the mobile deep link is one of the two paths the crate must expose and there is no way to know Mail's half works. The rest ride along with the refactor rather than blocking it. | # | Defect | Where | What breaks | |---|---|---|---| | 3 | The calendar has no rate limit handling. `error_for` maps 410 and 412 and sends everything else to `Other`; grepping the crate for `429`, `Retry-After` or `backoff` finds nothing | calendar api.rs:191-197, push.rs:29, 345, 378 | a 429 reaches `push::drain`, counts as a real attempt through `mark_attempt_failed`, and five of them retire the queued write permanently. A user's edit is dropped silently because Google was busy | | 4 | margin's HTTP clients have no timeouts. The calendar's own header named this as fix number one (calendar auth.rs:3) and nobody applied it upstream | margin gdrive.rs:25, updates.rs:6 | a hung socket hangs a backup with no bound | | 5 | margin bakes a build-machine path into the binary and reads it at runtime in preference to the compiled copy | margin gdrive.rs:22-23, 41-42 | the build machine's absolute path ships in every binary, a developer's on-disk file silently outranks what was compiled, and with `build.rs` two lines long a clone without credentials fails to compile rather than failing at connect. Fix with step 6 | | 6 | `gdrive_disconnect` revokes the suite-wide grant with `let _ =` and says nothing | margin gdrive.rs:777-803 | Disconnect in the writing studio signs the person out of the calendar and the mail client too. Mail names that before offering the button (mail auth.rs:1026-1029) | | 7 | `chacha20poly1305` 0.10 against 0.11, no on-disk format difference | calendar Cargo.toml:40, mail Cargo.toml:64 | nothing today; settled at 0.11 | | 8 | `reqwest` 0.12 against 0.13 | margin Cargo.toml:39, calendar Cargo.toml:29, mail Cargo.toml:37-43 | nothing today; settled at 0.13, and step 6 says why the three hand-written helpers this supposedly forced are avoidable | ## `margin-google` `crates/google`, on reqwest 0.13, depending on `margin-secrets` and `margin-http`. It owns the OAuth flow and nothing above it: no Gmail types, no Calendar types. The app hands it a config and a place to record accounts, and gets back a token getter. ```rust pub struct Config { pub base_scopes: &'static [&'static str], /// An account that did not grant all of these is refused, not stored. May be empty. pub required_scopes: &'static [&'static str], /// For the listener page and the not-set-up sentence. pub app_name: &'static str, /// e.g. "studio.margin.mail:/oauth2redirect". pub android_redirect: &'static str, /// `include_str!(concat!(env!("OUT_DIR"), "/google-credentials.json"))` from the app. pub credentials_json: &'static str, } /// One app writes a SQLite row and the other a JSON file; neither shape belongs in this crate. pub trait AccountSink: Send + Sync { fn upsert(&self, app: &tauri::AppHandle, account: &Granted) -> Result<(), String>; fn forget(&self, app: &tauri::AppHandle, account_id: &str) -> Result<(), String>; fn email_of(&self, app: &tauri::AppHandle, account_id: &str) -> Result, String>; /// (account_id, email) for every account with a token, read once at launch. fn known(&self, app: &tauri::AppHandle) -> Result, String>; } pub struct Granted { pub account_id: String, pub email: String, pub display_name: String, pub scopes: Vec, /// Non-empty means nothing was stored and there is no account. pub missing_required: Vec, } /// Managed in Tauri state, replacing both apps' own (calendar auth.rs:214-219, mail auth.rs:239-243). pub struct AuthState { /* sessions, pending, config, sink, secrets */ } impl AuthState { pub fn new(config: Config, sink: Arc, secrets: margin_secrets::Store) -> Self; } /// Returns the consent URL; the answer arrives later as the `auth` event. `hint` is the address /// typed on the connect screen, when there was one. pub async fn connect(app: tauri::AppHandle, extra_scopes: Vec, hint: Option) -> Result; /// The whole consent again for a connected account, to pick up a scope it did not grant. pub async fn grant(app: tauri::AppHandle, account_id: String, extra_scopes: Vec) -> Result; /// Revokes at Google, then forgets the token and the session here. The account goes from this /// device whether or not Google answered; the return says whether it heard. pub async fn disconnect(app: &tauri::AppHandle, state: &AuthState, account_id: &str) -> Result; pub enum Revoked { AtGoogle, LocallyOnly(String) } /// A live access token, refreshing if needed. Single-flight. pub async fn valid_access_token(state: &AuthState, account_id: &str) -> Result; /// From `setup`, before any command can run. Seeds the session map with stored emails. pub fn init_sessions(app: &tauri::AppHandle, data_dir: PathBuf); #[cfg(mobile)] pub fn listen_for_redirects(handle: &tauri::AppHandle); #[cfg(mobile)] pub async fn handle_redirect(app: tauri::AppHandle, incoming: &url::Url); #[cfg(mobile)] pub async fn abandon_pending(app: tauri::AppHandle, reason: Option); ``` **Desktop, and phones by default.** A loopback listener: bind 127.0.0.1 on a port the OS picks, open the URL in the system browser through `tauri-plugin-opener`, never an in-app webview, and wait `AUTH_TIMEOUT_SECS` (120 desktop, 900 mobile). It keeps the four testable pieces the calendar split it into, `write_http_message`, `request_path`, `parse_redirect` and `await_code`, with the `Redirect` enum, the CSRF state check, the `access_denied` case, the favicon skip, the 8 KiB buffer, the 150 ms poll and the 5 s read timeout (mail auth.rs:307-437). Phones use it too: a Desktop client may redirect to loopback on any port without registering it, and Google's token endpoint checks the client id, the secret and the redirect rather than the calling OS. What used to make that impossible on a phone was that leaving for Safari suspends the process, which `browser.rs` fixes by keeping the consent sheet in front of the app rather than replacing it. **Mobile deep link.** Runs instead when the credentials file carries a client for this platform, setting `Credentials::platform_client` at load time (mail auth.rs:118-122). With no listener the verifier goes in `Pending { state, verifier, redirect, expires }` behind a mutex with a 900 s expiry. `handle_redirect` takes the verifier rather than reading it, so both arrival routes fire harmlessly: `get_current` for the link that launched a process the OS had killed, `on_open_url` for the usual case. Android uses `Config::android_redirect`, iOS the reversed client id, worth having there as the only route to `ASWebAuthenticationSession`, the only iOS browser that shares Safari's cookies. **Refresh and clock skew.** One constant, `EXPIRY_SKEW_SECS = 60`, with expiry stored as `now() + expires_in.saturating_sub(EXPIRY_SKEW_SECS)` (mail auth.rs:61, 1122). That is all either app does and it is enough: it covers a request in flight when the clock rolls over, and does not pretend to fix a machine whose wall clock is wrong. Refresh is single-flight by holding the tokio mutex across the refresh await, so concurrent callers queue on one token request; that serialises refreshes across accounts too, the right trade for something happening once an hour per account. A rotated refresh token is written back only when it changed (mail auth.rs:1114-1118). margin has none of this: it reads the session, drops the lock, then awaits (gdrive.rs:498-521). **Revocation** is `POST https://oauth2.googleapis.com/revoke` with the refresh token, before anything local is touched, because the token is what names the grant. One OAuth client covers the suite and the endpoint acts on the authorization behind the token rather than on the string, so this signs the person out of every Margin app on every machine; the crate returns `Revoked` so the app can say so. **The multi-account store** is `HashMap` keyed on the Google `sub` from the id_token, falling back to the email when it is absent. `Session` holds the access token, its expiry and the email; the refresh token is never in that map and never in memory outside a refresh. The id_token is parsed and never signature-verified, deliberately: it arrived over TLS from Google's own token endpoint, the same trust the access token rides on. The crate emits the `auth` event on Mail's shape (`accountId`, `email`, `scopes`, `missingRequired`, `error`), a superset of the calendar's that costs it two empty arrays; two schemas for one event name costs more. ## `margin-secrets` `crates/secrets`. A sealed key-value file, not an OAuth token store. Mail already uses it for three unrelated things: refresh tokens keyed by account id, the backup key under `"margin-mail backup key"` (backup/crypto.rs:41, 119-138), and the R2 credentials under `"margin-mail r2 credentials"` (backup/r2.rs:29). Neither of those ids can collide with an account id, because a Google `sub` is digits and an address cannot carry a space. ```rust pub struct Store { /* dir, context */ } impl Store { /// `context` is a per-app constant mixed into the key, e.g. "margin-mail token store v1". pub fn new(dir: PathBuf, context: &'static str) -> Self; pub fn put(&self, id: &str, secret: &[u8]) -> Result<(), String>; pub fn get(&self, id: &str) -> Result>, String>; pub fn delete(&self, id: &str) -> Result<(), String>; pub fn put_str(&self, id: &str, secret: &str) -> Result<(), String>; pub fn get_str(&self, id: &str) -> Result, String>; } ``` A value, not the `OnceLock` global both apps have today (mail secrets.rs:49-55), which exists only because the free functions deliberately do not carry an `AppHandle`; a `Store` held by `AuthState` gets the same property without process-wide state. **The format** is unchanged from what is on disk, so there is no migration. `tokens.enc` is JSON, a `BTreeMap` of id to base64 (standard alphabet, padded) of `nonce || ciphertext || tag`, the nonce 24 random bytes, XChaCha20-Poly1305 throughout. One seal per entry rather than one over the map, so a corrupt entry costs that entry. `tokens.salt` is 32 random bytes, per install, written once and never rotated: losing it costs a reconnect and nothing else. **The key** is `SHA256(context || salt || machine_id)`, never persisted. `machine_id` is `/etc/machine-id` on Linux, `IOPlatformUUID` scraped out of `/usr/sbin/ioreg` on macOS, and deliberately empty on iOS and Android, where the sandbox is the real boundary and a reinstall would rotate any identifier and silently destroy the store. The salt sits beside the ciphertext and the context is a constant in a public binary, so the machine id is the only thing binding a token to the machine that stored it: a copied-home-directory defence and nothing stronger, and the file should keep saying so. A salt that exists and will not read is refused rather than replaced, because replacing it turns one transient IO failure into permanent loss of every token (mail secrets.rs:107-113). No keyring, because macOS ties a keychain item's ACL to the code signature so every ad-hoc rebuild re-prompts, and the `keyring` crate has no Android backend at all. **Atomic replace.** Write to `.tmp` opened with `mode(0o600)` on unix so the ciphertext is never briefly world readable, `write_all`, `sync_all`, then `rename` (mail secrets.rs:246-265). Deliberately not the apps' general `atomic_write`, which sets no mode. One hardening to fold in: neither copy fsyncs the parent directory after the rename. **The version drift** settles at `chacha20poly1305 = "0.11"`, which Mail already runs. 0.11 moved to `hybrid-array`: `Key::from_slice` and `XNonce::from_slice` are deprecated and the array conversions carry the length in the type, so the one panic those calls had is now a compile error. The on-disk format is identical, so the calendar's upgrade is an API edit with no data migration. ## `margin-http` `crates/http`, lifted wholesale from Margin Mail's `google/api.rs`, the only place in the suite where any of this exists. There are nine `reqwest::Client` constructions across the three apps, no two alike, two of them with no timeouts. ```rust /// Named profiles rather than a builder, so a new call site picks one rather than inventing one. pub fn auth() -> &'static reqwest::Client; // connect 10s, total 30s, h2 + tcp keepalive pub fn api() -> &'static reqwest::Client; // connect 10s, total 60s, pool idle 30s, gzip, http2 pub fn bulk() -> &'static reqwest::Client; // connect 10s, total 120s, uploads and downloads pub fn untrusted() -> &'static reqwest::Client; // connect 5s, total 10s, referer(false), 3 redirects pub enum ApiError { Unauthorized(String), InsufficientScope(String), RateLimited { retry_after_ms: u64 }, NotFound(String), Offline(String), /// There and then not: reset, closed early, cut off mid-body. Its own kind because it is the /// one failure worth retrying at once, and the one a client that never does shows on a wake. Dropped(String), Other(String), } pub fn error_for(status: u16, context: &str, scope: &str, retry_after_ms: Option, body: &str) -> ApiError; pub fn retry_after(headers: &reqwest::header::HeaderMap) -> Option; pub fn strip_urls(text: &str) -> String; pub async fn read_json(resp: reqwest::Response, context: &str, scope: &str) -> Result; pub const MAX_BACKOFF_MS: u64 = 64_000; pub const MAX_ATTEMPTS: u32 = 5; pub const DROPPED_WAITS_MS: [u64; 2] = [250, 1_250]; pub fn backoff_ms(attempt: u32, jitter_ms: u64) -> u64; pub async fn with_retry(call: F) -> Result where F: FnMut() -> Fut, Fut: Future>; /// For a call that must not be made twice: a send on a connection that dropped may have sent. pub async fn with_retry_no_replay(call: F) -> Result where F: FnMut() -> Fut, Fut: Future>; /// A rolling window of spend. Budget is a constructor argument: Gmail's 6,000 units a minute is not /// the Calendar API's limit, and the per-call unit table stays in the app that knows it. pub struct Quota { /* VecDeque<(at_ms, units)> */ } impl Quota { pub fn new(budget: u32, window_ms: u64) -> Self; pub fn spent(&mut self, now_ms: u64) -> u32; /// How long before `units` more would fit. Zero when they fit now. pub fn wait_for(&mut self, now_ms: u64, units: u32) -> u64; pub fn charge(&mut self, now_ms: u64, units: u32); } ``` `error_for` reads Google's machine-readable reason out of all three places Google puts it, `error.status`, `error.errors[].reason` and `error.details[].reason`, because a 403 for an insufficient scope only says so in the third (mail api.rs:250-275, 341-440). `dailyLimitExceeded` is deliberately not retryable: it is the project's day gone, and retrying in thirty seconds only spends the next day's. `strip_urls` drops whole sentences carrying a link, rather than the bare URL, because "Enable it by visiting then retry" is not English. Backoff is `min(2^n seconds + jitter, 64s)`, with jitter a parameter so the schedule is a pure function and testable. Set a user agent, `Margin/`, on every profile: today nothing does except `mail/imap/discover.rs:543-554`, which spoofs Chrome, correct for autodiscovery and wrong everywhere else, so `untrusted()` takes an override. margin gains timeouts on both clients, the calendar gains the whole retry layer and with it defect 3, and both gain the `Dropped` kind, which is what makes the first request after a laptop wakes succeed instead of showing an error. ## Credentials, the build script, and the reqwest split There is one Google Cloud project, `margin-500217`, one OAuth desktop client, and three byte-identical copies of `google-credentials.json` in three repo roots, all gitignored, with only the example files committed. See [guidelines/distribution.md](guidelines/distribution.md). The mechanism the calendar and Mail share is right and stays: `src-tauri/build.rs` copies the file from the repo root into `OUT_DIR`, falling back to `google-credentials.example.json` when the real file is absent, and the auth code pulls it in with `include_str!(concat!(env!("OUT_DIR"), "/google-credentials.json"))` (mail auth.rs:63). Nothing is read at runtime, and a clone with no credentials compiles and fails at the first connect with the "not set up yet" sentence. The two `build.rs` files are byte identical, so this becomes `margin_google::build::embed_credentials()` called from each app's `build.rs`. margin is the app to fix. Delete the runtime read at gdrive.rs:41-42 outright, point `CREDENTIALS_JSON` (gdrive.rs:22-23) at `OUT_DIR`, and add the build script call. That takes the build machine's absolute path out of the binary, removes the case where a developer's on-disk file outranks the compiled one, and makes a fresh clone compile. margin's example file carries only an `installed` block where the other two carry `android` and `ios`; bring it to the same shape. **reqwest** settles at 0.13, with two corrections. The three hand-written helpers in Mail are avoidable and should go: `form_body` (auth.rs:502-511), `query_string` (api.rs:723-729) and `url_with` (api.rs:732-738) exist because `form` is a feature in reqwest 0.13 and Mail sets `default-features = false` without listing it (reqwest 0.13.1 `Cargo.toml`: `form = ["dep:serde", "dep:serde_urlencoded"]`, absent from `default`). In 0.12 `serde_urlencoded` was an unconditional dependency, which is why the calendar never noticed; adding `"form"` deletes all three and the comments explaining them, and should happen before the shared crate copies the workaround forward. Second, Mail already compiles two reqwest majors: `Cargo.lock` has 0.12.28 and 0.13.1, the older pulled in by `css-inline 0.21.2`, so one version in the tree is not reachable through these crates and one version in code we write is. Keep Mail's feature set as the crate's, plus `form`: `rustls`, `webpki-roots`, `json`, `gzip`, `http2`. ## Sync engines: do not share one Both are honestly described as "incremental sync of a Google resource into a local SQLite mirror with a sync token, a poll loop and an event stream to the UI". That sentence is true of both and it is where the similarity ends. Six things differ in kind, not in degree. | | Margin Calendar | Margin Mail | |---|---|---| | Cursor | one sync token per calendar in a column, plus a `calendarList` token in `meta` keyed by account (store/schema.rs:43, pull.rs:26, 204) | one per account database, Gmail's `historyId` (mirror/write.rs:29) | | Commit point | end of the page chain, because `nextSyncToken` only arrives on the last page; the whole chain is one transaction and an interruption restarts it (pull.rs:136-195) | as soon as changes are on disk and deliberately before hydration, so a failed crawl does not re-read the log (changes.rs:51-56) | | Cursor expiry | 410 drops that calendar's rows and cursor and re-syncs it alone (pull.rs:105-132) | drops nothing; `reconcile` lists the window into a TEMP TABLE and diffs locally, because the mirror holds decisions the state database joins against (changes.rs:141-228) | | Fetch | one phase, `events.list` returns whole events | ids, then metadata in batches of 50, then bodies, with a `hydrated` column so an interrupted crawl resumes across restarts (hydrate.rs:1-9, changes.rs:69-92) | | Writes | `If-Match`, and a 412 is a lost race that is surfaced and never retried with the etag dropped, since dropping it is the clobber the check prevents (api.rs:310-360, push.rs:5-8) | no etag exists, so writes are declarative: what the labels should be, not what to do to them (api.rs:649-650), which is why they are safe to replay | | The seam | `Transport`, six methods, every one naming a Google Calendar type, one implementation and a test stub (transport.rs:15-62) | `Provider`, fourteen methods, no Google type at all, three implementations: Gmail, IMAP and a 612-line fake (provider/mod.rs:166-249) | An abstraction over "a token per collection, committed at the end of a chain, with etags" and "a log per account, committed halfway, with declarative writes and a quota accountant in the middle" would be larger and harder to read than either engine it replaced. Do not build it. What is worth extracting is the scaffolding, the same to the line in places: roughly 150 to 250 lines into `margin-db` and a small `margin-sync`. The poll loop shape, spawn then `FIRST_PASS_SECS = 2` then an interval chosen by window focus, with `fn focused(app)` byte-identical (calendar sync/mod.rs:39, 205-223; mail sync/mod.rs:51, 374-393), the intervals staying per app at 60/300 s against 12/60 s. The `Sink` trait, so a pass runs in a test with a recorder instead of an `AppHandle` (calendar sync/mod.rs:69-78, mail sync/mod.rs:238-249). The connection-borrowing seam, carrying the same justification verbatim in both, that nothing inside may await because the guard is a std one and holding it across a suspension point would make the future non-Send (calendar sync/mod.rs:114-115, mail sync/mod.rs:220-221). The push-before-pull budgets, 20 s for the outbox in a pass and 4 s at quit (calendar sync/mod.rs:42, 44; mail outbox.rs:30, 32). And the `meta` table with its `schema_version` key, the refusal to open a database written by a newer build and forward-only numbered steps inside a transaction (calendar store/schema.rs:100-136, mail mirror/schema.rs:21-63), which belongs in `margin-db`. The four event names both apps agree on, `store-changed`, `sync-progress`, `auth` and `menu-action`, are fixed in `margin-ipc` rather than in the engines. All of this buys consistency rather than deletion, which is the honest reason to do it. ## Backup **margin's** is whole-file mirroring. `collect_local_files` gathers `*.margin` books and the custom dictionary (gdrive.rs:576-600), hashes each, and uploads anything whose hash moved (gdrive.rs:810-835). `gdrive_sync` downloads any remote file with no local counterpart and skips any that has one (gdrive.rs:877-884), so the local copy always wins and there is no merge. Nothing is encrypted: the books go up as they are, into a visible folder called `margin` at the Drive root under `drive.file`, which `docs/publishing.md:131, 162-164` defends as deliberate. **Margin Mail's** is an append-only encrypted journal. Segments of 500 records (backup/mod.rs:48) named `//-.seg`, the account hash a 128-bit truncated SHA-256 of the address so a folder listing is not a list of somebody's email addresses (mod.rs:52-77). Each segment is sealed with XChaCha20-Poly1305 with its own name as additional authenticated data, so a store that reorders, replays or moves a segment gets a decryption failure rather than a wrong answer (crypto.rs:60-105). Merge is a union, because a device only ever writes under its own sequence. The key comes from a 24-word BIP39 phrase through Argon2id at RFC 9106's second profile, 64 MiB, three passes, one lane (phrase.rs:21-48), with a constant salt because a second device has the phrase and nothing else. The store is a three-method trait, `put` / `get` / `list` (store.rs:20-30), the intersection of Drive's REST API and S3, with two implementations: Drive and S3 sigv4 signed by hand. **The one genuine overlap is five HTTP functions.** margin's `ensure_folder`, `find_file`, `list_in_folder`, `upload_file` and `download_file` (gdrive.rs:361-496) and Mail's `ensure_folder`, `find_file`, `list_folder`, `upload` and `download` (google/drive.rs:100-256) are the same calls written twice. Mail's is better in five specific ways: it pages at 1000 rather than 100 (drive.rs:176 against gdrive.rs:432), it escapes the Drive query language (drive.rs:47-49), it randomises the multipart boundary where margin hardcodes `margin7f3e2a1b9c8d` (drive.rs:74-78 against gdrive.rs:455), it percent-encodes the file id into the path (drive.rs:228-231), and it returns a classified `ApiError`. Move Mail's five into `margin-google` as a `drive` module with the `drive.file` scope constant, `escape`, and the `FOLDER_NAME = "margin"` root, which both apps hold as their own string literal today (gdrive.rs:16, drive.rs:28); each app then names its own subfolder, `margin/mail/` for Mail. **Should the newer design replace the older? No, and not because of effort.** They back up different things. Mail's journal is append-only records of decisions, which is what makes a union merge correct; margin's payload is a book file a person edits on two machines, where a union is meaningless and the answer is last-writer-wins or a real merge. Wrapping books in an encrypted journal would also break the property `docs/publishing.md` defends, that the folder in a person's Drive is legible and their books are files they can open. What margin should take is narrower: the five Drive verbs, `ApiError`, and the sealed store for its refresh token. The whole-file mirror stays, and its real defect, no merge and local always wins, is a product decision for margin's own docs rather than something this consolidation should quietly change. ## Per app, in order **Margin Mail** first, because it is the source for all three crates. 1. Define `listen_for_redirects` in `src-tauri/src/lib.rs`, ported from calendar lib.rs:150-172. Defect 2, and it blocks any mobile build. 2. Add `"form"` to the reqwest features (Cargo.toml:37-43); delete `form_body`, `query_string`, `url_with`. 3. Extract `google/secrets.rs` into `margin-secrets` as a `Store` value, on-disk format byte for byte. Rewire `google/auth.rs`, `backup/crypto.rs:119-138` and `backup/r2.rs:29`. 4. Extract the error, retry, backoff and `Quota` half of `google/api.rs` into `margin-http`, with `Quota::new(6_000, 60_000)` at the call site and the `Call` unit table staying in Mail. 5. Extract `google/auth.rs`, `google/browser.rs`, `build.rs` and the five verbs of `google/drive.rs` into `margin-google`. Mail implements `AccountSink` over `crate::accounts`. `auth::remove`'s database teardown and its `keep_data` flag stay in Mail; the crate's `disconnect` does the revoke, the token and the session and nothing else. **Margin Calendar** second, and mostly deletion. 1. Upgrade `chacha20poly1305` to 0.11 (Cargo.toml:40) and adopt `margin-secrets`. No data migration. Keep `reference()` locally: two lines serving a column no other app has. 2. Adopt `margin-http`. Replace `error_for` (api.rs:191-197) with the crate's, keeping the 410 and 412 mappings on top, and wrap `push::drain` in `with_retry_no_replay`, because an event write carrying an etag must not be replayed. Defect 3. 3. Adopt `margin-google`. Delete `google/auth.rs`, `google/browser.rs` and `build.rs`; implement `AccountSink` over `store::write::upsert_account`; pass the existing scope string as a slice for `base_scopes` and an empty `required_scopes`; move `listen_for_redirects` out of lib.rs. 4. Take the two extra `auth` event fields as empty arrays and update `src/` to ignore them. **Margin (the writing studio)** last, and it gains the most. 1. Before anything else, stop writing the refresh token in plaintext. Add `margin-secrets`, move `BackupState.refresh_token` (gdrive.rs:68) into the sealed store, and have `load_state` migrate an existing plaintext token on first read and then clear the field. Defect 1, worth doing on its own schedule if the rest slips. 2. Fix the credentials path: add the build script call, point `CREDENTIALS_JSON` (gdrive.rs:22-23) at `OUT_DIR`, delete the runtime read (gdrive.rs:41-42), bring the example file to the three-block shape. Defect 5. 3. Adopt `margin-http` for both clients (gdrive.rs:25, updates.rs:6). Defect 4. 4. Adopt `margin-google`, deleting roughly 330 lines of gdrive.rs: the PKCE, the inline listener, the token endpoint calls, the session. `base_scopes` is `["openid", "email", "drive.file"]` and `required_scopes` is `["drive.file"]`, which margin already enforces by hand (gdrive.rs:181-186, 522-532) and the calendar still does not. 5. Adopt the crate's Drive verbs, deleting gdrive.rs:361-496. 6. Make `gdrive_disconnect` (gdrive.rs:777-803) report the revoke outcome, and add the settings sentence saying it signs the person out of every Margin app. Defect 6. What is left of `gdrive.rs` afterwards is the file mirror, the hash ledger and the Tauri commands. ## What stays per app The sync engines, whole, and the provider and transport traits. Every scope list, poll interval and quota budget, because they are facts about a resource rather than about OAuth. The per-call unit table in Mail's `google/api.rs:119-176`, which names Gmail methods. The account registry itself, a SQLite row in one app and `accounts.json` in the other, behind `AccountSink`. Mail's `auth::remove` database teardown and its `keep_data` flag. Mail's backup journal, phrase derivation and R2 store, and margin's whole-file Drive mirror. `imap/discover.rs`'s Chrome user agent, correct there and wrong everywhere else. And all the app copy: the consent explanation, the disconnect warning, the not-set-up sentence, because copy is product and the crate only supplies the app name it is built from.