apple-mail-mcp 2.10.7 → 2.10.8

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
@@ -486,6 +486,7 @@ for an explicitly-named IMAP account, never on an omitted account.
486
486
  | `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
487
487
  | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
488
488
  | `APPLE_MAIL_MCP_STATS_BUDGET_MS` | No | `25000` | Per-account wall-clock budget for `get-mail-stats` (minimum `1000`). Raise it for very large accounts |
489
+ | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | No | `50000` | Overall wall-clock deadline for one `get-mail-stats` call (minimum `2000`), covering account enumeration **and** every per-account read. Keep it below your client's request timeout |
489
490
 
490
491
  **Multiple IMAP accounts (C2):** set `APPLE_MAIL_MCP_IMAP_ACCOUNTS` to a JSON array, e.g.
491
492
  `[{"account":"Work","user":"me@co.com","host":"imap.co.com","keychainService":"imap.co.com"}]`.
@@ -775,9 +776,15 @@ Move a message to a different mailbox.
775
776
  | Parameter | Type | Required | Description |
776
777
  |-----------|------|----------|-------------|
777
778
  | `id` | string | Yes | Message ID |
778
- | `mailbox` | string | Yes | Destination mailbox |
779
+ | `mailbox` | string | Yes | Destination mailbox — full path (`Work/Archive`) or a leaf name that is unique on the account |
779
780
  | `account` | string | No | Account containing mailbox |
780
781
 
782
+ A destination is matched first as a full path, then as a leaf name. If a leaf
783
+ name matches **more than one** mailbox (e.g. `Archive` under both `Work` and
784
+ `Thornlands`), the move is refused with an error naming every candidate — pass
785
+ the full path. The same applies to `batch-move-messages`, `delete-mailbox` and
786
+ `rename-mailbox`.
787
+
781
788
  ---
782
789
 
783
790
  #### `list-attachments`
@@ -1157,6 +1164,15 @@ overruns is reported via `partial: true` + `failedAccounts` rather than being
1157
1164
  folded in as a silent zero; a scoped call to a single account returns an error
1158
1165
  naming the budget instead. Raise the budget if you have a very large account.
1159
1166
 
1167
+ The whole call is additionally bounded by one wall-clock deadline,
1168
+ `APPLE_MAIL_MCP_STATS_DEADLINE_MS` (default `50000`), which covers the Mail.app
1169
+ account enumeration as well as every per-account read. Per-step budgets alone
1170
+ were not enough: their worst cases **add up**, and the sum could exceed a
1171
+ client's request timeout, so the call died with nothing returned instead of
1172
+ degrading. Keep the deadline below your MCP client's request timeout — whatever
1173
+ cannot be read inside it is named in `failedAccounts`, so you always get a
1174
+ partial answer rather than a dead call.
1175
+
1160
1176
  ---
1161
1177
 
1162
1178
  #### `get-sync-status`
package/build/index.js CHANGED
@@ -78388,6 +78388,12 @@ var AppleMailManager = class {
78388
78388
  * presenting a fallback zero/empty as a real answer. (#130)
78389
78389
  */
78390
78390
  lastAccountsError = null;
78391
+ /**
78392
+ * Same, for the mailbox listing — read via `listMailboxesChecked()`. Kept
78393
+ * separate from `lastAccountsError` so a failed mailbox read on one account
78394
+ * can't be misread as the account enumeration having failed. (#135)
78395
+ */
78396
+ lastMailboxesError = null;
78391
78397
  /**
78392
78398
  * Remembers where each message id was last seen: id → {account, mailbox}.
78393
78399
  *
@@ -78417,12 +78423,12 @@ var AppleMailManager = class {
78417
78423
  /**
78418
78424
  * Returns cached accounts or fetches fresh data if cache is expired/empty.
78419
78425
  */
78420
- getCachedAccounts() {
78426
+ getCachedAccounts(options = {}) {
78421
78427
  const now = Date.now();
78422
78428
  if (this.cache.accounts && now < this.cache.accounts.expiry) {
78423
78429
  return this.cache.accounts.data;
78424
78430
  }
78425
- const accounts = this.fetchAccounts();
78431
+ const accounts = this.fetchAccounts(options);
78426
78432
  if (accounts === null) {
78427
78433
  return this.cache.accounts?.data ?? [];
78428
78434
  }
@@ -80187,7 +80193,7 @@ var AppleMailManager = class {
80187
80193
  /**
80188
80194
  * List all mailboxes for an account.
80189
80195
  */
80190
- listMailboxes(account) {
80196
+ listMailboxes(account, options = {}) {
80191
80197
  const targetAccount = this.resolveAccount(account);
80192
80198
  const listCommand = `
