Skip to main content
Glama
apple-mail-mcp

apple-mail-mcp

by TG-Techie

Apple Mail MCP Server

Tests Python 3.10+ License: MIT

An MCP server that provides programmatic access to Apple Mail, enabling AI assistants like Claude to read, send, search, and manage emails on macOS.

⚠️ Pre-1.0 — expect breaking changes. The MCP tool surface (tool names, parameters, return shapes) is still evolving as the project matures. Pin to a specific version (for example, apple-mail-mcp==0.8.1) and review the CHANGELOG before upgrading.

Tools (25)

Core: list_mailboxes, search_messages, get_messages, update_message Drafts lifecycle (v2 — verb-split): draft_create, draft_update, draft_delete, draft_send Sending: email_send_html Mailbox CRUD: create_mailbox, update_mailbox, delete_mailbox Attachments & Management: save_attachments, delete_messages Discovery & Rules: list_accounts, list_rules, get_thread, create_rule, update_rule, delete_rule Templates: list_templates, get_template, save_template, delete_template, render_template

See docs/reference/TOOLS.md for full parameter and return-shape documentation.

Related MCP server: Mac Local Mail MCP

Prerequisites

  • macOS 10.15 (Catalina) or later

  • Python 3.10 or later

  • Apple Mail configured with at least one account

  • uv (recommended) or pip

Installation

# From source (recommended for development)
git clone https://github.com/s-morgan-jeffries/apple-mail-mcp.git
cd apple-mail-mcp
uv sync --dev

Configuration

Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "apple-mail": {
      "command": "uv",
      "args": ["--directory", "/path/to/apple-mail-mcp", "run", "python", "-m", "apple_mail_mcp.server"]
    }
  }
}

Running as a fleet daemon

Where many agent sessions share one Mail, the server can instead run as one resident daemon, with a thin stdio proxy per session. The stdio entry above is unchanged, and a client that runs its own server needs neither.

  • mail-serve is the daemon. It serves the tools over streamable HTTP on 127.0.0.1:41108, loopback only (--port to change it; 41108 is provisional). It runs under a process supervisor (pm2 in the fleet that runs it), launched from this repo as uv run --project <this repo> mail-serve.

  • The daemon tends Mail's compose windows: at start, every 15 minutes, and soon after a composition fails and leaves its window open, it closes the windows this server opened and left (salvaged to Drafts, or discarded when empty), and only those; every other window is left and counted in the audit log. --tend-interval SECONDS changes the interval, and 0 turns it off (docs/research/compose-window-tending.md).

  • The daemon quits and relaunches Mail on its own. Every 5 minutes it asks Mail one cheap question under the Mail lock; when Mail leaves two in a row unanswered for 20 seconds, it restarts Mail, and it also restarts Mail once a day at 04:00 local time (a day whose hour passed while the daemon was down is skipped, and the daily one waits while a send or draft is being composed). It also restarts Mail before it wedges, when it has kept most of a CPU core busy for 15 minutes or its memory footprint has stayed above 3 GiB on two checks; these too wait for a composition. A restart holds the Mail lock throughout, asks Mail to quit, then sends SIGTERM and SIGKILL if Mail's process has not exited, relaunches it in the background with open -g, and waits for it to answer; once Mail answers, it runs a tending pass rather than waiting out tending's back-off. It never launches a Mail that was not running, never restarts Mail more than once an hour, and logs every restart to the audit log as restart_mail. --mail-probe-interval SECONDS changes the probe interval, and 0 turns probing and both kinds of restart off; --mail-restart-hour HOUR moves the daily one. The stdio server never restarts Mail.

  • Restarting the daemon is a deploy. A session already connected through its proxy reaches the new daemon on its next call; changed instructions reach a session when it next starts.

  • mail-proxy is what a session launches: uv run --project <this repo> mail-proxy. It takes no arguments and declares nothing of its own. Every listing and call, confirmation prompts included, is forwarded to the daemon, and the daemon's instructions are fetched when the proxy starts. APPLE_MAIL_SERVER_URL points it at a daemon elsewhere.

  • Registering mail-proxy in a session's MCP configuration is the operator's edit.

Permissions

On first run, macOS will prompt for Automation access. Grant permission in: System Settings > Privacy & Security > Automation > Terminal (or your IDE)

Optional: faster search via IMAP

