mcpField
C
che-apple-mail-mcp

che-apple-mail-mcp

Local
github.com/kiki830621Communication10Updated 2d ago

About che-apple-mail-mcp

che-apple-mail-mcp is a Swift MCP server for working with Apple Mail, including search, message actions, drafts, and mailbox management. Its SQLite search path and batch operations support handling large mail collections.

Key features

  1. 01SQLite-powered email search
  2. 02Search by subject, sender, recipient, and date
  3. 03Batch operations on up to 50 emails
  4. 04Manage mailboxes, rules, and VIP senders
  5. 05Compose, reply, forward, and save drafts
  6. 06Access headers, raw source, and attachments

Use cases

  1. 01Search a large Apple Mail archive
  2. 02Move or label groups of messages in batches
  3. 03Prepare replies and drafts for review
  4. 04Inspect message headers or save attachments

MCP support

Exposes Apple Mail account, mailbox, message, composition, attachment, and rule operations to MCP clients. The README describes 54 MCP tools.

Configuration

Local server config is the same for every client. Copy it into your client’s MCP config file.

No install method listed yet

We haven’t collected a package or remote endpoint for this server. Follow the instructions in the project README.

Repository
Packages
mcpbCheAppleMailMCP2.0.1

Tools

No tool list yet

Local servers expose tools only once running on your machine, and the maintainer has not provided a tool list.

Details

From the project README

che-apple-mail-mcp

License: MIT macOS Swift MCP

The most comprehensive Apple Mail MCP server - 54 tools with SQLite-powered millisecond search across 250K+ emails.

English | 繁體中文


Why che-apple-mail-mcp?

FeatureOther MCPsche-apple-mail-mcp
Total Tools~2054
LanguagePythonSwift (Native)
Search SpeedSeconds (AppleScript)Milliseconds (SQLite)
Search FieldsSubject/SenderSubject/Sender/Recipient/Date
Batch OperationsNoUp to 50 emails per call
Mailbox ManagementBasicFull CRUD
Email ColorsNo7 flag colors + background
VIP ManagementNoYes
Rule ManagementPartialFull CRUD
SignaturesNoYes
Raw Headers/SourceNoYes

Quick Start

Install the plugin. It brings the signed binary, the /archive-mail command family, the safety rules, and the staleness hook as one unit:

claude plugin marketplace add PsychQuant/che-apple-mail-mcp
claude plugin install che-apple-mail-mcp@che-apple-mail-mcp

Then grant permissions — the setup window shows live status and links straight to the right System Settings pane:

~/bin/CheAppleMailMCP --setup

💡 Full Disk Access is what makes the fast SQLite read path and batch_export_emails_markdown work. Without it the tools still run but read little or nothing, which is easy to mistake for a bug rather than a permission. macOS does not let an app request FDA programmatically — it has to be ticked by hand — which is exactly what the setup window is there to make quick.

Plugin vs MCP-only

