
psychquant/che-apple-mail-mcp
Developer toolsApple Mail MCP server — 53 tools with SQLite-powered millisecond search across 250K+ emails, .emlx parser, batch markdown export, and composing that never injects a message body through AppleScript...
Overview
- 54 tools with SQLite-powered millisecond search across 250K+ emails. Install the . It brings the signed binary, the /archive-mail command family, the safety rules, and the staleness hook as one unit: Then grant permissions — the setup window shows live status and links straight to the right System Settings pane: 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. Registering the MCP server by itself is a supported advanced path, but it is a install. Choose it knowingly — nothing at runtime will tell you these are missing (#353): Advanced: register the MCP server only (development, or you want no plugin) Install the binary to a local directory like ~/bin/.
README
che-apple-mail-mcp
The most comprehensive Apple Mail MCP server - 59 tools with SQLite-powered millisecond search across 250K+ emails.
Why che-apple-mail-mcp?
| Feature | Other MCPs | che-apple-mail-mcp |
|---|---|---|
| Total Tools | ~20 | 59 |
| Language | Python | Swift (Native) |
| Search Speed | Seconds (AppleScript) | Milliseconds (SQLite) |
| Search Fields | Subject/Sender | Subject/Sender/Recipient/Date |
| Batch Operations | No | Up to 50 emails per call |
| Mailbox Management | Basic | Full CRUD |
| Email Colors | No | 7 flag colors + background |
| VIP Management | No | Yes |
| Rule Management | Partial | Full CRUD |
| Signatures | No | Yes |
| Raw Headers/Source | No | Yes |
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
Maintainers can run make check-install to verify that these documented public GitHub
marketplaces still list the requested plugins. The Install instructions CI
runs on pull requests, pushes to main, and daily. PR checks use the checkout
manifest only for this repository (--checkout-repo), while external sources
are always fetched remotely; main/scheduled/manual checks inspect published
remote manifests. Thus a coordinated local rename can pass PR review, and changes in an external
marketplace can also be detected. A missing entry or invalid source fails with
INSTALL_RESOLUTION_INVALID (exit 1); bounded network failures report
INSTALL_RESOLUTION_UNCERTAIN (exit 2), also a failing check. Neither is silently
skipped. Unrelated entry metadata is ignored; an unreadable entry name prevents
a definite absence claim when the requested target was not otherwise found.
This checks remote marketplace membership; it does not execute or verify
the Claude loader, download the plugin, or install anything. Offline regression
fixtures run with make test-plugin. The resolver accepts fenced shell commands
using owner/repo or GitHub HTTPS repository sources and plugin@marketplace
install targets; recognized installation-looking commands in unsupported forms
(including wrappers or global options) fail explicitly, as do such commands in
unsupported fence languages or outside a supported fence. Shell continuations
are rejected rather than silently skipped. Installation-looking ANSI-C or locale-translated dollar quoting
is unsupported; variables and fully encoded shell programs are not interpreted.
Console/shellsession fences are supported. The remote check requires curl 8.4.0+ so unknown-length responses are also
bounded; an older or unavailable curl returns exit 2 before downloading. Each source has
at most three attempts, a 10-second request limit and 1 MiB response limit;
README and checkout inputs must be regular files (no final-component symlinks);
reads are bounded before parsing. README input is capped at 1 MiB and each physical or continued logical line at 64 KiB for inspection; all
sources share a 90-second network budget (plus subprocess termination grace).
LF/CRLF lines and tabs are accepted; other control and line-separator characters
are rejected. A published remote manifest may briefly lag a recent update due to
caching or propagation; confirm the remote state and rerun a failed check.
Scheduled monitoring depends on GitHub Actions remaining enabled; it is not a
guarantee that inactive repositories will continue to receive scheduled runs.
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_markdownwork. 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 plugin | Present with MCP-only |
|---|---|
| All 59 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 known body-injection trigger has been removed; this rule explains the reported upstream regression FB11734014 and current refusal recipes |
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 |
Recent Releases
For full details see CHANGELOG.md.
v2.7.2 (2026-05-10) — attachmentFragment cluster + fallback parity
- Hardened
attachmentFragmentindent across all 3 callers + removed deadMailController.attachmentScripthelper 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_metadataSQLite 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
Dataindex, causinghtml_bodyto begin with"sion: 1.0\n\n"for some Android Gmail messages — raw base64 leaked into LLM context and triggered AUP false-positives downstream (#72) save_attachmentnow reads fromAttachments///cache when.partial.emlxbody 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_emailplain mode now embeds RFC 3676>quoted original (parity withreply_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_additionaldeduplicates case-insensitively (#34)- Attachment path deny-list (
~/.ssh, Keychains, TCC db, browser cookies) + symlink-resolved + newMAIL_MCP_ATTACHMENT_ROOTSenv allow-list (#38) - All 17 id-taking tools hard-validate
idas Int at handler boundary — defeats AppleScript predicate injection (#50) - Gated integration tests for
reply_emailruntime (#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) gainformat: "plain" | "markdown" | "html"param (closes #14, #15) - New
message-compositioncapability spec
All 59 Tools
Compose signatures
compose_email, create_draft, and update_draft accept signature: {"mode":"mail_default"}, {"mode":"none"}, or {"mode":"named","name":"Professional"}. The server selects Mail’s native signature after From selection; it never appends a handwritten signature to body. A selection receipt is not proof of body insertion. Verify a draft on first use of this Mail setup before a formal send, or use none with caller-supplied signature text. Do not append the same signature manually when choosing Mail-managed insertion. Explicit selection refuses unsupported UI rather than guessing; no legacy body-assignment fallback exists.
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:
| Field | Meaning |
|---|---|
results |
Array 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. |
returned |
Number of objects in results |
limit |
Effective limit applied to the query |
truncated |
true 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"
- Find CheAppleMailMCP and enable permission for Mail.app
- 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 --setupopens 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-fdaprints the status headlessly (and opens the pane when access is denied) — handy from a terminal or script.- The
check_fdaMCP 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 (/mcpin 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.
Upstream evidence (#310). Apple Developer Forums 738842 includes an October 2023 script and received MIME example; its author said FB11734014 was Open then. A March 2024 reply could not reproduce on macOS 13.3.1, while a January 2025 reply reported similar symptoms on macOS 15.2. Checked September 14, 2026: this public thread does not establish the private report’s current status or universal behavior across Ventura and later versions. Apple may fix the regression; reassess architecture choices against received MIME on the target version. The project continues to avoid body injection. Normal quotations of the original message in replies/forwards are separate.
Accessibility (compose, #175/#304) — a separate grant from Full Disk Access, needed by the GUI compose paths. The reported upstream regression FB11734014 can wrap an AppleScript-injected outgoing-message body in ``, 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 this project has removed the known body-injection trigger: 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). See the canonical refusal table for the current eight input/eligibility reasons and remedies. 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. This is a product integration limit, not proof of impossibility (#310): #306’s July 29 experiment reports four rich-paste draft variants passing and the HTML combination passing in Sent and received MIME. Those ASCII probes do not establish CJK, all OS versions, or sending with every flavor; the product still accepts only plain; #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 — 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. It also has no mailto: length limit (#388): a body over the GUI path’s 8000 encoded characters is written as is.
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)
| Index | Color |
|---|---|
| 0 | Red |
| 1 | Orange |
| 2 | Yellow |
| 3 | Green |
| 4 | Blue |
| 5 | Purple |
| 6 | Gray |
| -1 | Clear |
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:
| Tool | SQLite/.emlx path | AppleScript 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 extraction errors; destination errors are terminal (#402) |
get_email_metadata |
✓ | ✓ on any error (since #71) |
classify_emails uses the Envelope Index to locate each message but reads its source through AppleScript on purpose: the preview, the refresh before a move and the final native comparison must use the same representation as Mail’s own source (a sanctioned exception in .claude/rules/r-must-direct-db.md, #356). A batch of up to 200 such reads has not been measured; use smaller batches for slow sources.
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
.emlxstorage
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, andcheck_fdaretries 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.emlxonly, malformed MIME, file not yet synced)
The first three lines end with FDA probe: (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
| Problem | Solution |
|---|---|
| Server disconnected | Rebuild with swift build -c release |
| Not allowed to send Apple events | Add permissions in System Settings > Automation |
| Mail.app not responding | Ensure Mail.app is running with configured accounts |
| Commands timing out | Large 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 expected | Watch 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”.
| Operation | What the tool reports | What you can look at | What that does not tell you |
|---|---|---|---|
Sends: compose_email, reply_email, forward_email |
send 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 mailbox |
A 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_draft |
A timeout error saying that saving or sending may already have occurred | search_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_draft |
deleted_old: false and a note saying the deletion outcome is unknown |
list_drafts, or search_emails with the draft’s subject |
A 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_junk |
A timeout error | Repeating the same call sets the same value (each one sets an absolute value, not a toggle). get_email_metadata reports read and flagged |
The 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 "" 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 "" 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": "/Users/me/MailArchive/report.pdf"
}
Discovering account_id:
- From
search_emailsresults — each object in theresultsarray (aSearchResult) carries anaccount_idfield alongsideaccount_name(populated by decoding the account UUID from the SQLitemailboxes.urlauthority viaMailboxURL.decode— Mail.app’s storage convention encodes the account UUID in the mailbox URL authority; there is no directSELECT mailboxes.account_id). Recommended: pass it through directly. - Manually — read
~/Library/Mail/V10/MailData/Signatures/AccountsMap.plist. The top-level keys are the UUIDs; theAccountURLvalue contains the matching email address percent-encoded in the authority. - In AppleScript —
tell application "Mail" to get id of every accountreturns the UUID list.
Backward compatibility: account_id is optional. When omitted (or empty), tools fall back to the legacy account "" 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 "" 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
resolveAccountIdForToolchokepoint across all 14 AppleScript-routed write handlers (so an email-formaccount_nameresolves to the UUID selector, not just an acceptedaccount_id). - #180 — threaded
account_idthrough the read-tool AppleScript fallbacks (list_emails/search_emails/get_email/ headers / source / metadata / attachments /get_unread_count) viaresolveMailboxRef/resolveMsgRef(the PR-E that was previously deferred is now done). - #179 —
get_special_mailboxesacceptsaccount_id/account_namefor per-account special-mailbox real names. - #191 — the account-level action tools
check_for_new_mailandsynchronize_accountgained theaccount_idescape hatch (synchronize_accountacceptsaccount_idalone).
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 "" 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 ") 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 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.
Attachment destination policy
save_attachment.save_path uses the same destination policy as Markdown export.
Set CHE_MAIL_EXPORT_ALLOWED_ROOTS to colon-separated absolute directories to
restrict both tools to those roots. A nonempty list replaces the default
home-directory allowance; the existing system and sensitive-home denylist always
applies. For example, configure /Volumes/Archive/Mail for a dedicated archive.
Without this setting, ordinary paths within the user’s home are accepted; the
home denylist is not an exhaustive list of sensitive files. Configure narrow roots
when unrelated home files must be protected.
~/Library is denied except for the cloud-sync folders macOS keeps inside it:
paths inside an existing provider folder in ~/Library/CloudStorage (Dropbox,
Google Drive, OneDrive, Box; ~/Dropbox is a link into it), inside iCloud
Drive (~/Library/Mobile Documents/com~apple~CloudDocs), and inside the
existing Documents folder of an app’s iCloud container (what Finder shows
inside iCloud Drive, such as an Obsidian vault) are permitted for both tools. The rest of each
app container stays denied (Mail keeps its synced signatures in
com~apple~mail/Data). Two kinds of link have their targets denied too, also
when the target does not exist yet: a denied name that is itself a link
(~/.zshrc kept in Dropbox), and a link directly inside a denied folder, also
when that folder is itself a link (~/.claude/hooks kept in a config repository).
Links further down, such as the per-file links some dotfile tools create inside
~/.config//, are not followed: a denylist cannot follow every link, so
configure narrow roots when that matters. The home denylist also covers the
shell startup files (.zshrc, .zshenv, .zprofile, .zlogin, .zlogout,
.bashrc, .bash_profile, .bash_login, .bash_logout, .profile) and agent
configuration (.claude, .claude.json, .mcp.json, .codex, .cursor), and
names are compared the way the case-insensitive APFS volume compares them, so a
case or Unicode variant of a denied name is denied too. Do not take a full
save_path from message content: the policy limits where a write lands, not
which file inside home a caller chooses.
The full filename path is checked before creating directories or invoking Mail.
Dot/dot-dot, empty, backslash and control-character components are rejected.
Existing symlinks work only when their canonical target satisfies the policy;
links introduced after validation cannot redirect publication. Allowed missing
parents are created, and existing regular files are atomically replaced. A new
file gets 0644 as allowed by the umask, as before; a replaced file keeps its read
and write bits; execute bits are never kept, because the bytes come from the
sender. The temporary file is created 0600 and keeps that mode until it is
renamed into place; an ACL that the destination folder passes on to new files
still applies to it. Path forms that were previously
normalized, such as a//b, a/./b or a trailing /., are now refused.
AppleScript saves each attempt to a fresh private temporary file, then the server
streams it to the authorized destination. destination_rejected and
destination_write_failed are terminal errors; changing the attachment name or
requesting a download does not bypass them. Previously accepted destinations
outside home, including /tmp, require an explicit allowed-root configuration.
Without Full Disk Access the AppleScript tier is the only path, and the server
now writes the final file itself (Mail writes only to its private stage). Two
things have not been tested
(#579): whether
such a server is refused or prompted when it writes into ~/Desktop,
~/Documents, ~/Downloads or a cloud-sync folder, and whether a large
attachment is complete when Mail’s save returns. A failure of the private stage
itself is reported as stage_failed, not as a destination error.
Per-message draft status
search_emails (full and summary), list_emails, get_email_metadata, and
export manifest items include is_draft:
truewhen the message, or any copy of it (the same message in another mailbox, such as Gmail’s All Mail), has Mail’s draft type (messages.type5) or Mail’s draft flag.falseonly when the type is 0 and every piece of evidence is present and negative: neither the message nor any copy has type 5, the draft flag, a NULL type or flag, or a folder named as drafts (Drafts,草稿and the other common localizations) or of unknown name.nullotherwise, including the AppleScript fallback, message types other than 0 and 5, and an index without the flag or copy columns.
A folder name is never evidence of a draft; it only keeps a message from being
reported as a known non-draft. The type alone is not enough: drafts synced from
a server or another device are stored with type 0, often in the account’s Drafts
folder. Folder names are read on every query, so a Drafts folder created or
renamed while the server runs counts at once. A drafts folder whose name is not in
the list (another language) can still let such a draft be reported false. Unknown is not false. Summary has six
fields; ids and count keep their shapes. With dedup: "logical" the flag
describes the row kept for each group.
For correspondence exports, set opts.skip_drafts: true. Known drafts get status
draft_skipped (counted in drafts_skipped, apart from the Message-ID dedup’s
skipped); a message whose status is unknown is a per-item draft_status_unknown
error that names the cause when the lookup failed. Neither fetches the body or
attachments, so these items can omit message_id. To decide an unknown item,
read its X-Uniform-Type-Identifier header with get_email_headers:
com.apple.mail-draft marks a draft written by Apple Mail, but a draft written
elsewhere may not carry it, so a missing header does not make the item a
non-draft. Otherwise leave it out. The default remains false for compatibility and can
still export drafts, with the observed flag in the manifest. skip_drafts given
outside opts, or an opts that is neither an object nor null, is rejected. The field is a
read-time observation, not a durable message identity. Consumer adoption
(including the archive-mail default exclusion tracked by plugins#127) remains
explicit.
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.
Export identity cache
Batch export uses Mail’s configured account address lists, including EWS and configured send-from aliases, through a memory-only 300-second cache. Concurrent refreshes share one metadata operation; failures back off for 60 seconds. The caller wait budget is five seconds, including time queued behind other Mail work. A late refresh can populate the cache for the next call. No per-message Apple Event or address-cache file is added.
Set opts.refresh_identity: true after changing aliases to bypass the cached
result/backoff; it joins an update already in progress. Refresh requires Mail to
be running and an existing Automation grant. It does not request a new grant.
Unavailable metadata preserves the SQLite-primary fallback but marks its direction
as inferred. Partial native metadata supports known positive matches; non-matches
need a complete snapshot and a represented account to be confident.
The manifest reports identity_source (mail_account_cache or
sqlite_primary_fallback), identity_complete, and identity_cache_age_seconds
when a cached snapshot is available. The address list itself is not returned in
this manifest. These are configured addresses observed at refresh time, not a
claim about unconfigured SMTP From permissions or permanent address ownership.
Verified special-mailbox paths
Per-account get_special_mailboxes keeps its leaf names and optional _path
fields. Index paths are candidates only: a path is returned after the exact
account-scoped Mail object matches the account’s native special-mailbox child.
Two ordinary sibling names, root position and old tuple path slots cannot supply
that proof. Missing index/proof, ambiguity or a literal slash within a component
leaves the path absent; consumers must keep the leaf fallback.
The read-only native regression gate is
SpecialMailboxNativeProofLiveTests.testRecordedNativeCandidatesMatchExpectedRoles.
It requires MAIL_APP_INTEGRATION_TESTS=1 and a private
MAIL_SPECIAL_MAILBOX_FIXTURE JSON file with account, role, leaf, components,
expectedMatch and expectedAvailable per record. Include a known positive; no
folders or messages are created. Do not commit private account/path fixtures.
Technical Details
- Framework: MCP Swift SDK v0.10.0
- Read path: SQLite (Envelope Index) +
.emlxfile 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 \
--apple-id --team-id
# 3. Export both for the signed targets
export DEVELOPER_ID='Developer ID Application: Your Name (TEAMID)'
export NOTARY_PROFILE=''
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!
Install
This server does not publish a one-line install command.
Open the repository installation guide New to MCP? Start with the mcpskill.org guide →Configuration
{
"mcpServers": {
"che-apple-mail-mcp": {
"command": "/full/path/to/che-apple-mail-mcp/.build/release/CheAppleMailMCP"
}
}
}