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, mmdiscover observe MailMate's on-disk state without changing it. (mmopen activates MailMate's UI but doesn't modify any message.)
  • mm-<name> (with dash) — write operations. mm-modify, mm-send change state (or send mail). mm-mailboxes is an exception: read-only, but uses the dash to keep mmm<tab> free for the daily-driver mmmessage. Typing mm-<tab> filters to the write-leaning commands.

Limitations

A few rough edges to be aware of:

  1. Non-move mm-modify actions briefly spawn a MailMate viewer window. Same-account move actions use a fast path — a direct .eml rename 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 the mid: 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-modify is 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).

  1. eml-id is machine-local; prefer Message-ID:. The integer eml-id (also shown as MailMate's "Msg ID" column) is just the filename of the .eml on 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 RFC Message-ID: header (which mmmessage prints) and pass that to the CLIs. The mid:%3C<message-id>%3E URL scheme works portably for the same reason.

  2. MailMate must be running. Anything that goes through mm-modify requires MailMate open and unblocked by modal dialogs. mm-send likewise needs MailMate running — emate mailto opens 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.

  3. Single-account mm-send defaults. mm-send passes flags straight through to emate 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, and open, plus MailMate's bundled emate.
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.expand_path("~/.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, uses Mailmate::Identity.mine? to decide inbound/outbound, writes a markdown note to ~/code/claude/people/projects/email/inbox/<YYYY>/<MM>/, and returns moveMessage (archive) + notify actions.

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 EmlLookupHeaderReaderMidUrl. It's user-runnable so you can verify the gem works on your machine.

License

MIT. See LICENSE.txt.