agentleFS
Sign inSign up

apple-mail-mcp

sweetrb/apple-mail-mcp/CLAUDE.md

This file provides guidance for AI agents (Claude, etc.) when using this MCP server. This MCP server enables AI assistants to interact with Apple Mail on macOS via AppleScript. All operations are local - no data leaves the user's machine. The AppleScript backend needs no config. The opt-in IMAP (fast search/counts on large mailboxes) and SMTP (clean sending, no macOS 15+ <blockquote> wrapping) backends are configured via non-secret APPLEMAILMCP_* settings — supplied in an env block or, for hosts that…

CLAUDE.md79 starsChanged 4 days ago
# CLAUDE.md - Apple Mail MCP Server

This file provides guidance for AI agents (Claude, etc.) when using this MCP server.

## Overview

This MCP server enables AI assistants to interact with Apple Mail on macOS via AppleScript. All operations are local - no data leaves the user's machine.

## Configuring IMAP / SMTP (when a user asks to set it up)

The AppleScript backend needs no config. The **opt-in** IMAP (fast search/counts on
large mailboxes) and SMTP (clean sending, no macOS 15+ `<blockquote>` wrapping)
backends are configured via non-secret `APPLE_MAIL_MCP_*` settings — supplied in an
`env` block **or**, for hosts that strip `env` (e.g. Claude Desktop), a
`config.json` at `~/Library/Application Support/apple-mail-mcp/config.json` (loaded
into the environment at startup) — with passwords kept in the macOS Keychain.
**Don't reverse-engineer it: the full walkthrough is [`docs/IMAP-SETUP.md`](docs/IMAP-SETUP.md)**
(app passwords, Keychain, both config methods, multi-account, SMTP, headless/SSH
Keychain gotchas). Verify with the `doctor` tool.

## Critical: Backslash Escaping

**When sending content with backslashes to any tool, you MUST escape them.**

The MCP protocol uses JSON for parameters. In JSON, `\` is an escape character. To include a literal backslash:

| You want            | Send in JSON parameter |
| ------------------- | ---------------------- |
| `\`                 | `\\`                   |
| `\\`                | `\\\\`                 |
| `Mobile\ Documents` | `Mobile\\ Documents`   |

### Why This Matters

If you send a single backslash without escaping:

- The JSON parser interprets `\` as an escape sequence
- Invalid sequences like `\ ` (backslash-space) cause silent failures
- The email send/draft may fail with no clear error

### Examples

**Correct - shell path with an escaped space:**

```text
body: "Run: cp ~/Library/Mobile\\ Documents/report.pdf ~/Desktop/"
```

→ arrives as: `Run: cp ~/Library/Mobile\ Documents/report.pdf ~/Desktop/`

**Correct - regex in the body:**

```text
body: "Invoice numbers match \\d+"
```

→ arrives as: `Invoice numbers match \d+`

**Incorrect - Will fail:**

```text
body: "Run: cp ~/Library/Mobile\ Documents/report.pdf ~/Desktop/"
```

## Tool Usage Tips

### Using Message IDs (Required)

All message operations require an `id` parameter. **Always get IDs first** using `list-messages` or `search-messages`:

**A bare numeric ID is only meaningful together with the mailbox it came from.** Mail.app numbers
messages per mailbox, and a label store (Gmail, iCloud) reports the _same_ message under the _same_
id in several mailboxes at once — `INBOX`, `Important` and `All Mail` will all answer to id `75815`.
Moving or deleting the `All Mail` copy is a different operation from moving or deleting the `INBOX`
copy, so the server binds each id to the mailbox you listed it from and operates only there.

Practical consequences:

- **List or search the mailbox you intend to act on, immediately before acting on it.** Don't carry
  numeric ids across from an unrelated listing, and don't invent them.
- An id the server has never seen listed is resolved only if exactly one mailbox holds it. If
  several do, the call **fails** and names them — re-list the mailbox you meant rather than retrying
  the same id or trying a different tool.
- `imap:…` ids (returned when an IMAP account is configured) already encode account + mailbox +
  UID, so they are unambiguous anywhere and are never subject to this.
- A batch call's success count reports messages the server actually operated on. Treat a `notfound`
  or an ambiguity error for some ids as a partial result and re-list, rather than assuming the whole
  batch applied.

```text
# List messages returns IDs
list-messages mailbox="INBOX"
→ Messages with IDs like "12345", "12346", etc.

