apple-mail-mcp 2.20.4 → 2.20.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
@@ -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();
@@ -82615,6 +82735,7 @@ function getByIdScanThreshold() {
82615
82735
  return 5e4;
82616
82736
  }
82617
82737
  var BYID_SCAN_BUDGET_SECONDS = 9;
82738
+ var BYID_PROBE_TIMEOUT_SECONDS = 3;
82618
82739
  var BYID_COVERAGE_MARKER = "COV";
82619
82740
  var SCOPED_MISS_MARKER = "SCOPED";
82620
82741
  function describeUnscopedMiss(id, raw) {
@@ -84416,16 +84537,20 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84416
84537
  */
84417
84538
  unscopedByIdScript(id, innerAction, opts = {}) {
84418
84539
  const threshold = getByIdScanThreshold();
84419
- const sizeGuard = (mbVar, acctNameExpr) => threshold > 0 ? `set _mbCount to 0
84540
+ const sizeGuard = (mbVar, acctNameExpr) => threshold > 0 ? `set _mbCount to -1
84420
84541
  try
84421
- set _mbCount to count of messages of ${mbVar}
84542
+ with timeout of ${BYID_PROBE_TIMEOUT_SECONDS} seconds
84543
+ set _mbCount to count of messages of ${mbVar}
84544
+ end timeout
84422
84545
  end try
84423
- if _mbCount > ${threshold} then
84546
+ if _mbCount < 0 or _mbCount > ${threshold} then
84424
84547
  set _skipPath to ""
84425
84548
  try
84426
84549
  ${mailboxPathFragment(mbVar, "_skipPath")}
84427
84550
  end try
84428
- set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & (_mbCount as string) & ")${DIAG_ITEM_SEP}"
84551
+ set _sizeLabel to "size unknown (count probe exceeded ${BYID_PROBE_TIMEOUT_SECONDS}s)"
84552
+ if _mbCount > -1 then set _sizeLabel to (_mbCount as string)
84553
+ set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & _sizeLabel & ")${DIAG_ITEM_SEP}"
84429
84554
  set _probe to false
84430
84555
  end if` : "";
84431
84556
  const budgetCheck = `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
@@ -87600,10 +87725,14 @@ function currentCallTiming() {
87600
87725
  return callTiming.getStore();
87601
87726
  }
87602
87727
  function withErrorHandling(handler, errorPrefix) {
87603
- return async (params) => {
87728
+ return async (params, extra) => {
87604
87729
  const arrivedAt = Date.now();
87730
+ const signal = extra?.signal;
87605
87731
  return serializeAppleScript(async () => {
87606
- const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt };
87732
+ if (signal?.aborted) {
87733
+ return errorResponse(`${errorPrefix}: request was cancelled before it started`);
87734
+ }
87735
+ const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt, signal };
87607
87736
  return callTiming.run(timing, async () => {
87608
87737
  try {
87609
87738
  return await handler(params);
@@ -87864,6 +87993,9 @@ function unlistableStoreError(account, error3, knownAccounts) {
87864
87993
  return parts.join("\n\n");
87865
87994
  }
87866
87995
 
87996
+ // src/index.ts
87997
+ init_callStop();
87998
+
87867
87999
  // src/tools/batchResults.ts
87868
88000
  async function hybridBatchCounts(ids, appleFn, imapFn) {
87869
88001
  const distinctIds = [...new Set(ids)];
@@ -88042,12 +88174,17 @@ function mergeMessages(imapRows, appleRows, limit) {
88042
88174
  }
88043
88175
  async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
88044
88176
  const rows = [];
88177
+ const timedOutMailboxes = [];
88045
88178
  const accountsQueried = [];
88046
88179
  const accountsFailed = [];
88047
88180
  const failedMailboxes = [];
88048
88181
  const failedMailboxReasons = {};
88049
88182
  const omittedMessages = [];
88050
88183
  for (const config2 of configs) {
88184
+ if (deps.signal?.aborted) {
88185
+ timedOutMailboxes.push(`${config2.accountLabel} / *`);
88186
+ continue;
88187
+ }
88051
88188
  const perAccountArgs = { ...args, account: void 0 };
88052
88189
  try {
88053
88190
  const res = kind === "search" ? await imapSearchMessages(perAccountArgs, { ...deps, config: config2 }) : await imapListMessages(perAccountArgs, { ...deps, config: config2 });
@@ -88059,6 +88196,9 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
88059
88196
  for (const [mailbox, reason] of Object.entries(res.failedMailboxReasons)) {
88060
88197
  failedMailboxReasons[`${config2.accountLabel} / ${mailbox}`] = reason;
88061
88198
  }
88199
+ timedOutMailboxes.push(
88200
+ ...(res.timedOutMailboxes ?? []).map((mailbox) => `${config2.accountLabel} / ${mailbox}`)
88201
+ );
88062
88202
  omittedMessages.push(
88063
88203
  ...(res.omittedMessages ?? []).map((o) => ({
88064
88204
  ...o,
@@ -88076,7 +88216,8 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
88076
88216
  accountsFailed,
88077
88217
  failedMailboxes,
88078
88218
  failedMailboxReasons,
88079
- omittedMessages
88219
+ omittedMessages,
88220
+ timedOutMailboxes
88080
88221
  };
88081
88222
  }
88082
88223
  function configMatchesAccount(config2, account) {
@@ -88694,7 +88835,11 @@ var LIST_OUTPUT_SCHEMA = {
88694
88835
  failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional(),
88695
88836
  // Messages that belong on the page but could not be read, each with its
88696
88837
  // 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()
88838
+ omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional(),
88839
+ // Mailboxes a cancelled or out-of-time search-messages never finished (#276),
88840
+ // and why. Non-empty implies `partial: true`.
88841
+ timedOutMailboxes: external_exports.array(external_exports.string()).optional(),
88842
+ stoppedBy: external_exports.enum(["deadline", "cancelled"]).optional()
88698
88843
  };
88699
88844
  var BATCH_COUNT_OUTPUT_SCHEMA = {
88700
88845
  ok: external_exports.boolean().optional(),
@@ -88769,11 +88914,12 @@ function appleScanForAccounts(accounts, scan) {
88769
88914
  }
88770
88915
  return { rows, diagnostics };
88771
88916
  }
88772
- function mergedMessageResponse(fan, apple, limit, verb) {
88917
+ function mergedMessageResponse(fan, apple, limit, verb, stopSignal) {
88773
88918
  const merged = mergeMessages(fan.rows, apple.rows, limit);
88919
+ const timedOutMailboxes = fan.timedOutMailboxes ?? [];
88774
88920
  const diagnostics = {
88775
88921
  ...apple.diagnostics,
88776
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0,
88922
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0 || timedOutMailboxes.length > 0,
88777
88923
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88778
88924
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88779
88925
  };
@@ -88786,9 +88932,13 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88786
88932
  timedOutAccounts: diagnostics.timedOutAccounts,
88787
88933
  failedMailboxes: fan.failedMailboxes,
88788
88934
  failedMailboxReasons: fan.failedMailboxReasons,
88789
- omittedMessages: fan.omittedMessages
88935
+ omittedMessages: fan.omittedMessages,
88936
+ ...timedOutMailboxes.length > 0 ? {
88937
+ timedOutMailboxes,
88938
+ stoppedBy: stoppedByDeadline(stopSignal) ? "deadline" : "cancelled"
88939
+ } : {}
88790
88940
  };
88791
- const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true);
88941
+ const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true) + stoppedNote(timedOutMailboxes, stopSignal);
88792
88942
  if (merged.length === 0) {
88793
88943
  const base2 = diagnostics.partial ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.` : "No messages found";
88794
88944
  return successResponse(`${base2}${coverageBlock}`, structured);
@@ -88835,7 +88985,7 @@ function collectForensics(tool, args) {
88835
88985
  registerTool(
88836
88986
  "search-messages",
88837
88987
  {
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.",
88988
+ 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
88989
  inputSchema: {
88840
88990
  query: external_exports.string().optional().describe("Text to search for in subject or sender"),
88841
88991
  body: external_exports.string().optional().describe(
@@ -88878,6 +89028,15 @@ registerTool(
88878
89028
  if (tooDeep) return errorResponse(tooDeep);
88879
89029
  }
88880
89030
  if (shouldUseImap(account)) {
89031
+ const stop = createCallStop(currentCallTiming(), searchDeadlineMs());
89032
+ try {
89033
+ return await searchViaImap(stop.signal);
89034
+ } finally {
89035
+ stop.dispose();
89036
+ }
89037
+ }
89038
+ return searchViaAppleScript();
89039
+ async function searchViaImap(signal) {
88881
89040
  const imapArgs = {
88882
89041
  query,
88883
89042
  body,
@@ -88892,14 +89051,15 @@ registerTool(
88892
89051
  isFlagged
88893
89052
  };
88894
89053
  if (account !== void 0) {
88895
- const r = await imapSearchMessages({ ...imapArgs, account });
89054
+ const r = await imapSearchMessages({ ...imapArgs, account }, { signal });
88896
89055
  return successResponse(r.text, {
88897
89056
  messages: r.messages,
88898
89057
  count: r.count,
88899
89058
  partial: r.partial,
88900
89059
  failedMailboxes: r.failedMailboxes,
88901
89060
  failedMailboxReasons: r.failedMailboxReasons,
88902
- omittedMessages: r.omittedMessages
89061
+ omittedMessages: r.omittedMessages,
89062
+ ...r.timedOutMailboxes?.length ? { timedOutMailboxes: r.timedOutMailboxes, stoppedBy: r.stoppedBy } : {}
88903
89063
  });
88904
89064
  }
88905
89065
  const { appleScriptOnly } = partitionAccountsForCounts(
@@ -88911,7 +89071,7 @@ registerTool(
88911
89071
  `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
89072
  );
88913
89073
  }
88914
- const fan = await fanOutImapMessages(imapArgs, "search");
89074
+ const fan = await fanOutImapMessages(imapArgs, "search", { signal });
88915
89075
  if (body) {
88916
89076
  const apple2 = {
88917
89077
  rows: [],
@@ -88923,7 +89083,18 @@ registerTool(
88923
89083
  )
88924
89084
  }
88925
89085
  };
88926
- return mergedMessageResponse(fan, apple2, limit, "matched");
89086
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
89087
+ }
89088
+ if (signal.aborted && appleScriptOnly.length > 0) {
89089
+ const apple2 = {
89090
+ rows: [],
89091
+ diagnostics: {
89092
+ ...emptyDiagnostics(),
89093
+ partial: true,
89094
+ timedOutAccounts: appleScriptOnly.map((a) => a.name)
89095
+ }
89096
+ };
89097
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
88927
89098
  }
88928
89099
  const apple = appleScanForAccounts(
88929
89100
  appleScriptOnly,
@@ -88940,51 +89111,53 @@ registerTool(
88940
89111
  isFlagged
88941
89112
  )
88942
89113
  );
88943
- return mergedMessageResponse(fan, apple, limit, "matched");
89114
+ return mergedMessageResponse(fan, apple, limit, "matched", signal);
88944
89115
  }
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.`
89116
+ function searchViaAppleScript() {
89117
+ if (offset > 0) {
89118
+ return errorResponse(
89119
+ `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.`
89120
+ );
89121
+ }
89122
+ if (body) {
89123
+ return errorResponse(
89124
+ `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.`
89125
+ );
89126
+ }
89127
+ const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(
89128
+ query,
89129
+ mailbox,
89130
+ account,
89131
+ limit,
89132
+ dateFrom,
89133
+ dateTo,
89134
+ from,
89135
+ subject,
89136
+ isRead,
89137
+ isFlagged
88948
89138
  );
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.`
89139
+ const coverageBlock = partialCoverageBlock(diagnostics);
89140
+ const structured = {
89141
+ messages: messages.map(messageSummary),
89142
+ count: messages.length,
89143
+ partial: diagnostics.partial,
89144
+ skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
89145
+ notSearchedMailboxes: diagnostics.notSearchedMailboxes,
89146
+ timedOutAccounts: diagnostics.timedOutAccounts
89147
+ };
89148
+ if (messages.length === 0) {
89149
+ const base2 = diagnostics.partial ? "No messages found in the portions that were searched." : "No messages found matching criteria";
89150
+ return successResponse(`${base2}${coverageBlock}`, structured);
89151
+ }
89152
+ const messageList = messages.map(
89153
+ (m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
89154
+ ).join("\n");
89155
+ return successResponse(
89156
+ `Found ${messages.length} message(s):
89157
+ ${messageList}${coverageBlock}`,
89158
+ structured
88953
89159
  );
88954
89160
  }
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
89161
  },
88989
89162
  "Error searching messages"
88990
89163
  )
@@ -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.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",