Read-only search and extraction for the local Apple Mail store on macOS.
Reads the files Mail already keeps on disk — the Envelope Index SQLite database for metadata,
.emlx files for bodies. No AppleScript, no IMAP, no dependencies. There is no code path that can
send, delete, move or flag a message.
As a Claude Code skill:
git clone https://github.com/hexrw/mailgrep ~/.claude/skills/mailgrep
ln -s ~/.claude/skills/mailgrep/bin/mailgrep ~/.local/bin/mailgrepThe second line puts mailgrep on your PATH. Skip it and the CLI still works at
~/.claude/skills/mailgrep/bin/mailgrep.
As a Claude Code plugin:
/plugin marketplace add hexrw/mailgrep
/plugin install mailgrep@mailgrep
Standalone, anywhere:
git clone https://github.com/hexrw/mailgrep
ln -s "$PWD/mailgrep/bin/mailgrep" ~/.local/bin/mailgreppip install . also works, but managed Python installs (Homebrew) refuse it under PEP 668. The shim
avoids that entirely — there are no dependencies to install, and it runs on macOS's own
/usr/bin/python3.
~/Library/Mail is protected by macOS privacy controls. Grant Full Disk Access to the application
that launches your shell — your terminal or editor, not mailgrep itself. macOS attributes file
access to the launching app, so granting it to the executable does nothing.
System Settings → Privacy & Security → Full Disk Access → add your terminal, then quit and reopen it.
mailgrep doctor detects a missing grant and names the exact app that needs it.
mailgrep doctor # access, schema, coverage, freshness
mailgrep search --from alec@example.com --since 2026-06-01
mailgrep search --subject invoice --body "signed copy"
mailgrep search --to me@example.com --mailbox INBOX --unread
mailgrep read 12345
mailgrep attachments 12345 --extract ./out
mailgrep accounts
mailgrep mailboxesRun doctor first — it answers most "why is this empty" questions on its own. --from matches
address or display name; --subject, --mailbox and --to are substring matches; --body decodes
every candidate message and is much slower than metadata filters. Add --json to any command.
Every command reports what it examined, what it could not read, and what it skipped. If a result set is truncated or some messages were unreadable, the output says so — silence means complete.
| Exit | Meaning |
|---|---|
| 0 | success |
| 1 | usage or lookup error |
| 2 | Full Disk Access missing, no Mail store, or access undetermined |
| 3 | envelope index schema not recognised |
| 4 | coverage incomplete (doctor only) |
| 5 | an attachment could not be extracted |
Attachments have four states. Mail strips attachment bytes out of the .emlx and stores them in
a sibling directory, so a naive parser gets an empty payload and writes a 0-byte file while
reporting success. Worse, not_downloaded attachments were never fetched from the server and do
not exist locally at all — on one real mailbox that was 42% of them. mailgrep refuses to write
empty output and tells you which state applies.
Exchange/EWS accounts store nothing locally. For those, this returns nothing rather than stale data. Not a bug.
Freshness cannot be measured, only bounded. Mail does not fetch while it is closed, and macOS
exposes no last-sync timestamp anywhere. doctor reports whether Mail is running and when it last
wrote locally — treat that as a lower bound, never as proof anything synced.
Message ids are SQLite ROWIDs and Mail reassigns them when it rebuilds its index. Use them within a session; don't store them.
doctor can report coverage as INCOMPLETE. That usually means drafts and outbox, which Mail stores
differently. search reads those directly from disk anyway, labelled (unindexed), so they still
turn up.
just test # or: python3 -m unittest discover -s tests -t tests118 tests, run entirely against synthetic fixtures in a temp directory — no real mail, no Full Disk Access, no network.
docs/store-format.md documents what was reverse-engineered about the store:
schema resolution, timestamp epochs, the attachment layout, flag bits, and the TCC behaviour. Worth
reading before changing anything.
MIT