apple-mail-mcp
# Apple Mail MCP Server
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants like Claude to read, send, search, and manage emails in Apple Mail on macOS.
[](https://www.npmjs.com/package/apple-mail-mcp)
[](https://www.npmjs.com/package/apple-mail-mcp)
[](https://www.npmjs.com/package/apple-mail-mcp)
[](https://github.com/sweetrb/apple-mail-mcp/actions/workflows/ci.yml)
[](https://scorecard.dev/viewer/?uri=github.com/sweetrb/apple-mail-mcp)
[](https://www.apple.com/macos/)
[](https://opensource.org/licenses/MIT)
[](https://modelcontextprotocol.io)
<p align="center">
<img src="https://raw.githubusercontent.com/sweetrb/apple-mail-mcp/main/codex/assets/screenshot.png" alt="Apple Mail MCP — read, search, send, and organize Apple Mail from Codex, Claude, and other AI assistants" width="680">
</p>
> **Note:** This is the **npm/Node.js** package — install with `npx` or `npm`. There is an unrelated Python project of the same name on PyPI ([`imdinu/apple-mail-mcp`](https://github.com/imdinu/apple-mail-mcp)) installed via `pipx`/`uvx`. If you're using `uvx` and seeing a `cyclopts` dependency 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 john@example.com"
- "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](https://claude.com/product/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:
```bash
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:
```bash
/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 `env` block,
> so supply settings via the config file at `~/Library/Application Support/apple-mail-mcp/config.json`
> — Method B in the [IMAP / SMTP Setup Guide](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md).
> Passwords stay in the macOS Keychain; run the `doctor` tool to verify.
### Using the Codex Marketplace
Install the same public marketplace in Codex:
```bash
codex plugin marketplace add sweetrb/apple-mail-mcp
codex plugin add apple-mail@apple-mail-mcp
```
The Codex package registers the same `apple-mail` MCP server through an exactly pinned runtime — `npx -y apple-mail-mcp@<plugin version>` — and includes the Apple Mail skill guidance. The pin in `codex/.mcp.json` is rewritten to match `package.json` by `scripts/sync-plugin-version.mjs` on every version bump, so the plugin manifest and the server it launches are always the same release; CI fails the PR if they drift.
### Other Hosts (Hermes, Antigravity)
Two more hosts can run the same `apple-mail` MCP server (`npx -y apple-mail-mcp`):
- **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in, so there is nothing in this repo to install from. Register the server with the CLI:
```bash
hermes mcp add apple-mail --command npx --args -y apple-mail-mcp
```
Or add it to `~/.hermes/config.yaml` by hand:
```yaml
mcp_servers:
apple-mail:
command: npx
args: ["-y", "apple-mail-mcp"]
```
Restart your Hermes session afterward so the tools load.
- **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](https://github.com/sweetrb/apple-mail-mcp/blob/main/.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).
### Manual Installation
**1. Install the server:**
```bash
npm install -g apple-mail-mcp
```
**2. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```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-email` submits 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](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md)** — 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-contacts` only. It reads the Contacts database directly through Node's built-in `node: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](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md).
- **Apple Mail** - Must have at least one account configured (iCloud, Gmail, Exchange, etc.)
## Features
### Messages
| Feature | Description |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **List Messages** | List messages with pagination, sender filter, date display |
| **Search Messages** | Search by sender, subject, content, date range, read/flagged status — across all accounts |
| **Read Messages** | Get full email content (plain text or HTML) |
| **Read Headers** | Get a message's raw RFC 5322 headers — the author's `Date:`, Message-ID, threading ids, `Received:` trace — without downloading the body |
| **Send Email** | Compose and send new emails (attach by file path or inline base64 content; optional `inReplyTo`/`references` threading over SMTP) |
| **Send Serial Email** | Mail merge — send personalized emails to a list of recipients with {{placeholder}} support |
| **Create Draft** | Save emails to Drafts folder (attach by file path or inline base64 content; threaded drafts filed over IMAP) |
| **Reply** | Reply to messages (with reply-all support and attachments) |
| **Forward** | Forward messages to new recipients (with extra attachments) |
| **Get Thread** | Group a conversation by normalized subject (across AppleScript or IMAP) |
| **Mark Read/Unread** | Change read status (single or batch) |
| **Flag/Unflag** | Flag or unflag messages (single or batch) |
| **Delete Messages** | Move messages to trash (single or batch) |
| **Move Messages** | Organize into mailboxes (single or batch) |
| **List Attachments** | View attachment metadata (name, type, size) |
| **Save Attachment** | Save attachments to disk |
| **Fetch Attachment** | Get an attachment's bytes as base64 (no disk write) |
Read/list/get tools also return **structured JSON** (`structuredContent`) alongside the text, so agents can consume results without parsing prose.
### Mailbox & Account Management
| Feature | Description |
| -------------------------------- | ------------------------------------------- |
| **List Mailboxes** | Show all folders with message/unread counts |
| **Create/Delete/Rename Mailbox** | Full mailbox lifecycle management |
| **List Accounts** | Show configured accounts |
| **Unread Count** | Get unread counts per mailbox |
### Rules, Contacts & Templates
| Feature | Description |
| ------------------------ | ---------------------------------------------------------------------------------------- |
| **List Rules** | View all mail rules and their enabled status |
| **Enable/Disable Rules** | Toggle mail rules on or off |
| **Create/Delete Rules** | Create rules with conditions + actions, or delete by name |
| **Search Contacts** | Look up contacts from Contacts.app by name |
| **Email Templates** | Save, list, use, and delete reusable email templates (persisted to disk across restarts) |
### Diagnostics
| Feature | Description |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Health Check** | Verify Mail.app connectivity |
| **Doctor** | Diagnose Mail permission, account state, and each IMAP/SMTP backend with actionable messages |
| **Statistics** | Message and unread counts per account, recently received stats |
| **Sync Status** | Check if Mail.app is actively syncing |
| **Effect reconciliation** | Every delete/move reports what it actually did to the mailbox (`countDelta`), and warns when more messages left than were operated on — see [Auditing destructive operations](#auditing-destructive-operations) |
### 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.
| Parameter | Type | Required | Description |
| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `query` | string | No | Text to search in subject/sender |
| `body` | string | No | Text to search in the message body. IMAP backend only (server-side `BODY` search); AppleScript-only accounts are reported as not searched |
| `from` | string | No | Filter by sender email address |
| `subject` | string | No | Filter by subject line |
| `mailbox` | string | No | Mailbox to search in (omit to search all mailboxes) |
| `account` | string | No | Account to search in (omit to search all accounts) |
| `isRead` | boolean | No | Filter by read status |
| `isFlagged` | boolean | No | Filter by flagged status |
| `dateFrom` | string | No | Start date filter (e.g., "January 1, 2026") |
| `dateTo` | string | No | End date filter (e.g., "March 1, 2026") |
| `limit` | number | No | Max results, 1–500 (default: 50) |
| `offset` | number | No | Newest matches to skip, ≥ 0 (for pagination). IMAP accounts only; without a `mailbox`, `offset` + `limit` may not exceed 5,000 |
**Returns:** List of matching messages with ID, date, subject, sender, and read state.
⚠️ **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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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:
| Field | Type | Meaning |
| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `partial` | boolean | Coverage was incomplete — **the result is not a confirmed "no such mail"**. True whenever any field below is non-empty. |
| `skippedLargeMailboxes` | string[] | Mailboxes never scanned because their message count exceeded `APPLE_MAIL_MAX_SEARCH_MAILBOX`, formatted `"Account / Mailbox (count)"` — e.g. `"iCloud / Archive (90694)"`. |
| `notSearchedMailboxes` | string[] | Mailboxes that _were_ reached but timed out or errored mid-scan, formatted `"Account / Mailbox"`. Also carries the IMAP path's `failedMailboxes`. |
| `timedOutAccounts` | string[] | Accounts whose whole-account AppleScript was killed by the per-account time budget — nothing from that account was searched. |
| `failedMailboxes` | string[] | IMAP-backend mailboxes that errored. These are merged into `notSearchedMailboxes` as well; read that field unless you need to attribute the failure to the IMAP path specifically. |
| `failedMailboxReasons` | object | The IMAP server's own error text for each entry in `failedMailboxes`, keyed the same way. |
| `omittedMessages` | object[] | IMAP messages that belong on the page but whose `FETCH` response could not be read even with a reduced item set — `{ id, mailbox, uid, reason }`. The page is short by exactly these; the `id` still works with `get-message` / `get-message-headers`. ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)) |
| `timedOutMailboxes` | string[] | IMAP mailboxes the search did not finish because the call was **cancelled** (`notifications/cancelled`) or reached its **deadline** (`APPLE_MAIL_MCP_SEARCH_DEADLINE_MS`, default 45s), formatted `"Account / Mailbox"`; an account never reached is `"Account / *"`. Results from the mailboxes that did finish are still returned. ([#276](https://github.com/sweetrb/apple-mail-mcp/issues/276)) |
| `stoppedBy` | string | `"deadline"` or `"cancelled"` — present only with `timedOutMailboxes`. |
**Deadline and cancellation ([#276](https://github.com/sweetrb/apple-mail-mcp/issues/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](#imap-backend--opt-in), 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.
| Parameter | Type | Required | Description |
| ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID |
| `preferHtml` | boolean | No | Return HTML source instead of plain text |
| `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders. A miss there is reported as "not found in <account> / <mailbox>"; it never falls back to the cross-mailbox scan |
| `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
**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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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 the `APPLE_MAIL_MCP_MAX_BUFFER` environment 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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/224)): the `Date:` header is the author's send time and survives such moves; `INTERNALDATE` / `date received` does not.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID (numeric or `imap:…`) |
| `mailbox` | string | No | Mailbox holding the message. With `account`, opens that mailbox directly instead of scanning every mailbox |
| `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
**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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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](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.
| Parameter | Type | Required | Description |
| ---------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | An `imap:` message id (numeric Mail.app ids are refused with an explanation) |
| `savePath` | string | No | Directory inside the configured allowed roots. Writes the bytes to a **new** `.eml` there instead of returning them inline, and raises the size ceiling to 25 MiB |
| `fileName` | string | No | File name to use with `savePath` (no path separators, no `..`). Default `<account>-<mailbox>-uidv<UIDVALIDITY>-uid<UID>.eml` |
| `maxBytes` | number | No | Refuse — never truncate — a message larger than this many raw bytes. Default and inline maximum 6 MiB (`6291456`); with `savePath` up to 25 MiB (`26214400`) |
**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.
| Parameter | Type | Required | Description |
| ------------ | ------- | -------- | ------------------------------------------------ |
| `mailbox` | string | No | Mailbox name (omit to list from all mailboxes) |
| `account` | string | No | Account name |
| `limit` | number | No | Max messages, 1–500 (default: 50) |
| `offset` | number | No | Number of messages to skip, ≥ 0 (for pagination) |
| `from` | string | No | Filter by sender email address or name |
| `unreadOnly` | boolean | No | Only show unread messages |
**Returns:** List of messages with ID, date, subject, and sender.
⚠️ **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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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.
| Parameter | Type | Required | Description |
| ------------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to` | string[] | Yes | Recipient addresses |
| `subject` | string | Yes | Email subject |
| `body` | string | Yes | Email body (plain text) |
| `cc` | string[] | No | CC recipients |
| `bcc` | string[] | No | BCC recipients |
| `account` | string | No | Mail.app account label, or an email-form SMTP From override. An SMTP override must match `APPLE_MAIL_MCP_SMTP_USER`, `APPLE_MAIL_MCP_SMTP_FROM`, or an address in `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` |
| `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths inside the configured read roots (e.g., `"/Users/me/Documents/report.pdf"`) and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
| `transport` | `"applescript"` \| `"smtp"` | No | Send transport. If omitted, **SMTP is used automatically when configured** (otherwise AppleScript). Pass `"smtp"` to require clean MIME, or `"applescript"` to force the Mail.app path — see [SMTP transport](#smtp-transport) |
| `replyTo` | string | No | SMTP only: `Reply-To` header, when replies should go somewhere other than the From address. Ignored on the AppleScript transport |
| `inReplyTo` | string | No | SMTP only (2.20.0): thread the message as a reply — the parent's `Message-ID` token with angle brackets, e.g. `"<abc@mail.example.com>"`. Emitted verbatim, never RFC 2047-encoded. See [Threading headers](#threading-headers-inreplyto--references) |
| `references` | string[] | No | SMTP only (2.20.0): the `References` chain, oldest first (the parent's own `References` + its `Message-ID`). Defaults to `[inReplyTo]` when omitted |
**Example:**
```json
{
"to": ["colleague@company.com"],
"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-email` refuses them unless the SMTP transport is in use, and
`create-draft` files 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_*` mirroring `APPLE_MAIL_MCP_SMTP_*`), flagged `\Seen`.
The copy carries the same `Message-ID` that was delivered, so replies thread
against it correctly, and it keeps the `Bcc` header the recipients never see —
your own archive is exactly where that belongs. Results report `sentCopy: true`
or `sentCopy: false` with `sentCopyError`; 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.
Use `transport: "applescript"` if you want Mail.app itself to file the copy.
- **`account` is a From override, not account selection.** Over SMTP, `account`
is 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, pass `transport: "applescript"` explicitly. For sender
safety, an email-form override must match the SMTP login user, the configured
`APPLE_MAIL_MCP_SMTP_FROM`, or an address listed in the comma-separated
`APPLE_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.
| Variable | Required | Default | Description |
| -------------------------------------- | -------- | --------------------------- | -------------------------------------------------------------------------------------------- |
| `APPLE_MAIL_MCP_SMTP_HOST` | Yes | — | SMTP server hostname (e.g. `smtp.fastmail.com`) |
| `APPLE_MAIL_MCP_SMTP_USER` | Yes | — | SMTP username |
| `APPLE_MAIL_MCP_SMTP_PORT` | No | `465` if secure, else `587` | SMTP port |
| `APPLE_MAIL_MCP_SMTP_SECURE` | No | `false` | `true` for implicit TLS (port 465); otherwise STARTTLS |
| `APPLE_MAIL_MCP_SMTP_ALLOW_PLAINTEXT` | No | `0` | Set `1` only for an explicitly trusted plaintext test/server; otherwise STARTTLS is required |
| `APPLE_MAIL_MCP_SMTP_FROM` | No | = user | From address |
| `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` | No | — | Comma-separated sender aliases permitted as per-message From overrides |
| `APPLE_MAIL_MCP_SMTP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
| `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` | No | = host | Keychain item service/server name |
| `APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
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`:
```bash
# Fastmail (Keychain service defaults to the host)
security add-internet-password -s smtp.fastmail.com -a you@example.com -w
# Gmail / Google Workspace, using a dedicated Keychain service name:
# APPLE_MAIL_MCP_SMTP_HOST=smtp.gmail.com
# APPLE_MAIL_MCP_SMTP_USER=you@gmail.com
# APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE=apple-mail-mcp-smtp
security add-generic-password -s apple-mail-mcp-smtp -a you@gmail.com -w
```
Once the env vars are set, a plain `send-email` (no `transport`) already goes
out clean:
```json
{
"to": ["colleague@company.com"],
"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:
```bash
apple-mail-send \
--from you@example.com --to colleague@company.com \
--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](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md).** 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](https://github.com/sweetrb/apple-mail-mcp/issues/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](https://github.com/sweetrb/apple-mail-mcp/issues/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-side `SEARCH`, typically sub-second), and `get-message`.
- **Folder ops:** `create-mailbox`, `rename-mailbox`, `delete-mailbox` — IMAP's `CREATE`/`RENAME`/`DELETE` succeed 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 single `UID STORE`/`UID MOVE`; numeric ids in the same batch still use AppleScript.
- **Counts & stats (2.1):** `get-unread-count` and `list-mailboxes` use `STATUS`; `get-mail-stats` uses `STATUS` + `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 no `account` is given.
- **Attachments (2.1):** `list-attachments`, `save-attachment`, `fetch-attachment` use `BODYSTRUCTURE` + `FETCH BODY[part]` for `imap:` ids — faster and able to see MIME-embedded attachments AppleScript misses.
- **Threading (2.1):** `get-thread` links a conversation via `References`/`Message-ID` (`HEADER SEARCH`) for an `imap:` 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 `account` given** — **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-trippable `imap:` 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-messages` searches the account's entire
store, not just one default folder** (v2.17.1, [#199](https://github.com/sweetrb/apple-mail-mcp/issues/199)).
Per account, the fan-out uses the server-advertised RFC 6154 `\All`
mailbox 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 applying `limit`/`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
one `SEARCH` + a bounded `FETCH` per mailbox over the pooled IMAP
connection — pin a `mailbox` to skip the fan-out when you already know
where to look. `list-messages` (no query) still defaults an omitted
mailbox to `INBOX` on every provider — only unscoped _search_ scans the
whole account.
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.
| Variable | Required | Default | Description |
| -------------------------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APPLE_MAIL_MCP_IMAP_USER` | Yes | — | Login address; setting it enables IMAP |
| `APPLE_MAIL_MCP_IMAP_ACCOUNT` | No | = user | Mail account name to match for routing |
| `APPLE_MAIL_MCP_IMAP_HOST` | No | `imap.gmail.com` | IMAP server hostname |
| `APPLE_MAIL_MCP_IMAP_PORT` | No | `993` | IMAP port (993 = implicit TLS) |
| `APPLE_MAIL_MCP_IMAP_ALLOW_PLAINTEXT` | No | `0` | Set `1` only for an explicitly trusted plaintext test/server; otherwise STARTTLS is required |
| `APPLE_MAIL_MCP_IMAP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
| `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | No | — | Keychain item service/server name |
| `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
| `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | No | — | JSON array of **additional** IMAP accounts for multi-account setups (see below) |
| `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
| `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
| `APPLE_MAIL_MCP_STATS_BUDGET_MS` | No | `25000` | Per-account wall-clock budget for `get-mail-stats` (minimum `1000`). Raise it for very large accounts |
| `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | No | `50000` | Overall wall-clock deadline for one `get-mail-stats` call (minimum `2000`), measured from when the request arrived and covering time queued behind other tool calls, account enumeration **and** every per-account read. Keep it below your client's request timeout |
| `APPLE_MAIL_MCP_SEARCH_DEADLINE_MS` | No | `45000` | Overall wall-clock deadline for one IMAP `search-messages` call (minimum `2000`), measured from when the request arrived. On expiry the search returns partial results plus `timedOutMailboxes` instead of holding the tool-call queue. Keep it below your client's request timeout |
**Multiple IMAP accounts (C2):** set `APPLE_MAIL_MCP_IMAP_ACCOUNTS` to a JSON array, e.g.
`[{"account":"Work","user":"me@co.com","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_MS`
> of inactivity), so there's no per-call connection overhead ([#50](https://github.com/sweetrb/apple-mail-mcp/issues/50)).
>
> **iCloud:** set `APPLE_MAIL_MCP_IMAP_HOST=imap.mail.me.com`, `APPLE_MAIL_MCP_IMAP_USER`
> to your iCloud address, `APPLE_MAIL_MCP_IMAP_ACCOUNT` to 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`:
```json
{
"APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
"APPLE_MAIL_MCP_IMAP_HOST": "imap.gmail.com",
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
"APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT": "you@gmail.com",
"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):
1. **`notifications/message`** (logging) — a human-readable line, e.g.
`New mail in "Work": 2 new message(s) (INBOX now 1843).`
2. **`notifications/resources/updated`** — for the affected account's resource
`mail://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:
```json
{
"mcpServers": {
"apple-mail": {
"command": "node",
"args": ["/path/to/apple-mail-mcp/build/index.js"],
"env": {
"APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
"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.
| Parameter | Type | Required | Description |
| ------------ | -------- | -------- | --------------------------------------------------- |
| `recipients` | object[] | Yes | List of recipients, max 100 (see below) |
| `subject` | string | Yes | Email subject — use `{{Key}}` for placeholders |
| `body` | string | Yes | Email body — use `{{Key}}` for placeholders |
| `account` | string | No | Send from specific account |
| `delayMs` | number | No | Delay between sends in ms (default: 500, max 10000) |
Each recipient object:
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------- |
| `email` | string | Yes | Recipient email address |
| `variables` | object | Yes | Key-value pairs for placeholder replacement |
**Example:**
```json
{
"recipients": [
{ "email": "alice@example.com", "variables": { "Name": "Alice", "Company": "Acme" } },
{ "email": "bob@example.com", "variables": { "Name": "Bob", "Company": "Globex" } }
],
"subject": "Hello {{Name}}!",
"body": "Dear {{Name}},\n\nGreat to connect about {{Company}}.\n\nBest regards"
}
```
**Returns:** Per-recipient success/failure results with a summary count.
**⚠️ Safety:** Sends real mail immediately to every recipient and cannot be unsent. Confirm the recipient list, subject, and body with the user before calling.
---
#### `create-draft`
Save an email to Drafts without sending.
| Parameter | Type | Required | Description |
| ------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `to` | string[] | Yes | Recipient addresses |
| `subject` | string | Yes | Email subject |
| `body` | string | Yes | Email body (plain text) |
| `cc` | string[] | No | CC recipients |
| `bcc` | string[] | No | BCC recipients |
| `account` | string | No | Account for draft. With `inReplyTo`/`references` it must name a configured IMAP account (label or login) |
| `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
| `inReplyTo` | string | No | (2.20.0) Parent `Message-ID` token, e.g. `"<abc@mail.example.com>"` — makes this a **threaded draft** (see below) |
| `references` | string[] | No | (2.20.0) `References` chain, oldest first; defaults to `[inReplyTo]` |
**Returns:** Confirmation that draft was created, with `transport` (`applescript`, or `imap` for a threaded draft).
**Threaded drafts (2.20.0, #267):** Mail.app's `outgoing message` has no header property,
so a draft with `inReplyTo`/`references` cannot go through AppleScript. Instead the
server composes it with the same MIME builder as the SMTP send (attachments included) and
files it over IMAP into the account's Drafts mailbox (SPECIAL-USE `\Drafts`), flagged
`\Draft`. The account is `account` when given (it must be a configured IMAP account —
pass the exact `accountLabel` or login from `list-accounts`/your `APPLE_MAIL_MCP_IMAP_*`
config, e.g. `"user@icloud.com"`; a generic provider name like `"iCloud"` is not a
recognized alias and is refused);
otherwise the IMAP account whose login is the SMTP identity, else the only configured
IMAP account. When none qualifies — IMAP unconfigured, an unknown `account`, or several
accounts and no way to choose — the call **fails and files nothing**; it never falls back
to an unthreaded Mail.app draft. The result names the Drafts `mailbox`, the `account` and
the draft's `messageId`. Without threading headers, `create-draft` is unchanged
(AppleScript).
#### `get-thread`
Group a conversation by normalized subject (across the AppleScript or IMAP backend).
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------ |
| `id` | string | Yes | A message ID in the conversation (numeric or `imap:…`) |
| `account` | string | No | Account to search (omit to search all) |
| `mailbox` | string | No | Mailbox to search (omit to search all) |
| `limit` | number | No | Max messages in the thread (default 50) |
**Returns:** The conversation's messages, oldest-first.
#### `fetch-attachment`
Return an attachment's bytes as base64 (the read counterpart to inline-base64 send).
| Parameter | Type | Required | Description |
| ---------------- | ------ | -------- | --------------------------------------------- |
| `id` | string | Yes | Message ID (numeric or `imap:…`) |
| `attachmentName` | string | Yes | Attachment filename (from `list-attachments`) |
**Returns:** The attachment bytes, base64-encoded (also in `structuredContent.contentBase64`).
---
#### `resolve-message-id`
Map `imap:` message IDs to their numeric Mail.app IDs, via each message's RFC 5322 `Message-ID` (the join key both backends share). Needed for the AppleScript reply/forward path; direct SMTP replies and forwards read `imap:` IDs without numeric conversion. Numeric IDs pass through unchanged.
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------- |
| `ids` | string[] | Yes | 1–100 message IDs, each numeric or `imap:…` |
**Returns:** For each input ID, its `numericId` (or `null` when it can't be resolved) and the `messageId` used, plus `count` and `resolvedCount`. The lookup scopes to the message's account and checks its INBOX first, to avoid scanning a large All Mail/Archive mailbox.
> **You do not need this for flag colors (v2.10.0+).** Colors used to require the numeric-ID path, and older docs and tool descriptions said so. `flag-message` and `batch-flag-messages` now write the color over IMAP directly, as Mail.app's `$MailFlagBit0/1/2` keywords, so a smart mailbox keyed on flag color matches an IMAP-flagged message. Resolving IDs just to apply a color reintroduces the AppleScript/TCC dependency 2.10.0 removed. Flag, move, mark, and delete all accept `imap:` IDs as-is.
---
#### `reply-to-message`
Reply to an existing message.
| Parameter | Type | Required | Description |
| ------------- | --------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID to reply to |
| `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
| `replyAll` | boolean | No | Reply to all recipients (default: false) |
| `send` | boolean | No | Send immediately (default: true, false = save as draft) |
| `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
| `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 attachments, same rules as `send-email`: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
**Example - Reply to sender only:**
```json
{
"id": "12345",
"body": "Thanks for the update!"
}
```
**Example - Reply all, save as draft:**
```json
{
"id": "12345",
"body": "I'll review this and get back to everyone.",
"replyAll": true,
"send": false
}
```
**Example - Reply all with an attachment (2.20.0):**
```json
{
"id": "imap:eyJhIjoiV29yayIsInAiOiJJTkJPWCIsInUiOjQyfQ",
"body": "Here is the PDF you asked for.",
"replyAll": true,
"attachments": [
"/Users/me/Documents/quote.pdf",
{ "filename": "notes.txt", "contentBase64": "SGVsbG8K" }
]
}
```
**Attachments (2.20.0, #267):** same schema, limits (20 items, 25 MiB each) and read
roots as `send-email`, validated before the original is fetched — an out-of-roots path
fails with exactly `send-email`'s error. Recipients, threading headers and the quote are
identical to the same call without attachments. Over SMTP the files ride the same
nodemailer path `send-email` uses, so the Sent-folder copy (`sentCopy`) carries them too.
On the AppleScript transport they are added with `make new attachment` **after** the
reply content is set and before `save`/`send` (inline items are written to `0600` temp
files and removed afterwards), so a `send: false` draft contains both the attachments and
the quoted original. Results report `attachmentCount`.
**Delivery:** `imap:` IDs are read directly from their encoded account, mailbox, and UID. Numeric IDs are read through Mail.app. With SMTP configured, replies use clean MIME with the original `Message-ID` in `In-Reply-To` and the full `References` chain. Only the original plain-text body is quoted; the new text is not. Pass `transport: "smtp"` to require this path, or `transport: "applescript"` to use Mail.app explicitly.
**AppleScript transport quoting (v2.19.12):** every draft (`send: false`) and any send that falls back to AppleScript (SMTP not configured) also gets the original quoted — an "On \<date\>, \<sender\> wrote:" attribution line followed by the `> `-prefixed original body, built the same way as the SMTP path. Mail's own `reply`/`forward` AppleScript commands generate this quote internally but expose no way to read it back, so it's built here instead of relying on Mail's (unreadable) copy. Unlike SMTP, this path has no sending-identity restriction — it's read-only source lookup, not sending.
**Failure behavior:** once SMTP is selected, a missing password, unreadable source, missing reply address or `Message-ID`, or SMTP failure returns an error. It does not silently switch to AppleScript or create an unthreaded new message. With SMTP unconfigured and transport omitted, AppleScript remains available. `send: false` saves a Mail.app draft; combining it with `transport: "smtp"` is rejected before composing.
The direct IMAP source read is bounded to 25 MiB, including MIME attachments. Its account login must match the SMTP login, configured From, or an explicitly configured `APPLE_MAIL_MCP_SMTP_ALLOWED_FROM` identity; otherwise the call fails rather than sending from an unrelated account. The SMTP configuration still represents a single sending identity, not a per-account SMTP registry.
Success includes `transport`, `attachmentCount` and, for SMTP when returned by the server, the new `messageId`. Over SMTP a best-effort Sent-folder copy is filed over IMAP exactly as for `send-email` (`sentCopy` / `sentCopyError`, see [SMTP transport](#smtp-transport)). `send-email` is for **new conversations**: adding `Re:` to its subject does not add threading headers. Use this reply tool with the original id instead.
**⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
---
#### `forward-message`
Forward a message to new recipients.
| Parameter | Type | Required | Description |
| ------------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID to forward |
| `to` | string[] | Yes | Recipients to forward to |
| `body` | string | No | Message to prepend (plain text) |
| `send` | boolean | No | Send immediately (default: true, false = save as draft) |
| `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
| `attachments` | (string \| {filename, contentBase64})[] | No | Up to 20 additional attachments, same rules as `send-email`: absolute file paths inside the configured read roots and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |
**Delivery:** uses the same source lookup, transport selection, 25 MiB source limit, account-identity check, and failure behavior as `reply-to-message`. A forward deliberately starts a new conversation, so it has no `In-Reply-To` or `References` headers. The existing plain-text forwarding behavior is unchanged: original attachments are not reattached. See [SMTP transport](#smtp-transport). On the AppleScript transport, a `body` is merged with the forwarded-message header block and original text the same way the SMTP path builds it (see `reply-to-message`'s AppleScript transport quoting note); with no `body`, Mail's own forward content is left untouched — unless `attachments` are given, in which case the forwarded-message block is built the same way so Mail has a paragraph to anchor the new files after.
**Attachments (2.20.0, #267):** `attachments` adds new files to the forward, with or without a prepended `body`, under the same rules, validation and per-transport mechanics as `reply-to-message`'s attachments. They are independent of the original's attachments (which the SMTP path still does not reattach).
SMTP forwarding requires a readable plain-text original. HTML-only IMAP messages and failed Mail.app body reads return an error before sending instead of silently omitting the original content. Explicitly select `transport: "applescript"` to forward these with Mail.app; no automatic fallback occurs. An intentionally empty plain-text message is still valid.
**⚠️ Safety:** With the default `send: true`, sends real mail immediately and cannot be unsent. Confirm the recipients, subject, and body with the user before calling (or pass `send: false` to save a draft for review).
---
#### `mark-as-read` / `mark-as-unread`
Change read status of a message.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | string | Yes | Message ID |
---
#### `flag-message` / `unflag-message`
Flag or unflag a message. `flag-message` optionally takes a flag **color**; `unflag-message` removes the flag entirely (which also clears any color).
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID |
| `color` | string | No | (`flag-message` only) Flag color: `red`, `orange`, `yellow`, `green`, `blue`, `purple`, `gray` (`grey` accepted). Omit for Mail's default flag. |
**Flag colors** are an Apple Mail feature — the message's `flag index` (0 red, 1 orange, 2 yellow, 3 green, 4 blue, 5 purple, 6 gray), which is the property a Mail smart mailbox can match on. **The color is applied on both routes** (since 2.10.0): AppleScript sets the flag index directly, and for an **IMAP-routed** id (`imap:…`) the color is written as Mail.app's `$MailFlagBit0/1/2` keywords — a 3-bit field holding the same palette index. `\Flagged` on its own really is colorless, but those keywords ride alongside it in an ordinary `UID STORE`, so a smart mailbox keyed on flag color matches an IMAP-flagged message too. You do **not** need to resolve to a numeric id just to color a flag.
To **read** a color, the IMAP read path returns `flagColorIndex` in `structuredContent` — the same 0-6 palette index, omitted when the message carries no color bits. The AppleScript read path does not populate it.
---
#### `delete-message`
Delete a message (move to trash).
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | string | Yes | Message ID |
`structuredContent` carries `countDelta` — what the delete actually did to the
source mailbox. See [Auditing destructive operations](#auditing-destructive-operations).
**⚠️ Safety:** Destructive. Requires explicit user confirmation; search/list first to confirm the message id.
---
#### `move-message`
Move a message to a different mailbox.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Message ID |
| `mailbox` | string | Yes | Destination mailbox — full path (`Work/Archive`) or a leaf name that is unique on the account |
| `account` | string | No | Account containing mailbox |
A destination is matched first as a full path, then as a leaf name. If a leaf
name matches **more than one** mailbox (e.g. `Archive` under both `Work` and
`Thornlands`), the move is refused with an error naming every candidate — pass
the full path. The same applies to `batch-move-messages`, `delete-mailbox` and
`rename-mailbox`.
`structuredContent` carries `countDelta` — what the move actually did to the
**source** mailbox. See [Auditing destructive operations](#auditing-destructive-operations).
---
#### `list-attachments`
List attachments on a message.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | string | Yes | Message ID |
**Returns:** List of attachments with name, MIME type, and `size` — the **decoded** byte count,
i.e. what `fetch-attachment` returns.
**Sizes over IMAP.** `BODYSTRUCTURE` reports a part's size in its transfer encoding, so a
base64 attachment's raw figure is ~4/3 of the file. `list-attachments` converts it: a
`Content-Disposition` `size=` parameter wins when present; 7bit/8bit/binary parts are exact
as reported; base64 parts are measured exactly from a short tail of each part (padding and
line wrap), fetched in one batched `FETCH` for the whole message; quoted-printable parts up
to 64 KiB are fetched and counted exactly. If a part can't be measured (the probe fails, the
wrap is irregular, or a quoted-printable part is larger), the size is an estimate within a
few bytes for base64 — an upper bound for quoted-printable — and the entry carries
`sizeApproximate: true`. `fetch-attachment` always reports the exact `bytes`.
---
#### `save-attachment`
Save a message attachment to disk.
The destination must not already exist: `save-attachment` fails closed instead
of overwriting an existing file. AppleScript and MIME fallback paths stage the
bytes privately, commit with an exclusive create, and leave the saved file
owner-readable/writable (`0600`).
| Parameter | Type | Required | Description |
| ---------------- | ------ | -------- | -------------------------- |
| `id` | string | Yes | Message ID |
| `attachmentName` | string | Yes | Filename of the attachment |
| `savePath` | string | Yes | Directory to save to |
---
### Batch Operations
All batch operations accept an array of message IDs (max 100 per batch) and return per-item success/failure results.
**Numeric IDs are scoped to the mailbox you listed them from.** Mail.app numbers messages per
mailbox, so on a label store (Gmail, iCloud) one message answers to the same id in `INBOX`,
`Important` and `All Mail` at once — and deleting the `All Mail` copy is not the same operation as
deleting the `INBOX` copy. Each id is therefore bound to the mailbox it was listed/searched from and
the operation is applied only there, so **list or search the mailbox immediately before acting on
it**. An id the server hasn't seen listed is accepted only when exactly one mailbox holds it;
if several do, that id fails with the candidate mailboxes named instead of being applied to an
arbitrary copy. `imap:…` ids carry their own account + mailbox + UID and are never ambiguous.
**Say which mailbox with `sourceMailbox` / `sourceAccount`.** The binding above is remembered
per running server, so a client that reconnects, restarts, or replays a saved list of ids has
nothing recorded and every id takes the slower whole-tree path — where, on a label store, it is
likely to be refused as ambiguous. Passing the source mailbox explicitly is the reliable way to
stay scoped, and it overrides the remembered location. These parameters name where the ids **came
from**; for `batch-move-messages` that is distinct from `mailbox`, the destination.
`sourceMailbox` and `sourceAccount` are an atomic scope pair: provide **both** for numeric ids.
The server never fills in a missing account from mutable default-send state, because the same
mailbox name can exist in more than one account and numeric ids are only unique within an account
and mailbox. A whitespace-only source field is rejected. `sourceAccount` by itself pins nothing,
since the mailbox is what an id is scoped to; `imap:…` ids ignore both fields because they carry
their own account, mailbox, and UID identity.
**A repeated id is one message.** `ids` is treated as a set: a duplicate names the same message,
so it is operated on once, and the batch returns **one result per distinct id**. `success` is
therefore a count of messages, not of list positions.
#### `batch-delete-messages`
| Parameter | Type | Required | Description |
| --------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `ids` | string[] | Yes | Message IDs to delete (max 100) |
| `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
| `sourceAccount` | string | No | Account the numeric ids were listed from. Required when `sourceMailbox` is supplied; on its own it pins nothing. |
`structuredContent` carries `countDelta` — what the batch actually did to each
source mailbox. See [Auditing destructive operations](#auditing-destructive-operations).
**⚠️ Safety:** Destructive. Requires explicit user confirmation; search/list first to confirm the message ids.
#### `batch-move-messages`
| Parameter | Type | Required | Description |
| --------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `ids` | string[] | Yes | Message IDs to move (max 100) |
| `mailbox` | string | Yes | Destination mailbox |
| `account` | string | No | Account containing mailbox |
| `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
| `sourceAccount` | string | No | Account the numeric ids were listed from. Required when `sourceMailbox` is supplied; on its own it pins nothing. |
`structuredContent` carries `countDelta` — what the batch actually did to each
**source** mailbox. See [Auditing destructive operations](#auditing-destructive-operations).
#### `batch-mark-as-read` / `batch-mark-as-unread`
| Parameter | Type | Required | Description |
| --------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `ids` | string[] | Yes | Message IDs (max 100) |
| `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
| `sourceAccount` | string | No | Account the numeric ids were listed from. Required when `sourceMailbox` is supplied; on its own it pins nothing. |
#### `batch-flag-messages` / `batch-unflag-messages`
| Parameter | Type | Required | Description |
| --------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ids` | string[] | Yes | Message IDs (max 100) |
| `color` | string | No | (`batch-flag-messages` only) Flag color — see [`flag-message`](#flag-message--unflag-message). Applied on both routes, so a mixed batch of numeric and `imap:` ids all end up colored. |
| `sourceMailbox` | string | No | Mailbox the **numeric** ids were listed from — pins them to it. Ignored for `imap:` ids. |
| `sourceAccount` | string | No | Account the numeric ids were listed from. Required when `sourceMailbox` is supplied; on its own it pins nothing. |
---
### Mailbox Operations
#### `list-mailboxes`
List all mailboxes for an account.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ---------------------------------------------------------- |
| `account` | string | No | Account to list from, or `"On My Mac"` for the local store |
**Returns:** List of mailbox **paths** (account-relative, e.g. `Archive/Inbox` for
a nested mailbox — a top-level `Inbox` stays `Inbox`) with message and unread
counts. A source that could not be read is **named** (`partial: true` +
`failedAccounts`) rather than dropped, and a listing Mail refused outright
returns an error naming the accounts that do exist — never an empty list.
**Nested mailboxes and Gmail labels.** Every `mailbox` parameter across this
server (search-messages, list-messages, get-unread-count, move-message,
delete-mailbox, rename-mailbox, create-rule's `moveTo`) accepts either the full
path or a leaf name that is unique across the account — the same rule
move-message has always used. A leaf name that matches more than one mailbox
(e.g. a top-level `Inbox` and an `Archive/Inbox` on an Exchange account) is
refused rather than guessed; pass the full path to disambiguate. This also
means Gmail's nested special mailboxes now report their real path, e.g.
`[Gmail]/All Mail` rather than `All Mail` — a visible change from before 2.17.0.
Matching ignores letter case **and Unicode normalization form**: a typed
precomposed `México` (NFC) finds a mailbox the server stores decomposed (NFD —
common for folders created on a Mac, and what iCloud keeps), and the server's
own stored spelling is what gets selected. Two mailboxes whose names differ
_only_ in case or normalization look identical in any listing, so a name that
matches both is refused with an error saying so — rename one of them. For the
same reason `create-mailbox` treats a normalization-equivalent existing name as
already existing, and `rename-mailbox` refuses to create such a twin.
**Messages awaiting expunge are not listed.** On IMAP accounts, search,
list, thread and mail-stats queries only match messages _not_ flagged
`\Deleted` (IMAP `UNDELETED`). iCloud keeps flagged-but-never-expunged
messages out of its message counts yet still returns them from `UID SEARCH`,
which made a filter-less `list-messages` fail on such a mailbox before 2.19.16.
**Very large IMAP mailboxes are read newest-first in bounded windows**
([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)). Above 10,000
messages, a filter-less `list-messages`/`search-messages` pages by message
sequence number: page `offset` of `limit` is sequence numbers
`N-offset-limit+1 .. N-offset`, so it fetches UIDs and flags for that range
alone (plus a small margin; `\Deleted` ones are skipped and the page topped up
from below) and full rows for the page. The cost is the page, not the offset:
`limit: 1` at offset 790,000 of a 793,614-message mailbox is one ~33-message
`FETCH`, not a whole-mailbox `SEARCH` or a walk over every skipped message.
Offsets count sequence positions. iCloud keeps messages awaiting expunge out of
the sequence space, so there they are exact; on a server that keeps them in it,
a page shifts by the number of such messages above it (they are never
returned).
A filtered search there runs the same criteria over newest-first sequence
windows (5,000 messages, growing to 50,000) and stops once the page is full;
the reported total then reads `at least N` unless the walk reached the bottom
of the mailbox. A filtered search's deep `offset` has no such shortcut — which
messages match is only known by searching the skipped windows — so its cost
still grows with the offset (in 50,000-message `SEARCH` windows); narrow it
with `dateTo` instead. A `SEARCH` or `FETCH` that fails names the mailbox, its size and
the server's own reason (or a likely timeout) instead of reporting "no
messages".
**Mail's local "On My Mac" mailboxes** are not children of any account — they
hang off the application — so they are reported under the synthetic account label
**`On My Mac`**. An unscoped call includes them (listed last); `account="On My
Mac"` lists only them. `on my computer`, `local` and `local folders` are accepted
as aliases.
They deliberately do **not** appear in `list-accounts`, which reports real
accounts only: the local store is a store, not an account. Nothing selects it
implicitly — omitting `account` still resolves to a real account for every other
tool.
The mail inside them is reachable too: `list-messages` and `search-messages`
accept `account="On My Mac"`, and `get-message` resolves an id that lives only in
a local mailbox. An id present **both** in an account mailbox and locally is
reported as ambiguous rather than silently resolving to the account copy.
---
#### `get-unread-count`
Get unread message count.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------------------------------------------- |
| `mailbox` | string | No | Mailbox to check (omit for **INBOX**) |
| `account` | string | No | Account to check (omit to sum each account's INBOX) |
**Returns:** The unread count for the requested scope.
> Omitting `mailbox` counts **INBOX**, not a cross-mailbox total. This changed in 2.8.15: summing every mailbox was slow and wrong on Gmail, where one message appears in INBOX, All Mail and every label it carries. For account-wide totals use [`get-mail-stats`](#get-mail-stats).
---
#### `create-mailbox`
Create a new mailbox.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------------- |
| `name` | string | Yes | Mailbox name |
| `account` | string | No | Account to create in |
---
#### `delete-mailbox`
Delete a mailbox.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------------------- |
| `name` | string | Yes | Mailbox name |
| `account` | string | No | Account containing mailbox |
**⚠️ Safety:** Destructive — deletes the mailbox and its contents. Requires explicit user confirmation; list mailboxes first to confirm the name.
---
#### `rename-mailbox`
Rename a mailbox (creates new, moves messages, deletes old).
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | -------------------------- |
| `oldName` | string | Yes | Current mailbox name |
| `newName` | string | Yes | New mailbox name |
| `account` | string | No | Account containing mailbox |
---
### Smart Mailbox Operations (intelligente Postfächer)
> **Requires Full Disk Access.** These tools read and write `~/Library/Mail/V*/MailData/SyncedSmartMailboxes.plist`, which is TCC-protected. Without Full Disk Access for the server's Node runtime the read simply finds nothing, and the tools report "no smart mailboxes" or "launch Mail at least once" rather than a permission error — see [Node runtime & TCC permissions](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md).
Smart mailboxes are Apple Mail's **criteria-based virtual views** — not real folders, so no messages are moved. AppleScript's `smart mailbox` / `intelligentes Postfach` terms don't compile reliably on localized (e.g. German) macOS, so these tools read and edit `~/Library/Mail/V*/MailData/SyncedSmartMailboxes.plist` directly.
**How writes stay safe:** creating or deleting a smart mailbox first backs the plist up to `SyncedSmartMailboxes.plist.bak`, edits a temp copy with `plutil`/`PlistBuddy`, validates it with `plutil -lint`, and only then atomically renames it into place. Your **existing** smart mailboxes — including any with date/data criteria — are never rewritten, only the single target entry is added or removed. These tools do **not** quit or restart Mail: **quit Mail first** for reliable results, since a running Mail may not show a new smart mailbox until it's relaunched and can overwrite plist edits it didn't make.
#### `list-smart-mailboxes`
List existing smart mailboxes.
**Parameters:** None
**Returns:** List of smart mailbox names + criteria summary.
---
#### `create-smart-mailbox`
Create a smart mailbox with a simple contains rule.
| Parameter | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------ |
| `name` | string | Yes | Name for the smart mailbox |
| `fromContains` | string | No | Match if From contains this |
| `subjectContains` | string | No | Match if Subject contains this |
| `bodyContains` | string | No | Match if Body contains this |
Provide at least one of the three `*Contains` fields.
**⚠️ Safety:** edits `SyncedSmartMailboxes.plist` (backed up + atomic, existing smart mailboxes preserved). Quit Mail first for reliable results; the new smart mailbox appears the next time Mail launches.
---
#### `delete-smart-mailbox`
Delete a smart mailbox by name.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------ |
| `name` | string | Yes | Smart mailbox name |
**⚠️ Safety:** destructive — removes the smart mailbox from `SyncedSmartMailboxes.plist` (backed up + atomic; every other smart mailbox is preserved). Not undoable in-app. Confirm the exact name with `list-smart-mailboxes` first, and quit Mail first for reliable results.
---
#### `create-newsletter-smart-mailboxes`
High-level tool: scan recent messages in your INBOXes, detect likely newsletters (volume + signals like List-Unsubscribe, noreply, repetitive subjects), and create smart mailboxes for them (names prefixed "NL: ...").
| Parameter | Type | Required | Description |
| ---------- | ------- | -------- | ------------------------------------------ |
| `dryRun` | boolean | No | Default true — only propose, do not create |
| `minCount` | number | No | Min messages from a sender (default 3) |
| `days` | number | No | Lookback window in days (default 90) |
Defaults to a **safe dry run** that only proposes. Pass `dryRun: false` to actually create the smart mailboxes for newsletters cluttering your Inbox.
**⚠️ Safety:** with `dryRun: false` this edits `SyncedSmartMailboxes.plist` (backed up + atomic, existing entries preserved) and can create many smart mailboxes at once — review a dry run first. Scans up to ~400 recent messages per inbox via AppleScript, which can be slow on large mailboxes.
---
### Account Operations
#### `list-accounts`
List all configured Mail accounts.
**Parameters:** None
**Returns:** List of account names and email addresses.
---
### Rules
#### `list-rules`
List all mail rules.
**Parameters:** None
**Returns:** List of rule names and enabled status.
---
#### `enable-rule` / `disable-rule`
Enable or disable a mail rule.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `name` | string | Yes | Rule name |
---
#### `create-rule`
Create a Mail rule with one or more conditions and actions.
| Parameter | Type | Required | Description |
| ------------ | -------- | -------- | ------------------------------------------------------------- |
| `name` | string | Yes | Rule name (must be unique) |
| `conditions` | object[] | Yes | One or more `{field, operator, value}` (see below) |
| `actions` | object | Yes | At least one of `markRead`, `markFlagged`, `delete`, `moveTo` |
| `matchAll` | boolean | No | `true` (default) = all conditions must match; `false` = any |
| `enabled` | boolean | No | Whether the rule is enabled on creation (default `false`) |
Each condition is `{ field, operator, value }` where `field` is one of `from`, `to`, `cc`, `subject`, `content` and `operator` is one of `contains`, `notContains`, `equals`, `beginsWith`, `endsWith`. Actions: `markRead` / `markFlagged` / `delete` (booleans), `moveTo` (mailbox name) with optional `moveToAccount`.
New rules are created **disabled by default**, including rules that delete or move
messages. Review the conditions and actions with `list-rules` and in Mail.app,
then call `enable-rule` explicitly when the rule is approved. Set
`enabled: true` only when immediate activation is deliberate.
**Example:**
```json
{
"name": "Newsletters",
"conditions": [{ "field": "from", "operator": "contains", "value": "newsletter" }],
"actions": { "markRead": true, "moveTo": "Reading" }
}
```
---
#### `delete-rule`
Delete a mail rule by name.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `name` | string | Yes | Rule name |
**⚠️ Safety:** Destructive. Requires explicit user confirmation; list rules first to confirm the name.
---
### Contacts
#### `search-contacts`
Search the macOS Contacts database by name, organization, nickname, or email substring.
Since 2.8.7 this reads the AddressBook SQLite files directly rather than driving Contacts.app over AppleScript, so **Contacts.app need not be running and no Automation grant is involved** — but the Node runtime does need **Full Disk Access**, and **Node 22.5+** (see [Requirements](#requirements)). Without either, the tool returns an empty list rather than an error.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------------------------------------------------------------------------- |
| `query` | string | Yes | Substring matched against full name, organization, nickname, or any email address |
**Returns:** List of contacts with name, email addresses, and phone numbers. Results are **not** truncated — a broad query returns every match.
---
### Templates
Email templates are **persisted to disk** so they survive server restarts, stored as JSON at `APPLE_MAIL_MCP_TEMPLATES_FILE` (default `~/Library/Application Support/apple-mail-mcp/templates.json`).
#### `save-template`
Save or update an email template.
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | -------------------------- |
| `name` | string | Yes | Template name |
| `subject` | string | Yes | Default subject line |
| `body` | string | Yes | Template body |
| `to` | string[] | No | Default recipients |
| `cc` | string[] | No | Default CC recipients |
| `id` | string | No | Template ID (for updating) |
---
#### `list-templates`
List all saved templates.
**Parameters:** None
---
#### `get-template`
Get a template by ID.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | string | Yes | Template ID |
---
#### `delete-template`
Delete a template.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id` | string | Yes | Template ID |
**⚠️ Safety:** Destructive — removes the template from the on-disk store. Requires explicit user confirmation; list templates first to confirm the id.
---
#### `use-template`
Create a draft from a template, with optional overrides.
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ------------------- |
| `id` | string | Yes | Template ID |
| `to` | string[] | No | Override recipients |
| `cc` | string[] | No | Override CC |
| `subject` | string | No | Override subject |
| `body` | string | No | Override body |
---
### Diagnostics
#### `health-check`
Verify Mail.app connectivity and permissions.
**Parameters:** None
**Returns:** Status of all health checks (app running, permissions, account access).
---
#### `doctor`
Run a full setup diagnostic: Mail.app automation permission, account state (flagging disabled accounts), and each configured IMAP/SMTP backend — each reported as ok / warn / fail with an actionable message.
**Parameters:** None
**Returns:** A per-check report (`structuredContent` carries the raw `{healthy, checks[]}`).
---
#### `get-mail-stats`
Get mail statistics.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `account` | string | No | Limit to one account (uses fast IMAP `STATUS` when that account is IMAP-configured). Omit to merge across all accounts. |
**Returns:** Total and per-account message/unread counts, plus recently received stats (24h, 7d, 30d). The scoped IMAP path also returns a `perMailbox` breakdown.
Gathering stats costs one IMAP `STATUS` per mailbox, and Gmail lists every label
as a mailbox, so a large account is not instant. Accounts are counted
**concurrently**, and each is bounded by `APPLE_MAIL_MCP_STATS_BUDGET_MS`
(default `25000`). In the merged all-accounts path an account that fails or
overruns is reported via `partial: true` + `failedAccounts` rather than being
folded in as a silent zero; a scoped call to a single account returns an error
naming the budget instead. Raise the budget if you have a very large account.
The whole call is additionally bounded by one wall-clock deadline,
`APPLE_MAIL_MCP_STATS_DEADLINE_MS` (default `50000`), which covers the Mail.app
account enumeration as well as every per-account read. Per-step budgets alone
were not enough: their worst cases **add up**, and the sum could exceed a
client's request timeout, so the call died with nothing returned instead of
degrading. Keep the deadline below your MCP client's request timeout — whatever
cannot be read inside it is named in `failedAccounts`, so you always get a
partial answer rather than a dead call.
**Concurrent `get-mail-stats` calls do not run concurrently.** Tool calls are
serialized so they cannot race into Mail.app's single-threaded AppleScript
dispatch, so each call waits for the ones ahead of it and per-call latency grows
with queue depth — N concurrent calls take about N × the single-call cost. Since
this is the most expensive read tool, that is very visible here: measured on 3
IMAP accounts, three concurrent calls returned at 5.5s / 10.3s / 15.6s against a
~5.2s solo cost. The deadline is measured from when the request **arrived**, so
that wait is spent from the same budget as the work: a call that waited ≥1s
reports `queueWaitMs`, and one that arrives with its deadline already spent
returns straight away naming the queue rather than starting work whose answer
would land after your client has given up. Issue these calls one at a time, and
prefer `get-unread-count` when a single number will do.
---
#### `get-sync-status`
Check Mail.app sync activity.
**Parameters:** None
**Returns:** Whether sync is detected, pending uploads, recent activity, and seconds since last change.
---
## Auditing destructive operations
`delete-message`, `move-message`, `batch-delete-messages` and
`batch-move-messages` report what they actually did, not merely that Mail.app did
not raise an error. This exists because of
[#155](https://github.com/sweetrb/apple-mail-mcp/issues/155): a batch delete was
reported to have removed two messages whose ids were never passed, and nothing in
the server recorded enough to explain it.
### `countDelta` — always on, no configuration
Every one of those four tools counts the affected **source** mailbox immediately
before and immediately after the mutation, **inside the same AppleScript it was
already running** (no extra `osascript` invocations, no measurable cost), and
returns the comparison in `structuredContent`:
```json
{
"ok": true,
"success": 2,
"failed": 0,
"countDelta": [
{
"account": "you@gmail.com",
"mailbox": "INBOX",
"before": 412,
"after": 408,
"expected": 2,
"observed": 4,
"status": "over"
}
]
}
```
`status` is deliberately not a pass/fail flag:
| `status` | Meaning | Warns? |
| --------- | ---------------------------------------------------------------------------------------------------------- | ------- |
| `match` | Exactly as many messages left the mailbox as the operation acted on. | No |
| `over` | **More** left than were operated on. Messages are unaccounted for. | **Yes** |
| `unknown` | No comparison this server is willing to assert. `unknownReason` says which of four situations produced it. | No |
`unknownReason` distinguishes four cases that are _not_ interchangeable:
| `unknownReason` | Meaning |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `count-unreadable` | Mail would not report a count at all (`before`/`after` null). |
| `no-expectation` | No expectation is predictable, so no comparison exists — a move whose destination **is** the source mailbox. |
| `count-did-not-move` | The count did not move. On a store that flags deletions instead of removing them this is the **ordinary, correct** reading for an operation that fully succeeded. |
| `count-partial` | The count moved, but by less than the operation accounted for. A flag-only store cannot produce this, which is why it is worth telling apart. |
> **`under` was removed in 2.11.0.** It used to mean "fewer left than expected"
> and was documented as routine. Field evidence retired it — see
> [Why `observed` is a lower bound](#why-observed-is-a-lower-bound-155).
#### What an `over` warning does and does not tell you
Only `over` produces a warning in the tool's text response. Be precise about what
that warning proves, because a warning is useful only for as long as it is
trusted:
- **It establishes** that more messages left the source mailbox across the window
of the operation than the operation accounted for. That is the data-loss
direction, and it is the #155 signature.
- **It does not establish that this server removed them.** The reading is a
before/after pair around a window, so anything else that removes mail from that
mailbox inside the window reads identically: a Mail.app rule firing mid-batch,
a server-side filter, another client (phone, webmail, a second Mail.app)
deleting or moving, or an IMAP expunge landing between the two counts.
**Concurrent departure is the benign cause to rule out first**, and the warning
text says so. What the asymmetry argument actually buys is the other half:
concurrent _arrivals_ cannot produce `over`, because a message arriving
mid-operation _raises_ the after-count and biases the reading short.
That is why `over` is the interesting direction — a strong signal, not a proof.
Setting `APPLE_MAIL_MCP_AUDIT_LOG` is what settles which one you have: the
collateral diff below **names** the messages that disappeared, and "the
newsletter my rule files every morning" is a very different report from a message
nothing should have touched.
#### Why `observed` is a lower bound (#155)
**`observed` is the movement of Mail's count. It is a lower bound on how many
messages left, not a count of how many left.**
On iCloud, @scottstern0325 ran the check that settled this: for two batches
reporting `observed: 0`, the messages were located **in Trash**, matched by
`date received` + sender against the audit log's pre-image. The deletes had
happened. Mail's count had not caught up. Across four readings — 0 of 4, 0 of 1
(a _single-id_ delete), 15 of 16, and 14 of 15 — the shortfall bore no relation
to batch size, which is what a lagging count looks like and not what a
store-behaviour rule looks like.
So a short reading is not evidence about your operation. **The per-id outcomes
are what report success; this number is not. Do not retry on the strength of
it** — that is how a message gets deleted twice.
Two things follow:
- A count that does not move at all is still the ordinary reading on a store that
flags deletions instead of removing them (Gmail label mailboxes, IMAP accounts
with "move deleted messages to Trash" off). It reports
`unknownReason: "count-did-not-move"` and says so, and it is never warned about
— a warning that fires on every ordinary Gmail delete would be ignored exactly
when it matters.
- **To confirm where messages went, match them at the destination by `date
received` plus sender — not by the numeric ids you passed.** Ids are renumbered
by the move and do not survive it.
**Removed in 2.11.0: the "reported success with no observed effect" warning.**
Shipped in 2.10.30, it fired when the count was flat, the snapshot read cleanly
and nothing had disappeared. Its premise was that the snapshot corroborated the
count — but both are read back-to-back in the same script, and the record that
prompted it turns out to have had _both_ instruments stale at once. It therefore
fired on stores that had done exactly what they were asked. It is gone rather
than narrowed; `over` is the only surviving assertion.
Three more honesty rules:
- The **expectation is per source mailbox**. On a Gmail label store, deleting the
`INBOX` copy drops the `\Inbox` label and deleting the `[Gmail]/All Mail` copy
trashes the message — different operations, but either way the mailbox the ids
came from loses exactly one entry per id. That is what is compared. A move's
**destination** count is not checked.
- A move whose destination **is** the source mailbox is **not compared at all**.
No message should leave, but what Mail does to the count when a message is
re-filed into the mailbox it already occupies is unspecified — so `expected` is
`null`, `status` is `unknown`, `note` says why, and no warning is raised. A
warning computed against a guessed expectation would fire on an operation that
did exactly what it was asked to, which is the one thing this instrumentation
must never do.
**This makes a self-move a blind spot for the always-on layer**, and the cost is
worth stating plainly: if messages genuinely do disappear during a self-move,
nothing warns you, because there was no expectation to compare against. `status`
is `unknown` rather than `match`, so the result does not claim the operation was
clean — but it does not flag it either. The collateral diff still names anything
that vanished, so **enable `APPLE_MAIL_MCP_AUDIT_LOG` if you need coverage for
same-mailbox moves.**
- A **repeated id is one message**. The batch tools operate on each distinct id
once and return one result per distinct id, so `success` counts messages rather
than list positions — and `expected` stays comparable with the mailbox instead
of double-counting a duplicate into a false `over`.
**`imap:` ids are reconciled too, as of 2.15.0.** `batch-delete-messages` and
`batch-move-messages` return the same `countDelta` structure on the IMAP path, so
one shape covers both backends and a mixed batch reports an entry per source
mailbox from whichever backend handled it. The entries are **concatenated, never
summed** — Mail's own count can lag (#155) while the server's `STATUS` cannot, and
averaging the two would hide which reading you were looking at.
Only the operations that actually remove messages from their source reconcile.
`batch-mark-as-read` and the flag tools change no count, so emitting
`expected: N, observed: 0` for them would manufacture an alarm; they report no
`countDelta` at all.
Note the mis-targeting class `countDelta` was originally built for cannot occur
on the IMAP path — a UID names exactly one message in exactly one mailbox — so
there the value is effect confirmation rather than target confirmation.
Single-message tools carry a post-condition check instead. `delete-message` and
`move-message` on an `imap:` id return a **`verification`** object in
`structuredContent`:
```json
{
"verification": {
"verdict": "verified",
"how": "COPYUID: UID 5 arrived in \"Archive\" as UID 91"
}
}
```
| `verdict` | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `verified` | The effect was **observed** — either the server's UIDPLUS `COPYUID` named the message's new UID in the destination, or the UID is no longer in the source mailbox. |
| `unverified` | The server **accepted** the command and nothing could confirm the effect. Populates `why`. |
`unverified` is **not a failure** and must not be rendered as one — it means
"accepted, no observation either way". It is reported rather than hidden because
an absent verification must never read as a successful one, the same rule the
collateral diff follows. A message that is still in the source mailbox after an
accepted move is reported `unverified` rather than failed, because a Gmail label
store can legitimately keep a message visible in an all-mail view after a move.
### `APPLE_MAIL_MCP_AUDIT_LOG` — opt-in forensic log
| Variable | Default | Description |
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `APPLE_MAIL_MCP_AUDIT_LOG` | _(off)_ | Absolute path to an NDJSON file. Setting it enables the audit log **and** the collateral diff below |
| `APPLE_MAIL_MCP_AUDIT_SUBJECTS` | `0` | Set `1` to also record message **subjects**. Separate, deliberate second opt-in — see Privacy |
| `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX` | `2000` | Skip the collateral snapshot for mailboxes larger than this many messages. `0` disables the snapshot entirely |
| `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK` | `250` | How many messages the collateral snapshot reads from Mail per request. Lower it if Mail declines slices on a very large mailbox |
When set, each destructive operation appends **one JSON object per line**
containing: timestamp, tool name, server version, the arguments it was called
with, the **pre-image** of every message it resolved (account, mailbox, numeric
id, RFC Message-ID, `date received`), the per-id outcome (`ok` / `notfound` /
`error` + reason), the `countDelta` above, and the collateral diff.
The pre-image is the part that matters after the fact: a Mail.app numeric id is
unique only within a mailbox and is reused, so on its own it proves nothing about
which message was acted on. The RFC Message-ID does.
**The record is framed against its own contents.** The Message-ID and (when
enabled) the subject are written by whoever sent the mail, so the control
characters this server frames records with are stripped out of every such value
before it is written — a Message-ID crafted to close a record and open a forged
one cannot invent evidence in the log it is being recorded in. The same stripping
is applied to every other value read out of Mail at runtime (`date received`,
mailbox and account names, the text of an error Mail raised, the candidate list
behind an "ambiguous id" refusal), so no emitter is an exception. A value that
arrives with those characters in it (which a well-formed Message-ID never does)
is logged with each of them replaced by `U+FFFD`, so the record shows that the
value was altered rather than quietly shortening it.
### Collateral identification — which messages actually disappeared
Also gated on `APPLE_MAIL_MCP_AUDIT_LOG`. The mailbox's `(numeric id, Message-ID)`
pairs are captured before and after the mutation and diffed, so the log names
every message that left — **including ones the caller never listed**:
```json
{
"account": "you@gmail.com",
"mailbox": "INBOX",
"snapshot": "ok",
"disappeared": [
{ "id": "75811", "messageId": "a@example.com" },
{ "id": "75814", "messageId": "d@example.com" }
],
"unrequested": [{ "id": "75814", "messageId": "d@example.com" }],
"appeared": []
}
```
A non-empty `unrequested` **is** the #155 symptom, with names attached. Please
attach that line to the issue if you ever see one.
`id` is always the plain decimal id you passed, even on a mailbox whose ids
exceed AppleScript's 2^29 integer range (where Mail hands them back as
`9.99999999E+8`). That normalisation is also what keeps `unrequested` truthful:
compared in the raw form, a message you explicitly asked to delete would be
reported here as collateral.
This is O(mailbox size), so it is bounded by `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_MAX`.
When the bound bites, the record says so explicitly (`"snapshot": "skipped"` with
a reason) rather than omitting the field — a silently skipped snapshot would read
as "nothing collateral happened", which is worse than no snapshot at all.
`countDelta` is unaffected by the skip and still reconciles the counts.
#### Partial snapshots on large mailboxes
The mailbox is read in `APPLE_MAIL_MCP_AUDIT_SNAPSHOT_CHUNK`-sized slices, each
retried once on its own. It used to be a single whole-mailbox read, which meant
Mail declining that one request lost the **entire** diff — and the bigger the
mailbox, the more likely that was. The mechanism that attributes collateral
damage was therefore least reliable exactly when the blast radius was largest.
When a slice still will not read, the snapshot is reported as `partial` and it
**names its own gap**:
```json
{
"snapshot": "partial",
"unobserved": [{ "phase": "after", "ranges": "251-500" }],
"appeared": [],
"skipReason": "Mail would not read 251-500 (after) of this mailbox, so the snapshot has a hole in it. …"
}
```
Each half of the diff is withheld when the snapshot that could **refute** it has
a hole, because a wrong name here is worse than a missing one:
| Hole in | `disappeared` / `unrequested` | `appeared` |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| neither (`"ok"`) | reported | reported |
| `before` | reported (an undercount — a message never read before cannot be missed after) | withheld |
| `after` | **withheld** — a message absent from a partial `after` may merely be unread, and naming it would present an innocent message as evidence of data loss | reported |
| both | withheld | withheld |
**An absent field means "not computable", never "empty".** Check
`"snapshot": "ok"` before reading `disappeared` as a clean bill of health.
> ⚠️ **`"ok"` is necessary, not sufficient.** The enumeration's range is bounded
> by Mail's own message count, and #155 established that count can lag the
> mutation. A count reading **low** truncates the enumeration silently — the
> unread tail is never requested, so it never registers as a failed slice and the
> status still says `"ok"` — and messages past that bound would then look like
> they disappeared. Until that is fixed, treat a `disappeared` entry as a lead to
> check, not a proof.
### Privacy, and what the file costs you
- **Default:** identifying metadata only — Message-ID, date, mailbox, account,
numeric id. Enough to say _which_ message, nothing about what it says.
- **Subjects are behind their own opt-in** (`APPLE_MAIL_MCP_AUDIT_SUBJECTS=1`)
because a subject line is frequently the entire sensitive payload, and it is
not needed to diagnose #155.
- **Message bodies are never logged, under any setting.**
- The file **grows without bound** and is never rotated or truncated by this
server. Point it somewhere you control, and delete it when you are done. It is
written with your user's permissions, wherever you point it; there is no
default location precisely so that turning it on is a deliberate act.
- Writes go to that file and nowhere else. Diagnostics go to **stderr**; nothing
is ever written to stdout, which is the JSON-RPC transport.
---
## Usage Patterns
### Basic Workflow
```
User: "Check my inbox for new emails"
AI: [calls list-messages]
"You have 12 messages. Here are the most recent..."
User: "Show me emails from Sarah"
AI: [calls search-messages with query="Sarah"]
"Found 3 emails from Sarah across all mailboxes..."
User: "Read the first one"
AI: [calls get-message with id="..."]
"Subject: Project Update..."
```
### Working with Accounts
By default, operations use Mail.app's configured default send account. Search operations check all accounts when no account is specified. To work with specific accounts:
```
User: "What email accounts do I have?"
AI: [calls list-accounts]
"You have 3 accounts: iCloud, Gmail, Work Exchange"
User: "Show unread emails in my Work account"
AI: [calls list-messages with account="Work Exchange", mailbox="INBOX"]
"Your Work account has 5 unread messages..."
```
To pin which account is used when a tool call omits `account`, set the
`APPLE_MAIL_MCP_DEFAULT_ACCOUNT` environment variable to an account **name or
email**. When unset (the default), the server falls back to Mail.app's
default-send account if it is enabled, otherwise the first enabled account. A
**disabled** account is never selected implicitly — this env var (an explicit,
deliberate pin) is one of the few ways to target one ([#47](https://github.com/sweetrb/apple-mail-mcp/issues/47)).
### Sending Emails Safely
```
User: "Draft an email to the team about the deadline"
AI: [calls create-draft with to=["team@..."], subject="...", body="..."]
"I've created a draft. Please review it in Mail.app before sending."
User: "Send it"
AI: [User opens Mail.app and sends manually, or AI calls send-email]
```
### Sending Personalized Emails (Mail Merge)
```
User: "Send a personalized email to Alice (alice@acme.com), Bob (bob@globex.com),
and Carol (carol@initech.com). Subject: 'Project Update for {{Company}}',
Body: 'Hi {{Name}}, here is the latest update for {{Company}}.'"
AI: [calls send-serial-email with recipients, subject template, and body template]
"Successfully sent 3 email(s):
- alice@acme.com: sent
- bob@globex.com: sent
- carol@initech.com: sent"
```
### Organizing Messages
```
User: "Move all newsletters to Archive"
AI: [calls search-messages to find newsletters]
AI: [calls move-message for each, with mailbox="Archive"]
"Moved 8 newsletters to Archive"
```
---
## Documentation
- [Threat model](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/THREAT-MODEL.md)
- [IMAP / SMTP setup guide](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md)
- [Node runtime and TCC permissions](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md)
- [Stability and performance audit](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/STABILITY-PERF-AUDIT-2026-06-17.md)
## Installation Options
### npm (Recommended)
```bash
npm install -g apple-mail-mcp
```
### From Source
```bash
git clone https://github.com/sweetrb/apple-mail-mcp.git
cd apple-mail-mcp
```
The repo ships prebuilt, dependency-free `build/index.js` and `build/cli.js` bundles, so a bare clone runs with nothing but Node installed. `npm install` and `npm run build` are only needed when you change the source.
> You can also install straight from GitHub with `npm install -g github:sweetrb/apple-mail-mcp`, but that builds from source (requires pnpm) — prefer the registry package above.
If installed from source, use this configuration:
```json
{
"mcpServers": {
"apple-mail": {
"command": "node",
"args": ["/path/to/apple-mail-mcp/build/index.js"]
}
}
}
```
#### Running from a clone in Claude Code (project-scope `.mcp.json`)
This repo ships a `.mcp.json` at its root so that, when you run `claude` from inside a clone, the server is registered automatically as a **project-scope** server — no manual config needed. Just launch Claude Code from the repo directory and approve the server when prompted (the bundled `build/index.js` is committed, so no build step is required).
The entrypoint is written as:
```text
"args": ["${CLAUDE_PROJECT_DIR:-.}/build/index.js"]
```
`CLAUDE_PROJECT_DIR` is the variable Claude Code injects into a project/user-scoped server's environment, and it resolves to the repo root. **You must launch `claude` from inside the repo** for this to work — the bare `.` fallback is only a last resort and is _not_ reliable, because it resolves against the launching process's working directory, not the repo.
> **Why not `${CLAUDE_PLUGIN_ROOT}`?** `CLAUDE_PLUGIN_ROOT` is set **only** for marketplace plugin installs, never for a project-scope clone, so it can't drive the clone workflow. Conversely, a plugin install can't use `CLAUDE_PROJECT_DIR` (in a plugin, that points at the _user's_ project, not the plugin's own directory). Claude Code does **not** support nested defaults like `${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}`, so a single entrypoint string cannot serve both contexts. The two distribution paths are therefore decoupled: the **plugin** carries its own MCP config in `.claude-plugin/plugin.json` (using `${CLAUDE_PLUGIN_ROOT}`), while the root `.mcp.json` is dedicated to the **clone** workflow (using `${CLAUDE_PROJECT_DIR:-.}`). Because `plugin.json` declares its own `mcpServers`, the plugin does not also auto-load the root `.mcp.json`, so there is no double-registration.
> **Heads-up on scope precedence:** project-scope (`.mcp.json`) outranks user-scope. If you _also_ have an `apple-mail` entry registered at user scope (e.g. an absolute path in `~/.claude.json`), the project-scope entry wins and the user-scope one is ignored entirely. Pick one — for local development on this repo, the project-scope `.mcp.json` is the intended source. To pin a specific local build instead, register it at **local** scope (`claude mcp add apple-mail -s local -- node /abs/path/build/index.js`), which outranks project scope.
---
## Security and Privacy
- **No third parties** - The server talks only to Mail.app on this Mac (AppleScript) and, when you configure them, directly to **your own** mail provider over TLS (IMAP/SMTP). Nothing is sent to this project or any other service. With the default AppleScript backend everything stays on-device; the opt-in IMAP/SMTP backends necessarily reach your provider, which is what they are for.
- **Permission required** - macOS will prompt for automation permission on first use.
- **No credential storage** - The server doesn't store any passwords or authentication tokens.
- **Email safety** - Use `create-draft` to review emails before sending.
- **Attachment read boundary** - Outbound file attachments may come from
ordinary files under the home directory, `/Volumes`, or temporary directories
by default. Hidden files and known credential/configuration locations
(including `.ssh`, `.aws`, `.config/gh`, Keychains, and
application `config.json` files) are denied. Set
`APPLE_MAIL_MCP_ATTACHMENT_READ_ROOTS` to a colon-separated list of explicit
absolute additional roots when a deliberate other location is required.
Paths are canonicalized before use, so symlink escapes are rejected. `/tmp`
is world-writable and is a convenience root, not a user-content trust
boundary. Inline base64 attachments are unaffected.
---
## Known Limitations
| Limitation | Reason |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS only | Apple Mail and AppleScript are macOS-specific |
| MCP `send-email` is plain-text | The `send-email` tool sends plain text (reading HTML content is supported). To send HTML, use the bundled `apple-mail-send` CLI with `--html-body-file` (sends `multipart/alternative` via SMTP) |
| Attachment read path restrictions | Outbound file attachments must use full absolute paths inside the default home-directory, `/Volumes`, or temporary roots, except hidden files and protected credential/configuration locations. Set `APPLE_MAIL_MCP_ATTACHMENT_READ_ROOTS` to add an explicit absolute root for another deliberate location; symlink escapes are rejected. |
| Smart mailboxes need Mail quit | Smart mailboxes are supported (see [Smart Mailbox Operations](#smart-mailbox-operations-intelligente-postfächer)), but `create-`/`delete-smart-mailbox` edit `SyncedSmartMailboxes.plist` directly — a running Mail may not show a new one until relaunched, and can overwrite plist edits it didn't make. Quit Mail first. Reading them needs Full Disk Access for the Node runtime |
| Very large mailboxes not searchable _via AppleScript_ | Apple Mail's AppleScript bridge times out on mailboxes with tens of thousands of messages, so unscoped `search-messages` skips mailboxes above `APPLE_MAIL_MAX_SEARCH_MAILBOX` (default 5000) and reports them as a partial result. Scope with `mailbox` + a date window — or configure the [IMAP backend](#imap-backend--opt-in), which searches these server-side in well under a second. ([#24](https://github.com/sweetrb/apple-mail-mcp/issues/24)) |
| Can't delete/rename server-side mailboxes or mutate drafts _via AppleScript_ | Mail.app's AppleScript bridge can only `delete`/`rename` **local "On My Mac"** mailboxes and cannot delete/move drafts — it throws `AppleEvent handler failed` for IMAP/Gmail/Workspace/iCloud/Exchange mailboxes (the GUI can do it). Without IMAP configured, `delete-mailbox`/`rename-mailbox`/`delete-message`/`move-message` return a clear "do it in Mail.app directly" error instead of a generic failure. With the [IMAP backend](#imap-backend--opt-in) configured for the account, these operations run via IMAP and succeed. ([#42](https://github.com/sweetrb/apple-mail-mcp/issues/42)) |
| Message ID format | Message IDs must be numeric (AppleScript ids) or `imap:…` tokens from the IMAP read path (validated by schema) |
| Batch size cap | Batch operations are limited to 100 messages per request |
| Date filter format | Date filters must be valid parseable dates (e.g., "January 1, 2026" or "2026-03-15"); bare numbers or non-date strings are rejected |
| Attachment save path restrictions | `save-attachment` only allows saving to home directory, `/tmp`, `/private/tmp`, and `/Volumes`; path traversal is blocked |
| Attachment count limit | `send-email` and `create-draft` accept a maximum of 20 file attachments |
| IMAP attachment fetch size | `fetch-attachment` / `save-attachment` over IMAP refuse a part larger than 25 MiB — rejected before download when the server declares the size, and the stream is cut off at the limit when it does not |
### Mail.app `<blockquote>` wrapping on macOS 15+ (workaround in v1.6.0)
On macOS 15+ Mail.app wraps AppleScript-injected message bodies in
`<blockquote type="cite">` under the `Apple-Mail-URLShareWrapperClass` template,
so mail sent via the default `applescript` transport renders to recipients as
quoted/forwarded content (Apple radar **FB11734014**, open since Ventura, no
fix). Since v1.6.0, `send-email` accepts `transport: "smtp"` to bypass Mail.app
and send clean MIME directly — see [SMTP transport](#smtp-transport). The
AppleScript path is still the default and still exhibits Apple's wrapping.
([#12](https://github.com/sweetrb/apple-mail-mcp/issues/12))
### Reply / Forward from Background Processes (Fixed in v1.4.0)
Prior to v1.4.0, `reply-to-message` and `forward-message` would send messages with **empty body text** when the MCP server ran as a background process (e.g., spawned via `execSync` from Node.js, which is how Claude Code invokes it).
**Root cause:** The AppleScript `reply msg with opening window` command creates a GUI compose window asynchronously. When `set content` runs immediately after, the window may not be ready, and the content assignment is silently ignored. Delays (`delay 1`, `delay 2`) were unreliable — the compose window's readiness depends on system load, Mail.app state, and whether the process has GUI access.
**Fix:** Replaced `with opening window` with `without opening window` for both `reply` and `forward` commands. With this approach, `set content` works immediately and reliably from background processes. `In-Reply-To` and `References` headers are still set correctly by Mail.app, and no GUI compose window is opened.
**Update (v2.5.0):** when SMTP is configured, `reply-to-message` and `forward-message` now prefer **clean direct SMTP** instead of AppleScript — the same prefer-direct model as `send-email`. Replies are threaded with RFC 5322 `In-Reply-To`/`References` headers built from the original message; forwards start a new conversation. With transport omitted, the AppleScript `without opening window` path above is used only when SMTP is not configured or a draft is requested. Since v2.17.8, a selected SMTP path returns source/configuration/threading errors instead of silently falling back; explicit `transport: "applescript"` remains available.
See [#7](https://github.com/sweetrb/apple-mail-mcp/issues/7) for full details and the list of approaches that were tested.
### Backslash Escaping (Important for AI Agents)
When sending content containing backslashes (`\`) to this MCP server, **you must escape them as `\\`** in the JSON parameters.
**Why:** The MCP protocol uses JSON for parameter passing. In JSON, a single backslash is an escape character. To include a literal backslash in content, it must be escaped as `\\`.
**Correct — email containing a shell path with an escaped space:**
```json
{
"to": ["colleague@company.com"],
"subject": "File Location",
"body": "Run: cp ~/Library/Mobile\\ Documents/report.pdf ~/Desktop/"
}
```
→ arrives as: `Run: cp ~/Library/Mobile\ Documents/report.pdf ~/Desktop/`
In a JSON string literal, `\\` — two characters — denotes **one** literal backslash. Four backslashes (`\\\\`) denote **two** literal backslashes, so send those only when the text genuinely contains `\\`.
**Incorrect — the unescaped backslash makes this invalid JSON:**
```text
"body": "Run: cp ~/Library/Mobile\ Documents/report.pdf ~/Desktop/"
```
`\ ` (backslash-space) is not a valid JSON escape sequence, so the call is rejected — or, with a laxer parser, the backslash is silently dropped.
**Common patterns requiring escaping:**
- Shell escaped spaces: `Mobile\ Documents` → `Mobile\\ Documents` in JSON
- Regex patterns: `\d+` → `\\d+` in JSON
- A literal double backslash: `\\` → `\\\\` in JSON
**If you see errors** when sending emails with backslashes, double-check that backslashes are properly escaped in the JSON payload.
---
## Troubleshooting
### "Mail.app not responding"
- Ensure Mail.app is not frozen
- Try opening Mail.app manually
- Restart the MCP server
### "Permission denied"
- macOS needs automation permission
- Go to System Settings > Privacy & Security > Automation
- Ensure your terminal/Claude has permission to control Mail
- **On a Mac that isn't set to US English**, macOS words this refusal in the system
language — `Not authorised to send Apple events to Mail. (-1743)` on en-GB/en-AU/en-IE,
and fully translated on fr/de/es. Before **v2.17.10** the server didn't recognise
those spellings, so `health-check`/`doctor` reported `permissions: ok` and then blamed
missing accounts (_"No Mail accounts found. Set up an account in Mail.app first."_).
If you see that on a Mac whose Mail accounts are already configured, upgrade and
re-run `doctor`.
### "Message not found"
- Message may have been deleted or moved
- Message IDs change if the message is moved between mailboxes
- Use `search-messages` to find the current message ID
- "**not found in the mailboxes scanned**" with a list of mailboxes means the numeric-id scan
skipped those because they hold more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default
`50000`). The message may be in one of them: pass `account` + `mailbox` to read it from there, or
use its `imap:` id ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)).
- "**stopped … before reaching: …**" lists the mailboxes the scan never got to (its ~9s budget ran
out, or a mailbox's size count timed out and Mail.app was left busy finishing it). A timed-out
mailbox is remembered for 7 days and skipped from then on, so retrying shortly usually covers the
rest. "**Mail.app did not answer within 3s**" means Mail.app was still busy with such a count when
the lookup started — retry in a little while.
- "**Mail.app is not running**" — numeric-id reads need Mail.app open; the server never launches it
for them. `imap:` ids read over IMAP without Mail.app.
### "... is present in more than one mailbox"
- A bare numeric ID identifies a message only _within a mailbox_, and a label store (Gmail, iCloud)
reports the same message under the same ID in `INBOX`, `Important` and `All Mail` at once. The
server refuses rather than guessing which copy you meant.
- Fix it by running `list-messages`/`search-messages` on the mailbox you actually want to act on,
then using the IDs from that result — the operation is then scoped to that mailbox.
- It only affects IDs the server hasn't seen listed (carried over from an earlier session, or typed
by hand). `imap:…` IDs encode their own mailbox and never hit this.
### `search-messages` says "Partial results" or skips a mailbox
- This is expected for very large IMAP/Gmail mailboxes (e.g. Gmail's `All Mail`, `Important`): Apple Mail can't scan them via AppleScript before timing out, so they're skipped and named in the result rather than silently returning empty.
- To search inside one, scope the call with `mailbox` **and** a `dateFrom`/`dateTo` window.
- Raise or disable the threshold with `APPLE_MAIL_MAX_SEARCH_MAILBOX` (default `5000`; `0` disables the guard) — note that disabling it can make a single search take minutes.
- A `Partial results` warning means coverage was incomplete; it is **not** a confirmed "no such mail."
### "Account not found"
- Account names must match exactly (case-sensitive)
- Use `list-accounts` to see exact account names
### "Failed to send email"
- Check your network connection
- Verify Mail.app can send emails manually
- Check if the account is configured correctly in Mail.app
### "invalid outputSchema … unsupported dialect" — every tool is refused
- Full text: `Tool '<name>' has an invalid outputSchema: JSON Schema declares an unsupported dialect ("$schema": "http://json-schema.org/draft-07/schema#"). The default validator supports JSON Schema 2020-12 only.` The server connects, but **no tool is usable**.
- **Upgrade to 2.10.12 or later.** Earlier versions advertised their tool schemas in JSON Schema **draft-07** (the MCP SDK's converter default); MCP has since standardized on **2020-12** and clients reject anything else. 2.10.12 normalizes every advertised `inputSchema`/`outputSchema` to 2020-12 on the way out. See [issue #147](https://github.com/sweetrb/apple-mail-mcp/issues/147).
- Nothing to configure — restart your host app after upgrading so it re-reads the tool list.
### `apple-mail` server fails to connect when run from a clone
- The root `.mcp.json` resolves its entrypoint via `${CLAUDE_PROJECT_DIR:-.}/build/index.js`. **Launch `claude` from inside the repo directory** — `CLAUDE_PROJECT_DIR` only resolves to the repo root in that case; the bare `.` fallback uses the launching shell's working directory and will point at the wrong place otherwise.
- If you've been editing the source, rerun `npm run build` — the server is `build/index.js`, and the committed bundle only reflects your changes after a rebuild.
- Run `claude mcp list` to check status. If you see a _conflicting scopes_ warning for `apple-mail`, you have it registered at more than one scope; project-scope wins. See [Running from a clone](#running-from-a-clone-in-claude-code-project-scope-mcpjson) for how scope precedence resolves.
- If `claude mcp get apple-mail` shows **⏸ Pending approval**, approve the project-scope server (Claude Code prompts on startup, or run it again after approving).
---
## Development
This repo is **pnpm-only** — `package.json`'s `preinstall` guard hard-fails an `npm install`, because npm resolves off-lockfile and the committed bundle would then mismatch CI.
```bash
corepack enable && pnpm install --frozen-lockfile # Install dependencies
pnpm run build # Typecheck, then bundle src/index.ts + src/cli.ts into build/ (esbuild)
pnpm test # Run unit tests
pnpm run test:integration # Run integration tests (requires Mail.app)
pnpm run test:all # Run all tests (unit + integration)
pnpm run lint # Check code style
pnpm run format # Format code
```
---
## Author
**Rob Sweet** - President, [Superior Technologies Research](https://www.superiortech.io)
A software consulting, contracting, and development company.
- Email: rob@superiortech.io
- GitHub: [@sweetrb](https://github.com/sweetrb)
## License
MIT License - see [LICENSE](https://github.com/sweetrb/apple-mail-mcp/blob/main/LICENSE) for details.
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/sweetrb/apple-mail-mcp/blob/main/CONTRIBUTING.md) for guidelines.
## Related Projects
Part of a family of macOS MCP servers:
- [apple-notes-mcp](https://github.com/sweetrb/apple-notes-mcp) — MCP server for Apple Notes (create, search, update, and export notes)
- [apple-numbers-mcp](https://github.com/sweetrb/apple-numbers-mcp) — MCP server for Apple Numbers (read and write .numbers spreadsheets)
- [apple-photos-mcp](https://github.com/sweetrb/apple-photos-mcp) — MCP server for Apple Photos (query metadata and export originals)
## Recurring macOS permission prompts
If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), see [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md) — the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
TDQS
Scored across 52 tools
Every tool has an explicit 'Use when' / 'Do not use when' clause distinguishing it from its nearest neighbors, including the single-vs-batch mirror pairs (mark-as-read vs batch-mark-as-read), the read variants (get-message / get-message-headers / get-message-rfc822 / get-thread), and the diagnostics trio (health-check / doctor / get-sync-status). Despite the large surface, no two tools appear to do the same thing.
Nearly all tools follow a consistent kebab-case verb_noun pattern (search-messages, create-rule, batch-move-messages, resolve-message-id), and the batch- prefix is applied uniformly. The only deviations are the noun-style names 'doctor' and 'health-check', which break the verb_noun convention but remain clear.
At 52 tools this is well past the 25-tool threshold for 'too many', and much of the bulk comes from mechanical single/batch mirrors (delete/move/mark-read/mark-unread/flag/unflag) plus niche surface like create-newsletter-smart-mailboxes, get-message-rfc822, and a full template CRUD set that could be consolidated. The domain is broad, but the count is heavy enough to burden selection.
The surface covers the full mail lifecycle: search/list, read body/headers/raw RFC822, threads, attachments (list/fetch/save), send/draft/reply/forward/mail-merge, mailbox and smart-mailbox CRUD, rules CRUD with enable/disable, templates CRUD, stats, sync status, and permissions diagnostics. Contacts lookup is also included, leaving no obvious dead ends for the stated Apple Mail automation purpose.