80193
80199
  set mailboxList to {}
@@ -80201,9 +80207,10 @@ var AppleMailManager = class {
80201
80207
  return mailboxList as text
80202
80208
  `;
80203
80209
  const script = buildAccountScopedScript(targetAccount, listCommand);
80204
- const result = executeAppleScript(script, { timeoutMs: 6e4 });
80210
+ const result = executeAppleScript(script, { timeoutMs: options.timeoutMs ?? 6e4 });
80205
80211
  if (!result.success) {
80206
80212
  console.error(`Failed to list mailboxes: ${result.error}`);
80213
+ this.lastMailboxesError = result.error ?? "AppleScript transport failed";
80207
80214
  return [];
80208
80215
  }
80209
80216
  if (!result.output.trim()) return [];
@@ -80221,6 +80228,20 @@ var AppleMailManager = class {
80221
80228
  }
80222
80229
  return mailboxes;
80223
80230
  }
80231
+ /**
80232
+ * listMailboxes() plus whether the underlying AppleScript read actually worked.
80233
+ *
80234
+ * An empty list is ambiguous on its own — Mail with no mailboxes and a timed-out
80235
+ * transport both produce `[]`, and folding the second into a total as 0 is the
80236
+ * silent-zero class #130 fixed elsewhere. A caller summing counts across
80237
+ * accounts needs to tell them apart. (#135)
80238
+ */
80239
+ listMailboxesChecked(account, options = {}) {
80240
+ this.lastMailboxesError = null;
80241
+ const mailboxes = this.listMailboxes(account, options);
80242
+ const error2 = this.lastMailboxesError;
80243
+ return error2 ? { mailboxes, failed: true, error: error2 } : { mailboxes, failed: false };
80244
+ }
80224
80245
  /**
80225
80246
  * Get unread count for a mailbox.
80226
80247
  */
@@ -80756,18 +80777,22 @@ end tell`;
80756
80777
  /**
80757
80778
  * List all mail accounts (uses cache).
80758
80779
  */
80759
- listAccounts() {
80760
- return this.getCachedAccounts();
80780
+ listAccounts(options = {}) {
80781
+ return this.getCachedAccounts(options);
80761
80782
  }
80762
80783
  /**
80763
80784
  * listAccounts() plus whether the underlying AppleScript read actually worked.
80764
80785
  *
80765
80786
  * `failed: true` means the list is a fallback (stale cache or empty) because the
80766
80787
  * transport errored — NOT that Mail has no accounts. (#130)
80788
+ *
80789
+ * `timeoutMs` bounds the AppleScript read when the cache is cold, so a caller
80790
+ * working to an overall deadline can spend a known slice here instead of the
80791
+ * blanket 30s. A cache hit costs nothing and ignores it. (#135)
80767
80792
  */
80768
- listAccountsChecked() {
80793
+ listAccountsChecked(options = {}) {
80769
80794
  this.lastAccountsError = null;
80770
- const accounts = this.getCachedAccounts();
80795
+ const accounts = this.getCachedAccounts(options);
80771
80796
  const error2 = this.lastAccountsError;
80772
80797
  return error2 ? { accounts, failed: true, error: error2 } : { accounts, failed: false };
80773
80798
  }
@@ -80792,7 +80817,7 @@ end tell`;
80792
80817
  * "Mail answered, and there genuinely are no accounts" — collapsing the two is
80793
80818
  * what let a wedged transport report a confident "No Mail accounts found". (#130)
80794
80819
  */
80795
- fetchAccounts() {
80820
+ fetchAccounts(options = {}) {
80796
80821
  const script = buildAppLevelScript(`
80797
80822
  set accountList to {}
80798
80823
  repeat with acct in accounts
@@ -80808,7 +80833,10 @@ end tell`;
80808
80833
  set AppleScript's text item delimiters to "${RECORD_SEP}"
80809
80834
  return accountList as text
80810
80835
  `);
80811
- const result = executeAppleScript(script);
80836
+ const result = executeAppleScript(
80837
+ script,
80838
+ options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
80839
+ );
80812
80840
  if (!result.success) {
80813
80841
  console.error(`Failed to list accounts: ${result.error}`);
80814
80842
  this.lastAccountsError = result.error ?? "AppleScript transport failed";
@@ -82113,13 +82141,26 @@ async function useClient(deps, fn, retryOnDrop = false) {
82113
82141
  function withClient(deps, fn) {
82114
82142
  return useClient(deps, fn);
82115
82143
  }
82116
- async function findMailboxPath(client, name) {
82144
+ async function resolveMailbox(client, name) {
82117
82145
  const wanted = name.trim().toLowerCase();
82118
82146
  const boxes = await client.list();
82119
82147
  const byPath = boxes.find((b) => b.path.toLowerCase() === wanted);
82120
- if (byPath) return byPath.path;
82121
- const byName = boxes.find((b) => b.name.toLowerCase() === wanted);
82122
- return byName ? byName.path : null;
82148
+ if (byPath) return { kind: "found", path: byPath.path };
82149
+ const byName = boxes.filter((b) => b.name.toLowerCase() === wanted);
82150
+ if (byName.length === 1) return { kind: "found", path: byName[0].path };
82151
+ if (byName.length > 1) {
82152
+ return { kind: "ambiguous", candidates: byName.map((b) => b.path).sort() };
82153
+ }
82154
+ return { kind: "none" };
82155
+ }
82156
+ function ambiguousMailboxError(name, candidates, accountLabel) {
82157
+ const where = accountLabel ? ` on IMAP account ${accountLabel}` : "";
82158
+ return `Mailbox "${name}" is ambiguous${where} \u2014 it matches ${candidates.map((c) => `"${c}"`).join(" and ")}. Pass the full path.`;
82159
+ }
82160
+ async function findMailboxPathOrThrow(client, name) {
82161
+ const res = await resolveMailbox(client, name);
82162
+ if (res.kind === "ambiguous") throw new Error(ambiguousMailboxError(name, res.candidates));
82163
+ return res.kind === "found" ? res.path : null;
82123
82164
  }
82124
82165
  function imapCreateMailbox(name, deps = {}) {
82125
82166
  return withClient(deps, async (client) => {
@@ -82133,13 +82174,20 @@ function imapCreateMailbox(name, deps = {}) {
82133
82174
  }
82134
82175
  function imapDeleteMailbox(name, deps = {}) {
82135
82176
  return withClient(deps, async (client, cfg) => {
82136
- const path = await findMailboxPath(client, name);
82137
- if (!path) {
82177
+ const res = await resolveMailbox(client, name);
82178
+ if (res.kind === "ambiguous") {
82179
+ return {
82180
+ success: false,
82181
+ error: ambiguousMailboxError(name, res.candidates, cfg.accountLabel)
82182
+ };
82183
+ }
82184
+ if (res.kind === "none") {
82138
82185
  return {
82139
82186
  success: false,
82140
82187
  error: `Mailbox "${name}" not found on IMAP account ${cfg.accountLabel}.`
82141
82188
  };
82142
82189
  }
82190
+ const path = res.path;
82143
82191
  try {
82144
82192
  await client.mailboxDelete(path);
82145
82193
  return {
@@ -82153,13 +82201,20 @@ function imapDeleteMailbox(name, deps = {}) {
82153
82201
  }
82154
82202
  function imapRenameMailbox(oldName, newName, deps = {}) {
82155
82203
  return withClient(deps, async (client, cfg) => {
82156
- const path = await findMailboxPath(client, oldName);
82157
- if (!path) {
82204
+ const found = await resolveMailbox(client, oldName);
82205
+ if (found.kind === "ambiguous") {
82206
+ return {
82207
+ success: false,
82208
+ error: ambiguousMailboxError(oldName, found.candidates, cfg.accountLabel)
82209
+ };
82210
+ }
82211
+ if (found.kind === "none") {
82158
82212
  return {
82159
82213
  success: false,
82160
82214
  error: `Mailbox "${oldName}" not found on IMAP account ${cfg.accountLabel}.`
82161
82215
  };
82162
82216
  }
82217
+ const path = found.path;
82163
82218
  try {
82164
82219
  const res = await client.mailboxRename(path, newName);
82165
82220
  return { success: true, info: `Renamed "${res.path}" to "${res.newPath}" via IMAP.` };
@@ -82292,8 +82347,15 @@ function imapUnflagMessage(id, deps = {}) {
82292
82347
  async function imapMoveMessageById(id, destMailbox, deps = {}) {
82293
82348
  const ref = decodeImapId(id);
82294
82349
  if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
82295
- return withClient(depsForMessageRef(ref, deps), async (client) => {
82296
- const destPath = await findMailboxPath(client, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
82350
+ return withClient(depsForMessageRef(ref, deps), async (client, cfg) => {
82351
+ const dest = await resolveMailbox(client, destMailbox);
82352
+ if (dest.kind === "ambiguous") {
82353
+ return {
82354
+ success: false,
82355
+ error: ambiguousMailboxError(destMailbox, dest.candidates, cfg.accountLabel)
82356
+ };
82357
+ }
82358
+ const destPath = dest.kind === "found" ? dest.path : resolveMailboxPath(destMailbox, "list");
82297
82359
  const lock = await client.getMailboxLock(ref.path);
82298
82360
  try {
82299
82361
  await client.messageMove([ref.uid], destPath, { uid: true });
@@ -82468,7 +82530,7 @@ var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids, p
82468
82530
  });
82469
82531
  function imapBatchMove(ids, destMailbox, deps = {}) {
82470
82532
  return imapBatch(ids, deps, async (c, uids) => {
82471
- const dest = await findMailboxPath(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
82533
+ const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
82472
82534
  await c.messageMove(uids, dest, { uid: true });
82473
82535
  });
82474
82536
  }
@@ -85097,16 +85159,20 @@ registerTool(
85097
85159
  },
85098
85160
  withErrorHandling(async ({ account }) => {
85099
85161
  const budgetMs = Math.max(1e3, Number(process.env.APPLE_MAIL_MCP_STATS_BUDGET_MS ?? 25e3));
85162
+ const deadlineMs = Math.max(
85163
+ 2e3,
85164
+ Number(process.env.APPLE_MAIL_MCP_STATS_DEADLINE_MS ?? 5e4)
85165
+ );
85166
+ const startedAt = Date.now();
85167
+ const remainingMs = () => Math.max(0, deadlineMs - (Date.now() - startedAt));
85100
85168
  const withBudget = async (work, label) => {
85169
+ const ms = Math.min(budgetMs, remainingMs());
85101
85170
  let timer;
85102
85171
  try {
85103
85172
  return await Promise.race([
85104
85173
  work,
85105
85174
  new Promise((_, reject) => {
85106
- timer = setTimeout(
85107
- () => reject(new Error(`${label} timed out after ${budgetMs}ms`)),
85108
- budgetMs
85109
- );
85175
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
85110
85176
  })
85111
85177
  ]);
85112
85178
  } finally {
@@ -85140,8 +85206,21 @@ registerTool(
85140
85206
  let totalUnread = 0;
85141
85207
  const recent = { last24h: 0, last7d: 0, last30d: 0 };
85142
85208
  const perAccount = [];
85143
- const sources = planCountSources(mailManager.listAccounts(), resolveImapConfigs());
85144
85209
  const failedAccounts = [];
85210
+ const imapConfigs = resolveImapConfigs();
85211
+ const enumerateMs = Math.max(1e3, Math.min(1e4, Math.floor(remainingMs() * 0.3)));
85212
+ const enumerated = mailManager.listAccountsChecked({ timeoutMs: enumerateMs });
85213
+ const sources = enumerated.failed ? imapConfigs.map((config2) => ({
85214
+ kind: "imap",
85215
+ config: config2,
85216
+ label: config2.accountLabel
85217
+ })) : planCountSources(enumerated.accounts, imapConfigs);
85218
+ if (enumerated.failed) {
85219
+ console.error(
85220
+ `Mail.app account enumeration failed for get-mail-stats: ${enumerated.error}`
85221
+ );
85222
+ failedAccounts.push("Mail.app accounts (AppleScript enumeration)");
85223
+ }
85145
85224
  const settled = await Promise.all(
85146
85225
  sources.filter((s) => s.kind === "imap").map(async (src) => {
85147
85226
  try {
@@ -85176,9 +85255,20 @@ registerTool(
85176
85255
  }
85177
85256
  for (const src of sources) {
85178
85257
  if (src.kind === "imap") continue;
85258
+ const left = remainingMs();
85259
+ if (left < 1e3) {
85260
+ failedAccounts.push(src.label);
85261
+ continue;
85262
+ }
85263
+ const read = mailManager.listMailboxesChecked(src.account.name, { timeoutMs: left });
85264
+ if (read.failed) {
85265
+ console.error(`AppleScript mail-stats failed for "${src.label}": ${read.error}`);
85266
+ failedAccounts.push(src.label);
85267
+ continue;
85268
+ }
85179
85269
  let m = 0;
85180
85270
  let u = 0;
85181
- for (const mb of mailManager.listMailboxes(src.account.name)) {
85271
+ for (const mb of read.mailboxes) {
85182
85272
  m += mb.messageCount;
85183
85273
  u += mb.unreadCount;
85184
85274
  }
@@ -85210,7 +85300,7 @@ registerTool(
85210
85300
  if (failedAccounts.length > 0) {
85211
85301
  lines2.push(
85212
85302
  ``,
85213
- `\u26A0\uFE0F PARTIAL: ${failedAccounts.length} account(s) could not be read (${failedAccounts.join(", ")}), so the real totals are higher. They either failed or exceeded the ${budgetMs}ms budget \u2014 raise APPLE_MAIL_MCP_STATS_BUDGET_MS if an account is simply large, or run the "doctor" tool to check the connection.`
85303
+ `\u26A0\uFE0F PARTIAL: ${failedAccounts.length} account(s) could not be read (${failedAccounts.join(", ")}), so the real totals are higher. They either failed, exceeded the ${budgetMs}ms per-account budget, or ran out of the ${deadlineMs}ms overall deadline \u2014 raise APPLE_MAIL_MCP_STATS_BUDGET_MS and/or APPLE_MAIL_MCP_STATS_DEADLINE_MS if an account is simply large, or run the "doctor" tool to check the connection.`
85214
85304
  );
85215
85305
  }
85216
85306
  return successResponse(lines2.join("\n"), {
@@ -358,6 +358,7 @@ GUI is ignoring.
358
358
  | `APPLE_MAIL_MCP_IMAP_IDLE` | `1` to enable IMAP IDLE new-mail push. |
359
359
  | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | Pooled-connection idle timeout in ms (default `30000`; `0` = never close). |
360
360
  | `APPLE_MAIL_MCP_STATS_BUDGET_MS` | Per-account wall-clock budget for `get-mail-stats` in ms (default `25000`, minimum `1000`). |
361
+ | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | Overall wall-clock deadline for one `get-mail-stats` call in ms (default `50000`, minimum `2000`) — covers the Mail.app account enumeration as well as every per-account read. Keep it below your MCP client's request timeout. |
361
362
  | `APPLE_MAIL_MCP_SMTP_HOST` | SMTP host; setting it enables `transport:"smtp"`. |
362
363
  | `APPLE_MAIL_MCP_SMTP_PORT` | SMTP port (`465` if secure, else `587`). |
363
364
  | `APPLE_MAIL_MCP_SMTP_SECURE` | `true` for implicit TLS (465); else STARTTLS. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.10.7",
3
+ "version": "2.10.8",
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",