34 KiB
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; 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 | 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.
- Scopes. The calendar has one string,
pub const SCOPES(calendar auth.rs:28). Mail has a six-entryBASE_SCOPES(mail auth.rs:29-35), aREQUIRED_SCOPEan account is refused for (auth.rs:41),scope_stringfor extras (auth.rs:274), andgranted_scopes/missing_requiredreading what Google granted back off the token response (auth.rs:292, 300); the calendar'sTokenResponsehas noscopefield. Parameters:base_scopesandrequired_scopes, slices, the second allowed to be empty. - Re-consent. Mail has
grant()(auth.rs:725) because Google offers installed apps no incremental authorization: picking upcalendar.eventsto answer an invite costs the whole consent screen again. No parameter; the calendar never calls it. login_hintandprompt. Mail'sauth_urltakes a hint and switchespromptbetweenconsentandselect_account consent(auth.rs:607-639); the calendar always sendsselect_account consent(calendar auth.rs:519-530). No parameter;Option<String>onconnect.- Revocation. The calendar's
revokereturns 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. - Where the account record lands. A SQLite row through
store::write::upsert_account(calendar store/write.rs:168-181), againstaccounts.jsonthroughcrate::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. - HTTP body building. Mail hand-rolls
form_bodyoverurl::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.
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<Option<String>, String>;
/// (account_id, email) for every account with a token, read once at launch.
fn known(&self, app: &tauri::AppHandle) -> Result<Vec<(String, String)>, String>;
}
pub struct Granted {
pub account_id: String,
pub email: String,
pub display_name: String,
pub scopes: Vec<String>,
/// Non-empty means nothing was stored and there is no account.
pub missing_required: Vec<String>,
}
/// 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<dyn AccountSink>, 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<String>, hint: Option<String>)
-> Result<String, String>;
/// 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<String>)
-> Result<String, String>;
/// 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<Revoked, String>;
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<String, String>;
/// 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<String>);
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<String, Session> 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.
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<Option<Vec<u8>>, 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<Option<String>, String>;
}
A value, not the OnceLock<PathBuf> 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<String, String> 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 <path>.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.
/// 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<u64>, body: &str)
-> ApiError;
pub fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64>;
pub fn strip_urls(text: &str) -> String;
pub async fn read_json<T: DeserializeOwned>(resp: reqwest::Response, context: &str, scope: &str)
-> Result<T, ApiError>;
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<T, F, Fut>(call: F) -> Result<T, ApiError>
where F: FnMut() -> Fut, Fut: Future<Output = Result<T, ApiError>>;
/// 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<T, F, Fut>(call: F) -> Result<T, ApiError>
where F: FnMut() -> Fut, Fut: Future<Output = Result<T, ApiError>>;
/// 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<App>/<version>, 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.
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 <account-hash>/<device-id>/<first>-<last>.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.
- Define
listen_for_redirectsinsrc-tauri/src/lib.rs, ported from calendar lib.rs:150-172. Defect 2, and it blocks any mobile build. - Add
"form"to the reqwest features (Cargo.toml:37-43); deleteform_body,query_string,url_with. - Extract
google/secrets.rsintomargin-secretsas aStorevalue, on-disk format byte for byte. Rewiregoogle/auth.rs,backup/crypto.rs:119-138andbackup/r2.rs:29. - Extract the error, retry, backoff and
Quotahalf ofgoogle/api.rsintomargin-http, withQuota::new(6_000, 60_000)at the call site and theCallunit table staying in Mail. - Extract
google/auth.rs,google/browser.rs,build.rsand the five verbs ofgoogle/drive.rsintomargin-google. Mail implementsAccountSinkovercrate::accounts.auth::remove's database teardown and itskeep_dataflag stay in Mail; the crate'sdisconnectdoes the revoke, the token and the session and nothing else.
Margin Calendar second, and mostly deletion.
- Upgrade
chacha20poly1305to 0.11 (Cargo.toml:40) and adoptmargin-secrets. No data migration. Keepreference()locally: two lines serving a column no other app has. - Adopt
margin-http. Replaceerror_for(api.rs:191-197) with the crate's, keeping the 410 and 412 mappings on top, and wrappush::draininwith_retry_no_replay, because an event write carrying an etag must not be replayed. Defect 3. - Adopt
margin-google. Deletegoogle/auth.rs,google/browser.rsandbuild.rs; implementAccountSinkoverstore::write::upsert_account; pass the existing scope string as a slice forbase_scopesand an emptyrequired_scopes; movelisten_for_redirectsout of lib.rs. - Take the two extra
authevent fields as empty arrays and updatesrc/to ignore them.
Margin (the writing studio) last, and it gains the most.
- Before anything else, stop writing the refresh token in plaintext. Add
margin-secrets, moveBackupState.refresh_token(gdrive.rs:68) into the sealed store, and haveload_statemigrate an existing plaintext token on first read and then clear the field. Defect 1, worth doing on its own schedule if the rest slips. - Fix the credentials path: add the build script call, point
CREDENTIALS_JSON(gdrive.rs:22-23) atOUT_DIR, delete the runtime read (gdrive.rs:41-42), bring the example file to the three-block shape. Defect 5. - Adopt
margin-httpfor both clients (gdrive.rs:25, updates.rs:6). Defect 4. - Adopt
margin-google, deleting roughly 330 lines of gdrive.rs: the PKCE, the inline listener, the token endpoint calls, the session.base_scopesis["openid", "email", "drive.file"]andrequired_scopesis["drive.file"], which margin already enforces by hand (gdrive.rs:181-186, 522-532) and the calendar still does not. - Adopt the crate's Drive verbs, deleting gdrive.rs:361-496.
- 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 ofgdrive.rsafterwards 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.