# Use ID for all subsequent operations
get-message id="12345"
mark-as-read id="12345"
delete-message id="12345"
reply-to-message id="12345" body="Thanks!"
```

### Recipient Arrays

The `to`, `cc`, and `bcc` parameters must always be arrays:

**Correct:**

```json
{
  "to": ["bob@example.com"],
  "subject": "Hello"
}
```

**Incorrect:**

```json
{
  "to": "bob@example.com",
  "subject": "Hello"
}
```

### send-email vs create-draft

- Use `send-email` for immediate sending
- Use `create-draft` when the user should review first
- Both support optional `attachments` parameter (array of absolute file paths)
- **Recommendation**: For important emails, use `create-draft` and tell the user to review in Mail.app

### send-serial-email (mail merge)

- Sends individual personalized emails to a list of recipients
- Use `{{placeholder}}` tokens in subject and body, replaced per-recipient
- Each recipient gets their own email — recipients don't see each other
- Max 100 recipients per batch, delay between sends (default 500ms, max 10000ms)
- Example variables: `{ "Name": "Alice", "Company": "Acme" }`

### reply-to-message

- Set `replyAll: true` to reply to all recipients
- Set `send: false` to save as draft instead of sending immediately
- Default behavior: reply to sender only, send immediately
- **Transport (v2.5.0):** when SMTP is configured, sends via **clean direct SMTP**, threading the reply with proper RFC 5322 `In-Reply-To`/`References` headers (built from the original) so it stays in the same conversation. Reads `imap:` sources directly over IMAP. `transport: "smtp"` requires clean delivery; a selected SMTP path returns configuration, source, and threading failures rather than silently falling back. With transport omitted, Mail.app's AppleScript `reply … without opening window` is used only when SMTP is unconfigured or `send: false`. Explicit `transport: "applescript"` remains available. The `without opening window` path opens no compose window, which ensures reliable body delivery from background processes (see [Known Issues](#known-issue-resolved-reply--forward-empty-body-from-background-processes) below)

### forward-message

- Requires message `id` and `to` array
- Optional `body` to prepend a message
- Set `send: false` to save as draft
- **Transport (v2.5.0):** when SMTP is configured, sends via **clean direct SMTP** (a forward starts a new conversation — no threading headers). Reads `imap:` sources directly over IMAP. A selected SMTP path never silently falls back. With transport omitted, uses AppleScript `forward … without opening window` when SMTP is unconfigured or a draft is requested — same background-process fix as reply-to-message

### Multi-account

SMTP forwarding requires a readable plain-text original: HTML-only IMAP messages
and failed Mail.app body reads return an error before sending. Explicit
`transport: "applescript"` can forward these with Mail.app; do not switch
transports automatically after an SMTP error. The plain-text SMTP forward path
does not reattach original attachments.

- Default account is Mail.app's configured default send account
- `search-messages` searches all accounts when no `account` is specified
- Use `list-accounts` to see available accounts
- Pass `account` parameter to target specific account
- **Two dates, and they can disagree (2.19.0, #224).** `get-message` returns `dateSent` (the `Date:` header — the author's send time) and `dateReceived` (arrival in the mailbox: IMAP `INTERNALDATE` / Mail's `date received`). A migration or re-import resets the arrival time, so on such a mailbox dozens of messages share one `dateReceived` while their `dateSent` values span years — use `dateSent` for chronology there. `get-message-headers` returns the raw header block (Message-ID, In-Reply-To/References, the `Received:` trace, custom `X-` headers) without downloading the body. `dateSent` is omitted when it is more than 7 days after `dateReceived` — that is Mail inventing one for a `Date:` it cannot parse, not a send time (2.19.6, #234). `get-message-headers` names its `backend`; a numeric id reads Mail's `all headers`, which can drop an unparseable `Date:` value (reported as absent, with `warnings[]`) — the `imap:` id for the same message has the real header.
- **Reads prefer direct IMAP when configured (v2.6.0).** When any `APPLE_MAIL_MCP_IMAP_*` account is configured, the read tools (`search-messages`, `get-thread`, `list-messages`, `list-mailboxes`, `get-unread-count`, `get-mail-stats`) go to IMAP instead of AppleScript:
  - explicit IMAP `account` → that account over IMAP (fast, server-side);
  - explicit non-IMAP `account` → AppleScript;
  - **no `account` → 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 — IMAP-served accounts aren't re-scanned; if all accounts are IMAP, AppleScript is skipped entirely). Message lists de-dup messages seen in both backends (preferring the IMAP copy and its `imap:` id) and sort newest-first; count tools count each account via exactly one backend so a coverage mismatch can never double-count.
  - With IMAP unconfigured, reads behave exactly as before (pure AppleScript). The mailbox-write ops (`create`/`delete`/`rename-mailbox`) still route to IMAP only for an explicitly-named IMAP account.
  - **`get-message-rfc822` is IMAP-only (2.19.10, #244).** It returns the stored bytes untouched with `uid`/`uidValidity`/`internalDate`/`flags`/`RFC822.SIZE` and a SHA-256, opened with `EXAMINE` and fetched with `BODY.PEEK[]`. A numeric id is refused: Mail's AppleScript bridge hands out its own rendering, not the original message, so there is nothing forensic to serve from it. Inline results are capped at 6 MiB of raw bytes (the base64 rides only in `structuredContent`, never in the text block) — point large messages at `savePath` (25 MiB) instead.

### Connection footprint (playing nice with Gmail)

IMAP connections are capped: **Gmail allows at most 15 simultaneous IMAP connections per account**, and Apple Mail itself needs some of those slots, so this server is built to stay light:

- It keeps **one pooled IMAP connection per account**, reused across calls and **closed after ~30s idle** (tune with `APPLE_MAIL_MCP_IMAP_IDLE_MS`; `0` = never close). An instance that isn't actively serving IMAP calls drops to **zero** connections.
- **IMAP IDLE is opt-in** (`APPLE_MAIL_MCP_IMAP_IDLE=1`) and adds **one persistent connection per account** (a long-lived push watcher) on top of the pooled request connection — leave it off unless you need new-mail notifications.
- The server **drops all pooled connections on shutdown** (SIGINT/SIGTERM and stdin-EOF) and, as of **v2.6.1**, **self-exits if it becomes orphaned** (a force-quit/crashed parent → reparented to launchd, `ppid === 1`; polled every 30s) so it can't linger holding sockets after its session is gone.
- **Watch out for many concurrent instances:** a host like the Claude desktop app spawns a separate set of MCP servers per open conversation (respawning them after a crash), so the footprint is **per instance × accounts**. With many active conversations (or IDLE on), the per-account total can approach Gmail's 15-connection cap and starve Apple Mail → intermittent "cannot connect." Mitigate by closing idle conversations, keeping IDLE off, and/or lowering `APPLE_MAIL_MCP_IMAP_IDLE_MS`.

## Error Handling

| Error                     | Likely Cause                                        |
| ------------------------- | --------------------------------------------------- |
| "Mail.app not responding" | Mail.app frozen or not running                      |
| "Message not found"       | Message ID is invalid or message was deleted/moved  |
| "Permission denied"       | macOS automation permission needed                  |
| "Account not found"       | Account name doesn't match exactly (case-sensitive) |
| "Failed to send email"    | Network issue or Mail.app configuration problem     |
| Silent failure            | Backslash not escaped in content                    |

## Security Considerations

- **Sending emails**: Always confirm with user before sending. Recommend `create-draft` for review.
- **Deleting messages**: Warn user that deletion moves to Trash (can be recovered).
- **Reading emails**: May contain sensitive information - summarize rather than display full content when appropriate.

## Example Workflows

### Check for important emails

```text
1. list-accounts → get available accounts
2. search-messages query="boss@company.com" → find emails from boss
3. get-message id="..." → read the full content
```

### Send a reply safely

```text
1. get-message id="..." → read original message
2. reply-to-message id="..." body="..." send=false → save as draft
3. Tell user to review in Mail.app before sending
```

### Compose and send

```text
1. create-draft to=["recipient@example.com"] subject="..." body="..."
2. Tell user: "I've created a draft. Review it in Mail.app and send when ready."
   OR if user confirms they want to send immediately:
