apple-mail-mcp 2.8.4 → 2.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e
12
12
  [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)
13
13
 
14
14
  <p align="center">
15
- <img src="codex/assets/screenshot.png" alt="Apple Mail MCP — read, search, send, and organize Apple Mail from Codex, Claude, and other AI assistants" width="680">
15
+ <img src="https://raw.githubusercontent.com/sweetrb/apple-mail-mcp/main/codex/assets/screenshot.png" alt="Apple Mail MCP — read, search, send, and organize Apple Mail from Codex, Claude, and other AI assistants" width="680">
16
16
  </p>
17
17
 
18
18
  > **Note:** This is the **npm/Node.js** package — install with `npx` or `npm`. There is an unrelated Python project of the same name on PyPI ([`imdinu/apple-mail-mcp`](https://github.com/imdinu/apple-mail-mcp)) installed via `pipx`/`uvx`. If you're using `uvx` and seeing a `cyclopts` dependency error, you're looking for that project, not this one.
@@ -43,6 +43,12 @@ Install the sweetrb/apple-mail-mcp MCP server so you can help me manage my Apple
43
43
 
44
44
  Claude will handle the installation and configuration automatically.
45
45
 
46
+ Or register it deterministically in one command:
47
+
48
+ ```bash
49
+ claude mcp add apple-mail -s user -- npx -y apple-mail-mcp
50
+ ```
51
+
46
52
  ### Using the Plugin Marketplace
47
53
 
48
54
  Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
@@ -54,6 +60,11 @@ Install as a Claude Code plugin for automatic configuration and enhanced AI beha
54
60
 
55
61
  This method also installs a **skill** that teaches Claude when and how to use Apple Mail effectively.
56
62
 
63
+ > **Configuring IMAP/SMTP for a plugin install:** a plugin install has no editable `env` block,
64
+ > so supply settings via the config file at `~/Library/Application Support/apple-mail-mcp/config.json`
65
+ > — Method B in the [IMAP / SMTP Setup Guide](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md).
66
+ > Passwords stay in the macOS Keychain; run the `doctor` tool to verify.
67
+
57
68
  ### Using the Codex Marketplace
58
69
 
59
70
  Install the same public marketplace in Codex:
@@ -69,14 +80,14 @@ The Codex package registers the same `apple-mail` MCP server through `npx -y app
69
80
 
70
81
  Configuration for two more hosts is included — each registers the same `apple-mail` MCP server (`npx -y apple-mail-mcp`):
71
82
 
72
- - **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in. Add the server with `hermes mcp add apple-mail --command npx --args -y apple-mail-mcp`, or merge [`.hermes-plugin/config.yaml`](.hermes-plugin/config.yaml) into `~/.hermes/config.yaml`. Details: [`.hermes-plugin/README.md`](.hermes-plugin/README.md).
73
- - **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).
83
+ - **[Hermes Agent](https://hermes-agent.nousresearch.com/)** (NousResearch) — Hermes has no plugin/marketplace drop-in. Add the server with `hermes mcp add apple-mail --command npx --args -y apple-mail-mcp`, or merge [`.hermes-plugin/config.yaml`](https://github.com/sweetrb/apple-mail-mcp/blob/main/.hermes-plugin/config.yaml) into `~/.hermes/config.yaml`. Details: [`.hermes-plugin/README.md`](https://github.com/sweetrb/apple-mail-mcp/blob/main/.hermes-plugin/README.md).
84
+ - **[Antigravity](https://antigravity.google/)** (Google) — add the server entry from [`.antigravity-plugin/mcp_config.json`](https://github.com/sweetrb/apple-mail-mcp/blob/main/.antigravity-plugin/mcp_config.json) to `~/.gemini/config/mcp_config.json` (or via Antigravity's MCP settings).
74
85
 
75
86
  ### Manual Installation
76
87
 
77
88
  **1. Install the server:**
78
89
  ```bash
79
- npm install -g github:sweetrb/apple-mail-mcp
90
+ npm install -g apple-mail-mcp
80
91
  ```
81
92
 
82
93
  **2. Add to Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
@@ -113,7 +124,7 @@ Both are driven by non-secret `APPLE_MAIL_MCP_*` settings — supplied via an `e
113
124
  block **or** a `config.json` file (for hosts like Claude Desktop that strip `env`)
114
125
  — with passwords kept in the macOS **Keychain**, never in config.
115
126
 
116
- 👉 **[IMAP / SMTP Setup Guide](docs/IMAP-SETUP.md)** — step-by-step: app passwords,
127
+ 👉 **[IMAP / SMTP Setup Guide](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md)** — step-by-step: app passwords,
117
128
  Keychain, both config methods, multi-account, SMTP, verification with the `doctor`
118
129
  tool, and troubleshooting. Verify any time by running the **`doctor`** tool.
119
130
 
@@ -381,7 +392,7 @@ SMTP not configured.
381
392
 
382
393
  ##### IMAP backend — opt-in
383
394
 
384
- > 📘 **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.
395
+ > 📘 **For step-by-step setup (app passwords, Keychain, config methods, multi-account, upgrading, troubleshooting), see the [IMAP / SMTP Setup Guide](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md).** The summary below is the reference; the guide is the walkthrough.
385
396
 
386
397
  AppleScript runs `search`/`list` predicates client-side over the Apple Event
387
398
  bridge, which is slow and can time out (false-empty) on large Gmail/IMAP
@@ -1133,7 +1144,7 @@ AI: [calls move-message for each, with mailbox="Archive"]
1133
1144
  ### npm (Recommended)
1134
1145
 
1135
1146
  ```bash
1136
- npm install -g github:sweetrb/apple-mail-mcp
1147
+ npm install -g apple-mail-mcp
1137
1148
  ```
1138
1149
 
1139
1150
  ### From Source
@@ -1145,6 +1156,8 @@ cd apple-mail-mcp
1145
1156
 
1146
1157
  The repo ships prebuilt, dependency-free `build/index.js` and `build/cli.js` bundles, so a bare clone runs with nothing but Node installed. `npm install` and `npm run build` are only needed when you change the source.
1147
1158
 
1159
+ > You can also install straight from GitHub with `npm install -g github:sweetrb/apple-mail-mcp`, but that builds from source (requires pnpm) — prefer the registry package above.
1160
+
1148
1161
  If installed from source, use this configuration:
1149
1162
  ```json
1150
1163
  {
@@ -1189,13 +1202,12 @@ The entrypoint is written as:
1189
1202
  | Limitation | Reason |
1190
1203
  |------------|--------|
1191
1204
  | macOS only | Apple Mail and AppleScript are macOS-specific |
1192
- | No sending HTML email | Emails are sent as plain text; reading HTML content is supported |
1205
+ | 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) |
1193
1206
  | Attachments require absolute paths | File attachments must use full absolute paths (e.g., `/Users/me/file.pdf`) |
1194
1207
  | No smart mailboxes | Cannot access Smart Mailboxes via AppleScript |
1195
1208
  | 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)) |
1196
1209
  | 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)) |
1197
- | In-memory templates | Email templates are not persisted across server restarts |
1198
- | Numeric-only message IDs | Message IDs must contain only digits (validated by schema) |
1210
+ | Message ID format | Message IDs must be numeric (AppleScript ids) or `imap:…` tokens from the IMAP read path (validated by schema) |
1199
1211
  | Batch size cap | Batch operations are limited to 100 messages per request |
1200
1212
  | 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 |
1201
1213
  | Attachment save path restrictions | `save-attachment` only allows saving to home directory, `/tmp`, `/private/tmp`, and `/Volumes`; path traversal is blocked |
@@ -1259,7 +1271,7 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
1259
1271
 
1260
1272
  ### "Permission denied"
1261
1273
  - macOS needs automation permission
1262
- - Go to System Preferences > Privacy & Security > Automation
1274
+ - Go to System Settings > Privacy & Security > Automation
1263
1275
  - Ensure your terminal/Claude has permission to control Mail
1264
1276
 
1265
1277
  ### "Message not found"
@@ -1315,11 +1327,11 @@ A software consulting, contracting, and development company.
1315
1327
 
1316
1328
  ## License
1317
1329
 
1318
- MIT License - see [LICENSE](LICENSE) for details.
1330
+ MIT License - see [LICENSE](https://github.com/sweetrb/apple-mail-mcp/blob/main/LICENSE) for details.
1319
1331
 
1320
1332
  ## Contributing
1321
1333
 
1322
- Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1334
+ Contributions are welcome! Please see [CONTRIBUTING.md](https://github.com/sweetrb/apple-mail-mcp/blob/main/CONTRIBUTING.md) for guidelines.
1323
1335
 
1324
1336
  ## Related Projects
1325
1337
 
@@ -1331,4 +1343,4 @@ Part of a family of macOS MCP servers:
1331
1343
 
1332
1344
  ## Recurring macOS permission prompts
1333
1345
 
1334
- 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) — the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
1346
+ If macOS keeps re-prompting for Full Disk Access or Automation for `node` (often after a `brew upgrade`), see [docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md](https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md) — the fix is to run this server under the official, Developer-ID-signed Node so the grant survives Node updates.
package/build/cli.js CHANGED
@@ -11868,6 +11868,12 @@ var import_nodemailer = __toESM(require_nodemailer(), 1);
11868
11868
  import { execFileSync } from "child_process";
11869
11869
  import { isAbsolute } from "path";
11870
11870
  import { existsSync } from "fs";
11871
+
11872
+ // src/utils/docsUrls.ts
11873
+ var SETUP_GUIDE_URL = "https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md";
11874
+ var SETUP_HINT = `Setup guide: ${SETUP_GUIDE_URL} \u2014 run the "doctor" tool to check your setup.`;
11875
+
11876
+ // src/services/smtpMailer.ts
11871
11877
  var SMTP_ENV = {
11872
11878
  host: "APPLE_MAIL_MCP_SMTP_HOST",
11873
11879
  port: "APPLE_MAIL_MCP_SMTP_PORT",
@@ -11900,7 +11906,7 @@ function resolveSmtpConfig(env = process.env) {
11900
11906
  if (!user) missing.push(SMTP_ENV.user);
11901
11907
  if (missing.length > 0) {
11902
11908
  throw new Error(
11903
- `SMTP transport is not configured. Set ${missing.join(" and ")} (plus a password via ${SMTP_ENV.password} or the Keychain). See the README "SMTP transport" section.`
11909
+ `SMTP transport is not configured. Set ${missing.join(" and ")} (plus a password via ${SMTP_ENV.password} or the Keychain). ` + SETUP_HINT
11904
11910
  );
11905
11911
  }
11906
11912
  const secure = /^(1|true|yes)$/i.test(env[SMTP_ENV.secure]?.trim() ?? "");
@@ -11917,7 +11923,7 @@ function resolveSmtpConfig(env = process.env) {
11917
11923
  }
11918
11924
  if (!pass) {
11919
11925
  throw new Error(
11920
- `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}".`
11926
+ `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}". ` + SETUP_HINT
11921
11927
  );
