mailmate
Ruby toolkit for MailMate on macOS — a smart-mailbox filter engine, on-disk index readers, and CLI tools (mmsearch, mmmessage, mmopen, mm-mailboxes, mmtags, 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
# Open in MailMate's UI instead of printing (delegates to mmopen)
mmmessage 183715 --mailmate
# Render an HTML-only message as clean markdown (no-op for text/plain bodies)
mmmessage 183715 --markdown
--markdown uses reverse_markdown + Nokogiri preprocessing to drop <style>/<script> blocks and strip newsletter preview-text padding (zero-width chars, runs of non-breaking / figure / narrow spaces, etc.). Conversion quality is good for plain replies/threads and rough for marketing-newsletter HTML (which uses nested layout tables); the raw source is still available with --raw.
mmopen — open a message in MailMate's UI
# By eml-id, Message-ID, message:// URL, or mid: URL — all six input forms
# that EmlLookup.resolve_id understands.
mmopen 183715
mmopen '<abc@example.com>'
mmopen 'message://%3Cabc%40example.com%3E'
# Print the mid: URL instead of opening it (useful in pipelines)
mmopen 183715 --print
# Spawn the viewer in the background — MailMate stays where it is and
# your keyboard focus is not stolen. Useful when scripting / cross-app.
mmopen 183715 --background # also: -g
mm-mailboxes — list accounts and mailboxes
# Grouped by account, with .eml counts per IMAP mailbox + smart-mailbox names
mm-mailboxes
# Skip counts (much faster on large stores)
mm-mailboxes --no-count
# Flat CSV (one row per mailbox; account repeated in column 1)
mm-mailboxes --csv
Account names are URL-decoded for display (%40 → @). Smart-mailbox names appear in their own section with - for count (not calculated — would require evaluating each filter).
mmtags — list tags
# Tags actually applied to messages, sorted by usage count
mmtags
# Tags defined in MailMate → Preferences → Tags (may include unused ones)
mmtags --defined
The default reads MailMate's #flags index (IMAP keywords, system flags excluded). The two views can differ: tags can be applied programmatically (via IMAP keyword) without being registered in Preferences, and tags can be defined in Preferences without being applied to any message yet.
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
# Pure-move (one or more moves, nothing else): same-account moves take a
# fast path — direct .eml rename on disk, no UI, no focus theft.
mm-modify 183715 move Archive
# Chain with other actions: everything (including the move) goes through
# MailMate's UI in user-supplied order. We're already paying for the UI for
# the tag/read; letting MailMate do the move too keeps its state in sync
# with itself, no #source-index staleness window.
mm-modify 183715 tag processed read move Archive
# Dry-run first
mm-modify 183715 archive --dry-run
# Verify the new flags after acting
mm-modify 183715 read --verify
# As of 1.2.0, `--verify` works in `--dry-run` mode too — pair them as a
# post-hoc state probe (run the action first, then re-run with --dry-run
# --verify to confirm the change took effect).
mm-modify 183715 read --dry-run --verify
Tip — batch your actions. Doing all related changes in one mm-modify invocation is still worthwhile: one open/close cycle instead of two, and the chain runs deterministically without you having to think about ordering. Splitting is safe — path_for falls back to a filesystem glob if MailMate's #source index is briefly stale after a fast-move — just slower than batching.
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,mmopen,mmtags,mmdiscoverobserve MailMate's on-disk state without changing it. (mmopenactivates MailMate's UI but doesn't modify any message.)mm-<name>(with dash) — write operations.mm-modify,mm-sendchange state (or send mail).mm-mailboxesis an exception: read-only, but uses the dash to keepmmm<tab>free for the daily-drivermmmessage. Typingmm-<tab>filters to the write-leaning commands.
Limitations
A few rough edges to be aware of:
- Non-move
mm-modifyactions briefly spawn a MailMate viewer window. Same-accountmoveactions use a fast path — a direct.emlrename on disk — so they're entirely silent. Everything else (read,flag,tag,archive,junk,delete, etc.) drives MailMate's URL handler: each invocation opens a message-viewer window via themid:URL in the background, runs AppleScript key-binding selectors against it, then closes the window.
As of 1.2.0 this runs in the background. MailMate is not brought to the foreground, your keyboard focus stays in whatever app you were using, and you can keep working — even during bulk loops. The viewer windows still appear briefly in MailMate's own window list before closing, but they don't take over your screen. The previous behavior (full-screen takeover, close-keystroke landing on the wrong window) is gone.
Two residual caveats:
- Don't bring MailMate to the foreground during a run. If you click into MailMate or Cmd-Tab to it while
mm-modifyis mid-flight, MailMate's responder chain shifts and subsequent perform-selectors may misbehave. Wait for the run to finish. - MailMate must be running. See #3.
The --keep-window flag leaves the spawned viewers open if you want to inspect them. Batch multiple actions into one mm-modify invocation when you can — they share a single open/close cycle (or skip it entirely for pure-move).
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
1.2.0 — mm-modify no longer brings MailMate to the foreground and is roughly 8× faster on single-action invocations. Internally: the open call uses open -g -a MailMate <url> to keep MailMate in the background, and the fixed --settle sleeps are replaced by active waits (polling for the spawned viewer window to appear). mmopen gains a --background / -g flag for ad-hoc use. mm-modify --verify now works in --dry-run mode as a post-hoc state probe. --settle is preserved for backward compat; it now caps the active-wait timeout rather than fixing sleep duration.
1.1.0 — reverse_markdown (and its transitive nokogiri dep) is now opt-in rather than auto-installed. Run gem install reverse_markdown if you want mmmessage --markdown; everything else is unchanged.
1.0.0 — initial public release; API stable from this point. Breaking changes bump the major version going forward.
Install
Requirements
- macOS with MailMate installed (and running, for any command that drives the UI or sends mail).
- Ruby ≥ 3.0.
- No third-party CLI tools — the gem only shells out to macOS-bundled
plutil,osascript, andopen, plus MailMate's bundledemate.
gem install mailmate
Then optionally bootstrap your config (will happen automatically on first invocation of any command from an interactive shell if it hasn't been run before):
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. Running it explicitly is only needed in non-TTY contexts (cron jobs, MCP servers) — there, the gem falls back to built-in defaults and warns once.
Optional: mmmessage --markdown
On the vast majority of Ruby setups (stock arm64-darwin or x86_64-darwin Ruby) this step is a no-op — nokogiri ships a precompiled binary, you can skip the rest of this section and move on. Keep reading only if your gem install actually fails.
mmmessage --markdown renders HTML-only message bodies as readable markdown. It needs the reverse_markdown gem, which has nokogiri as a transitive dependency:
gem install reverse_markdown
That single command pulls nokogiri in automatically — no separate gem install nokogiri step. This is kept out of the base install because nokogiri ships a native extension. On Ruby/platform combinations without a precompiled match nokogiri falls back to compiling from source — it vendors its own libxml2/libxslt, but it does need a C compiler, which on macOS means Xcode Command Line Tools (xcode-select --install). If gem install reverse_markdown fails, that's almost certainly the cause.
If you never use --markdown, you never pay any of this. If you do invoke --markdown without the gem already being installed (default on most Ruby versions), mmmessage exits with a clear install hint.
From source (development)
If you're hacking on the gem itself, skip gem install and put the repo's exe/ on your PATH. Clone wherever you keep source repos, then prepend its exe/ to PATH from your shell's rc file (~/.zshrc, ~/.bashrc, etc.):
git clone https://github.com/brianmd/mailmate.git
cd mailmate
# In your shell rc file, add (adjust the path to wherever you cloned):
# export PATH="/absolute/path/to/mailmate/exe:$PATH"
# Then reload the shell (open a new tab, or `source` the rc file).
Then mmdiscover as above.
MCP server
The gem also ships an MCP server (exe/mailmate-mcp) that exposes the same surface to AI assistants as JSON-RPC tools: search, message, modify, send, open, list_mailboxes, list_tags, resolve_id. After gem install mailmate, mailmate-mcp is on your PATH.
Claude Code (global, all projects)
claude mcp add --scope user mailmate "$(which mailmate-mcp)"
Or add manually to ~/.claude.json under "mcpServers":
"mailmate": {
"type": "stdio",
"command": "/absolute/path/to/mailmate-mcp",
"args": [],
"env": {}
}
Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"mailmate": {
"command": "/absolute/path/to/mailmate-mcp"
}
}
}
Restart Claude Desktop after any change to server code or config.
Commands
| Command | What it does |
|---|---|
mmsearch |
List messages matching a quicksearch expression. Output is aligned CSV. |
mmmessage |
Print one message by id (decoded headers + plain-text body). --mailmate opens in MailMate instead; --markdown renders HTML-only bodies as clean markdown. |
mmopen |
Open one message in MailMate's UI (via open mid:…). --print returns the URL. |
mm-mailboxes |
List accounts, IMAP mailboxes (with optional counts), and smart-mailbox names. |
mmtags |
List user tags applied to messages (with counts) or defined in Preferences. |
mm-modify |
Mark read/flag/tag/archive a message via AppleScript; same-account move uses a fast .eml-rename path with no UI takeover. |
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.