Apple Mail MCP Server
A Model Context Protocol (MCP) server that enables AI assistants like Claude to read, send, search, and manage emails in Apple Mail on macOS.
Note: This is the npm/Node.js package — install with
npxornpm. There is an unrelated Python project of the same name on PyPI (imdinu/apple-mail-mcp) installed viapipx/uvx. If you're usinguvxand seeing acycloptsdependency error, you're looking for that project, not this one.
What is This?
This server acts as a bridge between AI assistants and Apple Mail. Once configured, you can ask Claude (or any MCP-compatible AI) to:
- "Check my inbox for unread messages"
- "Find emails from [email protected]"
- "Send an email to the team about the meeting"
- "Create a draft email for me to review"
- "Reply to that message"
- "Forward this to my colleague"
- "Move old newsletters to the Archive folder"
The AI assistant communicates with this server, which then uses AppleScript to interact with the Mail app on your Mac. All data stays local on your machine.
Quick Start
Using Claude Code (Easiest)
If you're using Claude Code (in Terminal or VS Code), just ask Claude to install it:
Install the sweetrb/apple-mail-mcp MCP server so you can help me manage my Apple Mail
Claude will handle the installation and configuration automatically.
Or register it deterministically in one command:
claude mcp add apple-mail -s user -- npx -y apple-mail-mcp
Using the Plugin Marketplace
Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
/plugin marketplace add sweetrb/apple-mail-mcp
/plugin install apple-mail
This method also installs a skill that teaches Claude when and how to use Apple Mail effectively.
Configuring IMAP/SMTP for a plugin install: a plugin install has no editable
envblock, so supply settings via the config file at~/Library/Application Support/apple-mail-mcp/config.json— Method B in the IMAP / SMTP Setup Guide. Passwords stay in the macOS Keychain; run thedoctortool to verify.
Using the Codex Marketplace
See Apple Mail in Codex for setup and diagnostics, and the guided setup and OAuth proposal for proposed future authorization support.
Install the same public marketplace in Codex:
codex plugin marketplace add sweetrb/apple-mail-mcp
codex plugin add apple-mail@apple-mail-mcp
The Codex package includes a portable codex/plugin.json, typed stdio server
configuration in codex/mcp.json, the Apple Mail skill, and the existing assets.
It retains codex/.codex-plugin/plugin.json and codex/.mcp.json for compatible
older hosts. Both layouts register the same apple-mail MCP identity through
npx -y apple-mail-mcp@<plugin version>. Release synchronization updates both
runtime pins and manifest metadata; tests and CI reject drift. An unreleased
candidate can be discovered locally, but its exact npm version must be
published before that pinned launcher can be installed from the registry.
For ongoing upstream updates, keep the Git marketplace configured. After a new release is published, refresh its catalog:
codex plugin marketplace upgrade apple-mail-mcp
Install the published plugin version from the refreshed marketplace, then start
a new Codex session. The refresh does not replace the runtime pin with @latest;
the plugin and its pinned npm runtime must refer to the same available release.
The standalone plugin archive bundles the server and requires only Node.js 20+ on the Mac; it does not fetch a runtime from npm. Download it from the release assets, or use the candidate artifact attached to a successful CI run. See Standalone plugin archive for the distribution format and local build command.
An imported archive or separately uploaded private account plugin is a snapshot, not a subscription to this repository's releases. Updating that copy requires a replacement package. When switching to the maintained marketplace distribution, preserve settings and approvals, verify Mail access and reply behavior, then remove the redundant installation. See Updates and migration.
For a personal build, you may explicitly supply a local PNG:
node scripts/package-codex-plugin.mjs /path/apple-mail.tar.gz --icon /path/icon.png.
The optional image must be square, noninterlaced, 48–2,048 pixels per side, and
at most 4 MiB. It is bundled inside the skill's assets directory and used for
the generated portable and compatibility manifests' light/dark logo and composer
icons. Source manifests, the runtime, and default artwork remain unchanged;
omitting --icon preserves the standard archive. No image is fetched automatically.
Supply only artwork you have permission to use and distribute; this option does
not grant rights to Apple or other third-party artwork, or guarantee that a host
will refresh an existing listing's cached presentation.
Native-editor composition requires computer control supplied by the host; this plugin does not bundle a computer-control server. Its skill describes inherited-size HTML insertion that preserves native signatures and quoted history, plus actual recipient identity, MIME, ancestry, and rendering checks. It does not repair the AppleScript body setter or guarantee every mail client.
Other Hosts (Hermes, Antigravity)
Two more hosts can run the same apple-mail MCP server (npx -y apple-mail-mcp):
-
Hermes Agent (NousResearch) — Hermes has no plugin/marketplace drop-in, so there is nothing in this repo to install from. Register the server with the CLI:
hermes mcp add apple-mail --command npx --args -y apple-mail-mcpOr add it to
~/.hermes/config.yamlby hand:mcp_servers: apple-mail: command: npx args: ["-y", "apple-mail-mcp"]Restart your Hermes session afterward so the tools load.
-
Antigravity (Google) — add the server entry from
.antigravity-plugin/mcp_config.jsonto~/.gemini/config/mcp_config.json(or via Antigravity's MCP settings).
Manual Installation
1. Install the server:
npm install -g apple-mail-mcp
2. Add to Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"apple-mail": {
"command": "npx",
"args": ["apple-mail-mcp"]
}
}
}
3. Restart Claude Desktop and start using natural language:
"Show me my unread emails"
On first use, macOS will ask for permission to automate Mail.app. Click "OK" to allow.
Configuring email (IMAP & SMTP)
The server works out of the box over AppleScript with no configuration. Two opt-in power features take a one-time setup:
- Fast IMAP reads — server-side search, counts, and large-mailbox handling that AppleScript is too slow for (it times out on big Gmail mailboxes).
- Clean SMTP sending —
send-emailsubmits clean MIME directly, avoiding the macOS 15+ Mail.app<blockquote>wrapping that otherwise makes sent mail look quoted/indented like a reply.
Both are driven by non-secret APPLE_MAIL_MCP_* settings — supplied via an env
block or a config.json file (for hosts like Claude Desktop that strip env)
— with passwords kept in the macOS Keychain, never in config.
👉 IMAP / SMTP Setup Guide — step-by-step: app passwords,
Keychain, both config methods, multi-account, SMTP, verification with the doctor
tool, and troubleshooting. Verify any time by running the doctor tool.
Requirements
- macOS - Apple Mail and AppleScript are macOS-only
- Node.js 20+ - Required for the MCP server
- Node.js 22.5+ and Full Disk Access - Required by
search-contactsonly. It reads the Contacts database directly through Node's built-innode:sqlite, which does not exist before 22.5. On an older runtime, or without Full Disk Access for the Node binary, it logs one line to stderr and returns an empty list rather than an error — so "no contacts found" can mean "cannot read Contacts". Every other tool works on Node 20+. See Node runtime & TCC permissions. - Apple Mail - Must have at least one account configured (iCloud, Gmail, Exchange, etc.)
Features
Messages
Read/list/get tools also return structured JSON (structuredContent) alongside the text, so agents can consume results without parsing prose.
Mailbox & Account Management
Rules, Contacts & Templates
Diagnostics
MCP resources & prompts
Resources expose read-only context the client can attach without a tool call:
mail://accounts, mail://templates, and mail://mailboxes/{account}. Prompts
package common workflows: triage-inbox, compose-reply, weekly-summary.
Tool Reference
This section documents all available tools. AI agents should use these tool names and parameters exactly as specified.
Message Operations
search-messages
Search for messages matching criteria. Searches all accounts by default.
Returns: List of matching messages with ID, date, subject, sender, read state,
and flagColor when flagged with an explicit color.
Flag color as a label (#286). Every row from both backends carries
flagColor (the palette name — red, orange, yellow, green, blue,
purple, gray) whenever the message is flagged with an explicit color;
omitted for an unflagged message or one flagged with Mail's plain default (no
color chosen). Combined with the flagColor filter above, a colored flag
works as a label across mailboxes: flag a set of messages with
flag-message/batch-flag-messages, then find exactly that set later with
search-messages, without moving anything into a dedicated folder. Known
limitation, inherent to Apple's $MailFlagBit0/1/2 IMAP wire encoding, not
specific to this server: red (palette index 0) sets no keyword bits, so
over IMAP it is indistinguishable from a plain colorless flag, both when
reading flagColor back and when filtering on it. AppleScript-backed
accounts are unaffected (Mail's own flag index property is 0 for red, -1
for none).
⚠️ Row dates. Since 2.19.7 every row — on both the IMAP and AppleScript
backends — carries both dateSent (the message's Date: header) and
dateReceived (mailbox arrival — IMAP INTERNALDATE or Mail's date received).
They differ legitimately by transit time; when they differ by years, the
mailbox was migrated or re-imported and the arrival timestamp was reset — trust
dateSent for chronology (#224).
dateSent is omitted (not invented) when the message carries no parseable
Date: header, or when it is more than 7 days later than dateReceived — Mail
substitutes a timestamp of its own for a Date: header it cannot parse
(#234).
Large mailboxes & partial results. Apple Mail's AppleScript bridge cannot search very large IMAP/Gmail mailboxes (tens of thousands of messages) before the Apple Event times out — empirically even reading the newest 20 messages of a 44k-message mailbox takes ~45s. To avoid burning minutes only to return a misleading empty result, an unscoped (all-mailboxes) search skips mailboxes whose message count exceeds a threshold (default 5000), enforces a per-account time budget, and reports anything it skipped or that timed out rather than silently returning nothing. When coverage is incomplete the result includes an explicit warning, e.g.:
⚠️ Partial results — this is NOT a confirmed "no such mail":
- skipped mailbox(es) too large to search via AppleScript: Gmail / All Mail (44287) — scope the search with `mailbox` + a `dateFrom`/`dateTo` window to target them
To search inside a large mailbox, scope the call with mailbox (and ideally a
dateFrom/dateTo window). Tune or disable the skip threshold with the
APPLE_MAIL_MAX_SEARCH_MAILBOX environment variable (default 5000; set to 0
to disable the guard and attempt every mailbox regardless of size).
(#24)
Coverage diagnostics (structured fields). The warning above is prose for a
human reader; the same information is also returned as structured fields on
search-messages and list-messages, so a caller can tell "nothing matched"
apart from "I did not look everywhere" without parsing the text:
Deadline and cancellation (#276). Tool
calls run one at a time (so they cannot race into Mail.app), which means a long search holds every
later call behind it. An IMAP search-messages therefore stops at
APPLE_MAIL_MCP_SEARCH_DEADLINE_MS (default 45000, minimum 2000, measured from when the request
arrived — time queued behind other calls counts) and returns what it found plus timedOutMailboxes,
and it stops the same way when the client cancels the request. The mailbox being searched at that
moment is abandoned by closing its IMAP connection (the next call reconnects), so the queue is
released promptly rather than after a server-side SEARCH that can take a minute and a half on a
very large mailbox. A request cancelled while still queued is skipped. Keep the deadline below your
client's own request timeout.
Treat a non-empty skippedLargeMailboxes as actionable rather than
informational: re-run scoped to the named mailbox with a dateFrom/dateTo
window, or configure the IMAP backend, which searches
those mailboxes server-side. The list fields are optional and are omitted (or
empty) when coverage was complete.
A row the IMAP backend could only read with a reduced item set (its full
BODYSTRUCTURE, or its whole FETCH response, was unparseable — e.g. a message
forwarded as an attachment many levels deep) is still returned, with a
metadataIncomplete note saying what was missing; its hasAttachments is then
inferred from the top-level Content-Type.
get-message
Get the full content of a message.
Returns: Subject line and message body (plain text by default, HTML if preferHtml is true and HTML content is available). structuredContent also carries rfcMessageId and, since 2.19.0, two dates: dateSent (the message's Date: header — Mail's date sent) and dateReceived (arrival in the mailbox — Mail's date received / IMAP INTERNALDATE). They differ legitimately by transit time; when they differ by years, the mailbox was migrated or re-imported and the arrival timestamp was reset — trust dateSent for chronology (#224). Since 2.19.6 dateSent is omitted when it is more than 7 days later than dateReceived: a message cannot be sent after it arrived, and Mail.app substitutes a timestamp of its own for a Date: header it cannot parse (#234). isHtml reports what was actually returned — a message with no text/plain part returns its HTML part with isHtml: true. Bodies are decoded by each part's declared charset, falling back to windows-1252 for bytes that are not valid UTF-8.
Numeric ids — how the message is found (#270): with account + mailbox the read opens that one mailbox and stops there. Otherwise it opens the mailbox the id was last listed or searched from (a remembered location that turns out stale — the message was moved, e.g. by another client — is forgotten), and only then scans every mailbox. That cross-mailbox scan skips any mailbox holding more than APPLE_MAIL_MAX_BYID_SCAN_MAILBOX messages (default 50000; 0 disables the guard) and stops after ~9s, because Mail's whose id is probe costs seconds per hundred thousand messages; a miss then names the mailboxes it skipped and, if it stopped early, the ones it never reached. A mailbox whose size count does not answer within 3s is remembered — on disk, for 7 days, across restarts (APPLE_MAIL_MCP_STALLED_MAILBOXES_FILE, default ~/Library/Application Support/apple-mail-mcp/stalled-mailboxes.json; delete it to forget) — and never counted again, since Mail.app keeps working on an abandoned count and everything after it would queue; the scan stops the moment such a count times out rather than queue behind it. If Mail.app is not running these reads say so instead of launching it. Pass account + mailbox to read from one of those, or use the message's imap: id, which never scans. The same resolution backs get-message-headers, get-thread's seed lookup, and the original-message read of reply-to-message / forward-message and the attachment tools.
Large messages / attachments: reading a full message routes through
osascript, whose captured output buffer defaults to 64 MB. Override it with theAPPLE_MAIL_MCP_MAX_BUFFERenvironment variable (in bytes) if you work with messages whose raw MIME (e.g. a large embedded attachment) exceeds that — a value below the message size makes the read fail with a buffer-overflow error rather than truncating (#27).
get-message-headers
Return a message's raw RFC 5322 header block — without fetching the body or any attachment — plus the parsed fields chronological and threading work needs. Added in 2.19.0 for mailboxes whose arrival timestamps were reset by a migration (#224): the Date: header is the author's send time and survives such moves; INTERNALDATE / date received does not.
Returns: The raw header block as text. structuredContent carries raw, every header as ordered headers[] ({name, value}, folded lines joined, duplicates such as Received: kept in wire order, values left RFC 2047-encoded), headerCount, and the decoded key fields: date (ISO 8601, from the Date: header), dateHeader (verbatim), dateReceived (mailbox arrival time — IMAP INTERNALDATE or Mail's date received), messageId, subject, from, to, cc, replyTo, inReplyTo, references[] and received[] (first entry = last hop). Fields the message does not carry are omitted. backend says which backend read the block ("imap" or "applescript"), and warnings[] appears when a malformed block was repaired (#234).
Backends (get-message-headers): an imap: id reads the first 64 KiB of BODY.PEEK[] over IMAP and cuts the header block out of it (cheap even for a 20 MB message). That is deliberate: iCloud rewrites 8-bit header bytes to * in ENVELOPE and BODY[HEADER], and only BODY[] returns them as stored; each line is decoded as UTF-8, falling back to windows-1252, so a raw latin-1 display name survives. A header block larger than the window falls back to BODY.PEEK[HEADER]. A numeric id reads Mail's all headers property over AppleScript, with the same mailbox-scoped fast path as get-message. ⚠️ That property is Mail's own rendering, not the stored bytes: for a Date: value Mail cannot parse it drops the value and joins the next header onto the name (Date: Subject: …). The tool splits that back apart, reports the date as absent rather than as a Subject string, and says so in warnings[]; the imap: id for the same message has the real Date:.
get-message-rfc822
Acquire a message's complete original RFC 822 bytes exactly as the IMAP server stores them, together with the IMAP identity that links the copy back to its source (#244). Built for archival, forensic review and evidence preservation: nothing is decoded, charset-converted, line-ending-normalized or MIME-re-serialized, and the SHA-256 is computed over exactly the bytes returned. IMAP-only — it needs an imap: id, i.e. an IMAP-configured account (docs/IMAP-SETUP.md). There is no AppleScript path on purpose: Mail's bridge exposes its own rendering of a message, not the stored bytes.
Returns: a one-line summary as text. structuredContent carries the acquisition record: account, mailbox, uid, uidValidity (decimal string), internalDate (ISO 8601 INTERNALDATE — arrival, not the Date: header), flags[], size (the server's RFC822.SIZE), bytes (the count actually acquired and hashed), sha256 (hex, over exactly those bytes), messageId (from ENVELOPE, for convenience — the authoritative copy is in the bytes), readMethod (the IMAP commands issued), backend: "imap" and warnings[] — plus contentBase64 in inline mode or savedPath in file mode. The base64 is carried only in structuredContent; the text block never repeats it.
Read-only by construction. The mailbox is opened with EXAMINE and the body fetched with BODY.PEEK[], so \Seen is not set and no STORE, COPY, MOVE, APPEND or EXPUNGE is issued. No Mail.app, AppleScript or osascript is involved. The only side effect is the optional file, created with wx (an existing file or symlink is refused, never overwritten) and mode 0600, and only inside the allowed roots — the same policy as save-attachment.
Sizes. The inline ceiling exists because the MCP stdio transport drops any single message over 10 MB with no error text: 6 MiB of raw bytes is 8 MiB of base64. maxBytes is a ceiling, not a cut — an oversize message is refused with its RFC822.SIZE, so you can choose between raising maxBytes (inline, up to 6 MiB) and savePath (up to 25 MiB). warnings[] reports an RFC822.SIZE that disagrees with the byte count actually received (the hash still covers what was received) and a server that reported no UIDVALIDITY.
Verifying a derivative. shasum -a 256 file.eml must equal sha256. account + mailbox + uidValidity + uid is the durable pointer back to the source message — a UID on its own is meaningful only for one UIDVALIDITY, which the server may change (typically on a mailbox rebuild).
list-messages
List messages in a mailbox.
Returns: List of messages with ID, date, subject, sender, and flagColor
when flagged with an explicit color (see the flag-color note under
search-messages, which also supports filtering by it).
⚠️ Row dates. Since 2.19.7 every row — on both the IMAP and
AppleScript backends — carries both dateSent (the message's Date:
header) and dateReceived (mailbox arrival — IMAP INTERNALDATE, falling back
to the header date if the server withholds it; Mail's date received on the
AppleScript path). They differ legitimately by transit time; when they differ
by years, the mailbox was migrated or re-imported and the arrival timestamp
was reset — trust dateSent for chronology
(#224). Since 2.19.6 the
IMAP dateSent is recovered even when the server's own parse of the Date:
header failed (legacy locale dates such as jue ago 30 13:55:12 2007); both
backends omit dateSent (rather than reporting it) when it is more than 7 days
later than dateReceived (#234).
Before 2.19.7 the AppleScript path carried only dateReceived. Sort order keys
on the header date on both backends, which is the stable one.
send-email
Send a new email immediately.
⚠️ Safety: Sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling.
Example:
{
"to": ["[email protected]"],
"subject": "Meeting Tomorrow",
"body": "Hi, just confirming our meeting at 2pm tomorrow.",
"account": "Work",
"attachments": ["/Users/me/Documents/agenda.pdf"]
}
Threading headers (inReplyTo / references)
send-email and create-draft accept caller-built threading headers (2.20.0, #267) for
callers that compose the recipients and quoted text themselves. Prefer
reply-to-message — it derives recipients, In-Reply-To/References and the quote
from the original, and (since 2.20.0) takes attachments too.
- Each value must be a single RFC 5322 msg-id token with angle brackets
(
<left@right>, no whitespace); anything else is rejected by validation. - The headers are always emitted as plain
<id>tokens — never RFC 2047-encoded, even for Outlook/Exchange IDs longer than 76 characters (a long header is folded at whitespace between tokens, which is legal and does not alter the id). - Mail.app's AppleScript cannot set these headers, so they are never silently
dropped:
send-emailrefuses them unless the SMTP transport is in use, andcreate-draftfiles a threaded draft over IMAP instead of through Mail.app (below).
SMTP transport
On macOS 15+ (Sequoia/Tahoe), Mail.app wraps any AppleScript-injected body in
<blockquote type="cite"> under the Apple-Mail-URLShareWrapperClass template,
so emails sent through the default applescript transport render to recipients
as if they were quoted/forwarded (Apple radar FB11734014, open since
Ventura). The SMTP transport bypasses Mail.app entirely and submits clean MIME
directly. Once SMTP is configured, send-email uses it automatically (no
need to pass transport per call); pass transport: "applescript" to force the
Mail.app path.
Two differences to know when SMTP is auto-preferred:
- Sent-folder copy is best-effort over IMAP. SMTP submission itself never
touches IMAP, so since 2.18.0 the server files the sent message into the Sent
mailbox of whichever configured IMAP account's login matches the SMTP identity
(
APPLE_MAIL_MCP_IMAP_*mirroringAPPLE_MAIL_MCP_SMTP_*), flagged\Seen. The copy carries the sameMessage-IDthat was delivered, so replies thread against it correctly, and it keeps theBccheader the recipients never see — your own archive is exactly where that belongs. Results reportsentCopy: trueorsentCopy: falsewithsentCopyError; the field is absent when no configured IMAP account matches the SMTP identity, which is a skip, not a failure. A copy that fails never fails the send — the mail has already gone. Before appending, the Sent mailbox is searched for thatMessage-ID; if the submission server already filed it (Gmail's SMTP always does — the server waits up to ~3 s for it there), nothing is appended and the result addssentCopyAlreadyFiled: truealongsidesentCopy: true. Usetransport: "applescript"if you want Mail.app itself to file the copy. accountis a From override, not account selection. Over SMTP,accountis used as the From address only when it is an email address; a Mail.app account label (e.g."Work") can't select an account over SMTP, so a call that passes one is left on the AppleScript path automatically. To force account selection, passtransport: "applescript"explicitly. For sender safety, an email-form override must match the SMTP login user, the configuredAPPLE_MAIL_MCP_SMTP_FROM, or an address listed in the comma-separatedAPPLE_MAIL_MCP_SMTP_ALLOWED_FROM; any other From address is rejected before connecting.
Both plain-text and HTML bodies are supported — over SMTP an HTML body (CLI
--html-body-file) is sent as multipart/alternative with the plain-text
fallback.
Configure SMTP via environment variables on the MCP server. The password is read from the macOS Keychain by default, so no secret goes in config:
Non-implicit-TLS SMTP connections fail closed if STARTTLS is unavailable.
APPLE_MAIL_MCP_SMTP_ALLOW_PLAINTEXT=1 is a deliberate escape hatch for a
trusted isolated server or test fixture; it disables the upgrade requirement and
can expose credentials and message content. The server emits a warning when it
is used. Keep the default unset.
Store the password in the Keychain once (an app-specific password for Gmail/
iCloud). A generic-password item with an explicit service name keeps it from
colliding with the system mail account password, and matches
APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE:
# Fastmail (Keychain service defaults to the host)
security add-internet-password -s smtp.fastmail.com -a [email protected] -w
# Gmail / Google Workspace, using a dedicated Keychain service name:
# APPLE_MAIL_MCP_SMTP_HOST=smtp.gmail.com
# [email protected]
# APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE=apple-mail-mcp-smtp
security add-generic-password -s apple-mail-mcp-smtp -a [email protected] -w
Once the env vars are set, a plain send-email (no transport) already goes
out clean:
{
"to": ["[email protected]"],
"subject": "Standings",
"body": "Plain body — no blockquote wrapping."
}
apple-mail-send CLI (no MCP server required)
The package also installs an apple-mail-send binary — a standalone CLI over the
same SMTP path, for cron jobs, scheduled tasks, and scripts that can't run an MCP
session. It reads the identical APPLE_MAIL_MCP_SMTP_* env + Keychain config:
apple-mail-send \
--from [email protected] --to [email protected] \
--subject "Standings" --body-file /tmp/body.txt \
[--html-body-file /tmp/body.html] [--attach /tmp/report.pdf]
Repeatable --to/--cc/--bcc/--attach; an --html-body-file is sent as a
multipart/alternative alongside the plain --body-file. Exit codes follow
sysexits.h: 0 success, 64 usage error, 66 unreadable body file, 78
SMTP not configured.
IMAP backend — opt-in
📘 For step-by-step setup (app passwords, Keychain, config methods, multi-account, upgrading, troubleshooting), see the IMAP / SMTP Setup Guide. The summary below is the reference; the guide is the walkthrough.
AppleScript runs search/list predicates client-side over the Apple Event
bridge, which is slow and can time out (false-empty) on large Gmail/IMAP
mailboxes (see #24), and
its delete/rename mailbox and draft handlers don't work on server-side
accounts at all (#42). When an account is configured for IMAP, the MCP routes to
a server-side IMAP backend (#43)
that is fast and correct on exactly those mailboxes. This is opt-in and
additive: any account without IMAP configured behaves exactly as before
(AppleScript).
What routes to IMAP when an account is IMAP-configured:
- Read:
search-messages,list-messages(server-sideSEARCH, typically sub-second), andget-message. - Folder ops:
create-mailbox,rename-mailbox,delete-mailbox— IMAP'sCREATE/RENAME/DELETEsucceed on the iCloud/Gmail/Workspace/Exchange mailboxes Mail.app's AppleScript bridge can't touch (#42). - Message mutations:
mark-as-read/unread,flag-message/unflag-message,move-message,delete-message. - Batch mutations (2.1):
batch-mark-as-read/unread,batch-flag/unflag-messages,batch-move-messages,batch-delete-messages—imap:ids are grouped by mailbox and applied as a singleUID STORE/UID MOVE; numeric ids in the same batch still use AppleScript. - Counts & stats (2.1):
get-unread-countandlist-mailboxesuseSTATUS;get-mail-statsusesSTATUS+SEARCH SINCE— authoritative and fast even on huge mailboxes. As of v2.6.0 these prefer IMAP whenever it's configured (see Read routing below), merging across accounts when noaccountis given. - Attachments (2.1):
list-attachments,save-attachment,fetch-attachmentuseBODYSTRUCTURE+FETCH BODY[part]forimap:ids — faster and able to see MIME-embedded attachments AppleScript misses. - Threading (2.1):
get-threadlinks a conversation viaReferences/Message-ID(HEADER SEARCH) for animap:seed, falling back to subject grouping otherwise.
Message ids are backend-tagged. The IMAP read path emits self-describing ids
of the form imap:<token> (the token encodes the account, mailbox path, and
UID). Pass that id back to get-message, a message mutation, a batch op, or the
attachment/thread tools and it routes to IMAP automatically; bare numeric ids
continue to use AppleScript. So an agent never has to know which backend a
message came from — the id carries it.
Read routing (v2.6.0): reads PREFER direct IMAP whenever IMAP is configured.
The read tools — search-messages, get-thread, list-messages,
list-mailboxes, get-unread-count, get-mail-stats — now go to IMAP whenever
any APPLE_MAIL_MCP_IMAP_* account is configured, not just when an explicit
matching account is passed. There are three cases:
- Explicit IMAP account — single-account IMAP (fast server-side path).
- Explicit non-IMAP account — AppleScript (that account isn't on IMAP).
- No
accountgiven — merge across all accounts: the query fans out over every configured IMAP account, and AppleScript runs only for the accounts no IMAP config covers (the account list is partitioned — accounts already served by IMAP are not re-scanned via AppleScript). If every Mail account is IMAP-configured, AppleScript is skipped entirely. The results are merged so no account is dropped. Message lists still de-duplicate as a safety net (preferring the IMAP copy, which carries the round-trippableimap:id) and sort newest-first; count tools (get-unread-count,get-mail-stats) count each account via exactly one backend so a coverage mismatch can never double- (or under-) count.- An omitted mailbox on
search-messagessearches the account's entire store, not just one default folder (v2.17.1, #199). Per account, the fan-out uses the server-advertised RFC 6154\Allmailbox when one exists (Gmail/Workspace's[Gmail]/All Mail); otherwise it searches every selectable mailbox the server lists (iCloud, generic IMAP), merges the matches, de-duplicates by Message-ID, and sorts newest-first before applyinglimit/offset. A mailbox that can't be selected or searched is named in the result instead of silently dropping coverage. Scanning every mailbox on a large, deeply-nested account costs oneSEARCH+ a boundedFETCHper mailbox over the pooled IMAP connection — pin amailboxto skip the fan-out when you already know where to look.list-messages(no query) still defaults an omitted mailbox toINBOXon every provider — only unscoped search scans the whole account.
- An omitted mailbox on
If IMAP is not configured at all, every read behaves exactly as before
(pure AppleScript). The three mailbox-write ops (create-mailbox,
delete-mailbox, rename-mailbox) remain conservative — they route to IMAP only
for an explicitly-named IMAP account, never on an omitted account.
Multiple IMAP accounts (C2): set APPLE_MAIL_MCP_IMAP_ACCOUNTS to a JSON array, e.g.
[{"account":"Work","user":"[email protected]","host":"imap.co.com","keychainService":"imap.co.com"}].
Each entry accepts account, user, host, port, password, keychainService,
keychainAccount. Calls route to the account matching their account argument (or the
decoded imap: id), and each account keeps its own pooled connection.
Non-implicit-TLS IMAP connections require STARTTLS and fail closed when the server
does not offer a usable upgrade. APPLE_MAIL_MCP_IMAP_ALLOW_PLAINTEXT=1 is a
deliberate escape hatch for a trusted isolated server or test fixture; it disables
the upgrade requirement and can expose credentials and message content. Keep the
default unset.
As with SMTP, the password is read from the macOS Keychain by default (use
an app-specific password for Gmail/Workspace/iCloud), so no secret goes in
config. Gmail label semantics: common names (All Mail, Sent, Trash,
Spam, Important, …) map to their [Gmail]/… IMAP paths automatically.
Note: IMAP connections are pooled — one kept-alive connection per account is reused across calls (verified with a NOOP, closed after
APPLE_MAIL_MCP_IMAP_IDLE_MSof inactivity), so there's no per-call connection overhead (#50).iCloud: set
APPLE_MAIL_MCP_IMAP_HOST=imap.mail.me.com,APPLE_MAIL_MCP_IMAP_USERto your iCloud address,APPLE_MAIL_MCP_IMAP_ACCOUNTto the Mail account name (e.g.iCloud), and use an app-specific password (from appleid.apple.com) stored in the Keychain.
Connection footprint (playing nice with Gmail)
IMAP connections are a shared, capped resource: Gmail allows at most 15 simultaneous IMAP connections per account, and Apple Mail itself needs some of those slots. This server keeps its footprint small:
- One pooled connection per account, reused across calls and closed after
~30s idle (tune with
APPLE_MAIL_MCP_IMAP_IDLE_MS;0= never close). So a server that isn't actively serving IMAP calls holds zero connections. - IMAP IDLE is opt-in (
APPLE_MAIL_MCP_IMAP_IDLE=1). When on, it adds one persistent connection per account (a long-lived watcher), on top of the pooled request connection — leave it off if you don't need push notifications. - Connections are dropped on shutdown — SIGINT/SIGTERM and stdin-EOF (the MCP client/parent going away). As of v2.6.1 the server also self-exits if it becomes orphaned (parent force-quit/crashed → reparented to launchd), polling every 30s, so it can't linger holding sockets after its session is gone.
The catch is multiple concurrent instances. A host like the Claude desktop
app spawns a separate set of MCP servers per open conversation (and respawns
them after a crash), so the footprint is per instance × accounts. With IDLE
off, an idle instance trends to 0 connections; with many active conversations
or IDLE on, the per-account total climbs toward Gmail's 15-connection cap and can
starve Apple Mail of slots (→ intermittent "cannot connect"). If you hit that,
close idle Claude conversations, keep APPLE_MAIL_MCP_IMAP_IDLE off unless you
need push, and/or lower APPLE_MAIL_MCP_IMAP_IDLE_MS.
Configuration file (when the host strips env)
Some host apps (e.g. Claude Desktop) launch the MCP server with a scrubbed
environment and ignore the env block in their server config, so there's no way
to pass APPLE_MAIL_MCP_* settings through it. In that case, put them in a JSON
file the host doesn't manage — APPLE_MAIL_MCP_CONFIG_FILE, or by default
~/Library/Application Support/apple-mail-mcp/config.json:
{
"APPLE_MAIL_MCP_IMAP_USER": "[email protected]",
"APPLE_MAIL_MCP_IMAP_HOST": "imap.gmail.com",
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT": "[email protected]",
"APPLE_MAIL_MCP_IMAP_IDLE": "1"
}
The server reads it at startup and merges values into the environment without
overriding anything already set there (so an explicit env still wins). Store
only non-secret config here — passwords belong in the Keychain, never in this
file.
Push notifications (IMAP IDLE) — opt-in
When APPLE_MAIL_MCP_IMAP_IDLE=1, the server opens a dedicated, long-lived
connection to each configured IMAP account and watches its INBOX for new
mail. On arrival it pushes two MCP notifications to the client (no polling by the
client required):
notifications/message(logging) — a human-readable line, e.g.New mail in "Work": 2 new message(s) (INBOX now 1843).notifications/resources/updated— for the affected account's resourcemail://mailboxes/{account}, so a client subscribed to that resource knows to re-read it.
This requires an IMAP account to be configured (single-account env or
APPLE_MAIL_MCP_IMAP_ACCOUNTS); accounts that only use AppleScript aren't
watched. Detection is real-time via the IMAP IDLE EXISTS event where the
server pushes it, with an automatic polling fallback for servers that don't.
Dropped connections reconnect with backoff, and the watchers shut down cleanly on
SIGINT/SIGTERM.
Enable it in your MCP client config alongside the IMAP settings:
{
"mcpServers": {
"apple-mail": {
"command": "node",
"args": ["/path/to/apple-mail-mcp/build/index.js"],
"env": {
"APPLE_MAIL_MCP_IMAP_USER": "[email protected]",
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
"APPLE_MAIL_MCP_IMAP_IDLE": "1"
}
}
}
}
Note: this is most useful with clients that surface MCP logging messages or subscribe to resource-update notifications. Clients that ignore notifications are unaffected — the feature is opt-in and adds no behavior unless enabled.
send-serial-email
Send individual personalized emails to a list of recipients (mail merge). Each recipient receives their own email — recipients don't see each other. Supports {{placeholder}} tokens in both subject and body.
Each recipient object:
| Field | Type | Required | Description | | --