search_messages works out of the box via AppleScript. For large mailboxes (thousands of messages), AppleScript's whose clause can take 1–5 seconds per query. If you want faster server-side search, you can enable IMAP delegation per account by adding a Keychain entry.

How it works. If a Keychain entry exists for an account, the server uses IMAP (fast, server-side SEARCH). Otherwise — or on any IMAP failure (offline, wrong password, timeout) — it silently falls back to AppleScript. You never lose functionality; you only gain speed when IMAP is configured and reachable. No config flags, no environment variables; the Keychain entry's presence is the opt-in.

One-time setup per account.

  1. Generate an app-specific password at your provider. The procedure varies:

  2. Run the setup-imap subcommand. It prompts for the password (no echo), writes the Keychain entry, and verifies by connecting:

    apple-mail-mcp setup-imap --account iCloud

    Substitute the Mail.app account name exactly — whatever it's labeled in Mail.app (e.g. iCloud, Gmail, "Yahoo!"). The CLI:

    • looks up the account's primary email from Mail.app (override with --email),

    • prompts via getpass so the password never lands in shell history,

    • writes to Keychain at apple-mail-mcp.imap.<account> (idempotent — re-running with a new password updates the existing entry),

    • opens an IMAP connection and runs a real LOGIN to confirm the password works. On rejection it rolls back the Keychain entry so you can retry without leaving a broken item behind.

  3. If you see a one-time "security wants to use the 'login' keychain" prompt on the next IMAP-backed call, click Always Allow.

To remove the entry later: apple-mail-mcp setup-imap --account iCloud --uninstall.

Verifying the setup. The setup-imap command does this for you. If you want to spot-check post-hoc:

uv run python -c "from apple_mail_mcp.mail_connector import AppleMailConnector; \
    print(AppleMailConnector().search_messages(account='<ACCOUNT_NAME>', limit=1))"

If IMAP is working, the call returns in ~1 second. If it logs a WARNING about falling back (visible with --log-level=DEBUG), check that the account name matches Mail.app's account name exactly and that the email in your Keychain entry matches what email addresses of account returns.

Known provider quirks.

  • iCloud: the IMAP server accepts @icloud.com / @me.com aliases as LOGIN username, not the Apple ID email. The server (and setup-imap) reads email addresses of account from Mail.app for that reason.

  • Yahoo: app passwords have been progressively deprecated; the option may not be available for all accounts. If Yahoo's account-security page doesn't show the option, IMAP setup isn't possible for that account and AppleScript is the only path.

  • Gmail: requires 2-Step Verification enabled. If your Google Workspace admin has disabled app passwords at the tenant level, IMAP setup isn't possible for that account.

  • Gmail thread retrieval — All Mail visibility tradeoff. find_thread_members (used internally by thread-aware queries) is fastest when [Gmail]/All Mail is exposed over IMAP — that path is ~5 round-trips, mailbox-count-independent. Many users hide All Mail (Gmail Settings → Forwarding and POP/IMAP → Folder size limits → "Do not show in IMAP") because it duplicates every message. When hidden, the connector falls back to a per-mailbox X-GM-THRID iteration (still ~6× faster than the universal BFS, but proportional to your label count — ~25s on a 92-label account). Expose All Mail if you want the headline speed; keep it hidden if you prefer the cleaner IMAP folder list.

Write operations (draft_create, draft_update, draft_send) always use AppleScript regardless of IMAP configuration — these need Mail.app's compose UI.

Development

# Setup
uv sync --dev

# Common commands
make test              # Run unit tests
make lint              # Lint with ruff
make typecheck         # Type check with mypy
make check-all         # All checks (lint, typecheck, test, complexity, version-sync, parity)
make coverage          # Coverage report
make test-integration  # Integration tests (requires Mail.app)

# Validation scripts
./scripts/check_version_sync.sh          # Version consistency
./scripts/check_client_server_parity.sh  # Connector-server alignment
./scripts/check_complexity.sh            # Cyclomatic complexity
./scripts/check_applescript_safety.sh    # AppleScript safety audit

Branch Convention

{type}/issue-{num}-{description} — e.g., feature/issue-42-thread-support

Architecture

