apple-mail-archive-export
Skill0 starsChanged 4 months agoMITView on GitHub
Bulk-export or recover an entire Apple Mail account from its local on-disk store to verified .eml, .mbox, and a searchable SQLite index, reassembling detached attachments - without launching Mail.app, AppleScript, or the Apple Mail MCP. Use this whenever someone wants to archive, back up, export, bulk download, migrate, or rescue a mailbox, especially when they are leaving a job or school and losing access to a work or university email account, or when the server is already unreachable but the mail still appears in Mail.app. Also use it for questions about .emlx or .partial.emlx files, ~/Library/Mail internals, extracting attachments in bulk, converting Apple Mail to mbox or Thunderbird, or making an old mailbox searchable offline. Reach for this even when the request sounds small ("can I get my old emails out of Mail?", "export my inbox", "save all my attachments") - the naive approaches silently lose about half the attachments on a typical Exchange account, so the careful path matters even at small scale.
In this repository23 other files it gives its agents.
- AGENTS.mdAGENTS.md
- apple-mail-mcpb/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- .claude-plugin/CLAUDE.mdCLAUDE.md
- docs/CLAUDE.mdCLAUDE.md
- tasks/CLAUDE.mdCLAUDE.md
- tests/CLAUDE.mdCLAUDE.md
- tools/CLAUDE.mdCLAUDE.md
- context-guidance-auditSkill
- cowork-plugin-customizerSkill
- create-cowork-pluginSkill
- finalize-apple-mail-mcpSkill
- find-skillsSkill
- mail-scripting-dictionarySkill
- marketplace-release-handoffSkill
- mcp-builderSkill
- plugin-creatorSkill
- plugin-settingsSkill
- plugin-structureSkill
- python-performance-optimizationSkill
- reviewing-codeSkill
- testing-pythonSkill
- thermo-nuclear-code-quality-reviewSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 4 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 67
Run these in order. Steps 1-3 are mandatory.
- Very specific: line 81
If reads fail with EPERM, the terminal lacks Full Disk Access - grant it in System Settings > Privacy & Security > Full Disk Access.
- Very specific: line 109
If this is the only copy of the mail, say so plainly and check whether a backup exists (tmutil destinationinfo).
- Very specific: line 149
If written is far below total, or reassembled is 0 on an account with many partials, stop and investigate rather than shipping.
- Distinctive: line 297
Do not delete the account from Mail Settings or Internet Accounts - uncheck
- Very specific: line 298
"Enable this account": instead, which is reversible.
- Distinctive: line 305
Never upload it, never sync it to a service, never attach a sample to an issue, and never copy it outside the destination the person named.
- Very specific: line 310
If you are editing this skill rather than running it, you are working in Agentic-Assets/apple-mail-mcp, a public GitHub repo.
- Distinctive: line 332
All are standard-library Python 3 or bash.
- Very specific: line 340
references/pitfalls.md: - every trap hit during real recoveries, ordered by damage.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Apple Mail archive export
Recover a complete, verified, searchable local archive of an Apple Mail account
by reading its .emlx store directly off disk.
This works when the mail server is dead, the credentials are expired, or the account was never fully synced, because everything needed is already on the Mac. It never launches Mail.app and never touches the live store.
Why this exists instead of the obvious approaches
The obvious paths silently lose data, and each failure looks like success:
- Apple's own File > Export Mailbox omits attachments when an account is
offline, and mishandles
Fromescaping. - The Apple Mail MCP's
full_inbox_exportis disabled in its own source (UNBOUNDED_EXPORT_DISABLED) because full-mailbox walks spike Mail.app CPU; callers are capped at 50 messages per call. A 39,000-message mailbox would be ~786 serialized AppleScript round trips through a live Mail instance. - The popular
.emlxconverters contain no handling for.partial.emlx, so they drop attachments on every message whose attachments Mail detached - typically about half of an Exchange account.
Direct filesystem extraction did a 39,269-message / 15 GB account in 188
seconds with byte-level proof. Details and citations: references/pitfalls.md.
One honest exception, worth knowing so you can offer it: on a small, still-live
account, reading each message through Mail's AppleScript source property is
lossless and can fetch parts the local cache never stored. Some detached
messages have no payload on disk at all - 2,316 of 25,412 in a reference store -
and no filesystem method can recover those. It is the wrong default because it
needs a live server, does not scale, and makes Mail rewrite its own cache while
serving reads, but it is the right repair for the specific messages
export_emlx.py reports as missing_parts.
The one idea that matters most
.partial.emlx does not mean "not downloaded." It means Mail moved the
attachment part bodies into a sibling Attachments/ directory and left the
full MIME skeleton behind with X-Apple-Content-Length stubs. The bodies are
local. A correct export splices them back in.
Get this wrong and you produce a large, plausible archive missing half its attachments. This is the single most common way these exports fail.
Workflow
Run these in order. Steps 1-3 are mandatory; step 4 onward is shaping the output.
1. Discover which account you actually want
SKILL=<this skill dir>
python3 "$SKILL/scripts/discover_accounts.py"Account directories are named by opaque UUID, so this correlates
Accounts4.sqlite, each mailbox's Info.plist, and real message headers to tell
you which UUID is which address, how many messages it holds, and how
attachment-detached it is.
If reads fail with EPERM, the terminal lacks Full Disk Access - grant it in
System Settings > Privacy & Security > Full Disk Access. The script says so
explicitly rather than leaving you to guess.
Confirm the UUID with the user before continuing. Exporting the wrong account wastes hours and, on a shared machine, exposes mail that was not yours to read.
2. Snapshot before anything else
ARCHIVE=~/Documents/<Name>-Mail-Archive
bash "$SKILL/scripts/snapshot_account.sh" <AccountUUID> "$ARCHIVE"This is ~1 minute for 15 GB on an internal SSD, and it converts every later
mistake from "lost mail" into "delete the copy and redo." It uses ditto
deliberately - macOS rsync is openrsync, whose -a silently drops every
extended attribute and exits 0. Then it SHA-256s both trees and refuses to
declare success unless they match.
Quit Mail.app first (Cmd-Q, never force-quit). .emlx files are written whole so
they are not at risk, but Mail continuously rewrites Info.plist and the
Envelope Index.
Before writing tens of GB, check the destination is not cloud-synced unless that is the goal. iCloud Desktop/Documents sync, Dropbox, and Google Drive folders will all start uploading, and Drive enforces the account's quota.
If this is the only copy of the mail, say so plainly and check whether a backup
exists (tmutil destinationinfo). Two copies on one SSD share a failure domain;
that is worth stating rather than implying the mail is now safe.
3. Export, then prove it
python3 "$SKILL/scripts/export_emlx.py" \
--snapshot "$ARCHIVE/01-raw-snapshot/<AccountUUID>" \
--out "$ARCHIVE/10-export"
python3 "$SKILL/scripts/verify_export.py" \
--snapshot "$ARCHIVE/01-raw-snapshot/<AccountUUID>" \
--out "$ARCHIVE/10-export" --sample 2500export_emlx.py writes one .eml per message with attachments re-embedded, the
original folder tree preserved, chronologically sortable filenames, plus
index.sqlite (FTS5), messages.jsonl, and export-report.json.
verify_export.py is not optional, and running it is the main discipline this
skill is teaching. It decodes attachments back out of the exported .eml files
and byte-compares them against the originals on disk. The reason: a full export
once reported 39,269 written, 0 parse errors while silently emptying every
attached email in the corpus, because Python's set_payload() on a
message/rfc822 part accepts a string and produces an empty part without
complaining. Counts and exit codes cannot see that class of bug; only a byte
comparison can. It exits non-zero on any mismatch, so you can gate on it.
Sanity-check the report against the source before believing it:
| Report field | Should equal |
total / written | the .emlx count from step 1 |
reassembled | the .partial.emlx count from step 1 |
parse_errors, unreadable, reassembly_failed | 0 |
invariants_ok | true (the script asserts its own sums and exits non-zero if they do not close) |
missing_parts | small, and every entry explainable |
unidentified_files (verify) | 0. Anything else is a message the verifier declined to examine |
If written is far below total, or reassembled is 0 on an account with many
partials, stop and investigate rather than shipping. Pairing reassembled
against the independently counted partial total is the single most useful check,
because it fails loudly in exactly the case where detached attachments were
skipped instead of spliced.
Several report fields have alarming names and benign meanings - xacl_mismatch
in the hundreds is normal, and the export legitimately finds more messages than
Mail displays. Read references/report-fields.md before reporting a number as a
problem.
Sampling is stratified, so do not read --sample 2500 as "2,500 random
messages." The verifier forces in every message with an unusual shape (a nested
email, an .emlxpart sidecar, a re-zipped bundle, a rare payload extension, an
unusual part count, an oversize body) and fills the rest of the budget uniformly.
This matters because a uniform draw is close to useless for the bugs that actually
occur: on the 39,269-message reference account a uniform 2,500-message sample
contained none of the 26 messages carrying a nested email, while the stratified one
found a message whose entire body was missing. Use --all when the account is
small enough to afford it, and quote the coverage percentage when it is not.
4. Add browsable and searchable surfaces
python3 "$SKILL/scripts/make_mbox.py" --out "$ARCHIVE/10-export" # optional
python3 "$SKILL/scripts/check_mbox.py" --out "$ARCHIVE/10-export" # if you made mbox
python3 "$SKILL/scripts/split_mbox.py" --out "$ARCHIVE/10-export" # if any mbox > 4 GiB
python3 "$SKILL/scripts/extract_attachments.py" --out "$ARCHIVE/10-export" # optional
python3 "$SKILL/scripts/search.py" --archive "$ARCHIVE/10-export" --statsmake_mbox.py produces one mboxrd file per folder for importing into
Thunderbird, Apple Mail, or MailMate. It roughly doubles disk usage, so offer it
rather than assuming - the .eml tree plus the index already covers search and
long-term durability. Keep .eml as the archive of record, since mbox must
mutate body bytes to escape lines beginning From .
If you generate mbox, run check_mbox.py. It reverses the conversion -
strips the envelope line and injected flag headers, un-escapes >From , and
demands the original .eml back byte for byte. mbox is the one output that has
to mutate bytes, and header damage there is invisible to every count-based check:
an earlier version of make_mbox.py corrupted the folded Received: header on
85% of an Exchange mailbox while separator counts, attachment byte-comparison and
Message-ID multisets all passed. A round trip can only pass if every byte was
preserved or reversibly transformed, which is why it is worth its own gate.
Split anything past a few GiB before handing it to a client. Large single mbox
files are widely reported to fail on import, 4 GiB being the threshold usually named;
say that it is the conventional split point rather than a limit anyone here measured.
Splitting is cheap and provably lossless, a failed import is neither, which is the
whole argument. split_mbox.py splits every
oversized file in mbox/ into per-year files in 10-export/mbox-by-year/, copying
record bytes through verbatim and never re-encoding a message. It proves the split
instead of asserting it: the source file, the records it streamed, and the per-year
outputs re-read and concatenated in ascending year order must all give the same
SHA-256, and the per-year record counts must equal the UTC-year histogram in
index.sqlite. That closed accounting is only available because make_mbox.py
wrote records in date_epoch order, which makes each year a contiguous run. On the
reference account it turned the 9.0 GB Inbox and 8.5 GB Sent Items into four
per-year files each, largest 3.5 GiB, in 44 seconds with all three hashes equal for
both. Read the script's docstring before changing where the year comes from; the
envelope line is a deliberate choice over the index, and the timezone reasoning
behind it is written down there.
For getting the result into a specific client - Thunderbird needs an add-on,
Apple Mail wants a particular directory shape, Outlook takes neither - see
references/importing.md. It also covers which flag headers each client reads,
and why the imported unread count will disagree with Mail's old sidebar badge.
search.py gives full-text search over decoded subject, body, sender,
recipients, and attachment names. Decoding is the point: bodies are base64 or
quoted-printable on disk, so ripgrep over raw files under-recalls badly.
Offer extract_attachments.py when the user wants the files rather than the
mail. It writes 20-attachments/: one content-addressed copy of each distinct
attachment under blobs/<aa>/<bb>/<sha256>, then three hard-linked views over
the same inodes - by-folder/, by-sender/, and by-type/, each partitioned by
year - plus a JSONL manifest with one row per occurrence. Dedup by content hash
is what makes this affordable: on the reference account 44,299 occurrences
collapsed to 6,459 distinct files, because 85% of everything attached to business
mail is the same Outlook signature logo pasted over and over. That is 14.46 GB of
occurrences stored as 8.62 GB, and du on the tree agrees, so the saving is real
inodes rather than an accounting claim.
Two things in it are worth understanding before you edit it. Decode before you
sanitize, never after. A filename can hide ../../../escape.txt inside an RFC
2047 base64 word or split it across RFC 2231 continuations, so a sanitizer that
runs first sees something harmless and passes it through; the bundled test asserts
containment for both encodings and for Windows reserved names, absolute UNC paths,
and macOS case-insensitive collisions. And the rule that excludes body parts is
load-bearing, because Apple Mail detaches a text/calendar invitation body to
disk exactly like a real attachment: treat those as attachments and the count
inflates by roughly 1,800 on a corpus this size, which is most of the way to the
count discrepancy described in references/pitfalls.md.
The 14 attachments whose payload never made it to disk get a manifest row with
sha256: null and a .UNAVAILABLE.txt marker inside by-folder/, so the gap is
visible to someone browsing the tree and not only to someone running a query. The
same run also settles the leftover-payload question pitfalls.md raises: 380 of
the snapshot's payload directories were never consumed, and hashing all of them
showed 376 were byte-duplicates of attachments already stored and 4 were invite
bodies excluded by design. Reconcile that class of gap by content hash, never by
comparing path counts.
5. Make the archive self-sufficient, then write its README
Copy the scripts the user will actually need later next to the archive, so searching it does not depend on this skill still being installed:
mkdir -p "$ARCHIVE/20-tools"
cp "$SKILL/scripts/"{search.py,verify_export.py,check_mbox.py,export_emlx.py} \
"$ARCHIVE/20-tools/"They are standard-library Python 3, so a bare python3 20-tools/search.py --stats
works years from now with nothing to install.
Check where the archive is actually sitting before declaring done. A scratch
or temp directory is wiped on reboot, and ~/Desktop and ~/Documents may be
iCloud-synced. Move it somewhere durable and suffix the directory .noindex so
Spotlight does not index a second full copy of the mailbox.
Then write the README. Future-you will not remember any of this. Record where the copies are, the verified counts, the date coverage, what is not in the archive, and the search commands. State limitations plainly:
- The cache may not go back as far as the user's tenure. Coverage starts
wherever Mail last rebuilt its store. Check the earliest
Date:header and report it - if mail predates that, it exists only on the server, which makes "ask IT for a full mailbox export before the account is purged" urgent and time-sensitive. This is the highest-value thing to surface early, because it is the only part that is still recoverable and it expires. - Signatures no longer verify. Mail stores messages LF-normalized rather than in CRLF wire format, so DKIM/S-MIME cannot be re-validated. That loss happened when Mail wrote the files, not during export - say so rather than implying the export is byte-faithful to the original transmission.
- The archive is a slight superset of what Mail shows. Files with no database row (messages Mail deleted but never unlinked) are included, because the exporter walks the filesystem.
Never do these to a dead account
Mailbox > Rebuild in Mail is the most dangerous menu item available: its model
is "discard local, re-fetch from server," which with no server is a one-way
delete. It is also the folk remedy for "Mail is acting weird," and a broken
account is acting weird, so warn explicitly.
Do not delete the account from Mail Settings or Internet Accounts - uncheck
"Enable this account" instead, which is reversible. Avoid Erase Deleted
Items. Never call the MCP's synchronize_account on a decommissioned account.
The archive stays local. This skill's own repo is public.
An export is somebody's entire mail history: every address they ever wrote to,
every attachment, every subject line. It belongs on their disk and nowhere else.
Never upload it, never sync it to a service, never attach a sample to an issue,
and never copy it outside the destination the person named. The archive README
and report.json live inside the archive, which is why they may carry real
counts and real folder names.
If you are editing this skill rather than running it, you are working in
Agentic-Assets/apple-mail-mcp, a public GitHub repo. Measured numbers from
a real export are fine to commit and are what make these docs trustworthy. The
mail behind them is not: keep real addresses, subjects, account UUIDs, and
absolute /Users/... paths out of SKILL.md, the references/, the scripts,
and their tests. See root AGENTS.md § This repo is PUBLIC.
Bundled scripts
| Script | Purpose |
scripts/discover_accounts.py | Inventory accounts: UUID -> address, counts, size, partial ratio |
scripts/snapshot_account.sh | ditto snapshot + SHA-256 proof both trees match |
scripts/export_emlx.py | .emlx -> .eml with attachment reassembly, FTS5 index, JSONL, report |
scripts/verify_export.py | Byte-compare exported attachments against originals, covering both the decoded Attachments/ and encoded .emlxpart sources; stratified sampling; exits non-zero on mismatch |
scripts/make_mbox.py | One mboxrd file per folder, flags written for both mutt and Thunderbird |
scripts/check_mbox.py | Byte-exact round trip of every mbox message back to its .eml; exits non-zero on mismatch |
scripts/split_mbox.py | Per-year split of any mbox over --max-bytes (default 4 GiB) into mbox-by-year/; verbatim record bytes, proves the ascending concatenation still equals the source SHA-256; exits non-zero on any broken invariant |
scripts/extract_attachments.py | Every attachment into 20-attachments/, deduplicated by SHA-256 into blobs/ with hard-linked by-folder / by-sender / by-type views and a JSONL manifest; decodes RFC 2047/2231 filenames before sanitizing them; --self-test runs the containment suite |
scripts/test_extract_attachments.py | Containment and classification gate for the extractor: filename traversal, encoded traversal, reserved names, case collisions, sandbox escape, 91 cases |
scripts/search.py | Full-text + filtered search CLI over the archive |
All are standard-library Python 3 or bash. No dependencies to install.
Reference files
references/emlx-format.md- the on-disk format: byte layout, plist keys, the hash-bucket rule, attachment reassembly mechanics, theflagsbitfield, and how to read the Envelope Index safely. Read this when writing custom parsing or locating one message by id.references/pitfalls.md- every trap hit during real recoveries, ordered by damage. Read this before deviating from the workflow, and when something looks wrong. It is also the place to look when tempted to swapdittoforrsync, trust the Envelope Index for enumeration, inject a header between existing headers, or skip verification.references/report-fields.md- what every field inexport-report.json,verify_export.pyandcheck_mbox.pymeans, which invariants to assert, and which alarming-looking numbers are benign. Read this before telling a user a number looks wrong; several of them routinely cause false alarms.references/importing.md- getting the archive into Thunderbird, Apple Mail, MailMate or Outlook, which flag headers each client reads, and how to read a failed import. Read this whenever the goal is a working mailbox rather than a preserved one.references/future-work.md- known gaps and how to close them. Read this before extending the skill, when a request lands just outside what the scripts do (attachment-only extraction, exporting every account at once, marking messages Mail no longer lists), or when something here feels clumsy and you are wondering whether that is deliberate. It also records what is genuinely impossible, so you do not spend effort on it.
Match the output to what they actually asked for
The full workflow produces three things: a durable archive of record, importable mbox, and fast search. They have very different costs, and people asking for one often get quoted the price of all three. Ask which problem is being solved before committing to 56 GB.
| What they actually want | What that needs | Rough cost on a 39k-message, 15 GB account |
| "I'm losing access, save everything" | the whole workflow | ~56 GB, ~5 min |
| "I need to find things in my old mail" | snapshot + index.sqlite only | ~15 GB, and the index is 128 MB |
| "Get my mail into Thunderbird" | snapshot + .eml + mbox | ~40 GB |
| "Just get me the attachments" | snapshot + .eml, then extract | ~20 GB |
Search is the cheapest of these by a wide margin, and the export is oversized
for it alone. On the reference account index.sqlite is 128 MB out of a 56 GB
archive, about 0.2%, and it is the entire reason queries return in ~50 ms. If
someone still has server access and just wants better search than Mail's own, an
index-only run is the proportionate answer, not a full archive.
Two caveats before offering that. The index stores paths to the exported .eml
files, so an index-only build points back into the live Mail store: --open and
full-message reads then depend on Mail not moving or purging anything, and results
drift as Mail rewrites files underneath. And the durability argument does not apply,
because you would have fast search resting on the same cache they are trying to get
off. So index-only is right for "my mail is fine, Mail's search isn't" and wrong for
"I'm about to lose this mailbox."
Worth knowing when someone asks whether they needed any of this: Mail's own search
does work, and works well - body indexing was complete on 90,415 of 90,418 messages
in a reference store. What it cannot do is be scripted (Spotlight returns zero
results inside ~/Library/Mail, which macOS excludes), find the messages whose
Envelope Index rows Mail has already deleted (533 of 39,269 on the reference
account), or survive Mail purging a dead account's cache. Say that plainly rather
than implying their mail was unsearchable before.
Scaling notes
For a mailbox under a few thousand messages the whole workflow is under a minute
and there is no reason to cut corners. For very large accounts (100k+), export
per top-level mailbox with --only to keep runs restartable, and expect the
attachment re-encoding to dominate wall time rather than message parsing.
The archive footprint is roughly: raw snapshot (1x) + .eml export (~1.2x) +
optional mbox (~1.2x). Confirm free space before starting, and mention the total
so the user is not surprised.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

