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, mmdiscover only observe MailMate's on-disk state.
  • mm-<name> (with dash) — write operations. mm-modify, mm-send change state (or send mail). Typing mm-<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:

  1. 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 mmsearch doesn't yet route through it. The current implementation uses index prefilters plus direct .eml walks, 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-only query) disables the prefilter. Prefer narrowing with f/t/s/d first, and use --headers-only when you don't need the body matched. Finding a way to drive MailMate's native engine from the outside is open work.

  2. Bulk mm-modify takes over the whole computer, not just MailMate. Each invocation opens a message-viewer window via the mid: 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-modify ends 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.

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.

  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

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.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.