Skip to main content
Glama
yu2001-s
by yu2001-s
README.md
# Apple Mail

A self-contained [Codex Plugin](https://developers.openai.com/codex) and
[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for
Apple Mail on macOS. It adds Gmail-like stable draft IDs, explicit sending
identities, confirmation-gated sending, and persistent scheduled sends.

[![CI](https://github.com/yu2001-s/apple-mail/actions/workflows/ci.yml/badge.svg)](https://github.com/yu2001-s/apple-mail/actions/workflows/ci.yml)
[![platform: macOS](https://img.shields.io/badge/platform-macOS-111?logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)

## What is This?

This server acts as a local bridge between Codex and Apple Mail. Once
installed, you can ask Codex to:

- "Check my inbox for unread messages"
- "List my saved drafts and verify their From identities"
- "Create a draft from my work alias"
- "Schedule these reviewed drafts for tomorrow at 7:00 AM"
- "Show the status of my scheduled sends"

Mail access and the scheduled-send registry stay local on the Mac.

## Quick Start

### Install in Codex

```bash
codex plugin marketplace add yu2001-s/apple-mail
codex plugin add apple-mail@apple-mail-public
```

The Plugin contains its bundled MCP server, so installation does not depend on
an npm package or a developer-specific absolute path. Open a new Codex task
after installation so the tools and skill are reloaded.

On first use, macOS asks for permission to automate Mail.app. Allow the Codex
host process that launched the Plugin.

### Local development

```bash
corepack enable
pnpm install --frozen-lockfile
pnpm build:plugin
pnpm test
```

## 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](./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
- **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)                                                |
| **Send Email**        | Compose and send new emails (attach by file path or inline base64 content)                 |
| **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)                |
| **Reply**             | Reply to messages (with reply-all support)                                                 |
| **Forward**           | Forward messages to new recipients                                                         |
| **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                                                        |

### 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                    |
| `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)                    |

**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))

---

#### `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 |

**Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available).

> **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)).

#### `read-message`

Read one message as a complete resource. It returns From/Reply-To/To/Cc/Bcc,
date, account, mailbox, flags, both decoded plain-text and HTML bodies,
attachment metadata, and the RFC Message-ID.

Set `include_raw_mime: true` only when exact header or MIME verification is
needed; attachments make the source large. Use `batch-read-messages` to read up
to 20 inspected message IDs without dropping individual failures.

---

#### `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.

---

#### `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)                                                                                                                                                                                                        |
| `html_body`   | string                                  | No       | Optional HTML alternative; `body` remains the plain-text fallback                                                                                                                                                              |
| `cc`          | string[]                                | No       | CC recipients                                                                                                                                                                                                                  |
| `bcc`         | string[]                                | No       | BCC recipients                                                                                                                                                                                                                 |
| `from`        | string                                  | No       | Exact `identity_id`, email, or formatted sender returned by `list-sending-identities`                                                                                                                                          |
| `account`     | string                                  | No       | Deprecated compatibility selector; do not pass together with `from`                                                                                                                                                            |
| `attachments` | (string \| {filename, contentBase64})[] | No       | Up to 20 attachments: absolute file paths (e.g., `"/Users/me/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) |

**Example:**

```json
{
  "to": ["colleague@company.com"],
  "subject": "Meeting Tomorrow",
  "body": "Hi, just confirming our meeting at 2pm tomorrow.",
  "from": "me@company.com",
  "attachments": ["/Users/me/Documents/agenda.pdf"]
}
```

##### 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:

- **Direct `send-email` has no connector-managed Sent copy.** The provider may
  save one automatically. IMAP-backed `send-draft` is different: it verifies
  the provider copy and appends one when needed, so Mail.app can sync it.
- **Sending identity and transport account are separate resources.** Pass
  `from` using an identity returned by `list-sending-identities`. The connector
  resolves that identity to an authorized SMTP profile and refuses missing or
  ambiguous mappings instead of silently changing the sender.

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:

| 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_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                                                                                                       |
| `APPLE_MAIL_MCP_SMTP_ACCOUNTS`         | No       | β€”                           | JSON array of additional profiles (`account`, `host`, `port`, `secure`, `user`, `from`, `allowedFrom`, Keychain references) |

For multiple accounts or aliases, configure one profile per authenticated SMTP
account. `allowedFrom` may be an array or comma-separated string:

```json
[
  {
    "account": "Personal",
    "host": "smtp.mail.me.com",
    "port": 587,
    "user": "person@icloud.com",
    "from": "person@icloud.com",
    "allowedFrom": ["alias@icloud.com"],
    "keychainService": "smtp.mail.me.com",
    "keychainAccount": "person@icloud.com"
  }
]
```

Store that JSON as the value of `APPLE_MAIL_MCP_SMTP_ACCOUNTS`. Passwords
should remain in Keychain rather than in the JSON.

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](./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.
  - **Default mailbox is resolved per account.** When you don't pin a `mailbox`, a
    fan-out search scopes each account to its own default β€” Gmail/Workspace to
    `[Gmail]/All Mail`, every other IMAP host (iCloud, etc.) to `INBOX` (since
    `[Gmail]/All Mail` is Gmail-only and selecting it elsewhere would silently drop
    that account). Pin a `mailbox` to search a wider scope on non-Gmail accounts.

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_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)               |

**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.

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:

```jsonc
{
  "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                                                                                                        |
| `from`        | string                                  | No       | Exact sending identity returned by `list-sending-identities`                                                          |
| `account`     | string                                  | No       | Deprecated compatibility selector                                                                                     |
| `attachments` | (string \| {filename, contentBase64})[] | No       | Up to 20 attachments: absolute file paths and/or inline `{filename, contentBase64}` objects up to 25 MiB decoded each |

**Returns:** Stable `draft_id`, content `revision`, and actual From identity.

`read-draft` returns the current revision. Pass it back as
`expected_revision` to `update-draft` and `send-draft`; the connector rejects
the operation if Mail.app or an iPhone changed the draft after it was reviewed.

When the selected identity has an IMAP profile, the draft is stored as clean
RFC 5322 MIME in that provider's Drafts mailbox. It therefore synchronizes with
Mail.app and iPhone instead of existing only in a local compose window.
`update-draft` supports targeted text/HTML edits plus:

- `attachments_to_add`: the same path/inline-base64 format as `create-draft`.
- `attachment_names_to_remove`: exact filenames to remove; omitted attachments
  are preserved byte-for-byte through the edit.

The stable `draft_id` does not change when an edit replaces the underlying IMAP
message. `send-draft` submits the exact reviewed revision over the SMTP profile
authorized for its From identity, verifies or appends the Sent copy, and removes
the Drafts copy only after SMTP acceptance. Older drafts indexed through
AppleScript remain readable for compatibility; full MIME/attachment editing
requires an IMAP-backed draft.

If a network failure leaves the SMTP outcome uncertain, the draft is locked as
`needs_review` to prevent an automatic duplicate. After manually checking Sent,
use `resolve-draft-send-status` with the explicitly confirmed `sent` or
`not_sent` outcome. That recovery tool records/unlocks state and never submits
mail itself.

#### Scheduled draft sends

The draft API exposes stable `draft_id` values through `list-drafts` and
`read-draft`. A reviewed batch can be scheduled with `schedule-drafts`:

```json
{
  "draft_ids": ["apple-draft:00000000-0000-4000-8000-000000000001"],
  "send_at": "2026-07-29T07:00:00+08:00",
  "confirmed": true
}
```

`send_at` must be RFC 3339 with an explicit timezone. The tool installs a
per-user launchd worker that checks every 30 seconds; if the Mac is asleep, an
overdue job is processed after wake/login. This is a local scheduler, not
Mail.app's native Send Later mailbox, because Mail does not expose Send Later
in its public scripting dictionary.

Before sending, the worker re-reads the draft and verifies its exact reviewed
revision, including HTML and attachments. A changed or missing draft fails without
sending. A job is durably marked `sending` before Mail is called; after a worker
crash it becomes `needs_review` and is not automatically retried, preventing
accidental duplicates. Attached IMAP-backed drafts are supported; older
AppleScript-backed attached drafts remain read-only in the connector.

- `list-scheduled-sends` β€” inspect pending and terminal jobs.
- `reschedule-scheduled-send` β€” change the time of a pending job.
- `cancel-scheduled-send` β€” cancel a pending job; the draft remains in Drafts.

**⚠️ Safety:** scheduling creates a real future send. Confirm the exact drafts,
recipients/content, From identity, time, and timezone before passing
`confirmed: true`.

#### `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)                |
| `includeBodies` | boolean | No       | Return complete message resources instead of summaries |

**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      | Numeric message ID                            |
| `attachmentName` | string | Yes      | Attachment filename (from `list-attachments`) |

**Returns:** The attachment bytes, base64-encoded (also in `structuredContent.contentBase64`).

---

#### `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                                                   |
| `from`     | string  | No       | Exact sending identity returned by `list-sending-identities` |
| `replyAll` | boolean | No       | Reply to all recipients (default: false)                     |
| `send`     | boolean | No       | Send immediately (default: true, false = save as draft)      |

**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
}
```

> **Transport (v2.5.0):** when SMTP is configured, `reply-to-message` sends via **clean SMTP**, threading the reply with proper RFC 5322 `In-Reply-To`/`References` headers (built from the original message) so it lands in the same conversation. When SMTP is not configured (or the original lacks the headers needed to thread), it falls back to Mail.app's AppleScript `reply … without opening window` β€” same reliable-from-background-process path as before. See [SMTP transport](#smtp-transport).

**⚠️ 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                                      |
| `send`    | boolean  | No       | Send immediately (default: true, false = save as draft) |

> **Transport (v2.5.0):** when SMTP is configured, `forward-message` sends via **clean SMTP** (a fresh message with the original quoted, no threading headers β€” a forward starts a new conversation). When SMTP is not configured it falls back to Mail.app's AppleScript `forward … without opening window`. See [SMTP transport](#smtp-transport).

**⚠️ 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, applied via AppleScript as the message's `flag index` (0 red, 1 orange, 2 yellow, 3 green, 4 blue, 5 purple, 6 gray) β€” the same property a Mail smart mailbox can match on. For an **IMAP-routed** message id (`imap:…`) the flag is still set, but the color is **not** applied, because IMAP's `\Flagged` flag is colorless. To color a flag, use the message's AppleScript (numeric) id.

---

#### `delete-message`

Delete a message (move to trash).

| Parameter | Type   | Required | Description |
| --------- | ------ | -------- | ----------- |
| `id`      | string | Yes      | Message ID  |

**⚠️ 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        |
| `account` | string | No       | Account containing mailbox |

---

#### `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.

---

#### `save-attachment`

Save a message attachment to disk.

| 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.

#### `batch-delete-messages`

| Parameter | Type     | Required | Description                     |
| --------- | -------- | -------- | ------------------------------- |
| `ids`     | string[] | Yes      | Message IDs to delete (max 100) |

**⚠️ 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    |

#### `batch-mark-as-read` / `batch-mark-as-unread`

| Parameter | Type     | Required | Description           |
| --------- | -------- | -------- | --------------------- |
| `ids`     | string[] | Yes      | Message IDs (max 100) |

#### `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 applied to AppleScript (numeric) ids β€” see [`flag-message`](#flag-message--unflag-message). Any `imap:` ids in the batch are flagged but not colored. |

---

### Mailbox Operations

#### `list-mailboxes`

List all mailboxes for an account.

| Parameter | Type   | Required | Description          |
| --------- | ------ | -------- | -------------------- |
| `account` | string | No       | Account to list from |

**Returns:** List of mailbox names with message and unread counts.

---

#### `get-unread-count`

Get unread message count.

| Parameter | Type   | Required | Description                       |
| --------- | ------ | -------- | --------------------------------- |
| `mailbox` | string | No       | Mailbox to check (omit for total) |
| `account` | string | No       | Account to check                  |

---

#### `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)

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 `true`)      |

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`.

**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 contacts in Contacts.app.

| Parameter | Type   | Required | Description               |
| --------- | ------ | -------- | ------------------------- |
| `query`   | string | Yes      | Name to search for        |
| `limit`   | number | No       | Max results (default: 10) |

**Returns:** List of contacts with name, email addresses, and phone numbers.

---

### 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.

**Parameters:** None

**Returns:** Total and per-account message/unread counts, plus recently received stats (24h, 7d, 30d).

---

#### `get-sync-status`

Check Mail.app sync activity.

**Parameters:** None

**Returns:** Whether sync is detected, pending uploads, recent activity, and seconds since last change.

---

## 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"
```

---

## Running from source

```bash
git clone https://github.com/yu2001-s/apple-mail.git
cd apple-mail
corepack enable
pnpm install --frozen-lockfile
pnpm build:plugin
```

The repository ships prebuilt bundles for both local development and the Codex
Plugin. Re-run `pnpm build:plugin` after changing server source.

If installed from source, use this configuration:

```json
{
  "mcpServers": {
    "apple-mail": {
      "command": "node",
      "args": ["/path/to/apple-mail/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:

```json
"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

- **Local only** - All operations happen locally via AppleScript. No data is sent to external servers.
- **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.

---

## 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)                                                                                                                                                                                                                                                                                                                                                                                                     |
| Attachments require absolute paths                                           | File attachments must use full absolute paths (e.g., `/Users/me/file.pdf`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| No smart mailboxes                                                           | Cannot access Smart Mailboxes via AppleScript                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

### 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. The AppleScript `without opening window` path above remains the fallback when SMTP is not configured (or, for replies, when the original message lacks the headers needed to thread).

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 `\\`.

**Example - Email with file path:**

```json
{
  "to": ["colleague@company.com"],
  "subject": "File Location",
  "body": "The file is at C:\\\\Users\\\\Documents\\\\report.pdf"
}
```

The `\\\\` in JSON becomes `\\` in the actual string, which represents a single `\` in the email.

**Common patterns requiring escaping:**

- Windows paths: `C:\Users\` β†’ `C:\\\\Users\\\\` in JSON
- Shell escaped spaces: `Mobile\ Documents` β†’ `Mobile\\\\ Documents` in JSON
- Regex patterns: `\d+` β†’ `\\\\d+` 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

### "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

### `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

### `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

```bash
npm install            # Install dependencies
npm run build          # Typecheck, then bundle src/index.ts + src/cli.ts into build/ (esbuild)
npm test               # Run unit tests
npm run test:integration  # Run integration tests (requires Mail.app)
npm run test:all       # Run all tests (unit + integration)
npm run lint           # Check code style
npm run format         # Format code
```

---

## Acknowledgements

This project is derived from
[sweetrb/apple-mail-mcp](https://github.com/sweetrb/apple-mail-mcp), created by
Rob Sweet and released under the MIT License. The original copyright notice is
preserved.

## License

MIT License β€” see [LICENSE](./LICENSE) for details.

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md).

## 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](./docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md).

TDQS

A4.4/5.0

Scored across 61 tools

Disambiguation5/5

Each tool targets a distinct resource and action, with detailed 'use when' and 'do not use when' guidance that clearly differentiates similar-sounding tools like send-email vs send-serial-email vs send-draft, or search-messages vs list-messages. No two tools overlap in purpose.

Naming Consistency5/5

All 61 tools follow a consistent verb_noun snake_case pattern (e.g., delete-template, list-messages, batch-flag-messages). The naming is uniform and predictable, making it easy to infer tool behavior from its name.

Tool Count2/5

With 61 tools, the count is far above the typical well-scoped range of 3-15. While each tool has a clear purpose, the high numberβ€”especially the proliferation of batch variants and diagnostic toolsβ€”makes the surface feel bloated and potentially overwhelming for an agent.

Completeness5/5

The tool set covers virtually every aspect of email management: sending, receiving, drafting, replying, forwarding, mailbox CRUD, rule management, template management, smart mailboxes, contacts, attachments, scheduling, and diagnostics. There are no obvious gaps for a consumer email client.

Maintenance

ActivityStale
ResponsivenessNo issues