server.py + tools/ (FastMCP tools — thin orchestration)
  -> mail_connector.py (AppleScript bridge — domain logic)
     -> subprocess.run(["osascript", ...])
        -> Apple Mail.app
  • server.py — the FastMCP instance, the connector, confirmation and the error envelope

  • tools/ — the MCP tools, one module per domain: input validation, gates, response formatting

  • mail_connector.py and the modules beside it — AppleMailConnector, which runs the AppleScript, and the modules that build it (script_blocks.py, search_script.py, compose_script.py, rule_script.py)

  • security.py — Input sanitization, audit logging, confirmation flows

  • utils.py — Pure functions: escaping, parsing, validation

  • exceptions.py — Typed exception hierarchy

Security

  • Local execution only (no cloud processing)

  • Uses existing Mail.app authentication (no credential storage)

  • All inputs sanitized and AppleScript-escaped

  • Destructive operations require confirmation

  • Operation audit logging

  • See SECURITY.md for policy and docs/SECURITY.md for detailed analysis

Contributing

See CONTRIBUTING.md for development workflow, coding standards, and PR process.

License

MIT

Available Tools

25 tools
create_mailboxC

Create a new mailbox/folder.

Inputs: name (required), account (required), parent_mailbox. Returns: object

create_ruleA

Create a new Mail.app rule.

Additive — no confirmation prompt. Mail.app appends new rules to the end of the rule list, so the returned rule_index equals the new total rule count.

Inputs: name (required), actions (required), conditions (required), enabled, +1 more. Returns: object

delete_mailboxA

Delete a mailbox via IMAP.