3. send-email to=["recipient@example.com"] subject="..." body="..."
```

### Forward an email

```text
1. get-message id="..." → read the message to forward
2. forward-message id="..." to=["colleague@company.com"] body="FYI - see below"
```

### Organize inbox

```text
1. search-messages query="newsletter" → find newsletters
2. For each: move-message id="..." mailbox="Archive"
```

### Batch operations (efficient for multiple messages)

```text
1. search-messages query="old" → find messages to clean up
2. batch-delete-messages ids=["123", "456", "789"] → delete multiple
   OR
   batch-move-messages ids=["123", "456"] mailbox="Archive" → archive multiple
   OR
   batch-mark-as-read ids=["123", "456"] → mark multiple as read
   OR
   batch-mark-as-unread ids=["123", "456"] → mark multiple as unread
   OR
   batch-flag-messages ids=["123", "456"] → flag multiple
   OR
   batch-unflag-messages ids=["123", "456"] → unflag multiple
   Note: all batch operations are limited to 100 messages per request
```

### Check for attachments

```text
1. list-messages mailbox="INBOX" → get message IDs
2. list-attachments id="..." → see attachments (name, MIME type, size)
3. save-attachment id="..." attachmentName="report.pdf" savePath="/tmp" → save to disk
```

### Manage mailboxes

```text
1. list-mailboxes → see all folders
2. create-mailbox name="Projects" → create new folder
3. rename-mailbox oldName="Projects" newName="Active Projects" → rename
4. delete-mailbox name="Old Folder" → delete
```

### Manage smart mailboxes (intelligente Postfächer)

Smart mailboxes are **criteria-based virtual views**, not real folders — use these instead of `*-mailbox` when the user wants a saved filter/search (e.g. "a folder that shows all mail from X"). They work on localized macOS (e.g. German), where AppleScript's smart-mailbox terms fail, because they edit `SyncedSmartMailboxes.plist` directly (backed up + atomic; existing smart mailboxes are never rewritten).

```text
1. list-smart-mailboxes → see existing smart mailboxes
2. create-smart-mailbox name="From Boss" fromContains="boss@company.com"
   (provide at least one of fromContains / subjectContains / bodyContains)
