Your old Gmail, on your own box.
Mbox Archive turns Gmail Takeout exports into a private, searchable web
reader for your household. Everyone gets their own login and their own
folder-scoped archive β nobody can see anyone else's mail. Drop .mbox
files into a folder, hit Merge and Re-scan, and years of email become a
fast threaded view with labels, facets and full-text search, reachable
from any device on your LAN. Nothing ever leaves home.
It is a reader, not a mail server: no sending, no receiving, no syncing β it serves the archives you export. The whole app is two Docker containers (a Rust/axum backend and a SvelteKit frontend) plus a handful of bind-mounted folders.
- π§ One archive per person β separate folder, separate bcrypt-verified password, separate everything; the roster of profiles is the whole access model.
- π₯ Gmail Takeout ingestion β
.mboxexports merge into one archive per person, with Message-ID dedup, so overlapping exports collapse into single copies instead of doubles. - π§΅ Threads, labels & facets β conversations grouped by Gmail's
X-GM-THRID; a filter sidebar with label, date, and identity facets. - π Fast full-text search β the in-memory index takes roughly 500
bytes per message (a 300k-message archive indexes in seconds, not
minutes), and the search field understands
to:someone@else.com-style queries. - πͺͺ Multi-address identities β declare several accounts or aliases and
every message is tagged with the ones it involves; Gmail plus-aliases
(
you+shopping@β¦) collapse onto the base address automatically. - β¨οΈ Vim-style keyboard flow β
hjklbetween panes,gg/G,d/u,/to search,yto copy a permalink for what's on screen; press?in-app for the full cheat sheet. - πΌοΈ Per-profile polish β display name, portrait on the sign-in chooser, and a password change that instantly revokes every older session.
- ποΈ Verified, self-pruning backups β gzip snapshots are
decompression-checked before they count as done, and only the newest
Nare kept (BACKUP_KEEP, default 10). - π«₯ Small on purpose β runs on Pi-class hardware; LAN-only by design (the honest caveats live in the threat model).
Where you run things. Every command below is typed in a terminal on
the machine that hosts the containers, inside the cloned
mbox-archive/ folder. Docker with Compose v2 is the only requirement β
even the profile bootstrap runs inside the app's own image, so nothing
depends on your host shell.
RAM scales with the archive (~500 bytes per message in the index; a million messages β 500 MB), on top of disk space for the mbox files. Two people's full Takeouts run comfortably on a Pi-class box.
git clone https://github.com/ErCollao/mbox-archive.git
cd mbox-archiveThe backend refuses to start without at least one account (fail-closed), so seed yourself first:
docker compose run --rm --build -v "$PWD/scripts:/scripts:ro" \
backend bash /scripts/new-profile.sh daniel "Daniel"The first run builds the two images (a few minutes, once). The script
then asks for the password twice β that is the login β and creates
profiles/daniel/ and data/daniel/ for you.
docker compose up -dFirst boot generates the cookie signing key and starts indexing. On very
large archives, wait for Indexing complete in
docker compose logs backend. Then open http://localhost:3132 from
any device on your network: everyone picks who they are β each profile
sees only its own mail β and signs in with the password from step 2.
Request your exports at takeout.google.com
(Mail only), unzip, and copy the .mbox files into data/daniel/. Then
follow the in-app β maintenance page β Merge β Re-scan β verify.
The full story, including what happens when exports overlap, is in
Adding new mail.
That's it. Reading mail starts now; household members are one script away (People & profiles).
Three panes: filters on the left, conversations in the middle, the reader on the right. You land on your primary category, like Gmail's inbox; the sidebar offers categories, labels, status, date range, "has attachments", and β when several addresses are declared β an Addresses group. Rows show the thread, the newest sender, the date, and chips marking which of your addresses the conversation involves.
Click or press Enter to open; a thread's messages navigate with j/k,
f makes it full screen, and browser Back (or Esc) steps out of the
email β not out of the app. y copies a permalink to whatever is on
screen; p reopens the last one.
The search box covers subjects, senders and β with the Full toggle β
message bodies, over the whole index, instantly. to:someone@else.com
and from: terms work on their own; filters stay applied while you
search, so you can drill down inside a label or a date range. Press c
to clear every filter at once.
The whole view is driven without the mouse (in-app help: press ? or
the bottom-left corner key). Keys never fire while you're typing in a
field β there, hjkl are just letters.
| Key | What |
|---|---|
h l (or β β) |
focus previous / next pane β the focused pane lifts with a shadow; the email pane also gets a blue frame |
j k (or β β) |
move within the focused pane (filters, emails, or thread messages) |
Enter / Space |
open email Β· apply highlighted filter Β· toggle full screen in the email pane |
gg / G |
jump to first / last item of the focused pane |
d / u |
half page down / up in the list (keeps loading more at the end, like j) |
y / p |
copy a link to what's on screen / open the last copied link |
c |
clear all filters |
/ |
jump to the search field β Enter runs the search and hands the keys back; Esc leaves the field |
f |
full screen the open email |
Esc |
leave an open email; elsewhere hides all highlights (cursors remember their spots) |
| browser Back | closes an open email (opening a thread pushes a history entry, so Back steps out of the email, not out of the app) |
? |
this help |
Same archive, drawer-based layout β the filters slide in, the panes stack, and there is no app store in the loop.
Everything that keeps the appliance alive rather than reading it: new mail flowing in, the household roster in order, and copies of it all that survive a dead disk. It runs through the in-app β maintenance page and two small scripts.
Getting new mail in is a deliberate two-move ritual, not a sync β and that's what makes it safe.
- Export. takeout.google.com β
deselect everything β Mail β create export β download the zip β
unzip. You get one or more
.mboxfiles (big archives are split intombox-1.mbox,mbox-2.mbox, β¦). - Drop. Copy them into
data/<you>/β the drop inbox. As many files and as many rounds as you like. Nothing indata/is ever auto-deleted, and nothing you drop there can damage what's already archived. - Run the β maintenance page, in order. It is a per-profile runbook with your real folder names filled in:
Gmail exports generations of the same mail β your old Takeout from 2022 and today's will both contain thousands of the same messages, and two accounts of yours exchanged copies of every thread between them. Merge sorts that out by itself:
- Every message keeps its Gmail
Message-ID; Merge foldsdata/plus the existing archive into onearchive/merged.mboxand collapses duplicates to a single newest copy. The log reports exactly how many were skipped. - Mail you sent from account A to account B existed twice in the exports β after dedup it is one message inside one conversation, tagged with both addresses. No "double threads".
- Merge only adds: it never removes anything from the archive, it writes to a temp file and renames atomically (an interrupted merge cannot corrupt the archive), and it is safe to repeat as often as you like.
- After a merge, Re-scan rebuilds the index (seconds to a minute, even for big archives). Until your first Merge the boot scan indexes only the newest single mbox file β the runbook warns you when that's the case; Merge + Re-scan is always the complete answer.
The stats row up top tells you what's live: message count (should be β₯
last time) and the newest date (should match your new export). Search
for a message you know is in it. Only once you're happy, empty
data/<you>/ β everything is in the archive now. The Backup button
at the bottom of the runbook snapshots the archive and keeps the ten
newest verified copies; how that fits into real redundancy is in
Backups & retention.
Adding someone = one script; it works everywhere via docker. From the repo folder on the host machine:
docker compose run --rm -v "$PWD/scripts:/scripts:ro" \
backend bash /scripts/new-profile.sh hajni "Hajni"
docker compose restart backendThe script prompts for the password twice, writes
profiles/hajni/profile.json and creates the folders; the restart
loads them. Hajni now appears on the sign-in chooser, signs in, and can
set name, portrait and addresses in Settings. On Linux/macOS hosts
you can skip the docker wrapper and run ./scripts/new-profile.sh hajni
directly β it detects where it is.
(The seeded secret is a sha256 hash; the first successful login upgrades it to bcrypt automatically. Nothing to do.)
docker compose run --rm -v "$PWD/scripts:/scripts:ro" \
backend bash /scripts/delete-profile.sh hajni
docker compose restart backendThe script removes the login (secret, addresses, portrait) and then
offers to delete the mail too β showing the size it would free β never
without an explicit yes. --purge does profile and mail in one go,
guarded by typing the username. The restart revokes old session cookies
of the removed person (if they were the last profile, the backend
deliberately won't boot until a new one exists).
The avatar chip opens Settings: display name, password, portrait (JPEG/PNG/WebP, β€12 MB, cropped to a 512px square, shown on the sign-in chooser). Changes are written straight to the profile and take effect immediately β no restart, and a rename never logs anyone out.
Changing the password is the revoke switch: session cookies carry a
fingerprint of the secret they were issued against, so every older
session dies the moment the password changes β your current device just
stays signed in. Deleting archive/session.key instead logs everyone
out at once.
One person can declare several identities β separate Gmail accounts merged into one archive, or aliases that forward into it β under addresses in Settings (lowercase, max 30). With two or more declared:
- every email row gets a chip listing which of your addresses it
involves (
from,to, orcc), and the sidebar grows an Addresses filter group; - with 0 or 1, everything stays exactly as before β no chips, no group.
Worth knowing: chips are derived at read time from already-indexed
headers β adding an address relabels the whole archive instantly, no
rescan, and removing one forgets it cleanly. Gmail plus-aliases collapse
(hajni+news@gmail.com counts as hajni@gmail.com unless the exact
plus-address is declared). And merging two accounts' exports is just
Adding new mail twice: duplicates across the two
Takeouts collapse into single messages inside shared threads.
The runbook's Backup button writes verified gzip snapshots into
backup/<slug>/ (temp file β full decompression check β atomic
rename; a partial or corrupt backup never appears as a completed one).
It keeps the ten newest mbox-archive-*.mbox.gz per profile
automatically β tune with BACKUP_KEEP on the backend service; 0
disables pruning. For that to mean anything, BACKUP_DIR should point
at a different physical disk than the archive. Nothing schedules
backups yet: a nightly tar czf snapshot.tgz backup/ profiles/ archive/
cron (or a Synology task) covers the whole tree. profiles/ is small
and precious β it is the password hashes and address lists.
Update: git pull && docker compose up -d --build. All state lives
in bind-mounted folders outside the images, so updating β or rolling
back β never touches anyone's mail.
Stay healthy: both services carry healthchecks (docker compose ps
should say healthy); docker compose logs -f backend shows indexing,
merge and backup milestones; the frontend waits for a healthy backend
before starting.
LAN-only by design. One password per household member β each person's login opens only their own archive, nobody shares credentials β bcrypt-verified over plain HTTP behind the home router. This is a family appliance, not a vault. Don't put this on the open internet as-is: the app ships no TLS, no Secure cookie flag, no rate limiting, and no account lockout. For remote access, terminate TLS in a reverse proxy (Caddy/nginx) and add authentication in front.
Sessions are HMAC-signed year cookies carrying slug:expiry:fingerprint
(archive/session.key); delete that file to log everyone out at once,
or change a password to evict just that one person's older sessions.
Cookies are HttpOnly; SameSite=Lax; the API sends no CORS headers at
all, so other websites cannot ride a logged-in user's cookie. Email HTML
is served with a sandbox CSP and rendered in a scriptless sandboxed
iframe; attachment previews downgrade markup types to plain downloads.
Known and accepted: profile names and portraits are readable without login (the sign-in chooser needs it), and there is no login rate limiting or lockout β acceptable next to "anyone on the LAN could just unplug the disk".
Repo and runtime data layout, the module map (session format, merge &
backup mechanics, XSS posture), test suites, the disposable test-env/,
the demo corpus generator and screenshot conventions all live in
CONTRIBUTING.md. The short version:
cd frontend && npm test # vitest
./test-env/up.sh # disposable full stack on :3199 β verify here, never on live dataCI enforces cargo clippy --all-targets -- -D warnings + cargo test +
npm test + production build on every push.
All mbox heavy lifting comes from mboxshell
(MIT): parsing Gmail Takeout exports, the on-disk binary message index,
and full-text search. Built on top of it: thread grouping by
X-GM-THRID, facet counts and the filter language, identity/address
tagging, the archive lifecycle (merge β scan β backup), and the entire
web UI.
MIT β free to use, modify, and share, with attribution.








