Architecture¶
One command, mailcellar, over one Python package, src/mailcellar/:
mailcellar setupadds an account from a downloaded OAuth client: checks the file, copies it into the config folder, lists the account inaccounts.tomland signs in read-only.mailcellar backup | count | metakeeps a local backup of each Gmail account, in Got-Your-Back's (gyb) on-disk format;verifychecks, read-only, that its files are still intact;scheduleruns backups regularly, unattended.mailcellar dedupfinds duplicate messages within and across the backed-up accounts, decides which copies to remove, and removes them from Gmail — in stages, with checks, never permanently.mailcellar offloadtakes old mail out of Gmail by rule, keeping it in the backup, in the same stages.mailcellar restoreputs mail back: offloaded mail out of Gmail's Trash within 30 days, otherwise uploaded from the backup; a whole backup, into its own account or another one.mailcellar exportcopies a selection of the backup out, read-only, as an mbox file for a mail client or a zip of the.emlfiles with a CSV index.mailcellar searchfinds mail in the backups, offloaded mail included, by words in the sender, recipients, subject and body, from a full-text index in the data root.mailcellar report(storage, bulk mail, volume, threads, labels, correspondents, backup health, senders) reports on the backups from the per-message sidecars.
This page describes the system as it is. The maintainers' design notes and real-data measurements are kept outside the repository.
Data flow¶
Gmail is read from one place and changed from another, and the backup sits between them. Everything else reads the backup, not Gmail.
Gmail
│ ▲
│ └─────────────────────────────── changes Gmail (gmail.modify) ──────┐
│ reads (gmail.readonly) │
▼ │
backup · count · meta │
│ add to │
▼ │
┌───────────────────────────────────────┐ │
│ THE BACKUP <data>/<email>/ │ ◄── verify · export · report │
│ msg-db.sqlite + .eml (gyb format) │ (read only) │
│ meta.sqlite (sidecar) │ │
│ chain.jsonl (hash chain) │ │
└───────────────────┬───────────────────┘ │
│ read by │
▼ │
dedup index · match · plan ──► dedup-plan.csv ──┐ │
offload plan ──► offload-plan.csv ┤ │
▼ │
dedup apply · offload apply · restore --yes ──────────┘
(labels, Trash, untrash, messages.import)
│
▼
logs and records: dedup-apply-log.csv, offload-apply-log.csv,
restore-log.csv, and meta.sqlite (`offloaded`, `restored`)
Reading the picture from the top: backup copies Gmail into the backup and
touches nothing in Gmail. The planning commands only read the backup and write
their own plan file. Only the three steps at the bottom change Gmail, and only
when run for real (--label/--trash, or --yes), with the broader
gmail.modify permission. Their dry runs still read Gmail, to check each
message, but change nothing. Each takes its instructions from a plan or a
selection you can see first. The apply steps record what they did in a
log and in meta.sqlite, and restore also indexes the messages it uploads
in the backup of the account they went into.
Who touches what:
| Command | Reads | Writes | Gmail |
|---|---|---|---|
setup |
the client JSON you name; accounts.toml |
the account's client_secrets.json, accounts.toml, a readonly token |
read-only sign-in |
backup |
Gmail | the backup | read-only |
schedule install, remove |
the config folder | the launchd agent (macOS) or nothing (a cron line is printed); logs/backup.log when it runs |
none |
schedule status |
the backups' runs, the token files |
nothing | none |
count |
Gmail, the backup | nothing (a line in runs) |
read-only |
meta |
the .eml files; Gmail, for label and thread details |
meta.sqlite |
read-only |
verify, export, report |
the backup | export: the file you name; the others, nothing |
none |
hold add, release |
the backup | meta.sqlite (holds), chain.jsonl |
none |
hold list, show |
the backup | nothing | none |
search |
the backups | search.sqlite |
none |
dedup index |
the backups | dedup.sqlite |
none |
dedup match, plan |
dedup.sqlite |
dedup.sqlite (groups); dedup-plan.csv |
none |
offload plan |
the backup, rules.toml; Gmail's search, for rules with a query |
offload-plan.csv, plans/ |
none, or read-only for a query |
dedup apply, offload apply |
the plan; Gmail, to check each message | with --label/--trash: Gmail labels and Trash; logs; meta.sqlite (offload) |
read-only for a dry run, modify with --label/--trash |
restore |
the backup; Gmail, to look each message up | with --yes: Gmail; restore-log.csv; meta.sqlite |
read-only for a dry run, modify with --yes |
Modules¶
All under src/mailcellar/; installed by uv sync (editable), so uv run
mailcellar and python -m mailcellar both work.
| Module | Role |
|---|---|
cli.py |
The mailcellar command: dispatches setup, backup, count, meta (every configured account by default), verify, auth, schedule, dedup, offload, forget, restore, export, hold, search, report (plus senders), and config, which shows paths and each account's state without printing secrets; sets up logging (stderr; -v/-q anywhere on the line) |
progress.py |
Progress for long steps: done of total, rate and time left, rewritten on a terminal or logged now and then; Gmail's batches advance the innermost open Progress |
paths.py |
Where things are: config_root(), data_root() and where each was set, backup_folder(), find_backups(), pruned_folder() (where an older backup --prune moved mail) |
mailbox.py |
What the commands need from a mail account: the Mailbox protocol (listing, history, raw messages, labels, trash and untrash, Message-ID lookup, import), which GmailAccount provides, and open_mailbox(), the one place backup, dedup, offload and restore open an account. auth and setup use gmailapi directly, since they manage Gmail's credentials |
gmailapi.py |
Accounts (<config>/accounts.toml), OAuth tokens and scopes, a Workspace domain's service-account key for read-only access (<config>/service-accounts/<domain>.json, delegated_credentials), GmailAccount: adaptive batching, retries with backoff, listing, history, labels, trash and untrash, batched Message-ID searches, messages.import |
imap.py |
An IMAP account as a read-only Mailbox: a scan reads folders, UIDs, flags and new headers (EXAMINE, BODY.PEEK) and decides with pure functions over frozen values (IDs from Message-ID, date and size or content, folders and flags as labels, what is gone); the UID map is kept in meta.sqlite (imap_map) and written only through backup's connection (save_state). Writes raise |
message.py |
Message parsing: parse_message(), content_hash(), header_key(), extract_address(), PARSER_VERSION |
setup/cli.py |
setup: from a downloaded OAuth client to a configured, signed-in account. check_client, accounts_entries, pick_name, add_account, latest_download are pure; accounts.toml is only appended to, the client is written owner-only, and the sign-in is mailcellar auth's read-only one |
backup/commands.py |
backup (history-based or full listing), count, meta (sidecar backfill); --prune hands on to forget --deleted; the runs log |
backup/gyb.py |
gyb's format: writes msg-db.sqlite and <y>/<m>/<d>/<id>.eml, reads them back (iter_backup, backup_account), prune (moving files out, with a manifest) for forget |
backup/schedule.py |
schedule: installs backup --unattended as a launchd agent on macOS (a cron line elsewhere), status from the sidecars' runs and the token files, remove. The decisions (describe, launchd_plist, cron_line, overdue) are pure |
backup/verify.py |
verify: for each backup, every indexed message's .eml exists, parses and matches the content hash in meta.sqlite; the two databases agree on which messages exist (--offloaded, --sample). Both are opened read-only; the decisions (judge, select, disagreements) are pure |
backup/chain.py |
The hash chain (chain.jsonl): one link per command that adds to or removes from a backup, appended before the change is committed, and its anchor in <config>/anchors/; verify --chain replays it against every file. next_link, replay, compare, check_anchor are pure |
backup/sidecar.py |
The per-account sidecar meta.sqlite: schema, migrations, runs, the offload record |
dedup/cli.py |
argparse and one cmd_* per subcommand (index, match, plan, apply, auth); mailcellar auth is its auth, since authorisation serves every command, and dedup auth stays as an alias |
dedup/db.py |
dedup.sqlite schema and migrations |
dedup/index.py |
Catalogue backups into dedup.sqlite, from the sidecars where possible |
dedup/match.py |
Group copies of the same message |
dedup/rules.py, dedup/plan.py |
Keep / remove / review decisions; the plan CSV |
dedup/apply.py |
Live checks against Gmail, then label or trash; the audit log. Shared with offload. apply_plan() reads Gmail's state, check_row()/checked() decide, and act() carries out one account's rows; each returns new frozen Results (the plan rows are never changed) |
dedup/report.py, dedup/paths.py |
Text reports; default output locations |
offload/ |
rules.py (TOML rules), plan.py (selection, plan CSV, summary; the messages each protection keeps), review.py (plan --review: a local HTML page per rule from the plan's results; samples, largest messages, busiest threads and the page itself are pure), apply.py (backup checks, the offload record, batch manifests on top of dedup's label → trash loop), forget.py (what forget can forget, the 30-day hold, scrubbing every derived store; retention, --due by the rules' forget_after), cli.py (offload), forget_cli.py (the forget command; offload forget hands on to it for a release) |
restore/ |
sources.py and mbox.py (--from-pruned, --from-mbox, --from-folder: candidates from a pruned batch, an mbox file or another gyb folder), apply.py (which offloaded messages, narrowed by date, label and sender; backup checks; each message's way back: untrash, already in the mailbox, found by Message-ID, or import; indexing a new ID in the backup), cli.py (the command, and the selection options export shares), export.py (export: the same selection copied out read-only as an mbox file or a zip of .eml files with an index; the mbox entry, label header and index row are pure) |
hold/ |
holds.py (legal holds: Criteria, Hold and Facts; covers(), holding() and held_now() decide, pure; read_facts() and held() read the sidecar; HoldCheck is the local_check that keeps held mail in Gmail on offload apply and dedup apply; add() and release() write the holds table and the hash chain), cli.py (the command). forget (--deleted too) and --empty ask held() before removing anything |
search/ |
index.py (each message's sender, recipients, subject and readable body into the index, incrementally: document() and changes() are pure), query.py (the query language translated to FTS5 by fts_query(); keep() for the offload filters and render() for a result are pure; find() reads), db.py (search.sqlite schema and migrations), cli.py (the command) |
report/ |
Reports over all accounts' sidecars, attached read-only (db.py): storage (with each offload batch's space freed, from offloaded.gone_ms), bulk, volume, threads, labels, correspondents, health; senders.py for one backup; output.py formats tables |
Files on disk¶
<config>/ credentials, owner-only: $MAILCELLAR_CONFIG, else
~/.config/mailcellar, else ./config (the old place,
gitignored); paths.config_root()
accounts.toml folder name → address (work = "alex@example.org")
<name>/client_secrets.json the account's OAuth client (Desktop app)
<name>/token-readonly.json # + authorised_at: when consent was last given
<name>/token-modify.json # only with keep_modify_token; otherwise held per run
settings.toml data_dir: the data root; token_warn_days, keep_modify_token (optional)
<data root>/ everything derived from mail: $MAILCELLAR_DATA, else
data_dir in <config>/settings.toml, else ~/mailcellar
(or an existing ./data, the old default); paths.data_root()
<email>/
msg-db.sqlite gyb index (db_version 6)
<year>/<month>/<day>/<gmail_id>.eml
meta.sqlite sidecar (schema v11), with legal holds
_pruned/<email>/<timestamp>/ messages an older backup --prune moved out, + pruned.csv
_forgotten/<email>/<run>/ messages forget deleted, held 30 days, + forgotten.csv
logs/
backup.log output of the scheduled backup (schedule install); not rotated
offload/
rules.toml offload rules (user-written)
offload-plan.csv the latest offload plan, input of offload apply
plans/<utc time>.csv every plan made, named by its planned time
offload-apply-log.csv one row per plan row per apply run, with the plan's time
batches/<batch>/offloaded.csv manifest of each trash batch: plan row + batch + trashed
review/<rule>.html offload plan --review: a page per rule (senders, subjects)
restore/
restore-log.csv a row per message per restore run (dry run or --yes); last counts
search/
search.sqlite full-text index (schema v1); rebuilt if deleted
dedup/
dedup.sqlite dedup index (schema v5)
dedup-plan.csv output of plan, input of apply
dedup-apply-log.csv one row per planned removal per apply run; keep it
Databases¶
| Database | Owner | Contents | Versioning |
|---|---|---|---|
msg-db.sqlite |
backup/gyb.py |
gyb's exact schema: messages, uids, labels (names), settings |
fixed (gyb db_version 6) — never extended |
meta.sqlite |
backup/sidecar.py |
per message: parsed fields (incl. sender name, Reply-To, content hash, header key, list headers, In-Reply-To), thread ID, UTC date, size, parser_version; attachments (name, type, size, hash) and recipients; label IDs; label catalogue; history_id; a runs log of every backup and count (and prune, before it ran forget); offloaded, mail removed from Gmail on purpose; restored, mail put back and its ID now; holds, legal holds and their criteria |
SCHEMA_VERSION + MIGRATIONS; rows go stale by parser_version |
search.sqlite |
search/db.py |
per message: account, Gmail ID, path, size, date, sender, subject, text_version; an FTS5 index of sender, recipients, subject and body that stores no text (contentless_delete, SQLite 3.43+) |
SCHEMA_VERSION + MIGRATIONS; index.TEXT_VERSION forces a re-read, independent of PARSER_VERSION |
dedup.sqlite |
dedup/db.py |
all accounts' messages (paths relative to backups.folder), labels, match groups |
SCHEMA_VERSION + MIGRATIONS; meta.parser_version forces a re-parse |
The gyb format is kept exactly (verified byte-for-byte against gyb
1.97), so gyb and anything else that reads it can still use the backups;
mailcellar restore replaced gyb's restore (2026-10-25). Anything gyb has
no room for goes in meta.sqlite.
Backup¶
- First run (or
--full,--search,--refresh-labels, or expired history): list every message, download what's missing (format=raw), and store the mailboxhistoryIdtaken before the listing. - Later runs: read Gmail's history since that ID (kept ~4 weeks), look
up the current state of each changed message, and download new mail,
update labels, and skip Spam/Trash/Chat — from current state, so a
repeated run is harmless. The new
historyIdis stored only after a run with no failures. - Parse once: each downloaded message is parsed and written to
meta.sqlitein the same pass;metabackfills older mail. - Never deletes: taking mail out of the backup is
forget's, asked for by hand. Its--deletedtakes messages permanently gone from Gmail (Trash and Spam count as present;forget.gone_from), behind a typed count and a 10% guard (forget.deleted_decision), holding them 30 days. Messages recorded inoffloadedalways stay, and count neither toward the guard nor as deleted incountor a backup run.
Deduplication¶
Matching (dedup/match.py): two copies are the same message when they
have the same content hash — the visible text (HTML tags, URLs and
addresses removed) plus every attachment's hash — and either the same
Message-ID or the same header key (From, Date, Subject). Groups are
connected components; each is cross (spans accounts) or within.
Message-IDs reused for different content are reported, not grouped.
The rule was chosen by checking candidates against real backups: sender,
date and subject alone grouped different messages (two invoices, two
tickets); a hash of the whole body missed re-sends that differ only in
tracking links; and identical visible text is common across genuinely
separate mail, so the content hash is never used on its own.
Planning (dedup/rules.py), per group, keep / remove / review:
- cross-account: --keep <account> keeps that account's copies, or
--keep origin removes only copies auto-forwarded from another account
(X-Forwarded-For), leaving independent deliveries alone;
- within an account: keep one copy from the largest set with the same
labels; copies with other labels go to review;
- safety rules on every removal: sent/draft copies, a keeper in
Trash/Spam, stars or user labels the keeper lacks → review.
Applying (dedup/apply.py): dry run by default. --label [NAME] adds
a review label in Gmail; --trash [NAME] moves labelled copies to Trash
(recoverable for 30 days). Before touching a message it checks that the
local backup copy exists, the message and its keeper are still in Gmail,
the keeper isn't in Trash/Spam, and the message's labels haven't changed
since the plan. Removing the label in Gmail vetoes a message.
Restore¶
restore/apply.py, in two steps, passing frozen values (Candidate →
Decision → Result):
plan()(read-only) checks each backup copy (the.emlparses and matches the sidecar's content hash), reads Gmail's state (one lookup per message, or two ID listings above 1,000 messages) and searches for matching Message-IDs. Thendecide(), a pure function of what was read, picks each message's way back:untrash,in gmail(offloaded, already out of Trash),present(never offloaded, still there),found(same Message-ID and content under another ID, from a batchedrfc822msgid:search),import, orskipped(unusable backup copy, or the Message-ID held by other content).apply()(or, for the dry run, the puredry_run()) returns aResultper decision. It untrashes, then uploads withmessages.import(Date header as the internal date, spam classifier off) 200 messages at a time. Each success is recorded in the source backup'srestoredtable at once, so a rerun carries on. Imported messages are indexed under their new IDs withgyb.add_copy()(a hard link to the old.eml) in the backup of the account restored into.
Gmail matches an import to the message already holding its Message-ID, so
a retried upload returns the same message rather than a copy. A message
with no Message-ID has nothing to match, so import_messages sends it
again only after a rate limit, which Gmail refuses before acting on; after
a server error or a timeout it is reported as possibly imported. The old ID
of an imported message stays in the backup: sidecar.kept_ids() protects
it from forget --deleted, and superseded_ids() keeps it out of reports and
dedup. With --to, restored rows carry the target account and leave the
source's offload record alone.
Other sources (restore/sources.py, restore/mbox.py): --from-pruned,
--from-mbox and --from-folder build the same Candidates from a pruned
batch's pruned.csv, an mbox file (a candidate's span is where its entry
is; read_raw() reads it back) or another gyb folder, parsed once for the
sender, date and content hash. plan() and apply() are unchanged apart
from that: every message takes the other-account way back (found by
Message-ID and content, or import), and apply() with no backup folder
records and indexes nothing. The mbox reader splits at From lines at the
start of the file or after a blank line, undoes mboxrd escaping, drops the
headers Takeout adds, and maps Takeout's label names to Gmail's IDs; all of
that is pure.
Safety model¶
- Read-only by default: backups,
count,meta,index,match,planand dry-runapplyusegmail.readonlyor no API at all. gmail.modifyis requested only for the accountapply --label/--trashorrestore --yeschanges, and held in memory for that run, never stored, unlesskeep_modify_tokenis set; a stored modify token also serves read-only requests. It isn't revoked at Google afterwards, since that would revoke the read-only token too.- Nothing is ever permanently deleted from Gmail (no
batchDelete), and the backup keeps everything unlessforgetis asked for. Restore only adds, and skips what Gmail already has. - Every account call checks it reached the expected mailbox.
Testing¶
uv run pytest — no network. Gmail is replaced by fakes:
FakeMailbox (tests/fake_mailbox.py: one account, providing Mailbox,
with history computed from mailbox snapshots) and FakeService (the googleapiclient surface, for batching and retries);
FakeBackup builds gyb folders. Changes are also checked against real
backups, read-only, before they are relied on.