3. delete-smart-mailbox name="From Boss" → remove it
```

For decluttering an inbox full of newsletters, prefer the high-level tool — it defaults to a **dry run**:

```text
1. create-newsletter-smart-mailboxes → dry run: proposes "NL: <sender>" smart views by score
2. create-newsletter-smart-mailboxes dryRun=false → actually create the proposed views
```

Changes appear the next time Mail is launched — these tools do **not** quit or restart Mail. For reliable results, have the user quit Mail before creating/deleting smart mailboxes.

### Work with mail rules

```text
1. list-rules → see all rules and their status
2. disable-rule name="Newsletter Filter" → turn off a rule
3. enable-rule name="Newsletter Filter" → turn it back on
```

### Look up contacts

```text
1. search-contacts query="John" → find contacts by name
   Returns names, email addresses, and phone numbers from Contacts.app
```

### Use email templates

```text
1. save-template name="Weekly Report" subject="Weekly Report" body="..." to=["team@..."]
2. list-templates → see saved templates
3. use-template id="tmpl_1" → create draft from template
4. use-template id="tmpl_1" to=["other@..."] → override recipients
   Note: templates persist to disk (APPLE_MAIL_MCP_TEMPLATES_FILE) and survive restarts
```

### Send email with attachments

```text
1. send-email to=["colleague@company.com"] subject="Report" body="See attached." attachments=["/Users/me/report.pdf"]
   OR to let the user review first:
2. create-draft to=["colleague@company.com"] subject="Report" body="See attached." attachments=["/Users/me/report.pdf"]
   Note: attachment paths must be absolute and the files must exist; max 20 files per message
```

### Send personalized emails (mail merge)

```text
1. send-serial-email recipients=[
     {"email": "alice@acme.com", "variables": {"Name": "Alice", "Company": "Acme"}},
     {"email": "bob@globex.com", "variables": {"Name": "Bob", "Company": "Globex"}}
   ] subject="Hello {{Name}}" body="Great to connect about {{Company}}."
   Each recipient gets their own individual email with placeholders replaced.
