mailmate
Ruby toolkit for MailMate on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (mmsearch, mmmessage, mm-modify, mm-send, mmdiscover) for searching, reading, modifying, and sending mail.
Requires macOS with MailMate installed. The library code (filter parser, evaluator) works anywhere, but the integration with MailMate itself — AppleScript, on-disk index reads, the emate binary — is macOS-only by way of MailMate being macOS-only.
Example usage
mmsearch — find messages
# Default: today's mail, all mailboxes
mmsearch
# From "Medium" in the last 7 days
mmsearch 'f medium d 7d'
# Subject contains "rent due", not the word "draft"
mmsearch 's "rent due" !draft'
# Received in May 2026
mmsearch 'd 2026-05'
# Custom columns + cap results + raw CSV (no padding)
mmsearch 'f acme' 'id flags subject from' --limit 20 --no-align
mmmessage — read one message
# By local eml-id (the integer Msg ID column in MailMate)
mmmessage 183715
# By portable Message-ID (quote it — the angle brackets are shell metacharacters)
mmmessage '<CA+abc123@mail.example.com>'
# Raw .eml bytes (e.g. to pipe into `mail` parsers)
mmmessage 183715 --raw
# Body only, no headers block
mmmessage 183715 --text-only
mm-modify — change message state
# Mark a message read, flag it, and archive it — one open/wait cycle
mm-modify 183715 read flag archive
# Add a tag
mm-modify 183715 tag urgent
# Dry-run first
mm-modify 183715 archive --dry-run
# Verify the new flags after acting
mm-modify 183715 read --verify
mm-send — send mail
mm-send is a thin wrapper around MailMate's bundled emate mailto, with --markup markdown enforced. The body is read from stdin.
# One-liner
echo "Quick **markdown** body." | mm-send -t friend@example.com -s "Hello"
# Heredoc with cc + send-now
mm-send -t friend@example.com -c cc@example.com -s "Status update" --send-now <<'EOF'
## Update
- shipped the thing
- on to the next
EOF
# Attach files (positional args after options)
mm-send -t friend@example.com -s "Photos" /path/to/photo1.jpg /path/to/photo2.jpg <<<"See attached."
Why the names
The mm prefix is for tab completion: typing mm<tab> in a shell lists every command in the toolkit. The dash matters:
mm<name>(no dash) — read operations.mmsearch,mmmessage,mmdiscoveronly observe MailMate's on-disk state.mm-<name>(with dash) — write operations.mm-modify,mm-sendchange state (or send mail). Typingmm-<tab>filters to just the write commands so you can see at a glance what mutates.
Limitations
A few rough edges to be aware of:
Search is slow against this wrapper, even though MailMate itself is fast. MailMate has a fantastic search engine — its native quicksearch is near-instant even against large stores — but
mmsearchdoesn't yet route through it. The current implementation uses index prefilters plus direct.emlwalks, which works but is orders of magnitude slower than MailMate's own UI search, and painfully slow once a body-text term (b <term>, a bare term that falls through to body matching, or a--no-headers-onlyquery) disables the prefilter. Prefer narrowing withf/t/s/dfirst, and use--headers-onlywhen you don't need the body matched. Finding a way to drive MailMate's native engine from the outside is open work.Bulk
mm-modifytakes over the whole computer, not just MailMate. Each invocation opens a message-viewer window via themid:URL, runs AppleScript key-binding selectors against it, then closes the window. Two things follow from that:- Focus is stolen. When the
mid:URL fires, macOS brings MailMate forward and the spawned message-viewer window takes keyboard focus. Anything you were typing into another app goes to MailMate instead. - The close at the end can close the wrong window.
mm-modifyends by sending the standard "close window" keystroke. If focus has drifted (or the next app's window has come forward in the meantime), that keystroke lands on your window — your editor, your browser tab — not MailMate's viewer.
- Focus is stolen. When the
For one-off changes this is just annoying; for a loop of hundreds of messages it makes the machine unusable while it runs. Batch multiple actions into one mm-modify invocation when you can — they share a single open/close cycle. The --keep-window flag avoids the close-keystroke entirely if you don't mind cleaning up viewers manually.
eml-idis machine-local; preferMessage-ID:. The integer eml-id (also shown as MailMate's "Msg ID" column) is just the filename of the.emlon disk and differs on every install — copy/pasting an eml-id from your desktop to your laptop will refer to a different message (or none at all). For anything you want to keep, store the RFCMessage-ID:header (whichmmmessageprints) and pass that to the CLIs. Themid:%3C<message-id>%3EURL scheme works portably for the same reason.MailMate must be running. Anything that goes through
mm-modifyrequires MailMate open and unblocked by modal dialogs.mm-sendlikewise needs MailMate running —emate mailtoopens a draft window in the running MailMate process, so without MailMate up there's nowhere for the draft to land (this is true with or without--send-now). Headless / unattended use isn't supported.Single-account
mm-senddefaults.mm-sendpasses flags straight through toemate mailto. If you have multiple identities configured in MailMate and don't pass-f, MailMate picks the default identity — there's no opinionated multi-account routing in the wrapper.
Status
Pre-1.0 (0.x). Breaking changes allowed without version bumps. See docs/roadmap/Mailmate gem.md in the sibling people repo for the design history and remaining work.
Install
For development (no gem install needed):
git clone <this repo> ~/code/claude/mailmate
echo 'export PATH="$HOME/code/claude/mailmate/exe:$PATH"' >> ~/.zshrc
source ~/.zshrc
Then bootstrap your config:
mmdiscover
mmdiscover reads MailMate's Sources.plist and Identities.plist, shows you the accounts and addresses it found, and offers to write ~/.config/mailmate/config.yml from them. It also writes ~/.config/mailmate/bundle_loader.rb for MailMate bundles.
Commands
| Command | What it does |
|---|---|
mmsearch |
List messages matching a quicksearch expression. Output is aligned CSV. |
mmmessage |
Print one message by .eml id (decoded headers + plain-text body). |
mm-modify |
Mark read/flag/tag/archive/move a message via AppleScript. |
mm-send |
Send mail through emate with a markdown body on stdin. |
mmdiscover |
First-run bootstrap; (re-)writes the user config from MailMate's plists. |
Each command takes --help for usage. Tab-completion: mm<tab> lists every command; mms<tab> → mmsearch; mmm<tab> → mmmessage; mm-<tab> lists the write-side commands.
eml-id vs Message-ID
The CLI tools take an eml-id — the integer filename of MailMate's .eml storage (the same value as the Msg ID column in MailMate's UI, internally MailMate's #body-part-id). It's a counter MailMate maintains locally; eml-ids are NOT portable across machines. The same RFC Message-ID: will have a different eml-id on every install. If you need a cross-machine reference, use the message's Message-ID: header (which mmmessage <id> prints). The mid:%3C<message-id>%3E URL scheme works portably for the same reason.
Library
require "mailmate"
# Parse and evaluate a MailMate smart-mailbox filter
ast = Mailmate.compile_filter("from.name = 'Medium' and #date-received > '1 days ago'")
# ... feed `ast` to Mailmate::Evaluator ...
# Read the binary `#flags` index
reader = Mailmate::IndexReader.for("#flags")
reader.flags_for(180644) # → ["\\Seen", "$Forwarded"]
# Configuration
Mailmate.config.app_support_dir # → expanded path
Mailmate::Identity.mine?("brian@example.com") # → true if in identities
Using from a MailMate bundle
mmdiscover writes ~/.config/mailmate/bundle_loader.rb — a one-line bootstrap that lets MailMate bundle handlers find the gem. Every handler then does:
#!/usr/bin/env ruby
load File.("~/.config/mailmate/bundle_loader.rb")
require "mailmate"
# ... use Mailmate::IndexReader, Mailmate::HeaderReader, Mailmate::Identity, etc.
The bootstrap file is the only place that knows the gem's path on disk, so individual bundles stay portable across machines — copy a .mmBundle/ to another Mac, run mmdiscover there, and the bundle works.
Sample bundle
The gem ships a working sample at ~/Library/Application Support/MailMate/Bundles/Mailmate.mmBundle/:
Commands/Inbox Note.mmCommand— declares input (canonical body), env vars (from / subject / date / message-id), and output type (actions JSON).Support/bin/inbox_note.rb— the handler. Reads body from stdin, headers from env, usesMailmate::Identity.mine?to decide inbound/outbound, writes a markdown note to~/code/claude/people/projects/email/inbox/<YYYY>/<MM>/, and returnsmoveMessage(archive) +notifyactions.
To enable: restart MailMate (or use "Reload Bundles" in the Command menu). The "→ Inbox Note" entry will appear in Command → Mailmate gem bundle. Override the output directory by setting MAILMATE_INBOX_DIR in the .mmCommand's environment block.
See ~/code/claude/people/projects/email/mailmate-bundles.md for the bundle plist mechanics in full.
Configuration
Loading order: built-in defaults → ~/.config/mailmate/config.yml → environment variables (override YAML).
Available settings:
| Key (YAML) | Env var | Default |
|---|---|---|
app_support_dir |
MAILMATE_APP_SUPPORT_DIR |
~/Library/Application Support/MailMate |
identities (array) |
MAILMATE_IDENTITIES (comma-separated) |
[] |
A sample config.yml.example ships in the repo with placeholder values.
Tests
Two suites:
rake test # hermetic — no MailMate required, runs anywhere
rake test:live # live — runs against your actual MailMate install
rake test:live smoke-tests every smart mailbox in your Mailboxes.plist, decodes every Database.noindex/Headers/* index, and verifies one message round-trips through EmlLookup → HeaderReader → MidUrl. It's user-runnable so you can verify the gem works on your machine.
License
MIT. See LICENSE.txt.