Registering the MCP server by itself is a supported advanced path, but it is a strictly smaller install. Choose it knowingly — nothing at runtime will tell you these are missing (#353):

Shipped by the pluginPresent with MCP-only
All 54 MCP tools✅ yes
/archive-mail + -migrate / -rebuild-threads / -repair-synthetic-ids / -view❌ the archiving SOP does not exist
rules/compose-wrapper-free.md — what the cite-block was, and what a refused compose call means⚠️ background: since #304 the wrapper is structurally impossible, so this rule now explains the six refusal reasons and their recipes rather than guarding against a silent fallback
rules/confirmation-triggers.md, rules/false-positive-detection.md❌ no confirmation discipline on destructive operations
hooks/session-start.sh — staleness kill❌ a session can keep running a stale binary after an upgrade
Developer ID signed + notarized binary❌ a self-built binary is ad-hoc signed; on macOS 26 TCC cannot reliably hold FDA/Automation for it, so permissions appear to be granted and then stop working (#211)
Version sidecar → --self-update + the #303 staleness self-check❌ no sidecar next to a hand-built binary, so that check is permanently silent
Advanced: register the MCP server only (development, or you want no plugin)
git clone https://github.com/PsychQuant/che-apple-mail-mcp.git
cd che-apple-mail-mcp
swift build -c release

# --scope user     : available across all projects (stored in ~/.claude.json)
# --transport stdio: local binary execution via stdin/stdout
# --               : separator between claude options and the command
mkdir -p ~/bin
cp .build/release/CheAppleMailMCP ~/bin/
claude mcp add --scope user --transport stdio che-apple-mail-mcp -- ~/bin/CheAppleMailMCP

Install the binary to a local directory like ~/bin/. Avoid cloud-synced folders (Dropbox, iCloud, OneDrive) — sync activity causes MCP connection timeouts.

For a self-built binary to hold TCC permissions across rebuilds, sign it with a Developer ID; see Signing & Notarization. Otherwise expect to re-grant permissions after every build.


Recent Releases

For full details see CHANGELOG.md.

v2.7.2 (2026-05-10) — attachmentFragment cluster + fallback parity
  • Hardened attachmentFragment indent across all 3 callers + removed dead MailController.attachmentScript helper that bypassed v2.7.0's race-mitigation delays (#61, #62)
  • Attachment count cap (50) + env-configurable delays via CHE_MAIL_ATTACHMENT_DELAY_BETWEEN / _TRAILING (#63, #64)
  • get_email_metadata SQLite path now falls back to AppleScript on error — last read-tool gap closed; all 8 SQLite-first read tools now have parity fallback (#71)
v2.7.1 (2026-05-09) — base64 fix + .partial.emlx + observability
  • Critical: RFC822 header/body split was returning a relative array index instead of an absolute Data index, causing html_body to begin with "sion: 1.0\n\n<base64>" for some Android Gmail messages — raw base64 leaked into LLM context and triggered AUP false-positives downstream (#72)
  • save_attachment now reads from Attachments/<rowId>/<part_id>/<filename> cache when .partial.emlx body is empty — no more silent 0-byte writes for IMAP messages with stripped binaries (#66)
  • SQLite fast-path failures now log to stderr (SQLite ... fast path failed for rowId=...; falling through to AppleScript) (#69)
v2.7.0 (2026-05-04) — Mail.app race mitigation
  • Multi-attachment AppleScript paced with 0.3s between + 0.5s trailing delays to mitigate Mail.app silently dropping attachments under fast IPC (#60)
v2.6.0 (2026-05-03) — Security & validation hardening (8 PRs, 16 issues)
  • forward_email plain mode now embeds RFC 3676 > quoted original (parity with reply_email's #43 fix) (#44)
  • Hard-fail on tool param type mismatch — bool / [String] no longer silently coerced (#35)
  • Recipient email validation rejects header injection (control chars, missing/multiple @) (#41)
  • cc_additional deduplicates case-insensitively (#34)
  • Attachment path deny-list (~/.ssh, Keychains, TCC db, browser cookies) + symlink-resolved + new MAIL_MCP_ATTACHMENT_ROOTS env allow-list (#38)
  • All 17 id-taking tools hard-validate id as Int at handler boundary — defeats AppleScript predicate injection (#50)
  • Gated integration tests for reply_email runtime (#37, #45) + smoke matrix templates (#46, #47)
v2.5.0 (2026-04-17) — Composing format parameter
  • All 4 composing tools (compose_email / create_draft / reply_email / forward_email) gain format: "plain" | "markdown" | "html" param (closes #14, #15)
  • New message-composition capability spec

All 54 Tools

Accounts (2)
ToolDescription
list_accountsList all mail accounts
get_account_infoGet account details
Mailboxes (4)
ToolDescription
list_mailboxesList all mailboxes (folders)
create_mailboxCreate a new mailbox
delete_mailboxDelete a mailbox
get_special_mailboxesGet special mailbox names (inbox, drafts, sent, trash, junk, outbox)
Emails (7)
ToolDescription
list_emailsList emails in a mailbox
get_emailGet full email content
search_emailsSearch by subject/content
get_unread_countGet unread count
get_email_headersGet all email headers
get_email_sourceGet raw email source
get_email_metadataGet metadata (forwarded, replied, size)
Actions (8)
ToolDescription
mark_readMark as read/unread
flag_emailFlag/unflag email
set_flag_colorSet flag color (7 colors)
set_background_colorSet email background color
mark_as_junkMark as junk/not junk
move_emailMove to another mailbox
copy_emailCopy to another mailbox
delete_emailDelete email (to trash)
Compose (5)
ToolDescription
compose_emailSend new email (supports cc/bcc/attachments — bare addresses only: a display-name recipient in any list is refused on a send, use create_draft (#404); format: plain only since #304; optional from_address for multi-account sender selection — see #131, clean path supported via the verified From popup, #219). Bodies always come from Mail's own editor — see #175 / check_accessibility; a call that cannot run cleanly FAILS with a named reason and creates nothing (#304)
reply_emailReply to email. Optional: cc_additional, attachments, save_as_draft, format (since v2.4.0). Plain mode embeds RFC 3676 > quoted original (since v2.5.0 / #43). The new body is pasted into Mail's native reply (#218); a non-plain format or a missing Accessibility grant FAILS instead of falling back (#304)
forward_emailForward email. Optional body + format. Plain mode embeds RFC 3676 > quoted original (since v2.5.0+ / #44). A bodyless forward assigns nothing and needs no Accessibility grant; with a body, same rules as reply_email (#218 / #304)
redirect_emailRedirect email (keeps original sender)
open_mailtoOpen mailto URL
Reply-as-draft example (v2.4.0+)

Reply to a thread, add extra CC, attach files, and save as a draft for human review before sending:

reply_email(
    id="<message id from search_emails>",
    mailbox="INBOX",
    account_name="iCloud",
    body="Reply text",
    cc_additional=["[email protected]"],
    attachments=["/path/to/file.pdf"],
    save_as_draft=true
)
Drafts (3)
ToolDescription
list_draftsList draft emails — each entry carries subject + numeric id (#276, additive; feeds update_draft.draft_id / delete_email.id)
create_draftCreate a draft (supports attachments; optional from_address for multi-account sender selection — see #131, clean path supported via the verified From popup, #219). Bodies come from Mail's own editor — see #175 / check_accessibility — except the opt-in direct write (CHE_MAIL_EXPERIMENTAL_DIRECT_DRAFT=1, #472), described under Accessibility below; a call that cannot run cleanly FAILS with a named reason and creates nothing (#304). Display-name recipients (Name <addr>) in to / cc / bcc are filled through the compose window's AX-addressed fields with a token read-back before the save; a hidden Bcc field is revealed and left visible (bcc_field_revealed: true); cc/bcc addresses are re-read after the save (`recipients_verified: true
update_draftReplace an existing draft (upsert, #276): locate by draft_id or exact subject_match → create replacement (inherits create_draft eligibility + disclosure) → delete old. Deliberately create-THEN-delete with a post-create receipt (failure always leans toward keeping drafts — worst case both MAY exist, never neither); 0 or >1 matches always refuse (candidates listed). Replacement gets a NEW id. Display-name to / cc / bcc supported as on create_draft (#404)
Attachments (2)
ToolDescription
list_attachmentsList email attachments
save_attachmentSave attachment to disk
VIP (1)
ToolDescription
list_vip_sendersList VIP senders
Rules (5)
ToolDescription
list_rulesList mail rules
get_rule_detailsGet rule details
create_ruleCreate a new rule
delete_ruleDelete a rule
enable_ruleEnable/disable a rule
Signatures (2)
ToolDescription
list_signaturesList email signatures
get_signatureGet signature content
SMTP (1)
ToolDescription
list_smtp_serversList SMTP servers
Sync (2)
ToolDescription
check_for_new_mailCheck for new mail
synchronize_accountSync IMAP account
Batch (4)
ToolDescription
get_emails_batchGet up to 50 emails in one call (per-item errors)
list_attachments_batchList attachments for up to 50 emails
batch_export_emails_markdownServer-side bulk export to verbatim markdown + attachments (frozen frontmatter manifest; concurrency-serialized per output_dir — #193 / #236)
export_emails_markdownDEPRECATED — renamed to batch_export_emails_markdown (#233); alias removed no earlier than v3.0
Utilities (4)
ToolDescription
extract_name_from_addressExtract name from email address
extract_addressExtract email from full address
get_mail_app_infoGet Mail.app info
import_mailboxImport mailbox from file
Diagnostics (4)
ToolDescription
check_fdaCheck Full Disk Access status and retry opening this server's index reader now; reports the server's actual SQLite fast-path state (open / just recovered / still unavailable)
check_accessibilityCheck Accessibility permission (the compose/reply GUI paths; without it those tools refuse — except the opt-in direct-write create_draft path, which needs Full Disk Access instead)
check_automationCheck Automation permission (Apple Events to Mail) — non-prompting probe, four states with remediation (#293); the binary holds its OWN grant, osascript working ≠ binary authorized (#288)
get_mail_log_eventsRead Mail's own unified log to find where an event chain stops (e.g. draft saved → queued → engine notified → IMAP APPENDUID receipt) — read-only (#465). detail=brief (default) returns only the static event template, integer arguments and an account letter for [account - mailbox] lines — no account names, subjects or Message-IDs, as long as the template is a compile-time string (Mail's own are); detail=detailed adds the raw message, which contains account identifiers and received-mail content: do not paste it into public issues. Window ≤ 60 min, times must carry a UTC offset; page with since=next_start, offset=next_offset and the same until. No event-name filter yet (#466). An empty result never means the action did not happen. Verified on macOS 27.2 / Mail 16.0 only; needs an admin-group user
Response shape: search_emails / list_emails

Both tools return an envelope object { results, returned, limit, truncated } — not a bare array (changed in v2.14.0, #204). Read the matches from .results:

FieldMeaning
resultsArray of result objects (per-object fields unchanged from the pre-envelope shape). search_emails objects carry id, subject, sender, date_received, account_name, mailbox, to, plus account_id when the account UUID is resolvable. list_emails objects carry id, subject, sender.
returnedNumber of objects in results
limitEffective limit applied to the query
truncatedtrue when more results are available than were returned — raise limit or narrow the query to retrieve the rest (definitive on the SQLite fast path; a best-effort heuristic on the AppleScript fallback — see below)

truncated is definitive on the SQLite fast path (it fetches limit + 1 internally); on the AppleScript fallback it is a best-effort returned == limit heuristic. Any "enumerate → batch process" consumer should check truncated before assuming it has the full set.


Installation

Start with Quick Start — installing the plugin is the supported path and gives you the commands, safety rules, staleness hook and a signed binary. Everything below is the advanced / development route: it registers the MCP server alone, which is a strictly smaller install (see Plugin vs MCP-only for what is missing, since nothing at runtime will tell you).

Claude Desktop bundle version metadata

New .mcpb bundles built by scripts/package-mcpb.sh include server/.CheAppleMailMCP.version beside the executable (#312). The packager queries the copied binary's --version, requires it to match the manifest, and writes the sidecar before zipping. A failed, malformed or timed-out query refuses the package; the running server does not create this file on first use. Both the development and signed-release paths use this shared packager.

This enables the existing staleness diagnostic when a newer bundle replaces files at the running executable's location while an older image remains alive. The warning goes to the MCP server's stderr log and asks you to restart the host. It does not check GitHub for new releases, update an untouched old install, or detect a new bundle installed in a different directory. Desktop's update/ restart lifecycle has not been verified here; matching versions remain silent. Older bundles without the sidecar require an update to obtain it.

Use make release-signed VERSION=vX.Y.Z for distribution. scripts/build-mcpb.sh remains an explicitly unsigned development build.

Requirements
  • macOS 13.0+
  • Xcode Command Line Tools (for the build-it-yourself route below)
  • Apple Mail with at least one account configured
Step 1: Build
git clone https://github.com/PsychQuant/che-apple-mail-mcp.git
cd che-apple-mail-mcp
swift build -c release
Step 2: Configure
For Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "che-apple-mail-mcp": {
      "command": "/full/path/to/che-apple-mail-mcp/.build/release/CheAppleMailMCP"
    }
  }
}
For Claude Code (CLI)
# Copy to ~/bin and register (user scope = available in all projects)
mkdir -p ~/bin
cp .build/release/CheAppleMailMCP ~/bin/
claude mcp add --scope user --transport stdio che-apple-mail-mcp -- ~/bin/CheAppleMailMCP
Step 3: Grant Permissions

The fastest route is the setup window, which shows live Full Disk Access / Automation / Accessibility status, re-checks as you grant, and opens the right System Settings pane for you:

~/bin/CheAppleMailMCP --setup

To do it by hand instead:

Automation (control Mail.app):

open "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"
  1. Find CheAppleMailMCP and enable permission for Mail.app
  2. If using Claude Code, also add Terminal or iTerm

Full Disk Access (the SQLite fast path + export_emails_markdown read ~/Library/Mail):

open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"

macOS grants Full Disk Access to the responsible process — the app that launched this server — not to the binary itself. For an MCP server run by Claude Code inside a terminal, that responsible process is the terminal (Ghostty / Terminal / iTerm), so add your terminal app here and enable it. One grant on the terminal covers every MCP server it launches. (If you instead run the binary directly, or use the Claude Desktop bundle, add that binary — ~/bin/CheAppleMailMCP — since it is then its own responsible process.) The FDA-denied error message names these candidates for you — it does not auto-resolve the one exact app, because macOS exposes no reliable in-process API for that (#214). Without Full Disk Access, read tools silently fall back to the slower AppleScript path and SQLite-only features (projection, export_emails_markdown) fail. For the direct-launch path, a Developer ID-signed build makes the grant survive version bumps — see Signing & Notarization.

Guided setup (#213) — instead of the manual steps above, the binary ships setup helpers:

  • CheAppleMailMCP --setup opens a small window with live Full Disk Access status (re-checked on a timer, flips to "Ready ✅" the moment you grant) plus an on-demand Automation check, and "Open Full Disk Access settings" / "Copy binary path" buttons.
  • CheAppleMailMCP --check-fda prints the status headlessly (and opens the pane when access is denied) — handy from a terminal or script.
  • The check_fda MCP tool reports the same status to Claude on demand (call it when a SQLite-only feature errors). It also makes the running server retry opening its own index reader and reports the result (#537): a granted probe is no longer taken to mean the server can use the index. If the reader was unavailable and the retry opens it, the answer says it recovered; if the probe can read the file but the server's open still fails, it shows that error and says to reconnect the server (/mcp in Claude Code).

None of these can remove the single manual toggle (Apple puts FDA in the manual-only bucket alongside Accessibility / Screen Recording), but they make "what do I do?" obvious and give live feedback the instant you flip it on.

Accessibility (compose, #175/#304) — a grant separate from Full Disk Access, needed by the GUI compose paths. Mail.app wraps any AppleScript-injected outgoing-message body in <blockquote type="cite">, which some mobile clients render as a quotation of your own text — and which the sender cannot see locally, because the wrapper's inline style has no border. Since #304 the code that produces it no longer exists: every composing tool takes its body from Mail's own editor — a mailto: hand-off for compose_email / create_draft, the native reply/forward verb plus paste for reply_email / forward_email — and drives save/send/attach with keyboard shortcuts, which needs Accessibility (System Settings → Privacy & Security → Accessibility), granted to the same responsible process as FDA (your terminal / Claude Desktop). The check_accessibility MCP tool and the --setup window's Accessibility row report the status. Without it these tools now FAIL rather than fall back (forward_email without a body writes nothing and needs no grant) — the legacy path they used to fall back to is gone, so a call that cannot run cleanly returns a named error and creates nothing. The error points at open_mailto, which needs no TCC grant at all (it cannot carry attachments; you save or send the window yourself). Exactly six conditions refuse a call: a non-plain format; an empty subject; Accessibility not granted; a from_address that is not a bare addr-spec; an attachment path containing non-ASCII characters (#220); and a recipient carrying a display name that this path cannot fill (cc/bcc always; to on a send — a draft's to display name is filled through the GUI, #277). For a clean body from a non-default account, pass from_address: the GUI selects it in Mail's From popup and reads the selection back, aborting rather than risk the wrong sender (#219). Removed with the legacy path: format: "markdown" / "html" — no path shipped today delivers rich text without the body assignment that was deleted. That is what exists, not a proof of impossibility (#310): the paste path (#218) is a second wrapper-free route and NSPasteboard can carry rich flavors, but the MIME it produces is unverified — #306 settles it; #308 / #309 are alternatives, the require_wrapper_free and sanitize_links parameters, and the CHE_MAIL_DISABLE_MAILTO_COMPOSE / CHE_MAIL_DISABLE_PASTE_REPLY escape hatches. Two capabilities go with them, stated plainly: composing without a visible window (the hatches' original purpose) is no longer possible, and compose_email can no longer send to Name <addr> — use create_draft and send the draft yourself.

One exception, opt-in and experimental: with CHE_MAIL_EXPERIMENTAL_DIRECT_DRAFT=1, a create_draft call that meets every condition of the direct-write path (the two closed lists in the message-composition spec, Requirement: Direct-write draft path) writes the draft straight into Mail's store (#472 / #475). That path opens no window and needs no Accessibility; it needs Full Disk Access and Automation. Any call that fails a condition takes the GUI path above, with everything said there.

Automation TCC (-1743) and the zero-TCC escape hatch

If AppleScript-backed tools fail with AppleScript error (-1743): Not authorized to send Apple events to Mail, the Automation permission is missing for this binary. The signed MCP binary holds its OWN Automation grant — its TCC identity is keyed to the binary's signing identity (the #211 FDA lesson, Automation axis), separate from your terminal's. Empirically verified: osascript controlling Mail from your shell does NOT mean the binary is authorized. Grant it under System Settings → Privacy & Security → Automation — find the entry for the binary / its host (Claude Desktop extension: under Claude.app) and enable Mail. If no entry exists, a previous denial is being remembered and macOS will not re-prompt: run tccutil reset AppleEvents, then retry a Mail tool to retrigger the prompt. Grants are per-install, and a binary update can invalidate the entry (#211).

Until the grant is in place, open_mailto still works: it goes through LaunchServices (zero TCC, #287) and opens a cite-block-free compose window in the system default mail client. mailto cannot carry attachments (RFC 6068) — drag files in manually.

Step 4: Restart Claude
# For Claude Desktop
osascript -e 'quit app "Claude"' && sleep 2 && open -a "Claude"

# For Claude Code - start a new session
claude

Usage Examples

Natural Language (Claude Desktop)
"List all my mail accounts"
"Show unread emails in Gmail inbox"
"Search for emails about 'quarterly report'"
"Send an email to [email protected] about the meeting"
"Flag important emails in red"
"Create a rule to move newsletters to a folder"
Direct Tool Calls (Claude Code)
"Use list_accounts to show my accounts"
"Use search_emails to find emails containing 'invoice'"
"Use set_flag_color to mark email ID 12345 as blue"
"Use check_for_new_mail to refresh"

Flag & Background Colors

Flag Colors (set_flag_color)
IndexColor
0Red
1Orange
2Yellow
3Green
4Blue
5Purple
6Gray
-1Clear
Background Colors (set_background_color)

blue, gray, green, none, orange, purple, red, yellow


Performance & Storage

SQLite + .emlx fast path

Most read tools prefer Apple Mail's local Envelope Index (SQLite) and on-disk .emlx message files over AppleScript IPC, with transparent AppleScript fallback when the SQLite path can't satisfy a request:

ToolSQLite/.emlx pathAppleScript fallback
get_email✓✓ on any error
get_emails_batch✓ (per item)✓ (per item)
get_email_headers✓✓ on any error
get_email_source✓✓ on any error
search_emails✓✓ when reader unavailable
list_attachments✓✓ on any error
save_attachment✓✓ on any error
get_email_metadata✓✓ on any error (since #71)

get_mail_log_events is outside this table on purpose: it reads Mail's unified log (events: what happened and when) through /usr/bin/log, not the Envelope Index (state). It needs no Full Disk Access but does need a user in the admin group, and it is never a source of truth for whether a message exists — see .claude/rules/r-must-direct-db.md.

For save_attachment's read path the fast path is 10–100× faster than AppleScript (per #12 measurements). Other tools' speedup ratios depend on request shape; in general, large bulk reads see the biggest gain.

The fast path requires:

  • Full Disk Access granted to the host process (System Settings → Privacy & Security → Full Disk Access)
  • Apple Mail's local store at ~/Library/Mail/V10/...
  • Message has been synced to local .emlx storage
EWS / Exchange accounts intentionally bypass the fast path

Exchange (EWS) accounts in Apple Mail do not materialize .emlx files — message bodies live on the server and are fetched on demand. For these accounts, all 8 read tools (including get_email_metadata since #71) transparently degrade to AppleScript IPC (which is correct but slower). Symptoms:

  • A bulk fetch of 500 EWS messages will be noticeably slower than 500 IMAP/Gmail messages
  • This is not a bug — it's an Apple Mail storage architecture constraint (see #9)
Diagnosing fast-path bypass

When the fast path fails for a non-EWS account, the failure is logged to stderr (since #69). Run the binary in a terminal and watch stderr to distinguish:

  • EnvelopeIndexReader init failed: ... — DB unreachable when the server started (commonly: Full Disk Access missing). It is not final (#537): until the index opens, read tools use AppleScript; a read tool that runs at least 30 s after the last attempt tries to open it again, and check_fda retries at once.
  • EnvelopeIndexReader retry failed: ... — a retry did not open it. A retry that fails exactly as before is not logged again, so a setup without Mail (for example Exchange only) logs this once, not every 30 s. ... (requested by check_fda) marks a retry you asked for; those are always logged.
  • EnvelopeIndexReader retry recovered ... — the index opened after an earlier failure; read tools use the SQLite fast path again from the next call.
  • SQLite get_email fast path failed for rowId=N: ... — per-message failure (e.g., .partial.emlx only, malformed MIME, file not yet synced)

The first three lines end with FDA probe: <result> (errno N). It is the plain fopen check check_fda uses, recorded at the moment of the SQLite failure, so the two can be compared: denied or no-mail-data means the file itself was refused or missing; granted next to a failed open means the probe and the SQLite open disagree (please report it with the log line). Note that retrying cannot fix an open that keeps failing for a reason outside the server, such as a grant that is still missing.

Both cases transparently fall through to AppleScript with ... falling through to AppleScript in the log line, so behavior is preserved while observability is restored.


Troubleshooting

ProblemSolution
Server disconnectedRebuild with swift build -c release
Not allowed to send Apple eventsAdd permissions in System Settings > Automation
Mail.app not respondingEnsure Mail.app is running with configured accounts
Commands timing outLarge mailboxes take longer; try specific searches. If a call that sends, saves a draft or changes a message timed out, its outcome in Mail is unknown: see After a timeout before retrying
Bulk fetch slower than expectedWatch stderr for ... falling through to AppleScript lines. EWS/Exchange accounts always fall back (see Performance & Storage); other accounts logging fallback indicate a fixable .emlx issue
save_attachment fails with -1728 "Can't get account" or -1719 "Invalid mailbox index"Since #173 both errors come back with an actionable hint naming the failing reference (account / mailbox / message). Common causes: two Mail.app accounts share the same display_name, or an email-form account_name maps to several accounts — see Account Disambiguation below.
After a timeout: what can and cannot be known

When a call times out, the tool does not know whether Mail went on to finish the operation. Ending the osascript child (or abandoning an in-process call) does not undo Apple Events Mail has already accepted, so saving or sending may already have happened. The error says so. This section lists what you can look at afterwards and what each check does not prove. Nothing here lets a tool report "sent" or "not sent": only a positive observation counts as evidence, and a missing row is never read as "nothing happened".

OperationWhat the tool reportsWhat you can look atWhat that does not tell you
Sends: compose_email, reply_email, forward_emailsend state is UNKNOWN, NOT retrying (the tool never re-sends on its own)search_emails with field="subject" and the subject you used; read each row's mailbox and date_received. A matching row is evidence that a message with that subject exists in that mailboxA missing row does not show that no message went out: the index is written asynchronously, some accounts keep no Sent copy, and search_emails can come back empty without cause (#549, #536). A matching row may also be an earlier message with the same subject. Do not resend automatically; let the user decide
Saving a draft: create_draft, reply_email with save_as_draftA timeout error saying that saving or sending may already have occurredsearch_emails as above, or list_drafts (asks Mail, so it can time out again)A missing row does not prove the draft was not saved. Saving again may leave two drafts; delete the extra one
Deleting the old draft inside update_draftdeleted_old: false and a note saying the deletion outcome is unknownlist_drafts, or search_emails with the draft's subjectA row for the old id that is still listed means the index still lists it; a missing row is the index's view, not a confirmation from Mail. Do not retry the update or the deletion until you have looked
Setting a value: mark_read, flag_email, set_flag_color, set_background_color, mark_as_junkA timeout errorRepeating the same call sets the same value (each one sets an absolute value, not a toggle). get_email_metadata reports read and flaggedThe timed-out call may still complete later; if you meant a different value, check the state afterwards

search_emails reads the Envelope Index and needs Full Disk Access; without it, it asks Mail through AppleScript and can time out again. The other write tools (move_email, copy_email, delete_email, mailbox and rule management, and so on) are not classified here: check the state before retrying them.


Account Disambiguation

Mail.app's AppleScript account "<display_name>" selector is not unique when two accounts share the same display_name — a common pattern when an iCloud catch-all alias forwards a Gmail address back to itself, or when Google Workspace + personal Gmail overlap. Any AppleScript-routed tool (save_attachment fallback, get_email, mark_read, etc.) will then non-deterministically pick the wrong account → -1728 / -1719 errors.

The fix: pass account_id (Mail.app's globally-unique UUID) alongside account_name. When provided, save_attachment uses Mail.app's account id "<UUID>" selector instead, bypassing the ambiguity:

// Tool call: save_attachment with account_id
{
    "id": "273214",
    "mailbox": "[Gmail]/全部郵件",
    "account_name": "[email protected]",
    "account_id": "C38E0583-47F8-4468-BE70-43155C15549D",  // ← disambiguates
    "attachment_name": "report.pdf",
    "save_path": "/tmp/report.pdf"
}

Discovering account_id:

  • From search_emails results — each object in the results array (a SearchResult) carries an account_id field alongside account_name (populated by decoding the account UUID from the SQLite mailboxes.url authority via MailboxURL.decode — Mail.app's storage convention encodes the account UUID in the mailbox URL authority; there is no direct SELECT mailboxes.account_id). Recommended: pass it through directly.
  • Manually — read ~/Library/Mail/V10/MailData/Signatures/AccountsMap.plist. The top-level keys are the UUIDs; the AccountURL value contains the matching email address percent-encoded in the authority.
  • In AppleScript — tell application "Mail" to get id of every account returns the UUID list.

Backward compatibility: account_id is optional. When omitted (or empty), tools fall back to the legacy account "<display_name>" path — behavior identical to pre-#101 — with one save_attachment exception (#173): when account_name contains @ (email-shaped, the form SQLite-path tools like search_emails emit), save_attachment first reverse-looks-it-up in AccountsMap and silently upgrades to the account id "<UUID>" selector (the upgrade is logged to stderr). Exactly one match → that UUID; several accounts behind one address (iCloud catch-all + Gmail) → an actionable error listing every candidate instead of a raw -1728; no match → the legacy display-name path, unchanged. Edge: a Mail account whose description legitimately contains @ and happens to equal another account's email now resolves in the email namespace first — pass account_id explicitly to pin the selector. Other tools keep the strict pre-#101 fallback (the cross-tool sweep is #176).

Scope: account_id is accepted across the AppleScript-routed tools that reference mail by account. It began with save_attachment (#101); the #104 sweep then added the 13 single-message / movement / relay / mailbox tools below:

  • save_attachment (#101) — the precursor
  • PR-A — 5 single-message mutation tools: mark_read, flag_email, set_flag_color, set_background_color, mark_as_junk
  • PR-B — 3 movement/destruction tools: move_email, copy_email, delete_email
  • PR-C — 3 message-relay tools: reply_email, forward_email, redirect_email
  • PR-D — 2 mailbox CRUD tools: create_mailbox, delete_mailbox

The surface has since expanded beyond the #104 set:

  • #176 — generalized the email→UUID resolveAccountIdForTool chokepoint across all 14 AppleScript-routed write handlers (so an email-form account_name resolves to the UUID selector, not just an accepted account_id).
  • #180 — threaded account_id through the read-tool AppleScript fallbacks (list_emails / search_emails / get_email / headers / source / metadata / attachments / get_unread_count) via resolveMailboxRef / resolveMsgRef (the PR-E that was previously deferred is now done).
  • #179 — get_special_mailboxes accepts account_id / account_name for per-account special-mailbox real names.
  • #191 — the account-level action tools check_for_new_mail and synchronize_account gained the account_id escape hatch (synchronize_account accepts account_id alone).

Still not covered by account_id (tracked): get_account_info / list_mailboxes (#202).

compose_email / create_draft do not exhibit the display_name-collision defect — they make new outgoing message rather than referencing existing mail by account, so they never emit an account "<display_name>" selector. Multi-account sender selection is now available via the optional from_address parameter (#131) — pass any one of your configured Mail.app email addresses ("[email protected]" or RFC 5322 form "Alice <[email protected]>") to set the sender of the outgoing message; omit to use Mail.app's default account. Use list_accounts to discover the addresses configured on the running Mac.

Cross-account move/copy is not supported via account_id (#129 — from #127 verify). move_email and copy_email accept a single account_id, which is threaded through both the source msgRef and the destination mailboxRef. The architectural choice is correct (movement stays within one account, because Mail.app's AppleScript verb move msg to <mailboxRef> requires the destination mailbox to be expressed relative to a single account context). Mail.app's UI permits cross-account move via drag-and-drop, but the AppleScript-routed move_email / copy_email tools cannot replicate that — calling move_email with account_id of one account while expecting the destination to_mailbox to be resolved against a different account silently picks the wrong account's mailbox of that name (if both accounts happen to have one) or raises -1719 "Invalid mailbox index". If you need a copy of the message's contents under a different account, you can manually rebuild it via save_attachment + compose_email — note this is not a true move/copy: original metadata (Message-ID, received-date, flags, labels) and message identity are not preserved.


Development tests

Run make test on macOS with Xcode Command Line Tools, Python 3 and jq on PATH. It runs the Swift suite, then the plugin suites; any failure makes the command fail. make test-plugin runs only the plugin suites for faster iteration.

The plugin runner discovers plugin/tests/test-*.sh and test-*.py, checks that the required hook suite exists, and fails clearly if a dependency is missing. Each matching file must be a standalone Bash or Python suite; keep shared helpers under a different name or in a subdirectory. Plugin tests use isolated fixtures and mock processes/downloads. This Makefile wiring does not itself add a GitHub Actions workflow.

Technical Details

  • Framework: MCP Swift SDK v0.10.0
  • Read path: SQLite (Envelope Index) + .emlx file parser, with AppleScript fallback for EWS / unparseable .emlx
  • Write/state path: AppleScript via NSAppleScript
  • Transport: stdio
  • Platform: macOS 13.0+ (Ventura and later)

Signing & Notarization

The distributed binary is Developer ID-signed and notarized, and that is not cosmetic. The fast read path needs Full Disk Access (FDA), and macOS TCC keys an FDA grant to the binary's designated requirement. For an ad-hoc binary that requirement is the cdhash, so every version bump invalidated the grant and you had to re-add the binary to the Full Disk Access list after each release. A stable Developer ID signature keys the grant to the signing identity instead, so it survives version bumps (#211) — that signature, not notarization, is what delivers the persistence.

Notarization matters for quarantined-launch paths: a browser download or the .mcpb (Claude Desktop) install, where Gatekeeper assesses the binary on first launch. The plugin wrapper's curl + exec path sets no quarantine attribute, so Gatekeeper never fires there. We notarize anyway so the published release asset is safe to run by any means.

The first grant is still manual. FDA (kTCCServiceSystemPolicyAllFiles) has no programmatic request API — an app can only deep-link you to the settings pane. Signing makes that first grant permanent, not automatic.

One-time setup (maintainers)
# 1. Developer ID Application cert in your login keychain (needs an Apple Developer account)
security find-identity -p codesigning -v        # find your identity

# 2. notarytool keychain profile (prompts for an app-specific password — never pass it on the CLI)
xcrun notarytool store-credentials <profile-name> \
  --apple-id <your-apple-id> --team-id <your-team-id>

# 3. Export both for the signed targets
export DEVELOPER_ID='Developer ID Application: Your Name (TEAMID)'
export NOTARY_PROFILE='<profile-name>'
Dev install on your own machine (fast — no notarization)
make install-signed     # build + Developer ID sign + copy to ~/bin

Use this to get a stable FDA grant on your own Mac without waiting for Apple notarization: your own cert launches fine locally, and the grant survives future rebuilds. Grant Full Disk Access once to ~/bin/CheAppleMailMCP and you are done.

Distribution release (signed + notarized + published)
make release-signed VERSION=vX.Y.Z      # wraps scripts/release.sh with REQUIRE_CODESIGN=1

This builds a universal (arm64 + x86_64) binary, signs it, notarizes it (1–15 min Apple round-trip), and uploads it to the GitHub release. Forks without certs can still cut an unsigned dev release with SKIP_CODESIGN=1 ./scripts/release.sh vX.Y.Z.


Contributing

Contributions are welcome! Please feel free to submit a Pull Request.


License

MIT License - see LICENSE for details.


Author

Created by Che Cheng (@kiki830621)

If you find this useful, please consider giving it a star!

Comments

Markdown supported

New to MCP?

From what the protocol is to installing your first server, these answers cover most first questions.

An open protocol that defines how AI applications connect to external tools and data in a uniform way. Clients talk to the model; MCP servers provide tools, resources and prompt templates. They exchange standard messages, so one server can be reused by many clients.