apple-mail-mcp 2.20.4 → 2.20.5

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
@@ -281,15 +281,28 @@ human reader; the same information is also returned as structured fields on
281
281
  `search-messages` and `list-messages`, so a caller can tell _"nothing matched"_
282
282
  apart from _"I did not look everywhere"_ without parsing the text:
283
283
 
284
- | Field | Type | Meaning |
285
- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
- | `partial` | boolean | Coverage was incomplete — **the result is not a confirmed "no such mail"**. True whenever any field below is non-empty. |
287
- | `skippedLargeMailboxes` | string[] | Mailboxes never scanned because their message count exceeded `APPLE_MAIL_MAX_SEARCH_MAILBOX`, formatted `"Account / Mailbox (count)"` — e.g. `"iCloud / Archive (90694)"`. |
288
- | `notSearchedMailboxes` | string[] | Mailboxes that _were_ reached but timed out or errored mid-scan, formatted `"Account / Mailbox"`. Also carries the IMAP path's `failedMailboxes`. |
289
- | `timedOutAccounts` | string[] | Accounts whose whole-account AppleScript was killed by the per-account time budget — nothing from that account was searched. |
290
- | `failedMailboxes` | string[] | IMAP-backend mailboxes that errored. These are merged into `notSearchedMailboxes` as well; read that field unless you need to attribute the failure to the IMAP path specifically. |
291
- | `failedMailboxReasons` | object | The IMAP server's own error text for each entry in `failedMailboxes`, keyed the same way. |
292
- | `omittedMessages` | object[] | IMAP messages that belong on the page but whose `FETCH` response could not be read even with a reduced item set — `{ id, mailbox, uid, reason }`. The page is short by exactly these; the `id` still works with `get-message` / `get-message-headers`. ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)) |
284
+ | Field | Type | Meaning |
285
+ | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | `partial` | boolean | Coverage was incomplete — **the result is not a confirmed "no such mail"**. True whenever any field below is non-empty. |
287
+ | `skippedLargeMailboxes` | string[] | Mailboxes never scanned because their message count exceeded `APPLE_MAIL_MAX_SEARCH_MAILBOX`, formatted `"Account / Mailbox (count)"` — e.g. `"iCloud / Archive (90694)"`. |
288
+ | `notSearchedMailboxes` | string[] | Mailboxes that _were_ reached but timed out or errored mid-scan, formatted `"Account / Mailbox"`. Also carries the IMAP path's `failedMailboxes`. |
289
+ | `timedOutAccounts` | string[] | Accounts whose whole-account AppleScript was killed by the per-account time budget — nothing from that account was searched. |
290
+ | `failedMailboxes` | string[] | IMAP-backend mailboxes that errored. These are merged into `notSearchedMailboxes` as well; read that field unless you need to attribute the failure to the IMAP path specifically. |
291
+ | `failedMailboxReasons` | object | The IMAP server's own error text for each entry in `failedMailboxes`, keyed the same way. |
292
+ | `omittedMessages` | object[] | IMAP messages that belong on the page but whose `FETCH` response could not be read even with a reduced item set — `{ id, mailbox, uid, reason }`. The page is short by exactly these; the `id` still works with `get-message` / `get-message-headers`. ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)) |
293
+ | `timedOutMailboxes` | string[] | IMAP mailboxes the search did not finish because the call was **cancelled** (`notifications/cancelled`) or reached its **deadline** (`APPLE_MAIL_MCP_SEARCH_DEADLINE_MS`, default 45s), formatted `"Account / Mailbox"`; an account never reached is `"Account / *"`. Results from the mailboxes that did finish are still returned. ([#276](https://github.com/sweetrb/apple-mail-mcp/issues/276)) |
294
+ | `stoppedBy` | string | `"deadline"` or `"cancelled"` — present only with `timedOutMailboxes`. |
295
+
296
+ **Deadline and cancellation ([#276](https://github.com/sweetrb/apple-mail-mcp/issues/276)).** Tool
297
+ calls run one at a time (so they cannot race into Mail.app), which means a long search holds every
298
+ later call behind it. An IMAP `search-messages` therefore stops at
299
+ `APPLE_MAIL_MCP_SEARCH_DEADLINE_MS` (default `45000`, minimum `2000`, measured from when the request
300
+ arrived — time queued behind other calls counts) and returns what it found plus `timedOutMailboxes`,
301
+ and it stops the same way when the client cancels the request. The mailbox being searched at that
302
+ moment is abandoned by closing its IMAP connection (the next call reconnects), so the queue is
303
+ released promptly rather than after a server-side `SEARCH` that can take a minute and a half on a
304
+ very large mailbox. A request cancelled while still queued is skipped. Keep the deadline below your
305
+ client's own request timeout.
293
306
 
294
307
  Treat a non-empty `skippedLargeMailboxes` as actionable rather than
295
308
  informational: re-run scoped to the named mailbox with a `dateFrom`/`dateTo`
@@ -620,21 +633,22 @@ If IMAP is **not** configured at all, every read behaves exactly as before
620
633
  `delete-mailbox`, `rename-mailbox`) remain conservative — they route to IMAP only
621
634
  for an explicitly-named IMAP account, never on an omitted account.
622
635
 
623
- | Variable | Required | Default | Description |
624
- | -------------------------------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
625
- | `APPLE_MAIL_MCP_IMAP_USER` | Yes | — | Login address; setting it enables IMAP |
626
- | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | No | = user | Mail account name to match for routing |
627
- | `APPLE_MAIL_MCP_IMAP_HOST` | No | `imap.gmail.com` | IMAP server hostname |
628
- | `APPLE_MAIL_MCP_IMAP_PORT` | No | `993` | IMAP port (993 = implicit TLS) |
629
- | `APPLE_MAIL_MCP_IMAP_ALLOW_PLAINTEXT` | No | `0` | Set `1` only for an explicitly trusted plaintext test/server; otherwise STARTTLS is required |
630
- | `APPLE_MAIL_MCP_IMAP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
631
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | No | — | Keychain item service/server name |
632
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
633
- | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | No | — | JSON array of **additional** IMAP accounts for multi-account setups (see below) |
634
- | `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
635
- | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
636
- | `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 |
637
- | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | No | `50000` | Overall wall-clock deadline for one `get-mail-stats` call (minimum `2000`), measured from when the request arrived and covering time queued behind other tool calls, account enumeration **and** every per-account read. Keep it below your client's request timeout |
636
+ | Variable | Required | Default | Description |
637
+ | -------------------------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
638
+ | `APPLE_MAIL_MCP_IMAP_USER` | Yes | — | Login address; setting it enables IMAP |
639
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | No | = user | Mail account name to match for routing |
640
+ | `APPLE_MAIL_MCP_IMAP_HOST` | No | `imap.gmail.com` | IMAP server hostname |
641
+ | `APPLE_MAIL_MCP_IMAP_PORT` | No | `993` | IMAP port (993 = implicit TLS) |
642
+ | `APPLE_MAIL_MCP_IMAP_ALLOW_PLAINTEXT` | No | `0` | Set `1` only for an explicitly trusted plaintext test/server; otherwise STARTTLS is required |
643
+ | `APPLE_MAIL_MCP_IMAP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
644
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | No | — | Keychain item service/server name |
645
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
646
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | No | — | JSON array of **additional** IMAP accounts for multi-account setups (see below) |
647
+ | `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
648
+ | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
649
+ | `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 |
650
+ | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | No | `50000` | Overall wall-clock deadline for one `get-mail-stats` call (minimum `2000`), measured from when the request arrived and covering time queued behind other tool calls, account enumeration **and** every per-account read. Keep it below your client's request timeout |
651
+ | `APPLE_MAIL_MCP_SEARCH_DEADLINE_MS` | No | `45000` | Overall wall-clock deadline for one IMAP `search-messages` call (minimum `2000`), measured from when the request arrived. On expiry the search returns partial results plus `timedOutMailboxes` instead of holding the tool-call queue. Keep it below your client's request timeout |
638
652
 
639
653
  **Multiple IMAP accounts (C2):** set `APPLE_MAIL_MCP_IMAP_ACCOUNTS` to a JSON array, e.g.
640
654
  `[{"account":"Work","user":"me@co.com","host":"imap.co.com","keychainService":"imap.co.com"}]`.
package/build/cli.js CHANGED
@@ -12259,6 +12259,51 @@ var init_docsUrls = __esm({
12259
12259
  }
12260
12260
  });
12261
12261
 
12262
+ // src/utils/callStop.ts
12263
+ function describeStop(signal) {
12264
+ const reason = signal?.reason;
12265
+ if (reason && typeof reason === "object" && reason.kind === "deadline") {
12266
+ return `the ${Math.round(reason.deadlineMs / 1e3)}s search deadline (${reason.envVar})`;
12267
+ }
12268
+ return "the request being cancelled";
12269
+ }
12270
+ function stoppedByDeadline(signal) {
12271
+ const reason = signal?.reason;
12272
+ return !!reason && typeof reason === "object" && reason.kind === "deadline";
12273
+ }
12274
+ function raceStop(work, signal) {
12275
+ if (!signal) return work;
12276
+ if (signal.aborted) {
12277
+ work.catch(() => void 0);
12278
+ return Promise.resolve(STOPPED);
12279
+ }
12280
+ return new Promise((resolve4, reject) => {
12281
+ const onAbort = () => {
12282
+ work.catch(() => void 0);
12283
+ resolve4(STOPPED);
12284
+ };
12285
+ signal.addEventListener("abort", onAbort, { once: true });
12286
+ work.then(
12287
+ (value) => {
12288
+ signal.removeEventListener("abort", onAbort);
12289
+ resolve4(value);
12290
+ },
12291
+ (error2) => {
12292
+ signal.removeEventListener("abort", onAbort);
12293
+ reject(error2);
12294
+ }
12295
+ );
12296
+ });
12297
+ }
12298
+ var SEARCH_DEADLINE_ENV, STOPPED;
12299
+ var init_callStop = __esm({
12300
+ "src/utils/callStop.ts"() {
12301
+ "use strict";
12302
+ SEARCH_DEADLINE_ENV = "APPLE_MAIL_MCP_SEARCH_DEADLINE_MS";
12303
+ STOPPED = /* @__PURE__ */ Symbol("stopped");
12304
+ }
12305
+ });
12306
+
12262
12307
  // node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js
12263
12308
  var require_err_helpers = __commonJS({
12264
12309
  "node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js"(exports, module) {
@@ -58037,6 +58082,7 @@ __export(imapClient_exports, {
58037
58082
  resolveImapConfigs: () => resolveImapConfigs,
58038
58083
  resolveMailboxPath: () => resolveMailboxPath,
58039
58084
  shouldUseImap: () => shouldUseImap,
58085
+ stoppedNote: () => stoppedNote,
58040
58086
  unscopedSearchOffsetError: () => unscopedSearchOffsetError
58041
58087
  });
58042
58088
  import { createHash } from "node:crypto";
@@ -58357,6 +58403,12 @@ function structuredRow(m, account, path3) {
58357
58403
  ...env.messageId ? { messageId: env.messageId } : {}
58358
58404
  };
58359
58405
  }
58406
+ function stoppedNote(timedOut, signal) {
58407
+ if (timedOut.length === 0) return "";
58408
+ return `
58409
+
58410
+ Partial result. Stopped by ${describeStop(signal)} before finishing ${timedOut.length} mailbox(es): ${timedOut.map((m) => `"${m}"`).join(", ")}. This is NOT a confirmed "no such mail" \u2014 narrow the search with \`mailbox\` and/or \`dateFrom\`, or raise ${SEARCH_DEADLINE_ENV}.`;
58411
+ }
58360
58412
  function omittedNote(omitted, merged) {
58361
58413
  if (omitted.length === 0) return "";
58362
58414
  const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
@@ -58646,9 +58698,23 @@ async function run(args, listMode, deps) {
58646
58698
  const omittedMessages = [];
58647
58699
  let totalMatched = 0;
58648
58700
  let totalExact = true;
58649
- for (const path3 of paths) {
58701
+ const timedOutMailboxes = [];
58702
+ for (const [index, path3] of paths.entries()) {
58703
+ if (deps.signal?.aborted) {
58704
+ timedOutMailboxes.push(...paths.slice(index));
58705
+ break;
58706
+ }
58650
58707
  try {
58651
- const result = await fetchMailboxMatches(client, path3, criteria, page);
58708
+ const outcome = await raceStop(
58709
+ fetchMailboxMatches(client, path3, criteria, page),
58710
+ deps.signal
58711
+ );
58712
+ if (outcome === STOPPED) {
58713
+ abandonClient(client, cfg, deps);
58714
+ timedOutMailboxes.push(...paths.slice(index));
58715
+ break;
58716
+ }
58717
+ const result = outcome;
58652
58718
  totalMatched += result.total;
58653
58719
  totalExact &&= result.totalExact;
58654
58720
  fetched.push(...result.messages.map((message) => ({ message, path: path3 })));
@@ -58688,10 +58754,14 @@ async function run(args, listMode, deps) {
58688
58754
  const messages = ordered.map(
58689
58755
  ({ message, path: path3 }) => structuredRow(message, cfg.accountLabel, path3)
58690
58756
  );
58691
- const partial = failedMailboxes.length > 0 || omittedMessages.length > 0;
58757
+ const partial = failedMailboxes.length > 0 || omittedMessages.length > 0 || timedOutMailboxes.length > 0;
58758
+ const stopped = timedOutMailboxes.length > 0 ? {
58759
+ timedOutMailboxes,
58760
+ stoppedBy: stoppedByDeadline(deps.signal) ? "deadline" : "cancelled"
58761
+ } : {};
58692
58762
  const failureNote = (failedMailboxes.length > 0 ? `
58693
58763
 
58694
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
58764
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch) + stoppedNote(timedOutMailboxes, deps.signal);
58695
58765
  const verb = listMode ? "listed" : "matched";
58696
58766
  const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
58697
58767
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
@@ -58703,7 +58773,8 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `
58703
58773
  partial,
58704
58774
  failedMailboxes,
58705
58775
  failedMailboxReasons,
58706
- omittedMessages
58776
+ omittedMessages,
58777
+ ...stopped
58707
58778
  };
58708
58779
  }
58709
58780
  const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
@@ -58717,7 +58788,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
58717
58788
  partial,
58718
58789
  failedMailboxes,
58719
58790
  failedMailboxReasons,
58720
- omittedMessages
58791
+ omittedMessages,
58792
+ ...stopped
58721
58793
  };
58722
58794
  },
58723
58795
  true
@@ -58860,6 +58932,20 @@ async function dropPool(key) {
58860
58932
  await e.client.logout().catch(() => void 0);
58861
58933
  e.client.close?.();
58862
58934
  }
58935
+ function abandonClient(client, cfg, deps) {
58936
+ if (!deps.connect) {
58937
+ const key = poolKey(cfg);
58938
+ const entry = pools.get(key);
58939
+ if (entry && entry.client === client) {
58940
+ if (entry.idle) clearTimeout(entry.idle);
58941
+ pools.delete(key);
58942
+ }
58943
+ }
58944
+ try {
58945
+ client.close?.();
58946
+ } catch {
58947
+ }
58948
+ }
58863
58949
  async function dropAllPools() {
58864
58950
  await Promise.all([...pools.keys()].map((k) => dropPool(k)));
58865
58951
  }
@@ -59858,6 +59944,7 @@ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED,
59858
59944
  var init_imapClient = __esm({
59859
59945
  "src/services/imapClient.ts"() {
59860
59946
  "use strict";
59947
+ init_callStop();
59861
59948
  import_imapflow = __toESM(require_imap_flow(), 1);
59862
59949
  init_smtpMailer();
59863
59950
  init_docsUrls();
package/build/index.js CHANGED
@@ -19994,6 +19994,84 @@ var init_nodemailer = __esm({
19994
19994
  }
19995
19995
  });
19996
19996
 
19997
+ // src/utils/callStop.ts
19998
+ function searchDeadlineMs(env = process.env) {
19999
+ const raw = env[SEARCH_DEADLINE_ENV];
20000
+ if (raw === void 0 || raw.trim() === "") return DEFAULT_SEARCH_DEADLINE_MS;
20001
+ const n = Number(raw);
20002
+ if (!Number.isFinite(n)) return DEFAULT_SEARCH_DEADLINE_MS;
20003
+ return Math.max(MIN_SEARCH_DEADLINE_MS, Math.floor(n));
20004
+ }
20005
+ function createCallStop(timing, deadlineMs, envVar = SEARCH_DEADLINE_ENV) {
20006
+ const controller = new AbortController();
20007
+ const upstream = timing?.signal;
20008
+ const onCancel = () => {
20009
+ if (!controller.signal.aborted) controller.abort({ kind: "cancelled" });
20010
+ };
20011
+ if (upstream?.aborted) onCancel();
20012
+ else upstream?.addEventListener("abort", onCancel, { once: true });
20013
+ const startedAt = timing?.arrivedAt ?? Date.now();
20014
+ const remaining = Math.max(0, deadlineMs - (Date.now() - startedAt));
20015
+ const timer = setTimeout(() => {
20016
+ if (!controller.signal.aborted) {
20017
+ controller.abort({ kind: "deadline", deadlineMs, envVar });
20018
+ }
20019
+ }, remaining);
20020
+ timer.unref?.();
20021
+ return {
20022
+ signal: controller.signal,
20023
+ dispose() {
20024
+ clearTimeout(timer);
20025
+ upstream?.removeEventListener("abort", onCancel);
20026
+ }
20027
+ };
20028
+ }
20029
+ function describeStop(signal) {
20030
+ const reason = signal?.reason;
20031
+ if (reason && typeof reason === "object" && reason.kind === "deadline") {
20032
+ return `the ${Math.round(reason.deadlineMs / 1e3)}s search deadline (${reason.envVar})`;
20033
+ }
20034
+ return "the request being cancelled";
20035
+ }
20036
+ function stoppedByDeadline(signal) {
20037
+ const reason = signal?.reason;
20038
+ return !!reason && typeof reason === "object" && reason.kind === "deadline";
20039
+ }
20040
+ function raceStop(work, signal) {
20041
+ if (!signal) return work;
20042
+ if (signal.aborted) {
20043
+ work.catch(() => void 0);
20044
+ return Promise.resolve(STOPPED);
20045
+ }
20046
+ return new Promise((resolve5, reject) => {
20047
+ const onAbort = () => {
20048
+ work.catch(() => void 0);
20049
+ resolve5(STOPPED);
20050
+ };
20051
+ signal.addEventListener("abort", onAbort, { once: true });
20052
+ work.then(
20053
+ (value) => {
20054
+ signal.removeEventListener("abort", onAbort);
20055
+ resolve5(value);
20056
+ },
20057
+ (error3) => {
20058
+ signal.removeEventListener("abort", onAbort);
20059
+ reject(error3);
20060
+ }
20061
+ );
20062
+ });
20063
+ }
20064
+ var DEFAULT_SEARCH_DEADLINE_MS, MIN_SEARCH_DEADLINE_MS, SEARCH_DEADLINE_ENV, STOPPED;
20065
+ var init_callStop = __esm({
20066
+ "src/utils/callStop.ts"() {
20067
+ "use strict";
20068
+ DEFAULT_SEARCH_DEADLINE_MS = 45e3;
20069
+ MIN_SEARCH_DEADLINE_MS = 2e3;
20070
+ SEARCH_DEADLINE_ENV = "APPLE_MAIL_MCP_SEARCH_DEADLINE_MS";
20071
+ STOPPED = /* @__PURE__ */ Symbol("stopped");
20072
+ }
20073
+ });
20074
+
19997
20075
  // node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js
19998
20076
  var require_err_helpers = __commonJS({
19999
20077
  "node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js"(exports, module) {
@@ -65379,6 +65457,7 @@ __export(imapClient_exports, {
65379
65457
  resolveImapConfigs: () => resolveImapConfigs,
65380
65458
  resolveMailboxPath: () => resolveMailboxPath,
65381
65459
  shouldUseImap: () => shouldUseImap,
65460
+ stoppedNote: () => stoppedNote,
65382
65461
  unscopedSearchOffsetError: () => unscopedSearchOffsetError
65383
65462
  });
65384
65463
  import { createHash } from "node:crypto";
@@ -65699,6 +65778,12 @@ function structuredRow(m, account, path3) {
65699
65778
  ...env.messageId ? { messageId: env.messageId } : {}
65700
65779
  };
65701
65780
  }
65781
+ function stoppedNote(timedOut, signal) {
65782
+ if (timedOut.length === 0) return "";
65783
+ return `
65784
+
65785
+ Partial result. Stopped by ${describeStop(signal)} before finishing ${timedOut.length} mailbox(es): ${timedOut.map((m) => `"${m}"`).join(", ")}. This is NOT a confirmed "no such mail" \u2014 narrow the search with \`mailbox\` and/or \`dateFrom\`, or raise ${SEARCH_DEADLINE_ENV}.`;
65786
+ }
65702
65787
  function omittedNote(omitted, merged) {
65703
65788
  if (omitted.length === 0) return "";
65704
65789
  const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
@@ -65988,9 +66073,23 @@ async function run(args, listMode, deps) {
65988
66073
  const omittedMessages = [];
65989
66074
  let totalMatched = 0;
65990
66075
  let totalExact = true;
65991
- for (const path3 of paths) {
66076
+ const timedOutMailboxes = [];
66077
+ for (const [index, path3] of paths.entries()) {
66078
+ if (deps.signal?.aborted) {
66079
+ timedOutMailboxes.push(...paths.slice(index));
66080
+ break;
66081
+ }
65992
66082
  try {
65993
- const result = await fetchMailboxMatches(client, path3, criteria, page);
66083
+ const outcome = await raceStop(
66084
+ fetchMailboxMatches(client, path3, criteria, page),
66085
+ deps.signal
66086
+ );
66087
+ if (outcome === STOPPED) {
66088
+ abandonClient(client, cfg, deps);
66089
+ timedOutMailboxes.push(...paths.slice(index));
66090
+ break;
66091
+ }
66092
+ const result = outcome;
65994
66093
  totalMatched += result.total;
65995
66094
  totalExact &&= result.totalExact;
65996
66095
  fetched.push(...result.messages.map((message) => ({ message, path: path3 })));
@@ -66030,10 +66129,14 @@ async function run(args, listMode, deps) {
66030
66129
  const messages = ordered.map(
66031
66130
  ({ message, path: path3 }) => structuredRow(message, cfg.accountLabel, path3)
66032
66131
  );
66033
- const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0;
66132
+ const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0 || timedOutMailboxes.length > 0;
66133
+ const stopped = timedOutMailboxes.length > 0 ? {
66134
+ timedOutMailboxes,
66135
+ stoppedBy: stoppedByDeadline(deps.signal) ? "deadline" : "cancelled"
66136
+ } : {};
66034
66137
  const failureNote = (failedMailboxes.length > 0 ? `
66035
66138
 
66036
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
66139
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch) + stoppedNote(timedOutMailboxes, deps.signal);
66037
66140
  const verb = listMode ? "listed" : "matched";
66038
66141
  const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
66039
66142
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
@@ -66045,7 +66148,8 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `
66045
66148
  partial: partial2,
66046
66149
  failedMailboxes,
66047
66150
  failedMailboxReasons,
66048
- omittedMessages
66151
+ omittedMessages,
66152
+ ...stopped
66049
66153
  };
66050
66154
  }
66051
66155
  const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
@@ -66059,7 +66163,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
66059
66163
  partial: partial2,
66060
66164
  failedMailboxes,
66061
66165
  failedMailboxReasons,
66062
- omittedMessages
66166
+ omittedMessages,
66167
+ ...stopped
66063
66168
  };
66064
66169
  },
66065
66170
  true
@@ -66202,6 +66307,20 @@ async function dropPool(key) {
66202
66307
  await e.client.logout().catch(() => void 0);
66203
66308
  e.client.close?.();
66204
66309
  }
66310
+ function abandonClient(client, cfg, deps) {
66311
+ if (!deps.connect) {
66312
+ const key = poolKey(cfg);
66313
+ const entry = pools.get(key);
66314
+ if (entry && entry.client === client) {
66315
+ if (entry.idle) clearTimeout(entry.idle);
66316
+ pools.delete(key);
66317
+ }
66318
+ }
66319
+ try {
66320
+ client.close?.();
66321
+ } catch {
66322
+ }
66323
+ }
66205
66324
  async function dropAllPools() {
66206
66325
  await Promise.all([...pools.keys()].map((k) => dropPool(k)));
66207
66326
  }
@@ -67200,6 +67319,7 @@ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED,
67200
67319
  var init_imapClient = __esm({
67201
67320
  "src/services/imapClient.ts"() {
67202
67321
  "use strict";
67322
+ init_callStop();
67203
67323
  import_imapflow = __toESM(require_imap_flow(), 1);
67204
67324
  init_smtpMailer();
67205
67325
  init_docsUrls();
@@ -87600,10 +87720,14 @@ function currentCallTiming() {
87600
87720
  return callTiming.getStore();
87601
87721
  }
87602
87722
  function withErrorHandling(handler, errorPrefix) {
87603
- return async (params) => {
87723
+ return async (params, extra) => {
87604
87724
  const arrivedAt = Date.now();
87725
+ const signal = extra?.signal;
87605
87726
  return serializeAppleScript(async () => {
87606
- const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt };
87727
+ if (signal?.aborted) {
87728
+ return errorResponse(`${errorPrefix}: request was cancelled before it started`);
87729
+ }
87730
+ const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt, signal };
87607
87731
  return callTiming.run(timing, async () => {
87608
87732
  try {
87609
87733
  return await handler(params);
@@ -87864,6 +87988,9 @@ function unlistableStoreError(account, error3, knownAccounts) {
87864
87988
  return parts.join("\n\n");
87865
87989
  }
87866
87990
 
87991
+ // src/index.ts
87992
+ init_callStop();
87993
+
87867
87994
  // src/tools/batchResults.ts
87868
87995
  async function hybridBatchCounts(ids, appleFn, imapFn) {
87869
87996
  const distinctIds = [...new Set(ids)];
@@ -88042,12 +88169,17 @@ function mergeMessages(imapRows, appleRows, limit) {
88042
88169
  }
88043
88170
  async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
88044
88171
  const rows = [];
88172
+ const timedOutMailboxes = [];
88045
88173
  const accountsQueried = [];
88046
88174
  const accountsFailed = [];
88047
88175
  const failedMailboxes = [];
88048
88176
  const failedMailboxReasons = {};
88049
88177
  const omittedMessages = [];
88050
88178
  for (const config2 of configs) {
88179
+ if (deps.signal?.aborted) {
88180
+ timedOutMailboxes.push(`${config2.accountLabel} / *`);
88181
+ continue;
88182
+ }
88051
88183
  const perAccountArgs = { ...args, account: void 0 };
88052
88184
  try {
88053
88185
  const res = kind === "search" ? await imapSearchMessages(perAccountArgs, { ...deps, config: config2 }) : await imapListMessages(perAccountArgs, { ...deps, config: config2 });
@@ -88059,6 +88191,9 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
88059
88191
  for (const [mailbox, reason] of Object.entries(res.failedMailboxReasons)) {
88060
88192
  failedMailboxReasons[`${config2.accountLabel} / ${mailbox}`] = reason;
88061
88193
  }
88194
+ timedOutMailboxes.push(
88195
+ ...(res.timedOutMailboxes ?? []).map((mailbox) => `${config2.accountLabel} / ${mailbox}`)
88196
+ );
88062
88197
  omittedMessages.push(
88063
88198
  ...(res.omittedMessages ?? []).map((o) => ({
88064
88199
  ...o,
@@ -88076,7 +88211,8 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
88076
88211
  accountsFailed,
88077
88212
  failedMailboxes,
88078
88213
  failedMailboxReasons,
88079
- omittedMessages
88214
+ omittedMessages,
88215
+ timedOutMailboxes
88080
88216
  };
88081
88217
  }
88082
88218
  function configMatchesAccount(config2, account) {
@@ -88694,7 +88830,11 @@ var LIST_OUTPUT_SCHEMA = {
88694
88830
  failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional(),
88695
88831
  // Messages that belong on the page but could not be read, each with its
88696
88832
  // imap: id and why (#256 follow-up). Non-empty implies `partial: true`.
88697
- omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional()
88833
+ omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional(),
88834
+ // Mailboxes a cancelled or out-of-time search-messages never finished (#276),
88835
+ // and why. Non-empty implies `partial: true`.
88836
+ timedOutMailboxes: external_exports.array(external_exports.string()).optional(),
88837
+ stoppedBy: external_exports.enum(["deadline", "cancelled"]).optional()
88698
88838
  };
88699
88839
  var BATCH_COUNT_OUTPUT_SCHEMA = {
88700
88840
  ok: external_exports.boolean().optional(),
@@ -88769,11 +88909,12 @@ function appleScanForAccounts(accounts, scan) {
88769
88909
  }
88770
88910
  return { rows, diagnostics };
88771
88911
  }
88772
- function mergedMessageResponse(fan, apple, limit, verb) {
88912
+ function mergedMessageResponse(fan, apple, limit, verb, stopSignal) {
88773
88913
  const merged = mergeMessages(fan.rows, apple.rows, limit);
88914
+ const timedOutMailboxes = fan.timedOutMailboxes ?? [];
88774
88915
  const diagnostics = {
88775
88916
  ...apple.diagnostics,
88776
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0,
88917
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0 || timedOutMailboxes.length > 0,
88777
88918
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88778
88919
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88779
88920
  };
@@ -88786,9 +88927,13 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88786
88927
  timedOutAccounts: diagnostics.timedOutAccounts,
88787
88928
  failedMailboxes: fan.failedMailboxes,
88788
88929
  failedMailboxReasons: fan.failedMailboxReasons,
88789
- omittedMessages: fan.omittedMessages
88930
+ omittedMessages: fan.omittedMessages,
88931
+ ...timedOutMailboxes.length > 0 ? {
88932
+ timedOutMailboxes,
88933
+ stoppedBy: stoppedByDeadline(stopSignal) ? "deadline" : "cancelled"
88934
+ } : {}
88790
88935
  };
88791
- const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true);
88936
+ const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true) + stoppedNote(timedOutMailboxes, stopSignal);
88792
88937
  if (merged.length === 0) {
88793
88938
  const base2 = diagnostics.partial ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.` : "No messages found";
88794
88939
  return successResponse(`${base2}${coverageBlock}`, structured);
@@ -88835,7 +88980,7 @@ function collectForensics(tool, args) {
88835
88980
  registerTool(
88836
88981
  "search-messages",
88837
88982
  {
88838
- description: "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.",
88983
+ description: "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped). An IMAP search stops at its deadline (APPLE_MAIL_MCP_SEARCH_DEADLINE_MS, default 45s) or when the request is cancelled, returning the matches found so far with partial: true and timedOutMailboxes \u2014 narrow with mailbox/dateFrom rather than repeating it.\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.",
88839
88984
  inputSchema: {
88840
88985
  query: external_exports.string().optional().describe("Text to search for in subject or sender"),
88841
88986
  body: external_exports.string().optional().describe(
@@ -88878,6 +89023,15 @@ registerTool(
88878
89023
  if (tooDeep) return errorResponse(tooDeep);
88879
89024
  }
88880
89025
  if (shouldUseImap(account)) {
89026
+ const stop = createCallStop(currentCallTiming(), searchDeadlineMs());
89027
+ try {
89028
+ return await searchViaImap(stop.signal);
89029
+ } finally {
89030
+ stop.dispose();
89031
+ }
89032
+ }
89033
+ return searchViaAppleScript();
89034
+ async function searchViaImap(signal) {
88881
89035
  const imapArgs = {
88882
89036
  query,
88883
89037
  body,
@@ -88892,14 +89046,15 @@ registerTool(
88892
89046
  isFlagged
88893
89047
  };
88894
89048
  if (account !== void 0) {
88895
- const r = await imapSearchMessages({ ...imapArgs, account });
89049
+ const r = await imapSearchMessages({ ...imapArgs, account }, { signal });
88896
89050
  return successResponse(r.text, {
88897
89051
  messages: r.messages,
88898
89052
  count: r.count,
88899
89053
  partial: r.partial,
88900
89054
  failedMailboxes: r.failedMailboxes,
88901
89055
  failedMailboxReasons: r.failedMailboxReasons,
88902
- omittedMessages: r.omittedMessages
89056
+ omittedMessages: r.omittedMessages,
89057
+ ...r.timedOutMailboxes?.length ? { timedOutMailboxes: r.timedOutMailboxes, stoppedBy: r.stoppedBy } : {}
88903
89058
  });
88904
89059
  }
88905
89060
  const { appleScriptOnly } = partitionAccountsForCounts(
@@ -88911,7 +89066,7 @@ registerTool(
88911
89066
  `offset is supported only on IMAP accounts, and ${appleScriptOnly.map((a) => `"${a.name}"`).join(", ")} ${appleScriptOnly.length === 1 ? "is" : "are"} AppleScript-only. Pass an IMAP account to page, or omit offset.`
88912
89067
  );
88913
89068
  }
88914
- const fan = await fanOutImapMessages(imapArgs, "search");
89069
+ const fan = await fanOutImapMessages(imapArgs, "search", { signal });
88915
89070
  if (body) {
88916
89071
  const apple2 = {
88917
89072
  rows: [],
@@ -88923,7 +89078,18 @@ registerTool(
88923
89078
  )
88924
89079
  }
88925
89080
  };
88926
- return mergedMessageResponse(fan, apple2, limit, "matched");
89081
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
89082
+ }
89083
+ if (signal.aborted && appleScriptOnly.length > 0) {
89084
+ const apple2 = {
89085
+ rows: [],
89086
+ diagnostics: {
89087
+ ...emptyDiagnostics(),
89088
+ partial: true,
89089
+ timedOutAccounts: appleScriptOnly.map((a) => a.name)
89090
+ }
89091
+ };
89092
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
88927
89093
  }
88928
89094
  const apple = appleScanForAccounts(
88929
89095
  appleScriptOnly,
@@ -88940,51 +89106,53 @@ registerTool(
88940
89106
  isFlagged
88941
89107
  )
88942
89108
  );
88943
- return mergedMessageResponse(fan, apple, limit, "matched");
89109
+ return mergedMessageResponse(fan, apple, limit, "matched", signal);
88944
89110
  }
88945
- if (offset > 0) {
88946
- return errorResponse(
88947
- `offset is supported only on IMAP accounts; IMAP is not configured for ${account ? `account "${account}"` : "any account"}. Omit offset, or narrow the search with dateFrom/dateTo instead.`
89111
+ function searchViaAppleScript() {
89112
+ if (offset > 0) {
89113
+ return errorResponse(
89114
+ `offset is supported only on IMAP accounts; IMAP is not configured for ${account ? `account "${account}"` : "any account"}. Omit offset, or narrow the search with dateFrom/dateTo instead.`
89115
+ );
89116
+ }
89117
+ if (body) {
89118
+ return errorResponse(
89119
+ `Body search requires the IMAP backend, which is not configured for ${account ? `account "${account}"` : "any account"}. Configure IMAP (see the IMAP backend section of the README) or search by query/subject/from instead.`
89120
+ );
89121
+ }
89122
+ const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(
89123
+ query,
89124
+ mailbox,
89125
+ account,
89126
+ limit,
89127
+ dateFrom,
89128
+ dateTo,
89129
+ from,
89130
+ subject,
89131
+ isRead,
89132
+ isFlagged
88948
89133
  );
88949
- }
88950
- if (body) {
88951
- return errorResponse(
88952
- `Body search requires the IMAP backend, which is not configured for ${account ? `account "${account}"` : "any account"}. Configure IMAP (see the IMAP backend section of the README) or search by query/subject/from instead.`
89134
+ const coverageBlock = partialCoverageBlock(diagnostics);
89135
+ const structured = {
89136
+ messages: messages.map(messageSummary),
89137
+ count: messages.length,
89138
+ partial: diagnostics.partial,
89139
+ skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
89140
+ notSearchedMailboxes: diagnostics.notSearchedMailboxes,
89141
+ timedOutAccounts: diagnostics.timedOutAccounts
89142
+ };
89143
+ if (messages.length === 0) {
89144
+ const base2 = diagnostics.partial ? "No messages found in the portions that were searched." : "No messages found matching criteria";
89145
+ return successResponse(`${base2}${coverageBlock}`, structured);
89146
+ }
89147
+ const messageList = messages.map(
89148
+ (m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
89149
+ ).join("\n");
89150
+ return successResponse(
89151
+ `Found ${messages.length} message(s):
89152
+ ${messageList}${coverageBlock}`,
89153
+ structured
88953
89154
  );
88954
89155
  }
88955
- const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(
88956
- query,
88957
- mailbox,
88958
- account,
88959
- limit,
88960
- dateFrom,
88961
- dateTo,
88962
- from,
88963
- subject,
88964
- isRead,
88965
- isFlagged
88966
- );
88967
- const coverageBlock = partialCoverageBlock(diagnostics);
88968
- const structured = {
88969
- messages: messages.map(messageSummary),
88970
- count: messages.length,
88971
- partial: diagnostics.partial,
88972
- skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
88973
- notSearchedMailboxes: diagnostics.notSearchedMailboxes,
88974
- timedOutAccounts: diagnostics.timedOutAccounts
88975
- };
88976
- if (messages.length === 0) {
88977
- const base2 = diagnostics.partial ? "No messages found in the portions that were searched." : "No messages found matching criteria";
88978
- return successResponse(`${base2}${coverageBlock}`, structured);
88979
- }
88980
- const messageList = messages.map(
88981
- (m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
88982
- ).join("\n");
88983
- return successResponse(
88984
- `Found ${messages.length} message(s):
88985
- ${messageList}${coverageBlock}`,
88986
- structured
88987
- );
88988
89156
  },
88989
89157
  "Error searching messages"
88990
89158
  )
@@ -346,29 +346,30 @@ GUI is ignoring.
346
346
 
347
347
  ## Full environment variable reference
348
348
 
349
- | Variable | Purpose |
350
- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
351
- | `APPLE_MAIL_MCP_DEFAULT_ACCOUNT` | Account used when a tool omits `account` (name or email). |
352
- | `APPLE_MAIL_MCP_IMAP_USER` | Primary IMAP login. Setting it enables IMAP — but so does `APPLE_MAIL_MCP_IMAP_ACCOUNTS` on its own; either is sufficient. |
353
- | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | Mail.app account name to match for routing (default = USER). |
354
- | `APPLE_MAIL_MCP_IMAP_HOST` | IMAP host (default `imap.gmail.com`). |
355
- | `APPLE_MAIL_MCP_IMAP_PORT` | IMAP port (default `993`, implicit TLS). |
356
- | `APPLE_MAIL_MCP_IMAP_PASSWORD` | Password (discouraged; prefer Keychain). |
357
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | Keychain item service/server name. |
358
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | Keychain item account (default = USER). |
359
- | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | JSON array of accounts (multi-account). Sufficient on its own; also enables IMAP. |
360
- | `APPLE_MAIL_MCP_IMAP_IDLE` | `1` to enable IMAP IDLE new-mail push. |
361
- | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | Pooled-connection idle timeout in ms (default `30000`; `0` = never close). |
362
- | `APPLE_MAIL_MCP_STATS_BUDGET_MS` | Per-account wall-clock budget for `get-mail-stats` in ms (default `25000`, minimum `1000`). |
363
- | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | Overall wall-clock deadline for one `get-mail-stats` call in ms (default `50000`, minimum `2000`) — measured from when the request arrived, and covering time spent queued behind other tool calls as well as the Mail.app account enumeration and every per-account read. Keep it below your MCP client's request timeout. |
364
- | `APPLE_MAIL_MCP_SMTP_HOST` | SMTP host; setting it enables `transport:"smtp"`. |
365
- | `APPLE_MAIL_MCP_SMTP_PORT` | SMTP port (`465` if secure, else `587`). |
366
- | `APPLE_MAIL_MCP_SMTP_SECURE` | `true` for implicit TLS (465); else STARTTLS. |
367
- | `APPLE_MAIL_MCP_SMTP_USER` / `_FROM` | SMTP login / From address. |
368
- | `APPLE_MAIL_MCP_SMTP_PASSWORD` | Password (discouraged; prefer Keychain). |
369
- | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` / `_KEYCHAIN_ACCOUNT` | SMTP Keychain reference. |
370
- | `APPLE_MAIL_MCP_CONFIG_FILE` | Path to the config JSON (default app-support dir). |
371
- | `APPLE_MAIL_MCP_TEMPLATES_FILE` | Email-templates store path (default `~/Library/Application Support/apple-mail-mcp/templates.json`). |
372
- | `APPLE_MAIL_MCP_MAX_BUFFER` | Max AppleScript (`osascript`) output buffer in bytes (default 64 MiB). |
373
- | `APPLE_MAIL_MAX_SEARCH_MAILBOX` | Per-mailbox message-count guard for unscoped AppleScript search (default `5000`; `0` disables). Note: no `_MCP` in the name. |
374
- | `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` | Per-mailbox message-count guard for the cross-mailbox scan a numeric message id falls back to (default `50000`; `0` disables). Skipped mailboxes are named in the not-found error. Note: no `_MCP` in the name. |
349
+ | Variable | Purpose |
350
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
351
+ | `APPLE_MAIL_MCP_DEFAULT_ACCOUNT` | Account used when a tool omits `account` (name or email). |
352
+ | `APPLE_MAIL_MCP_IMAP_USER` | Primary IMAP login. Setting it enables IMAP — but so does `APPLE_MAIL_MCP_IMAP_ACCOUNTS` on its own; either is sufficient. |
353
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | Mail.app account name to match for routing (default = USER). |
354
+ | `APPLE_MAIL_MCP_IMAP_HOST` | IMAP host (default `imap.gmail.com`). |
355
+ | `APPLE_MAIL_MCP_IMAP_PORT` | IMAP port (default `993`, implicit TLS). |
356
+ | `APPLE_MAIL_MCP_IMAP_PASSWORD` | Password (discouraged; prefer Keychain). |
357
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | Keychain item service/server name. |
358
+ | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | Keychain item account (default = USER). |
359
+ | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | JSON array of accounts (multi-account). Sufficient on its own; also enables IMAP. |
360
+ | `APPLE_MAIL_MCP_IMAP_IDLE` | `1` to enable IMAP IDLE new-mail push. |
361
+ | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | Pooled-connection idle timeout in ms (default `30000`; `0` = never close). |
362
+ | `APPLE_MAIL_MCP_STATS_BUDGET_MS` | Per-account wall-clock budget for `get-mail-stats` in ms (default `25000`, minimum `1000`). |
363
+ | `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | Overall wall-clock deadline for one `get-mail-stats` call in ms (default `50000`, minimum `2000`) — measured from when the request arrived, and covering time spent queued behind other tool calls as well as the Mail.app account enumeration and every per-account read. Keep it below your MCP client's request timeout. |
364
+ | `APPLE_MAIL_MCP_SEARCH_DEADLINE_MS` | Overall wall-clock deadline for one IMAP `search-messages` call in ms (default `45000`, minimum `2000`), measured from when the request arrived. On expiry — or when the client cancels the request — the search abandons the mailbox in flight and returns partial results plus `timedOutMailboxes`. Keep it below your MCP client's request timeout. |
365
+ | `APPLE_MAIL_MCP_SMTP_HOST` | SMTP host; setting it enables `transport:"smtp"`. |
366
+ | `APPLE_MAIL_MCP_SMTP_PORT` | SMTP port (`465` if secure, else `587`). |
367
+ | `APPLE_MAIL_MCP_SMTP_SECURE` | `true` for implicit TLS (465); else STARTTLS. |
368
+ | `APPLE_MAIL_MCP_SMTP_USER` / `_FROM` | SMTP login / From address. |
369
+ | `APPLE_MAIL_MCP_SMTP_PASSWORD` | Password (discouraged; prefer Keychain). |
370
+ | `APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE` / `_KEYCHAIN_ACCOUNT` | SMTP Keychain reference. |
371
+ | `APPLE_MAIL_MCP_CONFIG_FILE` | Path to the config JSON (default app-support dir). |
372
+ | `APPLE_MAIL_MCP_TEMPLATES_FILE` | Email-templates store path (default `~/Library/Application Support/apple-mail-mcp/templates.json`). |
373
+ | `APPLE_MAIL_MCP_MAX_BUFFER` | Max AppleScript (`osascript`) output buffer in bytes (default 64 MiB). |
374
+ | `APPLE_MAIL_MAX_SEARCH_MAILBOX` | Per-mailbox message-count guard for unscoped AppleScript search (default `5000`; `0` disables). Note: no `_MCP` in the name. |
375
+ | `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` | Per-mailbox message-count guard for the cross-mailbox scan a numeric message id falls back to (default `50000`; `0` disables). Skipped mailboxes are named in the not-found error. Note: no `_MCP` in the name. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.20.4",
3
+ "version": "2.20.5",
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",