Project Overview

Build what doesn't exist.
A private mail archive, made with Claude Code and Python.

I was migrating from Google Workspace to Zoho mail. There was no need to import 8 years of messages into Zoho, but the history was still worth keeping. Google has an export tool called Takeout. It created a single 2.5 GB .mbox file. Rather than hand the archive to a third party, I built the reader: a splitter, a search index, and a full mail client - in the Python standard library alone, entirely through Claude Code.

Origin story

The existing options all wanted something in return - a live email account, a third-party extension, an upload to somebody's cloud - to read mail I already owned. None of that is a technical requirement. It is just how the tools happen to be built. So this one is built the other way: it runs local in my browser and never sends a message anywhere.

Neon Mail showing the label sidebar with All mail at 30,221 messages, Gmail categories beneath it, a full-text search, filter chips for unread, starred and attachments, a sort dropdown, and the matching message list.

The Problem

Closing a Google Workspace account is easy. Keeping the mail is not. Google Takeout hands you your archive in a format designed for migration between mail providers - not for reading.

01  THE EXPORT

Eight years in one file

Every message, label, star and attachment, concatenated into a single 2.5 GB .mbox. Correct, complete, and unreadable by any normal application.

02  THE OPTIONS

All of them wanted something

Desktop clients expect a live account to attach the archive to. Others need a third-party extension, or want the whole thing uploaded to read it back to you.

03  THE STAKES

Download it... then what?

Once the account closes, the export is the only copy. Whatever reads it has to keep working with no service behind it to depend on.

How It Was Built

Three problems, solved in order, because each one depends on the last: get the messages out of the blob, make every message searchable, then make them pleasant to read. Every line written through Claude Code.

STEP 01

Split the Archive

mbox_split.py walks the 2.5 GB file with Python's own mailbox module and writes each message as a separate .eml, packed into size-capped ZIPs.

STEP 02

Index for Search

A SQLite database with an FTS5 full-text index over every subject, sender, recipient and body. Indexing runs at roughly 300 messages a second and is incremental on re-run.

STEP 03

Build the Client

A local HTTP server and a single-page front end: labels, threads, attachments, search operators, sorting, read and starred state, and a Trash that can be undone.

STEP 04

Make It a Real App

A taskbar launcher opens it in an app-mode browser window and stops the server when the window closes. It behaves like an installed program, without being one.

What It Does

Everything expected of a mail client, minus the parts an archive does not need. There is no compose window, no sync, and no account - just fast, complete access to mail that already exists.

Search

  • Full-text across the whole archiveFTS5 Subject, sender, recipients and message bodies. Words are ANDed and prefix-matched, so invoi finds invoice.
  • Field operators from:linkedin, subject:delivered, body:refund, file:.pdf, or "an exact phrase" - combined freely in one box.
  • Sorting that understands mail headers Sender sorts by display name rather than the raw header, so "Amazon.com" <order-update@amazon.com> files under A. Subject sorting ignores Re: and Fwd: so replies stay with their thread.
  • Filters that stack Gmail labels in the sidebar, chips for unread, starred and attachments, all combining with whatever is in the search box.

Reading

  • HTML mail rendered as sentsandboxed Bodies render in a sandboxed iframe with a default-src 'none' policy. The sender's design is preserved exactly; it just cannot escape the frame or restyle the application around it.
  • Real attachments, separated from clutter Marketing mail ships logos and tracking pixels as MIME parts. Anything inline is classed as embedded art, so the paperclip filter counts only genuine files - 451 messages here, out of 4,358 total parts.
  • Threads grouped as Gmail had them Conversations use Gmail's own thread ID, so they group exactly the way they did before the export.
  • Deleting that is not destroying Delete is a database flag; the .eml files are untouched and one click restores from Trash. Permanent deletion exists, but only inside Trash, always confirms, and sends files to the Recycle Bin.

Under the Hood

Four SQLite tables and about three thousand lines of Python. No framework, no package manager, no build step - python neonmail.py serve and it runs.

The index is an index, not a store

The .eml files on disk stay the only copy of the mail. Message bodies are duplicated into SQLite so search can reach them, but attachment bytes never are - they are extracted from the original file on download. Delete the database and nothing is lost; re-running index rebuilds it.

TableHolds
messagesOne row per .eml: headers, both bodies, dates, counts, and the read / starred / deleted flags
labelsGmail labels, one row per message per label - what drives the sidebar
attachmentsOne row per MIME part, with the inline flag that separates real files from tracking pixels
msg_ftsThe FTS5 search index over subject, sender, recipients and body
Why the search index stores no text

msg_fts is declared content='' - a contentless FTS5 table. It stores the search terms and nothing else, rather than keeping a second copy of every message body. That is the difference between a database roughly the size of the mail and one twice that size.

The trade-off is that rows cannot be updated in place. Deleting a message means handing FTS5 the old values back so it can unpick them - which is why delete_message() reads the row before it removes it. Skip that and deleted mail keeps turning up in search results.

# an external-content FTS5 index needs the old values to delete cleanly
conn.execute(
    "INSERT INTO msg_fts(msg_fts, rowid, subject, sender, recipients, body) "
    "VALUES('delete', ?, ?, ?, ?, ?)",
    (msg_id, row["subject"], row["sender"],
     row["recipients"], row["body_text"]),
)

Standard library only

  • mailbox and email parse the archive
  • sqlite3 is the database and the search engine
  • http.server is the web server
  • ctypes reaches the Recycle Bin on Windows
  • Nothing to pip install, ever

Built to stay working

  • WAL journaling, so browsing while an index runs does not block
  • Incremental indexing - unchanged files are skipped by size and mtime
  • A schema version that migrates in place where it can, and rebuilds only when a re-parse is genuinely required
  • fix_paths.py repairs stored paths after the folder moves

Private by construction

  • Binds to 127.0.0.1 - not reachable from the network
  • No account, no telemetry, no sync
  • Remote images can be blocked per message, and scripts are always stripped
  • The archive never leaves the machine it lives on

Why Build It Instead

This is not a better mail client than the ones that already exist. It is a better mail client for this - which is the whole point, and the part that changed.

What bespoke gets you

  • Exactly the features that matter - no compose window, no sync, no onboarding, because an archive needs none of it
  • Your constraints, not a vendor's - offline and account-free were requirements here, not options on a pricing page
  • Decisions you can inspect - every trade-off is visible in the source instead of buried in a product roadmap
  • It cannot be discontinued - no subscription to lapse, no service to shut down, no forced migration in three years
  • It fits the data as it actually is - Gmail's quirks, this archive's particular mess of labels and categories

What changed to make it viable

  • The build cost collapsed - a weekend, not a project. That is what moves custom software from "not worth it" to obvious
  • Unfamiliar corners stopped being blockers - FTS5 quirks, MIME parsing, Win32 shell calls: all reachable without weeks of study
  • Iteration got cheap - sorting, categories and permanent delete were all added after first use, because using it revealed what it needed
  • The documentation kept pace - the README explains the reasoning, not just the commands, and stayed current as the code changed
  • Judgment still matters - what to build, what to leave out, and what "finished" means are still yours to decide