apple-mail-mcp 2.20.3 → 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`
@@ -309,15 +322,17 @@ inferred from the top-level `Content-Type`.
309
322
 
310
323
  Get the full content of a message.
311
324
 
312
- | Parameter | Type | Required | Description |
313
- | ------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
- | `id` | string | Yes | Message ID |
315
- | `preferHtml` | boolean | No | Return HTML source instead of plain text |
316
- | `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders |
317
- | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
325
+ | Parameter | Type | Required | Description |
326
+ | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
327
+ | `id` | string | Yes | Message ID |
328
+ | `preferHtml` | boolean | No | Return HTML source instead of plain text |
329
+ | `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders. A miss there is reported as "not found in <account> / <mailbox>"; it never falls back to the cross-mailbox scan |
330
+ | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
318
331
 
319
332
  **Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available). `structuredContent` also carries `rfcMessageId` and, since 2.19.0, two dates: `dateSent` (the message's `Date:` header — Mail's `date sent`) and `dateReceived` (arrival in the mailbox — Mail's `date received` / IMAP `INTERNALDATE`). They differ legitimately by transit time; when they differ by **years**, the mailbox was migrated or re-imported and the arrival timestamp was reset — trust `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)). Since 2.19.6 `dateSent` is **omitted** when it is more than 7 days later than `dateReceived`: a message cannot be sent after it arrived, and Mail.app substitutes a timestamp of its own for a `Date:` header it cannot parse ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)). `isHtml` reports what was actually returned — a message with no `text/plain` part returns its HTML part with `isHtml: true`. Bodies are decoded by each part's declared `charset`, falling back to windows-1252 for bytes that are not valid UTF-8.
320
333
 
334
+ **Numeric ids — how the message is found ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)):** with `account` + `mailbox` the read opens that one mailbox and stops there. Otherwise it opens the mailbox the id was last listed or searched from (a remembered location that turns out stale — the message was moved, e.g. by another client — is forgotten), and only then scans every mailbox. That cross-mailbox scan skips any mailbox holding more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default `50000`; `0` disables the guard) and stops after ~9s, because Mail's `whose id is` probe costs seconds per hundred thousand messages; a miss then names the mailboxes it did not scan. Pass `account` + `mailbox` to read from one of those, or use the message's `imap:` id, which never scans. The same resolution backs `get-message-headers`, `get-thread`'s seed lookup, and the original-message read of `reply-to-message` / `forward-message` and the attachment tools.
335
+
321
336
  > **Large messages / attachments:** reading a full message routes through
322
337
  > `osascript`, whose captured output buffer defaults to **64 MB**. Override it
323
338
  > with the `APPLE_MAIL_MCP_MAX_BUFFER` environment variable (in **bytes**) if you
@@ -618,21 +633,22 @@ If IMAP is **not** configured at all, every read behaves exactly as before
618
633
  `delete-mailbox`, `rename-mailbox`) remain conservative — they route to IMAP only
619
634
  for an explicitly-named IMAP account, never on an omitted account.
620
635
 
621
- | Variable | Required | Default | Description |
622
- | -------------------------------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
623
- | `APPLE_MAIL_MCP_IMAP_USER` | Yes | — | Login address; setting it enables IMAP |
624
- | `APPLE_MAIL_MCP_IMAP_ACCOUNT` | No | = user | Mail account name to match for routing |
625
- | `APPLE_MAIL_MCP_IMAP_HOST` | No | `imap.gmail.com` | IMAP server hostname |
626
- | `APPLE_MAIL_MCP_IMAP_PORT` | No | `993` | IMAP port (993 = implicit TLS) |
627
- | `APPLE_MAIL_MCP_IMAP_ALLOW_PLAINTEXT` | No | `0` | Set `1` only for an explicitly trusted plaintext test/server; otherwise STARTTLS is required |
628
- | `APPLE_MAIL_MCP_IMAP_PASSWORD` | No | — | Password (if set, used instead of the Keychain) |
629
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_SERVICE` | No | — | Keychain item service/server name |
630
- | `APPLE_MAIL_MCP_IMAP_KEYCHAIN_ACCOUNT` | No | = user | Keychain item account |
631
- | `APPLE_MAIL_MCP_IMAP_ACCOUNTS` | No | — | JSON array of **additional** IMAP accounts for multi-account setups (see below) |
632
- | `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
633
- | `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
634
- | `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 |
635
- | `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 |
636
652
 
637
653
  **Multiple IMAP accounts (C2):** set `APPLE_MAIL_MCP_IMAP_ACCOUNTS` to a JSON array, e.g.
638
654
  `[{"account":"Work","user":"me@co.com","host":"imap.co.com","keychainService":"imap.co.com"}]`.
@@ -2127,6 +2143,10 @@ In a JSON string literal, `\\` — two characters — denotes **one** literal ba
2127
2143
  - Message may have been deleted or moved
2128
2144
  - Message IDs change if the message is moved between mailboxes
2129
2145
  - Use `search-messages` to find the current message ID
2146
+ - "**not found in the mailboxes scanned**" with a list of mailboxes means the numeric-id scan
2147
+ skipped those because they hold more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default
2148
+ `50000`). The message may be in one of them: pass `account` + `mailbox` to read it from there, or
2149
+ use its `imap:` id ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)).
2130
2150
 
2131
2151
  ### "... is present in more than one mailbox"
2132
2152
 
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();