11922
11928
  }
11923
11929
  return { host, port, secure, user, pass, from };
@@ -12003,7 +12009,8 @@ Optional:
12003
12009
  --help Show this help
12004
12010
 
12005
12011
  SMTP connection comes from ${SMTP_ENV.host} / ${SMTP_ENV.user} (+ password via
12006
- ${SMTP_ENV.password} or the macOS Keychain). See the README "SMTP transport".`;
12012
+ ${SMTP_ENV.password} or the macOS Keychain). Setup guide:
12013
+ ${SETUP_GUIDE_URL}`;
12007
12014
  async function runCli(argv, deps = {}) {
12008
12015
  const send = deps.send ?? sendViaSmtp;
12009
12016
  const resolveConfig = deps.resolveConfig ?? resolveSmtpConfig;
@@ -12052,7 +12059,7 @@ async function runCli(argv, deps = {}) {
12052
12059
  config = resolveConfig(env);
12053
12060
  } catch (e) {
12054
12061
  err(e instanceof Error ? e.message : String(e));
12055
- err(`See the README "SMTP transport" section.`);
12062
+ err(`Setup guide: ${SETUP_GUIDE_URL}`);
12056
12063
  return EX_CONFIG;
12057
12064
  }
12058
12065
  let body;
package/build/index.js CHANGED
@@ -76219,7 +76219,7 @@ var ERROR_MAPPINGS = [
76219
76219
  // Permission errors
76220
76220
  {
76221
76221
  pattern: /not authorized|not permitted|access.*denied/i,
76222
- message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation."
76222
+ message: "Permission denied. Grant automation access in System Settings > Privacy & Security > Automation."
76223
76223
  },
76224
76224
  // Application not running
76225
76225
  {
@@ -79153,7 +79153,7 @@ ${actionStmts.join("\n")}
79153
79153
  message: "Mail.app is accessible"
79154
79154
  });
79155
79155
  } else {
79156
- const errorHint = mailCheck.error?.includes("not authorized") ? " (check Automation permissions in System Preferences)" : "";
79156
+ const errorHint = mailCheck.error?.includes("not authorized") ? " (check System Settings > Privacy & Security > Automation)" : "";
79157
79157
  checks.push({
79158
79158
  name: "mail_app",
79159
79159
  passed: false,
@@ -79173,7 +79173,7 @@ ${actionStmts.join("\n")}
79173
79173
  checks.push({
79174
79174
  name: "permissions",
79175
79175
  passed: !isPermError,
79176
- message: isPermError ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation" : `Permission check returned: ${permCheck.error}`
79176
+ message: isPermError ? "AppleScript permissions denied. Grant access in System Settings > Privacy & Security > Automation" : `Permission check returned: ${permCheck.error}`
79177
79177
  });
79178
79178
  if (isPermError) {
79179
79179
  return { healthy: false, checks };
@@ -79409,6 +79409,12 @@ var import_nodemailer = __toESM(require_nodemailer(), 1);
79409
79409
  import { execFileSync } from "child_process";
79410
79410
  import { isAbsolute as isAbsolute2 } from "path";
79411
79411
  import { existsSync as existsSync3 } from "fs";
79412
+
79413
+ // src/utils/docsUrls.ts
79414
+ var SETUP_GUIDE_URL = "https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md";
79415
+ var SETUP_HINT = `Setup guide: ${SETUP_GUIDE_URL} \u2014 run the "doctor" tool to check your setup.`;
79416
+
79417
+ // src/services/smtpMailer.ts
79412
79418
  var SMTP_ENV = {
79413
79419
  host: "APPLE_MAIL_MCP_SMTP_HOST",
79414
79420
  port: "APPLE_MAIL_MCP_SMTP_PORT",
@@ -79451,7 +79457,7 @@ function resolveSmtpConfig(env = process.env) {
79451
79457
  if (!user) missing.push(SMTP_ENV.user);
79452
79458
  if (missing.length > 0) {
79453
79459
  throw new Error(
79454
- `SMTP transport is not configured. Set ${missing.join(" and ")} (plus a password via ${SMTP_ENV.password} or the Keychain). See the README "SMTP transport" section.`
79460
+ `SMTP transport is not configured. Set ${missing.join(" and ")} (plus a password via ${SMTP_ENV.password} or the Keychain). ` + SETUP_HINT
79455
79461
  );
79456
79462
  }
79457
79463
  const secure = /^(1|true|yes)$/i.test(env[SMTP_ENV.secure]?.trim() ?? "");
@@ -79468,7 +79474,7 @@ function resolveSmtpConfig(env = process.env) {
79468
79474
  }
79469
79475
  if (!pass) {
79470
79476
  throw new Error(
79471
- `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}".`
79477
+ `No SMTP password found. Set ${SMTP_ENV.password}, or store an internet password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}". ` + SETUP_HINT
79472
79478
  );
79473
79479
  }
79474
79480
  return { host, port, secure, user, pass, from };
@@ -79811,7 +79817,9 @@ function resolveImapConfigs(env = process.env) {
79811
79817
  function resolveImapConfig(env = process.env, account) {
79812
79818
  const specs = listImapAccountSpecs(env);
79813
79819
  if (specs.length === 0) {
79814
- throw new Error(`IMAP not configured. Set ${IMAP_ENV.user} (login address) to enable it.`);
79820
+ throw new Error(
79821
+ `IMAP not configured. Set ${IMAP_ENV.user} (login address) to enable it. ${SETUP_HINT}`
79822
+ );
79815
79823
  }
79816
79824
  let spec;
79817
79825
  if (account) {
@@ -80308,6 +80316,28 @@ async function imapMoveMessageById(id, destMailbox, deps = {}) {
80308
80316
  }
80309
80317
  });
80310
80318
  }
80319
+ async function resolveTrashPath(client) {
80320
+ try {
80321
+ const boxes = await client.list();
80322
+ const special = boxes.find((b) => b.specialUse === "\\Trash");
80323
+ if (special) return special.path;
80324
+ const named = boxes.find(
80325
+ (b) => /^(trash|deleted messages|deleted items|bin)$/i.test(b.name) || /(^|\/)trash$/i.test(b.path)
80326
+ );
80327
+ if (named) return named.path;
80328
+ } catch {
80329
+ }
80330
+ return resolveMailboxPath("trash", "list");
80331
+ }
80332
+ async function trashUids(client, uids, srcPath) {
80333
+ const dest = await resolveTrashPath(client);
80334
+ if (srcPath.trim().toLowerCase() === dest.trim().toLowerCase()) {
80335
+ await client.messageDelete(uids, { uid: true });
80336
+ return { dest, expunged: true };
80337
+ }
80338
+ await client.messageMove(uids, dest, { uid: true });
80339
+ return { dest, expunged: false };
80340
+ }
80311
80341
  async function imapDeleteMessageById(id, deps = {}) {
80312
80342
  const ref = decodeImapId(id);
80313
80343
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
@@ -80316,9 +80346,11 @@ async function imapDeleteMessageById(id, deps = {}) {
80316
80346
  { ...deps, account: deps.account ?? ref.account },
80317
80347
  async (client) => {
80318
80348
  try {
80319
- const ok = await client.messageDelete([ref.uid], { uid: true });
80320
- if (!ok) return { success: false, error: `IMAP delete returned false for UID ${ref.uid}.` };
80321
- return { success: true, info: `Deleted UID ${ref.uid} from "${ref.path}" via IMAP.` };
80349
+ const { dest, expunged } = await trashUids(client, [ref.uid], ref.path);
80350
+ return {
80351
+ success: true,
80352
+ info: expunged ? `Permanently deleted UID ${ref.uid} from Trash ("${ref.path}") via IMAP.` : `Moved UID ${ref.uid} to Trash ("${dest}") via IMAP.`
80353
+ };
80322
80354
  } catch (e) {
80323
80355
  return { success: false, error: `IMAP delete failed for UID ${ref.uid}: ${errText(e)}` };
80324
80356
  }
@@ -80445,8 +80477,8 @@ var imapBatchFlag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) =>
80445
80477
  var imapBatchUnflag = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
80446
80478
  await c.messageFlagsRemove(uids, ["\\Flagged"], { uid: true });
80447
80479
  });
80448
- var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids) => {
80449
- await c.messageDelete(uids, { uid: true });
80480
+ var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids, path) => {
80481
+ await trashUids(c, uids, path);
80450
80482
  });
80451
80483
  function imapBatchMove(ids, destMailbox, deps = {}) {
80452
80484
  return imapBatch(ids, deps, async (c, uids) => {
@@ -80733,7 +80765,6 @@ async function routeMessage(id, opts) {
80733
80765
  }
80734
80766
 
80735
80767
  // src/tools/doctor.ts
80736
- var SETUP_GUIDE = "https://github.com/sweetrb/apple-mail-mcp/blob/main/docs/IMAP-SETUP.md";
80737
80768
  var CONFIG_FILE_HINT = "If your MCP host ignores the server 'env' block (e.g. Claude Desktop), put these in ~/Library/Application Support/apple-mail-mcp/config.json instead";
80738
80769
  async function runDoctor(mailManager2) {
80739
80770
  const checks = [];
@@ -80766,7 +80797,7 @@ async function runDoctor(mailManager2) {
80766
80797
  checks.push({
80767
80798
  name: "IMAP backend",
80768
80799
  status: "warn",
80769
- detail: `not configured \u2014 AppleScript is used for all accounts. Set ${IMAP_ENV.user} (+ Keychain/password), or ${IMAP_ENV.accounts} for multiple accounts, to enable server-side search and server-mailbox ops. ${CONFIG_FILE_HINT}. Setup guide: ${SETUP_GUIDE}`
80800
+ detail: `not configured \u2014 AppleScript is used for all accounts. Set ${IMAP_ENV.user} (+ Keychain/password), or ${IMAP_ENV.accounts} for multiple accounts, to enable server-side search and server-mailbox ops. ${CONFIG_FILE_HINT}. Setup guide: ${SETUP_GUIDE_URL}`
80770
80801
  });
80771
80802
  } else {
80772
80803
  for (const label of imapAccounts) {
@@ -80782,7 +80813,7 @@ async function runDoctor(mailManager2) {
80782
80813
  checks.push({
80783
80814
  name: "SMTP transport",
80784
80815
  status: isSmtpConfigured() ? "ok" : "warn",
80785
- detail: isSmtpConfigured() ? `configured (${smtpHost}); send-email auto-prefers clean SMTP (no Mail.app Sent-folder copy; a non-email "account" label still routes to AppleScript). Pass transport:"applescript" to force Mail.app. The apple-mail-send CLI is also available.` : `not configured \u2014 send-email uses AppleScript (subject to macOS 15+ blockquote wrapping). Set ${SMTP_ENV.host} and ${SMTP_ENV.user} (+ password via Keychain) to enable. ${CONFIG_FILE_HINT}. Setup guide: ${SETUP_GUIDE}`
80816
+ detail: isSmtpConfigured() ? `configured (${smtpHost}); send-email auto-prefers clean SMTP (no Mail.app Sent-folder copy; a non-email "account" label still routes to AppleScript). Pass transport:"applescript" to force Mail.app. The apple-mail-send CLI is also available.` : `not configured \u2014 send-email uses AppleScript (subject to macOS 15+ blockquote wrapping). Set ${SMTP_ENV.host} and ${SMTP_ENV.user} (+ password via Keychain) to enable. ${CONFIG_FILE_HINT}. Setup guide: ${SETUP_GUIDE_URL}`
80786
80817
  });
80787
80818
  const healthy = !checks.some((c) => c.status === "fail");
80788
80819
  return { healthy, checks };
@@ -0,0 +1,333 @@
1
+ # IMAP / SMTP Setup Guide
2
+
3
+ `apple-mail-mcp` works out of the box over AppleScript with no configuration. The
4
+ **IMAP backend is opt-in** and makes the server talk to your mail provider
5
+ directly for the operations where that's faster and more reliable than driving
6
+ Mail.app — most importantly **search, counts, and large mailboxes**, where
7
+ AppleScript times out. Configuring **SMTP** additionally lets the server send
8
+ clean MIME (avoiding the macOS 15+ Mail.app `<blockquote>` wrapping).
9
+
10
+ This is **additive and per-account**: any account you don't configure for IMAP
11
+ keeps using AppleScript exactly as before. You can mix — e.g. Gmail over IMAP,
12
+ everything else over AppleScript.
13
+
14
+ > **TL;DR**
15
+ > 1. Generate an **app-specific password** at your mail provider.
16
+ > 2. Store it in the **macOS Keychain** (the server reads it from there — it
17
+ > never goes in any config file).
18
+ > 3. Tell the server which account(s) to use, via an **`env` block** or a
19
+ > **`config.json` file** (see Step 3 below).
20
+ > 4. **Restart** your MCP client and run the **`doctor`** tool to verify.
21
+
22
+ ---
23
+
24
+ ## What IMAP accelerates
25
+
26
+ When an account is IMAP-configured, these route to IMAP (otherwise AppleScript):
27
+
28
+ | Capability | Tools |
29
+ |------------|-------|
30
+ | Server-side search / list | `search-messages`, `list-messages` |
31
+ | Read a message | `get-message` |
32
+ | Message mutations | `mark-as-read`/`unread`, `flag`/`unflag-message`, `move-message`, `delete-message` |
33
+ | Batch mutations | `batch-mark-as-read`/`unread`, `batch-flag`/`unflag-messages`, `batch-move-messages`, `batch-delete-messages` |
34
+ | Folder ops | `create-mailbox`, `rename-mailbox`, `delete-mailbox` |
35
+ | Counts & stats | `get-unread-count`, `list-mailboxes`, `get-mail-stats` |
36
+ | Attachments | `list-attachments`, `save-attachment`, `fetch-attachment` |
37
+ | Threading | `get-thread` |
38
+ | New-mail push | IMAP IDLE notifications (opt-in) |
39
+
40
+ ---
41
+
42
+ ## Prerequisites
43
+
44
+ - macOS with Mail.app configured (the AppleScript backend still handles anything
45
+ not IMAP-configured).
46
+ - **`apple-mail-mcp` v2.1.0+** for the IMAP acceleration features, and **v2.1.1+**
47
+ if you need the `config.json` method (see Step 3, Method B).
48
+ - For each account: the ability to create an **app-specific password** (requires
49
+ 2-factor auth enabled on that account).
50
+
51
+ Check your version with the `doctor` tool, or `npm view apple-mail-mcp version`.
52
+
53
+ ---
54
+
55
+ ## Step 1 — Generate an app-specific password
56
+
57
+ Use an **app-specific password**, never your real account password. Where to get one:
58
+
59
+ - **Gmail (personal):** [myaccount.google.com](https://myaccount.google.com) →
60
+ **Security** → (2-Step Verification must be ON) → **App passwords** → create one,
61
+ name it `apple-mail-mcp`. You get a 16-character password shown **once**.
62
+ - **Google Workspace** (e.g. a company `you@yourdomain.com` on Google): same flow,
63
+ signed in as that account. ⚠️ Your Workspace **admin must allow app passwords**;
64
+ if the option is missing, app-password IMAP isn't available for that account.
65
+ - **iCloud:** [account.apple.com](https://account.apple.com) → **Sign-In and
66
+ Security** → **App-Specific Passwords**. Note the two different identities below.
67
+ - **Other IMAP providers (Fastmail, etc.):** create an app password in the
68
+ provider's security settings.
69
+
70
+ > **iCloud gotcha — Apple ID vs IMAP login.** Your **Apple ID** (what you sign in
71
+ > to account.apple.com with, possibly a Gmail address) is *not* your IMAP login.
72
+ > The IMAP **username** is your iCloud mailbox address, e.g. `you@icloud.com`
73
+ > (or a `@me.com` alias). The app password is generated under the Apple ID but
74
+ > authenticates the iCloud mailbox login. Also note an iCloud app password only
75
+ > works for iCloud — it can't log into Gmail.
76
+
77
+ ---
78
+
79
+ ## Step 2 — Store the password in the Keychain
80
+
81
+ The server reads the password from the macOS login Keychain via `security
82
+ find-internet-password -s <service> -a <account>`. Add an entry to match. Run
83
+ this in **Terminal** (the `-w` flag prompts for the password silently, so it's
84
+ never in your shell history):
85
+
86
+ ```bash
87
+ # Gmail / Google Workspace (service is always imap.gmail.com)
88
+ security add-internet-password -U -s imap.gmail.com -a you@gmail.com -r imap -w
89
+
90
+ # iCloud (service imap.mail.me.com; account is the iCloud MAILBOX address)
91
+ security add-internet-password -U -s imap.mail.me.com -a you@icloud.com -r imap -w
92
+ ```
93
+
94
+ Paste the app-specific password at the prompt and press Return. The `-s` (service)
95
+ and `-a` (account) values must match what you put in the config in Step 3
96
+ (`KEYCHAIN_SERVICE` / `KEYCHAIN_ACCOUNT`).
97
+
98
+ If you also want **SMTP** sending and your provider uses the same app password for
99
+ SMTP (Gmail does), you don't need a second Keychain entry — just point the SMTP
100
+ `KEYCHAIN_SERVICE`/`KEYCHAIN_ACCOUNT` at the same item.
101
+
102
+ ---
103
+
104
+ ## Step 3 — Configure the server
105
+
106
+ The server reads `APPLE_MAIL_MCP_*` settings. There are two ways to supply them;
107
+ **use whichever your MCP client supports.**
108
+
109
+ ### Method A — `env` block in your MCP client config
110
+
111
+ Works with clients that pass an `env` block through to the server (e.g. **Claude
112
+ Code** via `~/.claude.json`, and most standard `mcpServers` configs).
113
+
114
+ ```jsonc
115
+ {
116
+ "mcpServers": {
117
+ "apple-mail": {
118
+ "command": "node",
119
+ "args": ["/path/to/apple-mail-mcp/build/index.js"],
120
+ "env": {
121
+ "APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
122
+ "APPLE_MAIL_MCP_IMAP_ACCOUNT": "you@gmail.com",
123
+ "APPLE_MAIL_MCP_IMAP_HOST": "imap.gmail.com",
124
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
125
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT": "you@gmail.com"
126
+ }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ ### Method B — `config.json` file (for hosts that strip `env`) *(v2.1.1+)*
133
+
134
+ Some host apps (notably **Claude Desktop**) launch MCP servers with a **scrubbed
135
+ environment and ignore the `env` block**, so Method A silently does nothing
136
+ there. For those, put the same settings in a JSON file the host doesn't manage:
137
+
138
+ - Default path: `~/Library/Application Support/apple-mail-mcp/config.json`
139
+ - Or set `APPLE_MAIL_MCP_CONFIG_FILE` to a path of your choice.
140
+
141
+ ```json
142
+ {
143
+ "APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
144
+ "APPLE_MAIL_MCP_IMAP_ACCOUNT": "you@gmail.com",
145
+ "APPLE_MAIL_MCP_IMAP_HOST": "imap.gmail.com",
146
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
147
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT": "you@gmail.com"
148
+ }
149
+ ```
150
+
151
+ At startup the server merges these into its environment **without overriding**
152
+ anything already set, so Method A still wins if both are present. **Only
153
+ non-secret config goes here — passwords stay in the Keychain.**
154
+
155
+ > Not sure which method? Configure with Method A; if `doctor` still says "not
156
+ > configured" after a restart, your host strips `env` — switch to Method B.
157
+ > Plugin-marketplace installs (Claude Code `/plugin install apple-mail`) have no
158
+ > editable `env` block at all, so they always use Method B.
159
+
160
+ ### `APPLE_MAIL_MCP_IMAP_ACCOUNT` and routing
161
+
162
+ `APPLE_MAIL_MCP_IMAP_ACCOUNT` should equal the **Mail.app account name** (what
163
+ `list-accounts` shows). A tool call routes to IMAP only when its `account`
164
+ argument matches a configured account's name or login — so this value is how the
165
+ server knows "calls for this account go to IMAP."
166
+
167
+ ### Multiple accounts
168
+
169
+ Configure additional accounts with `APPLE_MAIL_MCP_IMAP_ACCOUNTS`, a JSON **array
170
+ of objects, passed as a single string value** (the whole array is one env-var /
171
+ config-file string, as in the example below). The legacy single-account
172
+ vars above define the first/default account; the array adds the rest:
173
+
174
+ ```json
175
+ {
176
+ "APPLE_MAIL_MCP_IMAP_USER": "you@gmail.com",
177
+ "APPLE_MAIL_MCP_IMAP_ACCOUNT": "you@gmail.com",
178
+ "APPLE_MAIL_MCP_IMAP_HOST": "imap.gmail.com",
179
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE": "imap.gmail.com",
180
+ "APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT": "you@gmail.com",
181
+ "APPLE_MAIL_MCP_IMAP_ACCOUNTS": "[{\"account\":\"Work\",\"user\":\"you@company.com\",\"host\":\"imap.gmail.com\",\"keychainService\":\"imap.gmail.com\",\"keychainAccount\":\"you@company.com\"},{\"account\":\"iCloud\",\"user\":\"you@icloud.com\",\"host\":\"imap.mail.me.com\",\"keychainService\":\"imap.mail.me.com\",\"keychainAccount\":\"you@icloud.com\"}]"
182
+ }
183
+ ```
184
+
185
+ Each array entry accepts: `account`, `user`, `host`, `port`, `password`
186
+ (discouraged — prefer Keychain), `keychainService`, `keychainAccount`. Each
187
+ account keeps its own pooled IMAP connection.
188
+
189
+ ---
190
+
191
+ ## Step 4 (optional) — SMTP sending
192
+
193
+ Lets `send-email` submit clean MIME directly (use `transport: "smtp"`), avoiding
194
+ the macOS 15+ blockquote wrapping. SMTP is single-account (the default sender):
195
+
196
+ ```json
197
+ {
198
+ "APPLE_MAIL_MCP_SMTP_HOST": "smtp.gmail.com",
199
+ "APPLE_MAIL_MCP_SMTP_PORT": "587",
200
+ "APPLE_MAIL_MCP_SMTP_USER": "you@gmail.com",
201
+ "APPLE_MAIL_MCP_SMTP_FROM": "you@gmail.com",
202
+ "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE": "imap.gmail.com",
203
+ "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT": "you@gmail.com"
204
+ }
205
+ ```
206
+
207
+ (Reusing the IMAP Keychain item is fine when the provider accepts the same app
208
+ password for SMTP, as Gmail does.)
209
+
210
+ ---
211
+
212
+ ## Step 5 (optional) — New-mail push (IMAP IDLE)
213
+
214
+ Add `"APPLE_MAIL_MCP_IMAP_IDLE": "1"` to get new-mail notifications for every
215
+ configured IMAP account. See the README's "Push notifications (IMAP IDLE)"
216
+ section for details. Tune the pooled-connection idle timeout with
217
+ `APPLE_MAIL_MCP_IMAP_IDLE_MS` (default `30000`; `0` = never close).
218
+
219
+ ---
220
+
221
+ ## Step 6 — Restart and verify
222
+
223
+ 1. **Restart your MCP client** so the server picks up the new config (env and
224
+ config-file changes are read at startup).
225
+ 2. Run the **`doctor`** tool. Each configured account should report:
226
+ `IMAP: <account> → connected to <host>`, and SMTP `configured` if you set it up.
227
+
228
+ If an account shows `connection failed`, see Troubleshooting.
229
+
230
+ ---
231
+
232
+ ## Upgrading from an earlier version
233
+
234
+ - **From a version with no IMAP backend (before 1.7.0):** IMAP is purely
235
+ additive — nothing you had changes. Just follow Steps 1–6 for the accounts you
236
+ want to accelerate.
237
+ - **From 2.0.0 / 2.1.0:** the optimizations (attachments, batch, counts/stats,
238
+ threading) light up automatically for any account already IMAP-configured — no
239
+ new setup needed. If you weren't using IMAP yet, follow Steps 1–6.
240
+ - **If your host app strips `env`** (e.g. Claude Desktop): upgrade to **2.1.1+**
241
+ and move your settings into `config.json` (Method B). This is the most common
242
+ reason an upgrade "doesn't seem to use IMAP."
243
+ - **Already had iCloud working, now adding Gmail:** remember each provider needs
244
+ its **own** app password — an iCloud password can't authenticate Gmail.
245
+
246
+ ---
247
+
248
+ ## Troubleshooting
249
+
250
+ **`doctor` says "IMAP backend: not configured" after a restart.**
251
+ The server isn't seeing your settings. Most often the host stripped the `env`
252
+ block — switch to the `config.json` method (Method B) and confirm the file is at
253
+ `~/Library/Application Support/apple-mail-mcp/config.json` (or wherever
254
+ `APPLE_MAIL_MCP_CONFIG_FILE` points). Verify it parses: `python3 -m json.tool < <path>`.
255
+
256
+ **`doctor` says "connection failed" for an account.**
257
+ - The Keychain item is missing or under a different service/account. Check:
258
+ `security find-internet-password -s imap.gmail.com -a you@gmail.com` (should
259
+ return without "could not be found"). It must match your `KEYCHAIN_SERVICE` /
260
+ `KEYCHAIN_ACCOUNT`.
261
+ - You used your real password instead of an **app-specific** password.
262
+ - **iCloud:** you used your Apple ID as the login instead of the iCloud mailbox
263
+ address; try `you@icloud.com`, the `@me.com` alias, or the bare local part.
264
+ - **Workspace:** your admin disabled app passwords (or requires OAuth) — IMAP via
265
+ app password isn't available for that account.
266
+
267
+ **Storing the Keychain password over SSH / headless fails with `User interaction is not allowed`.**
268
+ `security add-internet-password` — and the server *reading* the password back —
269
+ need the login Keychain **unlocked**, which normally only happens in a **GUI login
270
+ session**. From a plain `ssh` session the Keychain is locked, so the write fails
271
+ with `SecKeychainAddInternetPassword: User interaction is not allowed` (exit 36),
272
+ and a server started there can't read passwords either. Options:
273
+
274
+ - **Run it in a GUI session** — a Terminal on the Mac itself, or via Screen Sharing.
275
+ - **Unlock over SSH with a tty:**
276
+ `ssh -t you@host 'security unlock-keychain && security add-internet-password -U -r imap -s imap.gmail.com -a you@gmail.com -w'`
277
+ — the `-t` lets `unlock-keychain` prompt for your macOS **login** password, after
278
+ which the add (and later server reads) succeed. A metadata check —
279
+ `security find-internet-password -s … -a …` with no `-w` — does work over plain
280
+ ssh, so you can confirm an item *exists* even when you can't unlock it.
281
+ - **Truly headless** (CI, or a server with no GUI login and no unlockable Keychain):
282
+ skip the Keychain — set `APPLE_MAIL_MCP_SMTP_PASSWORD` / `APPLE_MAIL_MCP_IMAP_PASSWORD`
283
+ (or a per-account `"password"` inside `APPLE_MAIL_MCP_IMAP_ACCOUNTS`) directly, and
284
+ restrict the config file's permissions to the service account.
285
+
286
+ **Calls aren't routing to IMAP even though `doctor` shows connected.**
287
+ The tool's `account` argument must match the configured account's name/login.
288
+ Set `APPLE_MAIL_MCP_IMAP_ACCOUNT` to the exact Mail.app account name from
289
+ `list-accounts`, and pass that same `account` to tools.
290
+
291
+ **A disabled Mail account.** IMAP connects to the server directly, so it works
292
+ even if the account is disabled in Mail.app — useful for reaching an account the
293
+ GUI is ignoring.
294
+
295
+ ---
296
+
297
+ ## Security notes
298
+
299
+ - **Passwords live only in the macOS Keychain.** No config file or env value
300
+ here holds a secret — only account names, hosts, and Keychain *references*.
301
+ (You *can* put a `password` directly in config, but don't.)
302
+ - Use **app-specific passwords** so you can revoke access per-integration without
303
+ changing your real password.
304
+ - The `config.json` and env values are non-sensitive (addresses/hosts), but
305
+ there's no reason to share them either.
306
+
307
+ ---
308
+
309
+ ## Full environment variable reference
310
+
311
+ | Variable | Purpose |
312
+ |----------|---------|
313
+ | `APPLE_MAIL_MCP_DEFAULT_ACCOUNT` | Account used when a tool omits `account` (name or email). |
314
+ | `APPLE_MAIL_MCP_IMAP_USER` | Primary IMAP login; setting it enables IMAP. |
315
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | Mail.app account name to match for routing (default = USER). |
316
+ | `APPLE_MAIL_MCP_IMAP_HOST` | IMAP host (default `imap.gmail.com`). |
317
+ | `APPLE_MAIL_MCP_IMAP_PORT` | IMAP port (default `993`, implicit TLS). |
318
+ | `APPLE_MAIL_MCP_IMAP_PASSWORD` | Password (discouraged; prefer Keychain). |
319
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | Keychain item service/server name. |
320
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | Keychain item account (default = USER). |
321
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | JSON array of additional accounts (multi-account). |
322
+ | `APPLE_MAIL_MCP_IMAP_IDLE` | `1` to enable IMAP IDLE new-mail push. |
323
+ | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | Pooled-connection idle timeout in ms (default `30000`; `0` = never close). |
324
+ | `APPLE_MAIL_MCP_SMTP_HOST` | SMTP host; setting it enables `transport:"smtp"`. |
325
+ | `APPLE_MAIL_MCP_SMTP_PORT` | SMTP port (`465` if secure, else `587`). |
326
+ | `APPLE_MAIL_MCP_SMTP_SECURE` | `true` for implicit TLS (465); else STARTTLS. |
327
+ | `APPLE_MAIL_MCP_SMTP_USER` / `_FROM` | SMTP login / From address. |
328
+ | `APPLE_MAIL_MCP_SMTP_PASSWORD` | Password (discouraged; prefer Keychain). |
329
+ | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` / `_KEYCHAIN_ACCOUNT` | SMTP Keychain reference. |
330
+ | `APPLE_MAIL_MCP_CONFIG_FILE` | Path to the config JSON (default app-support dir). |
331
+ | `APPLE_MAIL_MCP_TEMPLATES_FILE` | Email-templates store path (default `~/Library/Application Support/apple-mail-mcp/templates.json`). |
332
+ | `APPLE_MAIL_MCP_MAX_BUFFER` | Max AppleScript (`osascript`) output buffer in bytes (default 64 MiB). |
333
+ | `APPLE_MAIL_MAX_SEARCH_MAILBOX` | Per-mailbox message-count guard for unscoped AppleScript search (default `5000`; `0` disables). Note: no `_MCP` in the name. |
@@ -0,0 +1,109 @@
1
+ # Node runtime & TCC permission stability
2
+
3
+ macOS gates this MCP server's access to your data behind **TCC** permissions —
4
+ **Full Disk Access** (to read app data such as Mail, Notes, or Photos) and
5
+ **Automation / Apple Events** (to drive an app like Mail.app or Notes.app via
6
+ AppleScript). See this repo's Full Disk Access / Automation notes for *which*
7
+ operations need which permission.
8
+
9
+ This page is about a **separate, recurring annoyance**: being asked to approve
10
+ those permissions **over and over**, often right after a routine `brew upgrade`.
11
+
12
+ ## Symptom
13
+
14
+ - You granted Full Disk Access (and/or Automation) to "node", but days later
15
+ macOS prompts again — `"node" wants access to ...` or `"node" wants to control
16
+ "Mail"`.
17
+ - System Settings → Privacy & Security → Full Disk Access shows **several
18
+ identical "node" rows**, usually only one enabled.
19
+ - It tends to happen immediately after you update Node.
20
+
21
+ ## Cause
22
+
23
+ TCC binds a permission grant to the **code identity of the binary that performs
24
+ the access** — here, the `node` executable that launches the MCP server. For a
25
+ binary that is only **ad-hoc signed** (no Developer ID / Team ID), TCC keys the
26
+ grant to the binary's **cdhash**, a hash of its contents.
27
+
28
+ Homebrew's `node` formula is ad-hoc signed:
29
+
30
+ ```bash
31
+ $ codesign -dvvv "$(which node)" 2>&1 | grep -E 'Signature|TeamIdentifier'
32
+ Signature=adhoc
33
+ TeamIdentifier=not set
34
+ ```
35
+
36
+ Every Node update **replaces the binary**, which **changes the cdhash**, so TCC
37
+ no longer recognizes it as the thing you approved — and re-prompts. The extra
38
+ "node" rows are stale cdhashes from previous versions.
39
+
40
+ By contrast, properly signed apps (Chrome, Slack, …) keep their permissions
41
+ across auto-updates because TCC matches them on a stable **Designated
42
+ Requirement** derived from their Developer ID, not on the cdhash.
43
+
44
+ ## Fix: run the MCP under the official, Developer-ID-signed Node
45
+
46
+ Node binaries distributed from **nodejs.org** are signed with a real Developer
47
+ ID (`Node.js Foundation`, Team `HX7739G8FX`), notarized, and self-contained.
48
+ Pointing the MCP server at one gives TCC a **stable** identity to match, so a
49
+ permission you grant **persists across future Node updates**. It also decouples
50
+ the MCP runtime from your Homebrew/dev Node, which can keep updating freely.
51
+
52
+ ### Steps (Apple Silicon shown; use `darwin-x64` on Intel)
53
+
54
+ 1. Install a current LTS to a stable path (kept off `PATH` so it won't shadow
55
+ your dev Node):
56
+
57
+ ```bash
58
+ VER=v24.17.0 ARCH=darwin-arm64
59
+ mkdir -p ~/mcp-runtime && cd ~/mcp-runtime
60
+ curl -O https://nodejs.org/dist/$VER/node-$VER-$ARCH.tar.gz
61
+ curl -O https://nodejs.org/dist/$VER/SHASUMS256.txt
62
+ grep " node-$VER-$ARCH.tar.gz$" SHASUMS256.txt | shasum -a 256 -c - # must print OK
63
+ tar -xzf node-$VER-$ARCH.tar.gz
64
+ ln -sfn node-$VER-$ARCH node-current
65
+ ```
66
+
67
+ 2. Confirm it's Developer-ID signed:
68
+
69
+ ```bash
70
+ codesign -dvvv ~/mcp-runtime/node-current/bin/node 2>&1 | grep -E 'Authority=Developer ID|TeamIdentifier'
71
+ # Authority=Developer ID Application: Node.js Foundation (HX7739G8FX)
72
+ # TeamIdentifier=HX7739G8FX
73
+ ```
74
+
75
+ 3. Point this MCP server's launcher at it. For Claude Desktop, edit
76
+ `~/Library/Application Support/Claude/claude_desktop_config.json` and set this
77
+ server's `command` to the absolute path:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "apple-mail": {
83
+ "command": "/Users/<you>/mcp-runtime/node-current/bin/node",
84
+ "args": ["/path/to/apple-mail-mcp/build/index.js"]
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Servers launched via `npx` that don't need Full Disk Access can stay on
91
+ Homebrew Node.
92
+
93
+ 4. **Restart your MCP client** so the server relaunches under the new Node.
94
+
95
+ 5. **Grant the permissions once** to the new binary:
96
+ - *Full Disk Access*: System Settings → Privacy & Security → Full Disk Access
97
+ → **+** → ⌘⇧G → paste `~/mcp-runtime/node-current/bin/node`.
98
+ - *Automation*: the first time the server drives an app you'll get a one-time
99
+ `"node" wants to control "<App>"` prompt — click **Allow**.
100
+
101
+ Both grants are keyed to the official Node's stable signature, so you should
102
+ not be asked again — including after future Node updates. You can delete the
103
+ stale "node" rows from the Full Disk Access list.
104
+
105
+ ### Updating the dedicated Node later
106
+
107
+ Drop a newer official LTS tarball into `~/mcp-runtime/`, repoint the
108
+ `node-current` symlink, and restart your client. The signing identity is
109
+ unchanged, so existing grants carry over — no re-approval.
@@ -0,0 +1,131 @@
1
+ # apple-mail-mcp — Stability & Performance Audit
2
+
3
+ **Date:** 2026-06-17 · **Version audited:** 1.6.1 (`main` @ `4b5610d`)
4
+ **Scope:** full codebase — `appleMailManager.ts` (2652 LOC), `index.ts`, `utils/{applescript,serialize,mimeParse}.ts`, CI/publish workflows.
5
+
6
+ This audit looks only at robustness and speed; feature gaps and style are out of scope. Findings are ranked by **impact × likelihood**, with effort estimates so they can be scheduled. Line numbers are against `main` @ `4b5610d`.
7
+
8
+ > **Resolution status (all items addressed 2026-06-17).** Every finding below has shipped or been explicitly deferred:
9
+ >
10
+ > | # | Finding | Status | Released |
11
+ > |---|---------|--------|----------|
12
+ > | 1 | execSync maxBuffer | Fixed ([#27](https://github.com/sweetrb/apple-mail-mcp/issues/27)) | 1.6.2 |
13
+ > | 2 | stats locale + swallow | Fixed ([#28](https://github.com/sweetrb/apple-mail-mcp/issues/28)) | 1.6.3 |
14
+ > | 3 | list-messages / by-id swallow | Fixed ([#29](https://github.com/sweetrb/apple-mail-mcp/issues/29)) | 1.6.4 |
15
+ > | 4 | `\|\|\|` delimiter collisions | Fixed ([#30](https://github.com/sweetrb/apple-mail-mcp/issues/30)) | 1.6.5 |
16
+ > | 5 | batch fan-out | Fixed ([#31](https://github.com/sweetrb/apple-mail-mcp/issues/31)) | 1.6.6 |
17
+ > | 6+7 | get-message source scan / preferHtml | Fixed ([#32](https://github.com/sweetrb/apple-mail-mcp/issues/32)) | 1.6.7 |
18
+ > | 9 | rename-mailbox partial-move | Fixed ([#33](https://github.com/sweetrb/apple-mail-mcp/issues/33)) | 1.6.8 |
19
+ > | 10 | escape control chars | Fixed (audit hardening) | 1.6.9 |
20
+ > | 8 | aggregate stat timeouts | Fixed (audit hardening) | 1.6.9 |
21
+ > | 12 | saveAttachment prefix bypass | Fixed (audit hardening) | 1.6.9 |
22
+ > | 13 | searchContacts per-person try | Fixed (audit hardening) | 1.6.9 |
23
+ > | 14 | useTemplate empty override | Fixed (audit hardening) | 1.6.9 |
24
+ > | 11 | per-message bulk reads | **Deferred** — the #24 count-guard now bounds scans to small mailboxes, so the refactor's regression risk on the hottest paths outweighs the gain | — |
25
+ > | 15 | CI can't run integration suite | Addressed — `npm run test:all` documented as a pre-release gate in CONTRIBUTING; the release-publish race was also fixed with a `concurrency` guard on `publish.yml` | — |
26
+ >
27
+ > Known limitation surfaced during validation: Mail.app **cannot delete iCloud mailboxes via AppleScript** ("AppleEvent handler failed"), so `delete-mailbox` / `rename-mailbox` cannot complete on iCloud-account mailboxes. This is a platform constraint, not a code defect; the rename fix ensures it fails *safely* (source preserved).
28
+
29
+ ## Summary
30
+
31
+ The architecture is sound and several hard problems are already solved well (serial gate for Mail's single-threaded dispatch #11, the `with timeout`/SIGKILL executor, the `error:`-prefix protocol that surfaces AppleScript failures, the #24 search count-guard). The remaining risks cluster into four themes:
32
+
33
+ 1. **Swallowed failures that masquerade as empty/zero success** — the #24 family. Several read methods still do this (`list-messages`, `get-mail-stats`, `get-recently-received`, by-id lookups). This is the single highest-value theme because it produces *confidently wrong* answers.
34
+ 2. **The `1 MB execSync buffer** — every osascript call is capped at Node's default `maxBuffer`, so large messages/sources silently fail.
35
+ 3. **`|||` delimiter collisions** — the string serialization corrupts on any field containing `|||`.
36
+ 4. **N× full-tree-walk fan-out** in batch operations — correct but pathologically slow at scale.
37
+
38
+ ## Priority table
39
+
40
+ | # | Sev | Type | Finding | Effort |
41
+ |---|-----|------|---------|--------|
42
+ | 1 | High | stability | No `maxBuffer` on `execSync` → 1 MB cap truncates/throws on large messages, `getRawSource`, attachment extraction | S |
43
+ | 2 | High | stability | `get-mail-stats` / `get-recently-received` reintroduce the #15 locale bug + swallow → silent `0/0/0` | S |
44
+ | 3 | High | stability | `list-messages` & by-id lookups still have the #24 unbounded-all-mailbox + swallowed-timeout pattern (false empty) | M |
45
+ | 4 | High | stability | `\|\|\|` / `\|\|\|ITEM\|\|\|` delimiter collisions corrupt every parser on hostile/odd field values | M |
46
+ | 5 | High | perf | Batch ops are N separate osascript spawns, each a full account→mailbox→`whose id` tree walk | M |
47
+ | 6 | Med | perf/stability | `get-message` always reads full `source of msg` for attachmentless messages (slow; trips finding #1) | S |
48
+ | 7 | Med | stability | `get-message preferHtml` returns raw MIME source mislabeled as `htmlContent` (dumps base64 into context) | S |
49
+ | 8 | Med | stability | All-mailbox stat methods (`list-mailboxes`, `get-unread-count`, `get-sync-status`) on default 30s → silent empty/zero on timeout | S |
50
+ | 9 | Med | stability | `rename-mailbox` move-then-delete has no rollback / partial-move accounting (data-loss risk on large mailboxes) | M |
51
+ | 10 | Med | stability | `escapeForAppleScript` doesn't neutralize newlines/control chars in interpolated names/subjects | S |
52
+ | 11 | Low | perf | Per-message property round-trips in search/list (≥6 reads/msg); could bulk-read on small mailboxes | M |
53
+ | 12 | Low | stability | `saveAttachment` path-prefix check uses bare `startsWith` (sibling-prefix bypass: `/Volumes` vs `/Volumes-evil`) | S |
54
+ | 13 | Low | stability | `searchContacts` lacks per-person `try` → one bad contact aborts the whole search | S |
55
+ | 14 | Low | stability | `useTemplate` `overrides.x || template.x` drops intentional empty-string overrides; templates in-memory only | S |
56
+ | 15 | Low | process | Integration suite can't run in CI; real-Mail regressions only caught by local `npm run test:all` | M |
57
+
58
+ Effort: S ≈ <½ day, M ≈ ½–2 days.
59
+
60
+ ---
61
+
62
+ ## High severity
63
+
64
+ ### 1. No `maxBuffer` on `execSync` — 1 MB cap truncates large outputs
65
+ `executeAppleScript` (`utils/applescript.ts:349`) calls `execSync` with `encoding`, `timeout`, `killSignal`, `stdio` — but **no `maxBuffer`**, so Node's 1 MB default applies to every Mail operation. `getRawSource` (`appleMailManager.ts:922`) explicitly anticipates *20 MB* sources; `getMessageContent` returns full `source of msg` as `htmlContent`; large `search`/`list` outputs accumulate too. When output exceeds 1 MB, `execSync` throws `ENOBUFS`, which the executor reports as a failure, and the caller returns `null` → the user sees "message not found" / "attachment not found" or missing body for exactly the large/attachment-bearing messages where it matters most.
66
+ **Fix:** set a generous `maxBuffer` (e.g. 64 MB) in the `execSync` options, ideally configurable via env. Cheap and high-value.
67
+
68
+ ### 2. `get-mail-stats` / `get-recently-received` — locale bug + swallow → silent `0/0/0`
69
+ `getRecentlyReceivedStats` (`appleMailManager.ts:2506`) builds AppleScript date thresholds with hard-coded English month names: `date "January 5, 2026"` (`formatDate`, lines 2514–2539). This is the exact construct issue **#15** replaced with the locale-independent `buildAppleScriptDate` (line ~246) because `date "May 30, 2026"` throws *"Invalid date and time (-30720)"* on non-English system locales. The throw is swallowed by the nested per-account/per-inbox `try` blocks (2542, 2546), so on any non-English locale the method returns `{last24h:0, last7d:0, last30d:0}` as a clean success — and it feeds `getMailStats`. A correctness regression of the same class as #24.
70
+ **Fix:** build the three thresholds with `buildAppleScriptDate`; surface failure instead of returning zero.
71
+
72
+ ### 3. `list-messages` and by-id lookups still carry the #24 pattern
73
+ The #24 fix landed only on `searchMessages`. `listMessages` (all-mailboxes branch, `appleMailManager.ts:803–842`) still iterates every mailbox with an unbounded `messages of mb` materialization and a swallowing per-mailbox `try`, returning `[]` on timeout — a false "No messages found" on large multi-account setups. `getMessageById` / `getMessageContent` / `getRawSource` (789–942) likewise walk every mailbox of every account with no count-guard and a swallowing `on error … return ""`, so a slow account makes a real message look deleted.
74
+ **Fix:** extend the #24 approach (count-guard via `APPLE_MAIL_MAX_SEARCH_MAILBOX`, per-account budget, partial-result diagnostics) to `listMessages`; for by-id lookups, add the count-guard and distinguish "not found" from "timed out."
75
+
76
+ ### 4. `|||` delimiter collisions corrupt parsing
77
+ Results are serialized by concatenating fields with the literal `"|||"` and records with `"|||ITEM|||"`, then split in TS. No field value is sanitized first. Any subject, sender, contact name, attachment filename, or mailbox name containing `|||` shifts every subsequent field. Vulnerable parsers include `parseMessageList` (~1086), `getMessageById` (843), `getMessageContent` (898), `listAttachments` (~1785), `listMailboxes` (~1938), `fetchAccounts` (~2127), `listRules` (~2193), `searchContacts` (~2285), `getSyncStatus` (~2639). Concretely, an attachment named `report|||v2.pdf` makes the parser read `v2.pdf` as the MIME type and `parseInt` the size as `0`. `fetchMailboxNames` (~2148) is a separate variant that splits on `", "`, colliding with any mailbox name containing a comma-space.
78
+ **Fix:** switch field/record separators to ASCII control characters that cannot appear in mail text (Unit Separator ``, Record Separator ``), or length-prefix/JSON-encode each record. Update both the AppleScript emitters and the TS parsers together.
79
+
80
+ ### 5. Batch operations are N× full-tree-walk osascript spawns
81
+ `batchDeleteMessages` (1656), `batchMoveMessages` (1679), `batchMarkAsRead/Unread` (1697/1709), `batchFlag/Unflag` (1725+) each loop over the ids calling the single-id method. Every single-id method spawns its own osascript and walks `accounts → mailboxes of acct → messages whose id is …` to locate one message. So a 100-id batch = 100 processes, each a full breadth-first tree scan with a `whose id` query per mailbox, all serialized through the gate — minutes to hours on large accounts. (The batch APIs are capped at 100 ids, which bounds but doesn't fix it.)
82
+ **Fix:** one AppleScript that takes the whole id list, walks the tree once, and returns a per-id result vector; parse into `BatchOperationResult[]`.
83
+
84
+ ---
85
+
86
+ ## Medium severity
87
+
88
+ ### 6. `get-message` always full-source-scans attachmentless messages
89
+ `getMessageById` (789) computes `hasAttachments` by reading the entire `source of msg` whenever the fast attachment count is 0 (819–824), to catch MIME-embedded attachments. That's the slowest part of the common path and it interacts badly with finding #1 (1 MB cap). **Fix:** make the source-scan opt-in (e.g. a `deepAttachmentCheck` flag) or skip it when the fast count suffices for the caller.
90
+
91
+ ### 7. `preferHtml` returns raw MIME, not HTML
92
+ `getMessageContent` (865) sets `htmlContent = source of msg` — the entire raw MIME (headers + base64 attachments), not the HTML body. `get-message preferHtml:true` (`index.ts:256`) then returns that whole blob into the model context: token blowup plus the 1 MB buffer risk. **Fix:** extract the `text/html` MIME part (you already have `mimeParse.ts`), or rename/redocument and bound the size.
93
+
94
+ ### 8. All-mailbox stat methods silently degrade on 30s timeout
95
+ `listMailboxes` (~1913), `getUnreadCount` no-mailbox path (~1966), `getSyncStatus` (~2607) iterate all mailboxes/accounts at the **default 30s** timeout and return `[]`/`0` on timeout — a false "all read"/"no mailboxes." **Fix:** raise timeouts for these aggregate scans and surface partial/error rather than benign zeros.
96
+
97
+ ### 9. `rename-mailbox` partial-move data-loss risk
98
+ `renameMailbox` (~2046) creates the destination, moves messages in a loop, then deletes the source — all in one `try`. A mid-loop error jumps to `on error` with no rollback and no count of how many moved, leaving messages split across two mailboxes; mutating `messages of srcMailbox` while iterating it is itself unsafe. On a large mailbox the move can also exceed the 60s timeout and be SIGKILLed before completion. **Fix:** snapshot ids, verify source/destination counts match before deleting the source, and report partial moves.
99
+
100
+ ### 10. `escapeForAppleScript` doesn't handle newlines/control chars
101
+ `escapeForAppleScript` (155) escapes `\` and `"` but not raw newlines. AppleScript string literals can't span raw newlines, so a mailbox/template/contact name or subject containing `\n` can terminate the literal and inject a statement (or just error). Inputs are otherwise nicely escaped/validated — this is the one gap. **Fix:** strip or escape control characters (`\n`, `\r`, `\t`, line-continuation) in the escape function.
102
+
103
+ ---
104
+
105
+ ## Low severity
106
+
107
+ - **11 — per-message round-trips** (search/list): each emitted message does ≥6 property reads (`subject of msg`, `sender of msg`, …); on a mailbox these are serial Apple Events. Bulk list-property reads (`subject of msgs`, `sender of msgs`) cut round-trips on the small mailboxes that survive the #24 guard. Measure first.
108
+ - **12 — `saveAttachment` prefix check** (~1831/1890): `startsWith(homedir())` allows `/Users/robother`; `startsWith("/Volumes")` allows `/Volumes-evil`. Compare against `prefix + path.sep` or use a `path.relative` containment check.
109
+ - **13 — `searchContacts`** (~2250): no per-person `try`, so one contact with no `emails` aborts the scan and returns `[]`. Wrap the per-person read.
110
+ - **14 — templates**: `useTemplate` `overrides.subject || template.subject` (~2358) discards an intentional `""` override; the `Map` is in-memory and `nextTemplateId` resets to 1 each start, so ids collide across restarts. Documented limitation — note in README or persist.
111
+ - **15 — CI can't run integration tests**: `ci.yml` runs unit tests/lint/typecheck/build + a nice "committed build/ matches source" guard, but the real-Mail `test:integration` suite needs a GUI Mail.app and so only runs locally. Real AppleScript regressions can ship. Mitigation: make `npm run test:all` a documented pre-release gate (or a self-hosted runner).
112
+
113
+ ---
114
+
115
+ ## What's already done well (don't regress these)
116
+
117
+ - **Serial gate** (`utils/serialize.ts`) — correctly funnels all Mail calls through one promise chain with a settle delay; the right fix for Mail's single-threaded dispatch (#11).
118
+ - **Executor hardening** (`utils/applescript.ts`) — `with timeout` a few seconds under the process timeout so Mail aborts cleanly from inside its own dispatch, `SIGKILL` to reap wedged osascript, retry with backoff on transient errors, user-friendly error mapping.
119
+ - **`error:`-prefix protocol** — move/delete/reply/forward/mark/mailbox ops return `"error:" & errMsg` from `on error` and TS checks for it, surfacing AppleScript-level failures that `result.success` alone misses. The methods that *don't* use it (findings #2, #3, #8) are exactly where the swallow bugs live.
120
+ - **`moveMessageInternal`** — account-scoped resolution, explicit ambiguity refusal, distinct error strings, `{success,error}` return; the model the batch path should adopt.
121
+ - **Attachment input validation** — rejects path traversal/separators/null in names; resolves + prefix-checks save paths (modulo the `startsWith` nit in #12).
122
+ - **Cache stores mailbox *names* only** (not counts) with invalidation wired on the three structure-changing ops — the right call.
123
+ - **#24 search fix** — count-guard + per-account budget + partial diagnostics; findings #2/#3/#8 are about extending that discipline to the rest of the read surface.
124
+
125
+ ## Suggested sequencing
126
+
127
+ 1. **Quick wins (one small PR):** #1 maxBuffer, #2 locale/swallow, #10 escape control chars, #12 prefix check. All small, all pure stability.
128
+ 2. **The swallow-family PR:** #3 (extend #24 guard to list/by-id) + #8 (aggregate stat timeouts) — shares code with the #24 work.
129
+ 3. **The serialization PR:** #4 delimiters (touches many emitters/parsers; do it once, carefully, with tests).
130
+ 4. **The perf PR:** #5 batch single-walk, then measure #11 before investing.
131
+ 5. #6/#7/#9 as standalone medium fixes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.8.4",
3
+ "version": "2.8.6",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "build",
13
+ "docs",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],