```

### Check mail sync status

```text
1. get-sync-status → see if Mail.app is running and syncing
2. get-mail-stats → see total/unread counts and recently received counts
```

**`get-mail-stats` costs one IMAP `STATUS` per mailbox** (and Gmail lists every label as
a mailbox), so it is the most expensive read tool — prefer `get-unread-count` when a single
number will do. Accounts are counted concurrently under a per-account budget
(`APPLE_MAIL_MCP_STATS_BUDGET_MS`, default 25s), and the whole call is bounded by one
overall deadline (`APPLE_MAIL_MCP_STATS_DEADLINE_MS`, default 50s) that also covers the
Mail.app account enumeration. If the merged result carries
`partial: true`, **the totals are floors, not answers** — `failedAccounts` names what is
missing; say so rather than reporting the total as complete. `failedAccounts` may name
`Mail.app accounts (AppleScript enumeration)` rather than an account: that means the
AppleScript-only accounts could not be enumerated, so any account not covered by an IMAP
config is missing entirely from the totals.

**Never issue `get-mail-stats` calls in parallel — they will not overlap.** Tool calls are
serialized so they cannot race into Mail.app, so N concurrent calls take about N × the
cost of one, and the deadline (measured from when each request arrived) is spent waiting
rather than reading. A call that waited reports `queueWaitMs`; one that arrives with its
deadline already gone returns an error naming the queue. If you need several figures, make
**one** unscoped call and read the per-account breakdown out of it, or use
`get-unread-count` where a single number will do.

### Moving mail: name the destination unambiguously

`move-message`, `batch-move-messages`, `delete-mailbox` and `rename-mailbox` resolve a
destination as a **full path** first, then as a leaf name. A leaf name matching more than
one mailbox (`Archive` under both `Work` and `Thornlands`) is **refused** with an error
naming every candidate — retry with the full path from `list-mailboxes` rather than
guessing, and don't fall back to a different name. Matching ignores case and Unicode
normalization (NFC `é` finds an NFD-stored `é`, #253); an "ambiguous … Unicode normalization"
refusal means two visually identical mailboxes exist — tell the user, don't retry variants.

## Known Issue (Resolved): Reply / Forward Empty Body from Background Processes

### The Problem

Prior to v1.4.0, `reply-to-message` and `forward-message` would send replies/forwards with **empty body text** when the MCP server was running as a background process (e.g., spawned via `execSync` from a Node.js MCP server, which is how Claude Code invokes it).

The root cause was the AppleScript `reply msg with opening window` command. This creates a GUI compose window asynchronously. When `set content` runs immediately after, the window may not be ready yet, and the content assignment is **silently ignored**. Even adding delays (`delay 1`, `delay 2`) was unreliable — the compose window's readiness depends on system load, Mail.app state, and whether the process has GUI access.

A secondary issue: the old code appended `& content of theReply` (the original quoted message) to the body. This was always a no-op — the quoted content lives in the HTML layer of the compose window, not the plaintext `content` property.

### The Fix

Replaced `with opening window` with `without opening window` for both `reply` and `forward` commands. With this approach:

- `set content` works **immediately** — no delay needed
- Works reliably from background processes (Node.js `execSync`, MCP stdio transport)
- `In-Reply-To` and `References` headers are still set correctly by Mail.app (the `reply` command knows which message it's replying to)
- No GUI compose window is opened (better for a server process)
- `reply to all` and `send` both work as expected

### Approaches That Were Tested and Failed

| Approach                                         | Result                                                                                              |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `delay 1` / `delay 2` before `set content`       | Body still empty from background process (works interactively)                                      |
| `reply msg without opening window` (old attempt) | Previously dismissed, but actually works — `set content` is reliable without the window             |
| `set html content` on reply object               | AppleScript error — not a valid property                                                            |
| System Events UI scripting (keystroke)           | Blocked: "osascript is not allowed to send keystrokes" from background process                      |
| `make new outgoing message` with same subject    | Body arrives, but no `In-Reply-To`/`References` headers (can't set `reply id` on outgoing messages) |
| Manual headers on `outgoing message`             | Not possible — Mail.app's `outgoing message` class doesn't expose a `headers` property              |

### References

- GitHub Issue: [#7 — reply-to-message sends empty body when called from background process](https://github.com/sweetrb/apple-mail-mcp/issues/7)

## Testing Your Understanding

Before sending emails with paths or special characters, verify escaping:

- `~/path/to/file` - No escaping needed (no backslashes)
- `~/Library/Mobile\ Documents` - Needs escaping: `~/Library/Mobile\\ Documents`
- `file\ name.txt` - Needs escaping: `file\\ name.txt`
- `\d+` - Needs escaping: `\\d+`

## Recurring macOS permission prompts → offer the official-Node fix

If a user reports being **repeatedly** prompted for Full Disk Access or
Automation for "node" (especially after a `brew upgrade`), or that this MCP
loses its permissions every so often, the cause is almost always an **ad-hoc
signed Node** (typically Homebrew's) whose cdhash changes on every update, so
macOS TCC keeps treating it as a brand-new binary.

Detect it:

```bash
codesign -dvvv "$(which node)" 2>&1 | grep -E 'Signature=adhoc|TeamIdentifier=not set'
```

If that matches (ad-hoc / no Team ID), **offer to migrate this MCP to the
official Developer-ID-signed Node** per
[`docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md`](docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md):
install an official LTS Node to a stable path (e.g. `~/mcp-runtime/node-current`),
repoint this server's `command` at it, and have the user grant the permission
once — it then persists across Node updates. Do not repoint `npx`-launched
servers that don't need Full Disk Access.

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.