Mail.app's AppleScript dictionary doesn't expose a working delete primitive for mailboxes, so this operation goes through IMAP. Requires IMAP credentials in Keychain (#73 opt-in flow) — returns error_type: "imap_required" when missing.

Always elicits user confirmation (destructive). By default refuses non-empty mailboxes to prevent accidental data loss; pass delete_messages=True to cascade.

Refused (#164): targeting the bare [Gmail] parent or any [Gmail]/... child path returns error_type: "unsupported_gmail_system_label". Gmail's IMAP server doesn't support DELETE for these paths.

Inputs: name (required), account (required), delete_messages. Returns: object

delete_messagesA

Delete messages (always moves to the account's Trash mailbox).

Inputs: message_ids (required), account, permanent, source_mailbox. Returns: object

delete_ruleA

Delete a Mail.app rule by 1-based positional index.

Destructive — requires user confirmation via MCP elicitation before running. Cannot be undone (Mail.app does not version rule history).

Inputs: rule_index (required). Returns: object

delete_templateA

Delete a template by name.

Destructive — requires user confirmation via MCP elicitation before running.

Inputs: name (required). Returns: object

draft_createA

Create a draft (fresh, reply, or forward). DOES NOT SEND.

To actually send, call draft_send(draft_id) as a separate step after reviewing/editing the draft. The split is intentional — sending requires its own explicit action and is the only path where the outbound allowlist policy is enforced.

Modes (driven by reply_to / forward_of):

  • Neither: fresh draft (subject required).

  • reply_to=<message_id>: reply draft. Mail.app auto-derives recipients and subject prefix unless overridden.

  • forward_of=<message_id>: forward draft. Recipients default to empty (user must specify); subject prefixed with Fwd:.

Inputs: cc, to, bcc, body, +8 more. Returns: object

draft_deleteA

Delete (move to Trash) an existing draft. No send, no recovery expected — Mail.app moves the draft to Deleted Messages.

Inputs: draft_id (required). Returns: object

draft_sendA

Send an existing draft. THIS IS THE ONLY SEND TOOL.

Hard policy gate: every recipient (to/cc/bcc) on the draft must match the outbound allowlist (see outbound_allowlist.py). If any recipient is off-list, the send is blocked and the draft is left INTACT for human review — you can edit it via draft_update or open Mail.app and send/discard manually.

Off-list recipients are detected BEFORE any destructive operation, so a blocked draft_send is a pure no-op on Mail.app state.

Inputs: draft_id (required). Returns: object

draft_updateA

Update an existing draft. DOES NOT SEND.

Patch semantics: only fields you pass change. None means "keep existing"; empty list/string means "clear". To actually send, call draft_send(draft_id) afterwards.

IMPORTANT: Mail.app forbids mutating saved drafts, so this is implemented as delete-and-recreate. The returned draft_id is a NEW id — use it for any subsequent draft_update or draft_send call. The id you passed in is stale after this call returns.

Inputs: draft_id (required), cc, to, bcc, +6 more. Returns: object

email_send_htmlA

Send an HTML email directly. Does not save a draft first.

Body must be an HTML string. The email is composed via clipboard injection into Mail.app's rich-text compose window and sent immediately.

Outbound allowlist policy applies — all recipients must be on the allowlist.

Inputs: to (required), body (required), subject (required), cc, +2 more. Returns: object

get_messagesA

Get full details of one or more messages, with bodies.

Returns a list of message dicts (possibly of length 0 or 1). Pair with search_messages (metadata-only) and get_thread (thread member ids) to fetch bodies for specific messages.

Inputs: message_ids (required), account, mailbox, headers_only, +2 more. Returns: object

get_templateA

Read a single template by name.

Inputs: name (required). Returns: object

get_threadA

Return all messages in the thread containing the given message.

Looks up the anchor message by its id, then reconstructs the conversation via the connector's tiered IMAP threading dispatch (Tier 1 X-GM-THRID for Gmail, Tier 3 header-search BFS fallback) or the AppleScript path. Result rows are sorted by date_received ascending.

The returned ids can be piped into search_messages(source=[ids]) for filtered metadata or get_messages([ids]) for full bodies.

Known limitation: thread members whose subject was rewritten mid-conversation are missed on the AppleScript fallback path (subject prefilter tradeoff).

Inputs: message_id (required). Returns: object

list_accountsA

List all configured email accounts in Apple Mail.

Returns each account's id (UUID), display name, email addresses, account type, and enabled state. Account ids are stable across name changes; prefer them over names for identifying accounts.

Returns: Dictionary containing the accounts list.

Example: >>> list_accounts() {"success": True, "accounts": [ {"id": "B21B254B-...", "name": "Gmail", "email_addresses": ["me@gmail.com"], "account_type": "imap", "enabled": True}, ... ]}

Inputs: no named parameters. Returns: object

list_mailboxesA

List all mailboxes for an account.

Inputs: account (required). Returns: object

list_rulesA

List all Mail.app rules (read-only).

Returns each rule's display name and enabled state. Rule names are NOT guaranteed unique — Mail allows duplicates — and rules have no stable id via AppleScript. This tool is read-only; mutation (enable/disable, create, delete) is tracked as a separate enhancement.

Returns: Dictionary containing the rules list.

Example: >>> list_rules() {"success": True, "rules": [ {"name": "Junk filter", "enabled": True}, {"name": "News From Apple", "enabled": False}, ... ], "count": 2}

Inputs: no named parameters. Returns: object

list_templatesA

List all stored email templates.

Templates live as files at ~/.apple_mail_mcp/templates/.md. Override the location with the APPLE_MAIL_MCP_HOME environment variable.

Returns: Dictionary with each template's name and subject (or null if no subject header is set).

Inputs: no named parameters. Returns: object

render_templateA

Render a template into ready-to-send subject and body text.

No side effects — caller is responsible for passing the rendered text to create_draft or update_draft (with send_now=True when ready to send).

With message_id, the original sender's display name and email, the original subject, and today's date are auto-populated as recipient_name, recipient_email, original_subject, and today. Without message_id, only today is auto-filled. User-supplied vars always override auto-fills on conflict.

Inputs: name (required), vars, message_id. Returns: object

save_attachmentsB

Save attachments from a message to a directory.

Inputs: message_id (required), save_directory (required), attachment_indices. Returns: object

save_templateC

Create or overwrite a template.

Inputs: body (required), name (required), subject. Returns: object

search_messagesA

Search for messages matching criteria. Returns metadata-only rows.

Two corpus modes:

  • source=None (default): search the given account/mailbox using the IMAP/AppleScript SEARCH path. account is required.

  • source=[id1, id2, ...]: scope the search to the specific messages identified by the given ids. account/mailbox are ignored; the connector resolves each id self-sufficiently. The resulting message dicts are post-filtered by the other criteria (sender_contains, read_status, etc.) — full filter composition. The literal token "SELECTED" may appear in the list and is server-resolved at call time to Mail.app's current UI selection (zero-or-more messages). Mixed lists like ["SELECTED", "12345"] are valid. Missing ids drop out silently (partial-results).

For thread retrieval, call get_thread(message_id) to expand an anchor into thread member ids, then optionally pipe those ids into source=[ids] for filtered metadata browsing or into get_messages([ids]) for full bodies.

Inputs: limit, source, account, date_to, +10 more. Returns: object

update_mailboxA

Rename and/or re-parent (move) an existing mailbox.

Two delivery paths:

  • Rename only (new_name set, new_parent is None): AppleScript. Fast, no IMAP credentials needed.

  • Move (new_parent set; optionally combined with rename): IMAP RENAME. Requires IMAP credentials in Keychain (#73 opt-in flow) — returns error_type: "imap_required" when missing.

At least one of new_name / new_parent must be provided.

Refused (#164): operations targeting the bare [Gmail] parent or any [Gmail]/... child path return error_type: "unsupported_gmail_system_label". Applies to both the source name and the resulting destination (new_parent join). Gmail's IMAP server doesn't support normal RENAME semantics for these paths; user-created Gmail labels (Newsletters, etc.) behave normally.

Inputs: name (required), account (required), new_name, new_parent. Returns: object

update_messageA

Update one or more messages: change read state, flag, and/or move, in one atomic call (#135).

Patch semantics — caller specifies only the fields to change. All specified mutations apply in a single AppleScript pass via the bulk-update helper. Replaces the previous mark_as_read, move_messages, and flag_message tools.

Order of operations (matters for IMAP): read-state and flag changes apply first (in source mailbox), then the move. IMAP requires the message to exist in the source folder for STORE before MOVE.

Inputs: message_ids (required), account, flagged, flag_color, +4 more. Returns: object

update_ruleA

Update an existing Mail.app rule (patch semantics).

Patch semantics: only fields you provide are changed. conditions and actions, when provided, REPLACE their respective structures wholesale (not merged).

Conditional confirmation: prompts the user via MCP elicitation only when the patch touches conditions, actions, or match_logic — those replacements are irrecoverable. Patches limited to enabled and/or name (trivially reversible) skip the prompt. The enable/disable path replaces the removed set_rule_enabled tool: call update_rule(rule_index, enabled=True|False).

Refuses to update any rule whose existing actions include something outside the supported schema (run-AppleScript, redirect, reply text, play sound, custom highlight color); raises MailUnsupportedRuleActionError. Edit such rules in Mail.app's UI.

Inputs: rule_index (required), name, actions, enabled, +2 more. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.9/5.0

    Scored across 25 tools

    Disambiguation5/5

    Each tool targets a distinct resource and action: templates, drafts, messages, mailboxes, rules, and accounts are clearly separated. The message-related tools (search, get, update, delete, thread, attachments) each have unique purposes, and the draft tools (create/update/delete/send) are unambiguous. No two tools appear to do the same thing.

    Naming Consistency3/5

    Most tools follow a verb_noun pattern (list_templates, create_rule, get_messages, etc.), but the draft tools use a noun_verb pattern (draft_create, draft_send) and email_send_html also deviates. This inconsistency between subgroups makes the naming less predictable, though each subgroup is internally consistent.

    Tool Count4/5

    25 tools is above the typical ideal range (3-15) but still appropriate for a comprehensive mail-client server covering messages, drafts, templates, rules, mailboxes, and accounts. Each tool serves a specific purpose, and no tools are redundant, though the large count requires careful organization.

    Completeness5/5

    The tool surface covers the full lifecycle for all major domains: messages (search, retrieve, update, delete, thread), drafts (create, update, delete, send), templates (list, get, save, delete, render), rules (list, create, update, delete), and mailboxes (list, create, update, delete). There are no obvious gaps that would hinder common workflows.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables integration with Apple Mail on macOS using JavaScript for Automation (JXA). Supports reading, searching, sending, and managing emails across multiple accounts with full mailbox hierarchy support.
      5
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables MCP clients to search, read, and inspect Apple Mail on macOS, including conversations and attachments. It can create new, reply, reply-all, or forward drafts, but cannot send or modify existing messages.
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      Enables AI assistants to read, search, send, and manage email on macOS through programmatic access to Apple Mail.
      29
      MIT
    • F
      license
      Not graded
      quality
      F
      maintenance
      Enables reading, searching, composing, replying, forwarding, sending, flagging, and moving macOS Apple Mail messages through native scripting, with policy-controlled outgoing drafts and attachments.
      -