apple-mail-mcp 2.19.16 → 2.19.18

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
@@ -280,19 +280,27 @@ human reader; the same information is also returned as structured fields on
280
280
  `search-messages` and `list-messages`, so a caller can tell _"nothing matched"_
281
281
  apart from _"I did not look everywhere"_ without parsing the text:
282
282
 
283
- | Field | Type | Meaning |
284
- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
- | `partial` | boolean | Coverage was incomplete — **the result is not a confirmed "no such mail"**. True whenever any field below is non-empty. |
286
- | `skippedLargeMailboxes` | string[] | Mailboxes never scanned because their message count exceeded `APPLE_MAIL_MAX_SEARCH_MAILBOX`, formatted `"Account / Mailbox (count)"` — e.g. `"iCloud / Archive (90694)"`. |
287
- | `notSearchedMailboxes` | string[] | Mailboxes that _were_ reached but timed out or errored mid-scan, formatted `"Account / Mailbox"`. Also carries the IMAP path's `failedMailboxes`. |
288
- | `timedOutAccounts` | string[] | Accounts whose whole-account AppleScript was killed by the per-account time budget — nothing from that account was searched. |
289
- | `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. |
283
+ | Field | Type | Meaning |
284
+ | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `partial` | boolean | Coverage was incomplete — **the result is not a confirmed "no such mail"**. True whenever any field below is non-empty. |
286
+ | `skippedLargeMailboxes` | string[] | Mailboxes never scanned because their message count exceeded `APPLE_MAIL_MAX_SEARCH_MAILBOX`, formatted `"Account / Mailbox (count)"` — e.g. `"iCloud / Archive (90694)"`. |
287
+ | `notSearchedMailboxes` | string[] | Mailboxes that _were_ reached but timed out or errored mid-scan, formatted `"Account / Mailbox"`. Also carries the IMAP path's `failedMailboxes`. |
288
+ | `timedOutAccounts` | string[] | Accounts whose whole-account AppleScript was killed by the per-account time budget — nothing from that account was searched. |
289
+ | `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. |
290
+ | `failedMailboxReasons` | object | The IMAP server's own error text for each entry in `failedMailboxes`, keyed the same way. |
291
+ | `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)) |
290
292
 
291
293
  Treat a non-empty `skippedLargeMailboxes` as actionable rather than
292
294
  informational: re-run scoped to the named mailbox with a `dateFrom`/`dateTo`
293
295
  window, or configure the [IMAP backend](#imap-backend--opt-in), which searches
294
- those mailboxes server-side. All five fields are optional and are omitted when
295
- coverage was complete.
296
+ those mailboxes server-side. The list fields are optional and are omitted (or
297
+ empty) when coverage was complete.
298
+
299
+ A row the IMAP backend could only read with a reduced item set (its full
300
+ `BODYSTRUCTURE`, or its whole `FETCH` response, was unparseable — e.g. a message
301
+ forwarded as an attachment many levels deep) is still returned, with a
302
+ `metadataIncomplete` note saying what was missing; its `hasAttachments` is then
303
+ inferred from the top-level `Content-Type`.
296
304
 
297
305
  ---
298
306
 
@@ -826,7 +834,7 @@ Reply to an existing message.
826
834
  | Parameter | Type | Required | Description |
827
835
  | ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------- |
828
836
  | `id` | string | Yes | Message ID to reply to |
829
- | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
837
+ | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
830
838
  | `replyAll` | boolean | No | Reply to all recipients (default: false) |
831
839
  | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
832
840
  | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
@@ -1081,17 +1089,31 @@ Matching ignores letter case **and Unicode normalization form**: a typed
1081
1089
  precomposed `México` (NFC) finds a mailbox the server stores decomposed (NFD —
1082
1090
  common for folders created on a Mac, and what iCloud keeps), and the server's
1083
1091
  own stored spelling is what gets selected. Two mailboxes whose names differ
1084
- *only* in case or normalization look identical in any listing, so a name that
1092
+ _only_ in case or normalization look identical in any listing, so a name that
1085
1093
  matches both is refused with an error saying so — rename one of them. For the
1086
1094
  same reason `create-mailbox` treats a normalization-equivalent existing name as
1087
1095
  already existing, and `rename-mailbox` refuses to create such a twin.
1088
1096
 
1089
1097
  **Messages awaiting expunge are not listed.** On IMAP accounts, search,
1090
- list, thread and mail-stats queries only match messages *not* flagged
1098
+ list, thread and mail-stats queries only match messages _not_ flagged
1091
1099
  `\Deleted` (IMAP `UNDELETED`). iCloud keeps flagged-but-never-expunged
1092
1100
  messages out of its message counts yet still returns them from `UID SEARCH`,
1093
1101
  which made a filter-less `list-messages` fail on such a mailbox before 2.19.16.
1094
1102
 
1103
+ **Very large IMAP mailboxes are read newest-first in bounded windows**
1104
+ ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)). Above 10,000
1105
+ messages, a filter-less `list-messages`/`search-messages` pages by message
1106
+ sequence number from the top of the mailbox — it fetches UIDs and flags for
1107
+ just enough of the newest messages to fill `limit` + `offset` (skipping
1108
+ `\Deleted` ones) and full rows for the page alone, so `limit: 1` on a
1109
+ 793,614-message mailbox costs one small `FETCH`, not a whole-mailbox `SEARCH`.
1110
+ A filtered search there runs the same criteria over newest-first sequence
1111
+ windows (5,000 messages, growing to 50,000) and stops once the page is full;
1112
+ the reported total then reads `at least N` unless the walk reached the bottom
1113
+ of the mailbox. A `SEARCH` or `FETCH` that fails names the mailbox, its size and
1114
+ the server's own reason (or a likely timeout) instead of reporting "no
1115
+ messages".
1116
+
1095
1117
  **Mail's local "On My Mac" mailboxes** are not children of any account — they
1096
1118
  hang off the application — so they are reported under the synthetic account label
1097
1119
  **`On My Mac`**. An unscoped call includes them (listed last); `account="On My
package/build/cli.js CHANGED
@@ -58045,6 +58045,7 @@ var imapClient_exports = {};
58045
58045
  __export(imapClient_exports, {
58046
58046
  HEADER_WINDOW_BYTES: () => HEADER_WINDOW_BYTES,
58047
58047
  IMAP_ENV: () => IMAP_ENV,
58048
+ LARGE_MAILBOX_MESSAGES: () => LARGE_MAILBOX_MESSAGES,
58048
58049
  MAX_COMPOSE_SOURCE_BYTES: () => MAX_COMPOSE_SOURCE_BYTES,
58049
58050
  MAX_RFC822_FILE_BYTES: () => MAX_RFC822_FILE_BYTES,
58050
58051
  MAX_RFC822_INLINE_BYTES: () => MAX_RFC822_INLINE_BYTES,
@@ -58091,6 +58092,7 @@ __export(imapClient_exports, {
58091
58092
  mailFlagColorIndex: () => mailFlagColorIndex,
58092
58093
  matchMailbox: () => matchMailbox,
58093
58094
  normalizeMessageId: () => normalizeMessageId,
58095
+ omittedNote: () => omittedNote,
58094
58096
  resolveImapConfig: () => resolveImapConfig,
58095
58097
  resolveImapConfigs: () => resolveImapConfigs,
58096
58098
  resolveMailboxPath: () => resolveMailboxPath,
@@ -58401,7 +58403,12 @@ function structuredRow(m, account, path) {
58401
58403
  // a caller from "no attachments", so every IMAP-sourced message claimed to
58402
58404
  // have none. Falls back to false only when the fetch carried no
58403
58405
  // BODYSTRUCTURE at all.
58404
- hasAttachments: bodyStructureHasAttachments(m.bodyStructure),
58406
+ // A row read without BODYSTRUCTURE (#256 follow-up) judges by its top-level
58407
+ // Content-Type instead, and says so in `metadataIncomplete`.
58408
+ hasAttachments: m.bodyStructure ? bodyStructureHasAttachments(m.bodyStructure) : m.degraded ? contentTypeSuggestsAttachments(m) : false,
58409
+ ...m.degraded ? {
58410
+ metadataIncomplete: m.degraded === "bodystructure" ? "BODYSTRUCTURE unreadable; hasAttachments inferred from Content-Type" : "FETCH response unreadable; row rebuilt from raw headers, hasAttachments inferred from Content-Type"
58411
+ } : {},
58405
58412
  // Message-ID (when the envelope carries it) is the strongest cross-/intra-
58406
58413
  // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
58407
58414
  // AppleScript path does not expose it, so cross-backend dedup falls back to
@@ -58409,6 +58416,13 @@ function structuredRow(m, account, path) {
58409
58416
  ...env.messageId ? { messageId: env.messageId } : {}
58410
58417
  };
58411
58418
  }
58419
+ function omittedNote(omitted, merged) {
58420
+ if (omitted.length === 0) return "";
58421
+ const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
58422
+ return `
58423
+
58424
+ Partial result. ${omitted.length} message(s) ${merged ? "that may belong" : "that belong"} on this page could not be read and are not listed: ${list}. Reason: ${[...new Set(omitted.map((o) => o.reason))].join("; ")}. get-message or get-message-headers with those ids may still read them.`;
58425
+ }
58412
58426
  function describeMailboxFailure(error) {
58413
58427
  const raw = errText(error);
58414
58428
  const oneLine = raw.split("\n")[0].trim();
@@ -58428,44 +58442,215 @@ function messageIdentity(entry) {
58428
58442
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
58429
58443
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
58430
58444
  }
58431
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
58432
- const lock = await client.getMailboxLock(path);
58445
+ function isUnfiltered(criteria) {
58446
+ const keys = Object.keys(criteria);
58447
+ return keys.length === 1 && criteria.deleted === false;
58448
+ }
58449
+ async function statusMessageCount(client, path) {
58433
58450
  try {
58434
- const found = await client.search(criteria, { uid: true });
58435
- const uids = Array.isArray(found) ? found : [];
58436
- if (uids.length === 0 || newestCount === 0) return { messages: [], total: uids.length };
58437
- const status = await client.status(path, { messages: true });
58438
- if (typeof status.messages === "number" && uids.length > status.messages) {
58451
+ const st = await client.status(path, { messages: true });
58452
+ return typeof st.messages === "number" && st.messages >= 0 ? st.messages : void 0;
58453
+ } catch {
58454
+ return void 0;
58455
+ }
58456
+ }
58457
+ async function searchUids(client, path, criteria, size) {
58458
+ client.takeLastCommandError?.();
58459
+ const found = await client.search(criteria, { uid: true });
58460
+ if (Array.isArray(found)) return found;
58461
+ const cause = client.takeLastCommandError?.();
58462
+ const sized = size === void 0 ? "" : ` (${size.toLocaleString("en-US")} messages)`;
58463
+ throw new Error(
58464
+ `IMAP SEARCH on "${path}"${sized} failed: ` + (cause ? errText(cause) : "the server rejected it or the connection dropped before it answered (on a mailbox this size, usually a server-side timeout)")
58465
+ );
58466
+ }
58467
+ function mergeFetched(into, msg) {
58468
+ const prev = into.get(msg.uid);
58469
+ if (!prev) {
58470
+ into.set(msg.uid, msg);
58471
+ return;
58472
+ }
58473
+ const merged = { ...prev };
58474
+ for (const [key, value] of Object.entries(msg)) {
58475
+ if (value !== void 0 && value !== null)
58476
+ merged[key] = value;
58477
+ }
58478
+ into.set(msg.uid, merged);
58479
+ }
58480
+ async function fetchInto(client, uids, query, into) {
58481
+ const wanted = new Set(uids);
58482
+ for await (const msg of client.fetch(uids.join(","), query, { uid: true })) {
58483
+ if (typeof msg?.uid === "number" && wanted.has(msg.uid)) mergeFetched(into, msg);
58484
+ }
58485
+ }
58486
+ function fetchedHeaders(m) {
58487
+ return m.headers ? parseHeaderBlock(decodeHeaderBytes(asBuffer(m.headers))) : void 0;
58488
+ }
58489
+ function envelopeFromHeaders(m) {
58490
+ const h = fetchedHeaders(m);
58491
+ if (!h || h.headers.length === 0) return void 0;
58492
+ const env = {};
58493
+ if (h.subject) env.subject = h.subject;
58494
+ if (h.from) {
58495
+ const angle = h.from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
58496
+ env.from = angle ? [{ ...angle[1].trim() ? { name: angle[1].trim() } : {}, address: angle[2].trim() }] : [{ address: h.from.trim() }];
58497
+ }
58498
+ if (h.messageId) env.messageId = `<${h.messageId}>`;
58499
+ if (h.inReplyTo) env.inReplyTo = `<${h.inReplyTo}>`;
58500
+ if (h.dateHeader) env.date = h.dateHeader;
58501
+ return env;
58502
+ }
58503
+ function contentTypeSuggestsAttachments(m) {
58504
+ const type = fetchedHeaders(m)?.headers.find((h) => h.name.toLowerCase() === "content-type");
58505
+ return /^\s*multipart\/mixed\b/i.test(type?.value ?? "");
58506
+ }
58507
+ async function fetchRows(client, uids) {
58508
+ if (uids.length === 0) return { messages: [], omitted: [] };
58509
+ const byUid = /* @__PURE__ */ new Map();
58510
+ const causes = [];
58511
+ const noteCause = () => {
58512
+ const err = client.takeLastCommandError?.();
58513
+ if (err !== void 0) {
58514
+ const text = describeMailboxFailure(err);
58515
+ if (!causes.includes(text)) causes.push(text);
58516
+ }
58517
+ };
58518
+ const missing = () => uids.filter((uid) => !byUid.get(uid)?.envelope);
58519
+ client.takeLastCommandError?.();
58520
+ await fetchInto(client, uids, ROW_QUERY, byUid);
58521
+ noteCause();
58522
+ let gap = missing();
58523
+ if (gap.length > 0) {
58524
+ await fetchInto(client, gap, ROW_QUERY, byUid);
58525
+ noteCause();
58526
+ gap = missing();
58527
+ }
58528
+ if (gap.length > 0) {
58529
+ const reduced = /* @__PURE__ */ new Map();
58530
+ await fetchInto(client, gap, ROW_QUERY_NO_STRUCTURE, reduced);
58531
+ noteCause();
58532
+ for (const msg of reduced.values()) {
58533
+ if (!msg.envelope) continue;
58534
+ mergeFetched(byUid, { ...msg, degraded: "bodystructure" });
58535
+ }
58536
+ gap = missing();
58537
+ }
58538
+ if (gap.length > 0) {
58539
+ for (const uid of gap) {
58540
+ const bare = /* @__PURE__ */ new Map();
58541
+ await fetchInto(client, [uid], ROW_QUERY_HEADERS_ONLY, bare);
58542
+ noteCause();
58543
+ const msg = bare.get(uid);
58544
+ const envelope = msg ? envelopeFromHeaders(msg) : void 0;
58545
+ if (msg && envelope) {
58546
+ mergeFetched(byUid, { ...msg, envelope, degraded: "envelope" });
58547
+ }
58548
+ }
58549
+ gap = missing();
58550
+ }
58551
+ const omitted = new Set(gap);
58552
+ return {
58553
+ messages: uids.filter((uid) => !omitted.has(uid)).map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
58554
+ omitted: gap,
58555
+ ...causes.length > 0 ? { cause: causes.join("; ") } : {}
58556
+ };
58557
+ }
58558
+ function pageRows(fetch) {
58559
+ return {
58560
+ messages: fetch.messages,
58561
+ omitted: fetch.omitted,
58562
+ ...fetch.omitted.length > 0 ? { omittedReason: omissionReason(fetch) } : {}
58563
+ };
58564
+ }
58565
+ function omissionReason(fetch) {
58566
+ return "the server's FETCH response for this message could not be read, even without BODYSTRUCTURE or ENVELOPE" + (fetch.cause ? ` (${fetch.cause})` : "");
58567
+ }
58568
+ async function walkWindows(exists, page, firstWindow, readWindow) {
58569
+ const wanted = page.skip + page.take;
58570
+ const uids = [];
58571
+ let seen = 0;
58572
+ let matched = 0;
58573
+ let hi = exists;
58574
+ let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
58575
+ while (hi >= 1 && seen < wanted) {
58576
+ const lo = Math.max(1, hi - width + 1);
58577
+ const live = (await readWindow(lo, hi === exists ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
58578
+ matched += live.length;
58579
+ for (const uid of live) {
58580
+ if (seen >= wanted) break;
58581
+ if (seen >= page.skip) uids.push(uid);
58582
+ seen++;
58583
+ }
58584
+ hi = lo - 1;
58585
+ width = Math.min(width * 4, MAX_WINDOW);
58586
+ }
58587
+ return { uids, matched, exhausted: hi < 1 };
58588
+ }
58589
+ async function listLargeMailbox(client, exists, page) {
58590
+ let deletedSeen = 0;
58591
+ const walk = await walkWindows(
58592
+ exists,
58593
+ page,
58594
+ // Room for a few ghosts on the first read without a second round trip.
58595
+ page.skip + page.take + 64,
58596
+ async (lo, hi) => {
58597
+ const live = [];
58598
+ for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
58599
+ if (msg.flags?.has("\\Deleted")) deletedSeen++;
58600
+ else live.push(msg.uid);
58601
+ }
58602
+ return live;
58603
+ }
58604
+ );
58605
+ return {
58606
+ ...pageRows(await fetchRows(client, walk.uids)),
58607
+ total: Math.max(0, exists - deletedSeen),
58608
+ totalExact: true
58609
+ };
58610
+ }
58611
+ async function searchLargeMailbox(client, path, criteria, exists, page) {
58612
+ const walk = await walkWindows(exists, page, FIRST_SEARCH_WINDOW, async (lo, hi, width) => {
58613
+ const found = await searchUids(client, path, { ...criteria, seq: `${lo}:${hi}` }, exists);
58614
+ if (found.length > width) {
58439
58615
  throw new Error(
58440
- `IMAP SEARCH on "${path}" reported ${uids.length} matches, more than the mailbox's own ${status.messages} messages \u2014 discarding as corrupted rather than trusting it (see #246).`
58616
+ `IMAP SEARCH on "${path}" reported ${found.length} matches in a ${width}-message window \u2014 discarding as corrupted rather than trusting it (see #246).`
58441
58617
  );
58442
58618
  }
58443
- const newest = uids.slice().reverse().slice(0, newestCount);
58444
- const byUid = /* @__PURE__ */ new Map();
58445
- for await (const msg of client.fetch(
58446
- newest.join(","),
58447
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
58448
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
58449
- // the fetch (~17%), same single round trip, no extra request.
58450
- //
58451
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
58452
- // can finally mean what it says: imapflow's `envelope.date` is built from
58453
- // the header block, so it IS the `Date:` header, not arrival time.
58454
- //
58455
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
58456
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
58457
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
58458
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
58459
- // bytes per message. Too cheap to hide behind an opt-in.
58460
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
58461
- { uid: true }
58462
- )) {
58463
- byUid.set(msg.uid, msg);
58619
+ return found;
58620
+ });
58621
+ return {
58622
+ ...pageRows(await fetchRows(client, walk.uids)),
58623
+ total: walk.matched,
58624
+ totalExact: walk.exhausted
58625
+ };
58626
+ }
58627
+ async function fetchMailboxMatches(client, path, criteria, page) {
58628
+ const lock = await client.getMailboxLock(path);
58629
+ try {
58630
+ const exists = await statusMessageCount(client, path);
58631
+ if (exists === 0) return { messages: [], omitted: [], total: 0, totalExact: true };
58632
+ if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
58633
+ try {
58634
+ return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
58635
+ } catch (error) {
58636
+ const detail = errText(error);
58637
+ throw new Error(
58638
+ detail.includes(`"${path}" (`) ? detail : `reading the newest messages of "${path}" (${exists.toLocaleString("en-US")} messages) failed: ${detail}`
58639
+ );
58640
+ }
58464
58641
  }
58465
- return {
58466
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
58467
- total: uids.length
58468
- };
58642
+ const uids = await searchUids(client, path, criteria, exists);
58643
+ if (uids.length === 0 || page.take === 0) {
58644
+ return { messages: [], omitted: [], total: uids.length, totalExact: true };
58645
+ }
58646
+ const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
58647
+ if (typeof messages === "number" && uids.length > messages) {
58648
+ throw new Error(
58649
+ `IMAP SEARCH on "${path}" reported ${uids.length} matches, more than the mailbox's own ${messages} messages \u2014 discarding as corrupted rather than trusting it (see #246).`
58650
+ );
58651
+ }
58652
+ const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
58653
+ return { ...pageRows(await fetchRows(client, newest)), total: uids.length, totalExact: true };
58469
58654
  } finally {
58470
58655
  lock.release();
58471
58656
  }
@@ -58494,16 +58679,27 @@ async function run(args, listMode, deps) {
58494
58679
  const limit = args.limit ?? 50;
58495
58680
  const offset = args.offset ?? 0;
58496
58681
  const criteria = buildCriteria(args, listMode);
58497
- const newestPerMailbox = offset + limit;
58682
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
58498
58683
  const fetched = [];
58499
58684
  const failedMailboxes = [];
58500
58685
  const failedMailboxReasons = {};
58686
+ const omittedMessages = [];
58501
58687
  let totalMatched = 0;
58688
+ let totalExact = true;
58502
58689
  for (const path of paths) {
58503
58690
  try {
58504
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
58691
+ const result = await fetchMailboxMatches(client, path, criteria, page);
58505
58692
  totalMatched += result.total;
58693
+ totalExact &&= result.totalExact;
58506
58694
  fetched.push(...result.messages.map((message) => ({ message, path })));
58695
+ for (const uid of result.omitted) {
58696
+ omittedMessages.push({
58697
+ id: encodeImapId(cfg.accountLabel, path, uid),
58698
+ mailbox: path,
58699
+ uid,
58700
+ reason: result.omittedReason ?? "the server's FETCH response could not be read"
58701
+ });
58702
+ }
58507
58703
  } catch (error) {
58508
58704
  failedMailboxes.push(path);
58509
58705
  failedMailboxReasons[path] = describeMailboxFailure(error);
@@ -58527,18 +58723,17 @@ async function run(args, listMode, deps) {
58527
58723
  if (!unique.has(key)) unique.set(key, entry);
58528
58724
  }
58529
58725
  ordered = [...unique.values()].slice(offset, offset + limit);
58530
- } else {
58531
- ordered = fetched.slice(offset, offset + limit);
58532
58726
  }
58533
58727
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
58534
58728
  const messages = ordered.map(
58535
58729
  ({ message, path }) => structuredRow(message, cfg.accountLabel, path)
58536
58730
  );
58537
- const partial = failedMailboxes.length > 0;
58538
- const failureNote = partial ? `
58731
+ const partial = failedMailboxes.length > 0 || omittedMessages.length > 0;
58732
+ const failureNote = (failedMailboxes.length > 0 ? `
58539
58733
 
58540
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
58734
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
58541
58735
  const verb = listMode ? "listed" : "matched";
58736
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
58542
58737
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
58543
58738
  if (messages.length === 0) {
58544
58739
  return {
@@ -58547,10 +58742,11 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
58547
58742
  count: 0,
58548
58743
  partial,
58549
58744
  failedMailboxes,
58550
- failedMailboxReasons
58745
+ failedMailboxReasons,
58746
+ omittedMessages
58551
58747
  };
58552
58748
  }
58553
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalMatched} total ${verb}):
58749
+ const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
58554
58750
  ` + rows.join("\n") + `
58555
58751
 
58556
58752
  Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutations (mark/flag/move/delete-message), which route back to IMAP.` + failureNote;
@@ -58560,7 +58756,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
58560
58756
  count: messages.length,
58561
58757
  partial,
58562
58758
  failedMailboxes,
58563
- failedMailboxReasons
58759
+ failedMailboxReasons,
58760
+ omittedMessages
58564
58761
  };
58565
58762
  },
58566
58763
  true
@@ -59520,31 +59717,21 @@ async function imapThread(id, deps = {}, limit = 50) {
59520
59717
  }
59521
59718
  if (uidSet.size <= 1) return null;
59522
59719
  const uids = [...uidSet].slice(0, limit);
59523
- const msgs = [];
59524
- for await (const msg of client.fetch(
59525
- uids.join(","),
59526
- // Same reason as the list/search fetch: get-thread emits structured
59527
- // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
59528
- // silently disagree with the same message seen via search.
59529
- // INTERNALDATE and the Date: header ride along for the same reason as
59530
- // the list/search fetch: the per-message date is recovered and
59531
- // sanity-checked exactly as a row's dateSent is (#234).
59532
- {
59533
- envelope: true,
59534
- flags: true,
59535
- bodyStructure: true,
59536
- internalDate: true,
59537
- headers: ["date"]
59538
- },
59539
- { uid: true }
59540
- )) {
59541
- msgs.push(msg);
59542
- }
59720
+ const rows = await fetchRows(client, uids);
59721
+ const msgs = rows.messages.slice();
59722
+ const omittedMessages = rows.omitted.map((uid) => ({
59723
+ id: encodeImapId(ref.account, ref.path, uid),
59724
+ mailbox: ref.path,
59725
+ uid,
59726
+ reason: omissionReason(rows)
59727
+ }));
59543
59728
  msgs.sort((a, b) => dateMs(a) - dateMs(b));
59544
59729
  const subject = seed.envelope?.subject || "(no subject)";
59545
59730
  const structured = {
59546
59731
  subject,
59547
59732
  count: msgs.length,
59733
+ partial: omittedMessages.length > 0,
59734
+ omittedMessages,
59548
59735
  messages: msgs.map((m) => ({
59549
59736
  id: encodeImapId(ref.account, ref.path, m.uid),
59550
59737
  subject: m.envelope?.subject || "(no subject)",
@@ -59559,7 +59746,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59559
59746
  }))
59560
59747
  };
59561
59748
  const text = `Thread "${subject}" \u2014 ${msgs.length} message(s) via IMAP (References-linked, oldest first):
59562
- ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
59749
+ ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n") + omittedNote(omittedMessages, false);
59563
59750
  return { count: msgs.length, text, structured };
59564
59751
  } finally {
59565
59752
  lock.release();
@@ -59568,7 +59755,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59568
59755
  true
59569
59756
  );
59570
59757
  }
59571
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59758
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59572
59759
  var init_imapClient = __esm({
59573
59760
  "src/services/imapClient.ts"() {
59574
59761
  "use strict";
@@ -59594,7 +59781,24 @@ var init_imapClient = __esm({
59594
59781
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
59595
59782
  };
59596
59783
  defaultConnect = async (cfg) => {
59597
- const client = new import_imapflow.ImapFlow(buildImapConnectionOptions(cfg));
59784
+ let lastCommandError;
59785
+ const keepErr = (entry) => {
59786
+ if (entry && typeof entry === "object" && "err" in entry) {
59787
+ lastCommandError = entry.err;
59788
+ }
59789
+ };
59790
+ const noop = () => void 0;
59791
+ const client = new import_imapflow.ImapFlow({
59792
+ ...buildImapConnectionOptions(cfg),
59793
+ logger: { trace: noop, debug: noop, info: noop, warn: keepErr, error: keepErr, fatal: keepErr }
59794
+ });
59795
+ Object.assign(client, {
59796
+ takeLastCommandError: () => {
59797
+ const err = lastCommandError;
59798
+ lastCommandError = void 0;
59799
+ return err;
59800
+ }
59801
+ });
59598
59802
  client.on("error", () => {
59599
59803
  });
59600
59804
  try {
@@ -59622,6 +59826,27 @@ var init_imapClient = __esm({
59622
59826
  starred: "\\flagged"
59623
59827
  };
59624
59828
  NOT_DELETED = { deleted: false };
59829
+ LARGE_MAILBOX_MESSAGES = 1e4;
59830
+ FIRST_SEARCH_WINDOW = 5e3;
59831
+ MAX_WINDOW = 5e4;
59832
+ ROW_QUERY = {
59833
+ envelope: true,
59834
+ flags: true,
59835
+ bodyStructure: true,
59836
+ internalDate: true,
59837
+ headers: ["date"]
59838
+ };
59839
+ ROW_QUERY_NO_STRUCTURE = {
59840
+ envelope: true,
59841
+ flags: true,
59842
+ internalDate: true,
59843
+ headers: ["date", "content-type"]
59844
+ };
59845
+ ROW_QUERY_HEADERS_ONLY = {
59846
+ flags: true,
59847
+ internalDate: true,
59848
+ headers: ["date", "from", "subject", "message-id", "in-reply-to", "content-type"]
59849
+ };
59625
59850
  poolConnect = defaultConnect;
59626
59851
  pools = /* @__PURE__ */ new Map();
59627
59852
  connecting = /* @__PURE__ */ new Map();
package/build/index.js CHANGED
@@ -65374,6 +65374,7 @@ var imapClient_exports = {};
65374
65374
  __export(imapClient_exports, {
65375
65375
  HEADER_WINDOW_BYTES: () => HEADER_WINDOW_BYTES,
65376
65376
  IMAP_ENV: () => IMAP_ENV,
65377
+ LARGE_MAILBOX_MESSAGES: () => LARGE_MAILBOX_MESSAGES,
65377
65378
  MAX_COMPOSE_SOURCE_BYTES: () => MAX_COMPOSE_SOURCE_BYTES,
65378
65379
  MAX_RFC822_FILE_BYTES: () => MAX_RFC822_FILE_BYTES,
65379
65380
  MAX_RFC822_INLINE_BYTES: () => MAX_RFC822_INLINE_BYTES,
@@ -65420,6 +65421,7 @@ __export(imapClient_exports, {
65420
65421
  mailFlagColorIndex: () => mailFlagColorIndex,
65421
65422
  matchMailbox: () => matchMailbox,
65422
65423
  normalizeMessageId: () => normalizeMessageId,
65424
+ omittedNote: () => omittedNote,
65423
65425
  resolveImapConfig: () => resolveImapConfig,
65424
65426
  resolveImapConfigs: () => resolveImapConfigs,
65425
65427
  resolveMailboxPath: () => resolveMailboxPath,
@@ -65730,7 +65732,12 @@ function structuredRow(m, account, path) {
65730
65732
  // a caller from "no attachments", so every IMAP-sourced message claimed to
65731
65733
  // have none. Falls back to false only when the fetch carried no
65732
65734
  // BODYSTRUCTURE at all.
65733
- hasAttachments: bodyStructureHasAttachments(m.bodyStructure),
65735
+ // A row read without BODYSTRUCTURE (#256 follow-up) judges by its top-level
65736
+ // Content-Type instead, and says so in `metadataIncomplete`.
65737
+ hasAttachments: m.bodyStructure ? bodyStructureHasAttachments(m.bodyStructure) : m.degraded ? contentTypeSuggestsAttachments(m) : false,
65738
+ ...m.degraded ? {
65739
+ metadataIncomplete: m.degraded === "bodystructure" ? "BODYSTRUCTURE unreadable; hasAttachments inferred from Content-Type" : "FETCH response unreadable; row rebuilt from raw headers, hasAttachments inferred from Content-Type"
65740
+ } : {},
65734
65741
  // Message-ID (when the envelope carries it) is the strongest cross-/intra-
65735
65742
  // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
65736
65743
  // AppleScript path does not expose it, so cross-backend dedup falls back to
@@ -65738,6 +65745,13 @@ function structuredRow(m, account, path) {
65738
65745
  ...env.messageId ? { messageId: env.messageId } : {}
65739
65746
  };
65740
65747
  }
65748
+ function omittedNote(omitted, merged) {
65749
+ if (omitted.length === 0) return "";
65750
+ const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
65751
+ return `
65752
+
65753
+ Partial result. ${omitted.length} message(s) ${merged ? "that may belong" : "that belong"} on this page could not be read and are not listed: ${list}. Reason: ${[...new Set(omitted.map((o) => o.reason))].join("; ")}. get-message or get-message-headers with those ids may still read them.`;
65754
+ }
65741
65755
  function describeMailboxFailure(error2) {
65742
65756
  const raw = errText(error2);
65743
65757
  const oneLine = raw.split("\n")[0].trim();
@@ -65757,44 +65771,215 @@ function messageIdentity(entry) {
65757
65771
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
65758
65772
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
65759
65773
  }
65760
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
65761
- const lock = await client.getMailboxLock(path);
65774
+ function isUnfiltered(criteria) {
65775
+ const keys = Object.keys(criteria);
65776
+ return keys.length === 1 && criteria.deleted === false;
65777
+ }
65778
+ async function statusMessageCount(client, path) {
65762
65779
  try {
65763
- const found = await client.search(criteria, { uid: true });
65764
- const uids = Array.isArray(found) ? found : [];
65765
- if (uids.length === 0 || newestCount === 0) return { messages: [], total: uids.length };
65766
- const status = await client.status(path, { messages: true });
65767
- if (typeof status.messages === "number" && uids.length > status.messages) {
65780
+ const st = await client.status(path, { messages: true });
65781
+ return typeof st.messages === "number" && st.messages >= 0 ? st.messages : void 0;
65782
+ } catch {
65783
+ return void 0;
65784
+ }
65785
+ }
65786
+ async function searchUids(client, path, criteria, size) {
65787
+ client.takeLastCommandError?.();
65788
+ const found = await client.search(criteria, { uid: true });
65789
+ if (Array.isArray(found)) return found;
65790
+ const cause = client.takeLastCommandError?.();
65791
+ const sized = size === void 0 ? "" : ` (${size.toLocaleString("en-US")} messages)`;
65792
+ throw new Error(
65793
+ `IMAP SEARCH on "${path}"${sized} failed: ` + (cause ? errText(cause) : "the server rejected it or the connection dropped before it answered (on a mailbox this size, usually a server-side timeout)")
65794
+ );
65795
+ }
65796
+ function mergeFetched(into, msg) {
65797
+ const prev = into.get(msg.uid);
65798
+ if (!prev) {
65799
+ into.set(msg.uid, msg);
65800
+ return;
65801
+ }
65802
+ const merged = { ...prev };
65803
+ for (const [key, value] of Object.entries(msg)) {
65804
+ if (value !== void 0 && value !== null)
65805
+ merged[key] = value;
65806
+ }
65807
+ into.set(msg.uid, merged);
65808
+ }
65809
+ async function fetchInto(client, uids, query, into) {
65810
+ const wanted = new Set(uids);
65811
+ for await (const msg of client.fetch(uids.join(","), query, { uid: true })) {
65812
+ if (typeof msg?.uid === "number" && wanted.has(msg.uid)) mergeFetched(into, msg);
65813
+ }
65814
+ }
65815
+ function fetchedHeaders(m) {
65816
+ return m.headers ? parseHeaderBlock(decodeHeaderBytes(asBuffer(m.headers))) : void 0;
65817
+ }
65818
+ function envelopeFromHeaders(m) {
65819
+ const h = fetchedHeaders(m);
65820
+ if (!h || h.headers.length === 0) return void 0;
65821
+ const env = {};
65822
+ if (h.subject) env.subject = h.subject;
65823
+ if (h.from) {
65824
+ const angle = h.from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
65825
+ env.from = angle ? [{ ...angle[1].trim() ? { name: angle[1].trim() } : {}, address: angle[2].trim() }] : [{ address: h.from.trim() }];
65826
+ }
65827
+ if (h.messageId) env.messageId = `<${h.messageId}>`;
65828
+ if (h.inReplyTo) env.inReplyTo = `<${h.inReplyTo}>`;
65829
+ if (h.dateHeader) env.date = h.dateHeader;
65830
+ return env;
65831
+ }
65832
+ function contentTypeSuggestsAttachments(m) {
65833
+ const type = fetchedHeaders(m)?.headers.find((h) => h.name.toLowerCase() === "content-type");
65834
+ return /^\s*multipart\/mixed\b/i.test(type?.value ?? "");
65835
+ }
65836
+ async function fetchRows(client, uids) {
65837
+ if (uids.length === 0) return { messages: [], omitted: [] };
65838
+ const byUid = /* @__PURE__ */ new Map();
65839
+ const causes = [];
65840
+ const noteCause = () => {
65841
+ const err = client.takeLastCommandError?.();
65842
+ if (err !== void 0) {
65843
+ const text = describeMailboxFailure(err);
65844
+ if (!causes.includes(text)) causes.push(text);
65845
+ }
65846
+ };
65847
+ const missing = () => uids.filter((uid) => !byUid.get(uid)?.envelope);
65848
+ client.takeLastCommandError?.();
65849
+ await fetchInto(client, uids, ROW_QUERY, byUid);
65850
+ noteCause();
65851
+ let gap = missing();
65852
+ if (gap.length > 0) {
65853
+ await fetchInto(client, gap, ROW_QUERY, byUid);
65854
+ noteCause();
65855
+ gap = missing();
65856
+ }
65857
+ if (gap.length > 0) {
65858
+ const reduced = /* @__PURE__ */ new Map();
65859
+ await fetchInto(client, gap, ROW_QUERY_NO_STRUCTURE, reduced);
65860
+ noteCause();
65861
+ for (const msg of reduced.values()) {
65862
+ if (!msg.envelope) continue;
65863
+ mergeFetched(byUid, { ...msg, degraded: "bodystructure" });
65864
+ }
65865
+ gap = missing();
65866
+ }
65867
+ if (gap.length > 0) {
65868
+ for (const uid of gap) {
65869
+ const bare = /* @__PURE__ */ new Map();
65870
+ await fetchInto(client, [uid], ROW_QUERY_HEADERS_ONLY, bare);
65871
+ noteCause();
65872
+ const msg = bare.get(uid);
65873
+ const envelope = msg ? envelopeFromHeaders(msg) : void 0;
65874
+ if (msg && envelope) {
65875
+ mergeFetched(byUid, { ...msg, envelope, degraded: "envelope" });
65876
+ }
65877
+ }
65878
+ gap = missing();
65879
+ }
65880
+ const omitted = new Set(gap);
65881
+ return {
65882
+ messages: uids.filter((uid) => !omitted.has(uid)).map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
65883
+ omitted: gap,
65884
+ ...causes.length > 0 ? { cause: causes.join("; ") } : {}
65885
+ };
65886
+ }
65887
+ function pageRows(fetch) {
65888
+ return {
65889
+ messages: fetch.messages,
65890
+ omitted: fetch.omitted,
65891
+ ...fetch.omitted.length > 0 ? { omittedReason: omissionReason(fetch) } : {}
65892
+ };
65893
+ }
65894
+ function omissionReason(fetch) {
65895
+ return "the server's FETCH response for this message could not be read, even without BODYSTRUCTURE or ENVELOPE" + (fetch.cause ? ` (${fetch.cause})` : "");
65896
+ }
65897
+ async function walkWindows(exists, page, firstWindow, readWindow) {
65898
+ const wanted = page.skip + page.take;
65899
+ const uids = [];
65900
+ let seen = 0;
65901
+ let matched = 0;
65902
+ let hi = exists;
65903
+ let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
65904
+ while (hi >= 1 && seen < wanted) {
65905
+ const lo = Math.max(1, hi - width + 1);
65906
+ const live = (await readWindow(lo, hi === exists ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
65907
+ matched += live.length;
65908
+ for (const uid of live) {
65909
+ if (seen >= wanted) break;
65910
+ if (seen >= page.skip) uids.push(uid);
65911
+ seen++;
65912
+ }
65913
+ hi = lo - 1;
65914
+ width = Math.min(width * 4, MAX_WINDOW);
65915
+ }
65916
+ return { uids, matched, exhausted: hi < 1 };
65917
+ }
65918
+ async function listLargeMailbox(client, exists, page) {
65919
+ let deletedSeen = 0;
65920
+ const walk = await walkWindows(
65921
+ exists,
65922
+ page,
65923
+ // Room for a few ghosts on the first read without a second round trip.
65924
+ page.skip + page.take + 64,
65925
+ async (lo, hi) => {
65926
+ const live = [];
65927
+ for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
65928
+ if (msg.flags?.has("\\Deleted")) deletedSeen++;
65929
+ else live.push(msg.uid);
65930
+ }
65931
+ return live;
65932
+ }
65933
+ );
65934
+ return {
65935
+ ...pageRows(await fetchRows(client, walk.uids)),
65936
+ total: Math.max(0, exists - deletedSeen),
65937
+ totalExact: true
65938
+ };
65939
+ }
65940
+ async function searchLargeMailbox(client, path, criteria, exists, page) {
65941
+ const walk = await walkWindows(exists, page, FIRST_SEARCH_WINDOW, async (lo, hi, width) => {
65942
+ const found = await searchUids(client, path, { ...criteria, seq: `${lo}:${hi}` }, exists);
65943
+ if (found.length > width) {
65768
65944
  throw new Error(
65769
- `IMAP SEARCH on "${path}" reported ${uids.length} matches, more than the mailbox's own ${status.messages} messages \u2014 discarding as corrupted rather than trusting it (see #246).`
65945
+ `IMAP SEARCH on "${path}" reported ${found.length} matches in a ${width}-message window \u2014 discarding as corrupted rather than trusting it (see #246).`
65770
65946
  );
65771
65947
  }
65772
- const newest = uids.slice().reverse().slice(0, newestCount);
65773
- const byUid = /* @__PURE__ */ new Map();
65774
- for await (const msg of client.fetch(
65775
- newest.join(","),
65776
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
65777
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
65778
- // the fetch (~17%), same single round trip, no extra request.
65779
- //
65780
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
65781
- // can finally mean what it says: imapflow's `envelope.date` is built from
65782
- // the header block, so it IS the `Date:` header, not arrival time.
65783
- //
65784
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
65785
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
65786
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
65787
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
65788
- // bytes per message. Too cheap to hide behind an opt-in.
65789
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
65790
- { uid: true }
65791
- )) {
65792
- byUid.set(msg.uid, msg);
65948
+ return found;
65949
+ });
65950
+ return {
65951
+ ...pageRows(await fetchRows(client, walk.uids)),
65952
+ total: walk.matched,
65953
+ totalExact: walk.exhausted
65954
+ };
65955
+ }
65956
+ async function fetchMailboxMatches(client, path, criteria, page) {
65957
+ const lock = await client.getMailboxLock(path);
65958
+ try {
65959
+ const exists = await statusMessageCount(client, path);
65960
+ if (exists === 0) return { messages: [], omitted: [], total: 0, totalExact: true };
65961
+ if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
65962
+ try {
65963
+ return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
65964
+ } catch (error2) {
65965
+ const detail = errText(error2);
65966
+ throw new Error(
65967
+ detail.includes(`"${path}" (`) ? detail : `reading the newest messages of "${path}" (${exists.toLocaleString("en-US")} messages) failed: ${detail}`
65968
+ );
65969
+ }
65793
65970
  }
65794
- return {
65795
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
65796
- total: uids.length
65797
- };
65971
+ const uids = await searchUids(client, path, criteria, exists);
65972
+ if (uids.length === 0 || page.take === 0) {
65973
+ return { messages: [], omitted: [], total: uids.length, totalExact: true };
65974
+ }
65975
+ const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
65976
+ if (typeof messages === "number" && uids.length > messages) {
65977
+ throw new Error(
65978
+ `IMAP SEARCH on "${path}" reported ${uids.length} matches, more than the mailbox's own ${messages} messages \u2014 discarding as corrupted rather than trusting it (see #246).`
65979
+ );
65980
+ }
65981
+ const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
65982
+ return { ...pageRows(await fetchRows(client, newest)), total: uids.length, totalExact: true };
65798
65983
  } finally {
65799
65984
  lock.release();
65800
65985
  }
@@ -65823,16 +66008,27 @@ async function run(args, listMode, deps) {
65823
66008
  const limit = args.limit ?? 50;
65824
66009
  const offset = args.offset ?? 0;
65825
66010
  const criteria = buildCriteria(args, listMode);
65826
- const newestPerMailbox = offset + limit;
66011
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
65827
66012
  const fetched = [];
65828
66013
  const failedMailboxes = [];
65829
66014
  const failedMailboxReasons = {};
66015
+ const omittedMessages = [];
65830
66016
  let totalMatched = 0;
66017
+ let totalExact = true;
65831
66018
  for (const path of paths) {
65832
66019
  try {
65833
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
66020
+ const result = await fetchMailboxMatches(client, path, criteria, page);
65834
66021
  totalMatched += result.total;
66022
+ totalExact &&= result.totalExact;
65835
66023
  fetched.push(...result.messages.map((message) => ({ message, path })));
66024
+ for (const uid of result.omitted) {
66025
+ omittedMessages.push({
66026
+ id: encodeImapId(cfg.accountLabel, path, uid),
66027
+ mailbox: path,
66028
+ uid,
66029
+ reason: result.omittedReason ?? "the server's FETCH response could not be read"
66030
+ });
66031
+ }
65836
66032
  } catch (error2) {
65837
66033
  failedMailboxes.push(path);
65838
66034
  failedMailboxReasons[path] = describeMailboxFailure(error2);
@@ -65856,18 +66052,17 @@ async function run(args, listMode, deps) {
65856
66052
  if (!unique.has(key)) unique.set(key, entry);
65857
66053
  }
65858
66054
  ordered = [...unique.values()].slice(offset, offset + limit);
65859
- } else {
65860
- ordered = fetched.slice(offset, offset + limit);
65861
66055
  }
65862
66056
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
65863
66057
  const messages = ordered.map(
65864
66058
  ({ message, path }) => structuredRow(message, cfg.accountLabel, path)
65865
66059
  );
65866
- const partial2 = failedMailboxes.length > 0;
65867
- const failureNote = partial2 ? `
66060
+ const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0;
66061
+ const failureNote = (failedMailboxes.length > 0 ? `
65868
66062
 
65869
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
66063
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
65870
66064
  const verb = listMode ? "listed" : "matched";
66065
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
65871
66066
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
65872
66067
  if (messages.length === 0) {
65873
66068
  return {
@@ -65876,10 +66071,11 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
65876
66071
  count: 0,
65877
66072
  partial: partial2,
65878
66073
  failedMailboxes,
65879
- failedMailboxReasons
66074
+ failedMailboxReasons,
66075
+ omittedMessages
65880
66076
  };
65881
66077
  }
65882
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalMatched} total ${verb}):
66078
+ const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
65883
66079
  ` + rows.join("\n") + `
65884
66080
 
65885
66081
  Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutations (mark/flag/move/delete-message), which route back to IMAP.` + failureNote;
@@ -65889,7 +66085,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
65889
66085
  count: messages.length,
65890
66086
  partial: partial2,
65891
66087
  failedMailboxes,
65892
- failedMailboxReasons
66088
+ failedMailboxReasons,
66089
+ omittedMessages
65893
66090
  };
65894
66091
  },
65895
66092
  true
@@ -66849,31 +67046,21 @@ async function imapThread(id, deps = {}, limit = 50) {
66849
67046
  }
66850
67047
  if (uidSet.size <= 1) return null;
66851
67048
  const uids = [...uidSet].slice(0, limit);
66852
- const msgs = [];
66853
- for await (const msg of client.fetch(
66854
- uids.join(","),
66855
- // Same reason as the list/search fetch: get-thread emits structured
66856
- // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
66857
- // silently disagree with the same message seen via search.
66858
- // INTERNALDATE and the Date: header ride along for the same reason as
66859
- // the list/search fetch: the per-message date is recovered and
66860
- // sanity-checked exactly as a row's dateSent is (#234).
66861
- {
66862
- envelope: true,
66863
- flags: true,
66864
- bodyStructure: true,
66865
- internalDate: true,
66866
- headers: ["date"]
66867
- },
66868
- { uid: true }
66869
- )) {
66870
- msgs.push(msg);
66871
- }
67049
+ const rows = await fetchRows(client, uids);
67050
+ const msgs = rows.messages.slice();
67051
+ const omittedMessages = rows.omitted.map((uid) => ({
67052
+ id: encodeImapId(ref.account, ref.path, uid),
67053
+ mailbox: ref.path,
67054
+ uid,
67055
+ reason: omissionReason(rows)
67056
+ }));
66872
67057
  msgs.sort((a, b) => dateMs(a) - dateMs(b));
66873
67058
  const subject = seed.envelope?.subject || "(no subject)";
66874
67059
  const structured = {
66875
67060
  subject,
66876
67061
  count: msgs.length,
67062
+ partial: omittedMessages.length > 0,
67063
+ omittedMessages,
66877
67064
  messages: msgs.map((m) => ({
66878
67065
  id: encodeImapId(ref.account, ref.path, m.uid),
66879
67066
  subject: m.envelope?.subject || "(no subject)",
@@ -66888,7 +67075,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66888
67075
  }))
66889
67076
  };
66890
67077
  const text = `Thread "${subject}" \u2014 ${msgs.length} message(s) via IMAP (References-linked, oldest first):
66891
- ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
67078
+ ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n") + omittedNote(omittedMessages, false);
66892
67079
  return { count: msgs.length, text, structured };
66893
67080
  } finally {
66894
67081
  lock.release();
@@ -66897,7 +67084,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66897
67084
  true
66898
67085
  );
66899
67086
  }
66900
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
67087
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAX_RFC822_INLINE_BYTES, MAX_RFC822_FILE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
66901
67088
  var init_imapClient = __esm({
66902
67089
  "src/services/imapClient.ts"() {
66903
67090
  "use strict";
@@ -66923,7 +67110,24 @@ var init_imapClient = __esm({
66923
67110
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
66924
67111
  };
66925
67112
  defaultConnect = async (cfg) => {
66926
- const client = new import_imapflow.ImapFlow(buildImapConnectionOptions(cfg));
67113
+ let lastCommandError;
67114
+ const keepErr = (entry) => {
67115
+ if (entry && typeof entry === "object" && "err" in entry) {
67116
+ lastCommandError = entry.err;
67117
+ }
67118
+ };
67119
+ const noop = () => void 0;
67120
+ const client = new import_imapflow.ImapFlow({
67121
+ ...buildImapConnectionOptions(cfg),
67122
+ logger: { trace: noop, debug: noop, info: noop, warn: keepErr, error: keepErr, fatal: keepErr }
67123
+ });
67124
+ Object.assign(client, {
67125
+ takeLastCommandError: () => {
67126
+ const err = lastCommandError;
67127
+ lastCommandError = void 0;
67128
+ return err;
67129
+ }
67130
+ });
66927
67131
  client.on("error", () => {
66928
67132
  });
66929
67133
  try {
@@ -66951,6 +67155,27 @@ var init_imapClient = __esm({
66951
67155
  starred: "\\flagged"
66952
67156
  };
66953
67157
  NOT_DELETED = { deleted: false };
67158
+ LARGE_MAILBOX_MESSAGES = 1e4;
67159
+ FIRST_SEARCH_WINDOW = 5e3;
67160
+ MAX_WINDOW = 5e4;
67161
+ ROW_QUERY = {
67162
+ envelope: true,
67163
+ flags: true,
67164
+ bodyStructure: true,
67165
+ internalDate: true,
67166
+ headers: ["date"]
67167
+ };
67168
+ ROW_QUERY_NO_STRUCTURE = {
67169
+ envelope: true,
67170
+ flags: true,
67171
+ internalDate: true,
67172
+ headers: ["date", "content-type"]
67173
+ };
67174
+ ROW_QUERY_HEADERS_ONLY = {
67175
+ flags: true,
67176
+ internalDate: true,
67177
+ headers: ["date", "from", "subject", "message-id", "in-reply-to", "content-type"]
67178
+ };
66954
67179
  poolConnect = defaultConnect;
66955
67180
  pools = /* @__PURE__ */ new Map();
66956
67181
  connecting = /* @__PURE__ */ new Map();
@@ -87452,6 +87677,7 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87452
87677
  const accountsFailed = [];
87453
87678
  const failedMailboxes = [];
87454
87679
  const failedMailboxReasons = {};
87680
+ const omittedMessages = [];
87455
87681
  for (const config2 of configs) {
87456
87682
  const perAccountArgs = { ...args, account: void 0 };
87457
87683
  try {
@@ -87464,12 +87690,25 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87464
87690
  for (const [mailbox, reason] of Object.entries(res.failedMailboxReasons)) {
87465
87691
  failedMailboxReasons[`${config2.accountLabel} / ${mailbox}`] = reason;
87466
87692
  }
87693
+ omittedMessages.push(
87694
+ ...(res.omittedMessages ?? []).map((o) => ({
87695
+ ...o,
87696
+ mailbox: `${config2.accountLabel} / ${o.mailbox}`
87697
+ }))
87698
+ );
87467
87699
  } catch (e) {
87468
87700
  accountsFailed.push(config2.accountLabel);
87469
87701
  console.error(`IMAP fan-out failed for account "${config2.accountLabel}": ${String(e)}`);
87470
87702
  }
87471
87703
  }
87472
- return { rows, accountsQueried, accountsFailed, failedMailboxes, failedMailboxReasons };
87704
+ return {
87705
+ rows,
87706
+ accountsQueried,
87707
+ accountsFailed,
87708
+ failedMailboxes,
87709
+ failedMailboxReasons,
87710
+ omittedMessages
87711
+ };
87473
87712
  }
87474
87713
  function configMatchesAccount(config2, account) {
87475
87714
  const name = account.name.trim().toLowerCase();
@@ -88072,7 +88311,10 @@ var LIST_OUTPUT_SCHEMA = {
88072
88311
  failedMailboxes: external_exports.array(external_exports.string()).optional(),
88073
88312
  // Underlying error text per entry in `failedMailboxes`, same keys (#246
88074
88313
  // follow-up) — declared so a client can rely on it rather than parse text.
88075
- failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional()
88314
+ failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional(),
88315
+ // Messages that belong on the page but could not be read, each with its
88316
+ // imap: id and why (#256 follow-up). Non-empty implies `partial: true`.
88317
+ omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional()
88076
88318
  };
88077
88319
  var BATCH_COUNT_OUTPUT_SCHEMA = {
88078
88320
  ok: external_exports.boolean().optional(),
@@ -88151,7 +88393,7 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88151
88393
  const merged = mergeMessages(fan.rows, apple.rows, limit);
88152
88394
  const diagnostics = {
88153
88395
  ...apple.diagnostics,
88154
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0,
88396
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0,
88155
88397
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88156
88398
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88157
88399
  };
@@ -88163,9 +88405,10 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88163
88405
  notSearchedMailboxes: diagnostics.notSearchedMailboxes,
88164
88406
  timedOutAccounts: diagnostics.timedOutAccounts,
88165
88407
  failedMailboxes: fan.failedMailboxes,
88166
- failedMailboxReasons: fan.failedMailboxReasons
88408
+ failedMailboxReasons: fan.failedMailboxReasons,
88409
+ omittedMessages: fan.omittedMessages
88167
88410
  };
88168
- const coverageBlock = partialCoverageBlock(diagnostics);
88411
+ const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true);
88169
88412
  if (merged.length === 0) {
88170
88413
  const base = diagnostics.partial ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.` : "No messages found";
88171
88414
  return successResponse(`${base}${coverageBlock}`, structured);
@@ -88266,7 +88509,8 @@ registerTool(
88266
88509
  count: r.count,
88267
88510
  partial: r.partial,
88268
88511
  failedMailboxes: r.failedMailboxes,
88269
- failedMailboxReasons: r.failedMailboxReasons
88512
+ failedMailboxReasons: r.failedMailboxReasons,
88513
+ omittedMessages: r.omittedMessages
88270
88514
  });
88271
88515
  }
88272
88516
  const fan = await fanOutImapMessages(imapArgs, "search");
@@ -88620,7 +88864,9 @@ registerTool(
88620
88864
  messages: external_exports.array(MESSAGE_ROW_SCHEMA).optional(),
88621
88865
  count: external_exports.number().optional(),
88622
88866
  partial: external_exports.boolean().optional(),
88623
- failedMailboxes: external_exports.array(external_exports.string()).optional()
88867
+ failedMailboxes: external_exports.array(external_exports.string()).optional(),
88868
+ failedMailboxReasons: LIST_OUTPUT_SCHEMA.failedMailboxReasons,
88869
+ omittedMessages: LIST_OUTPUT_SCHEMA.omittedMessages
88624
88870
  }
88625
88871
  },
88626
88872
  withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
@@ -88650,7 +88896,8 @@ ${r.text}`, {
88650
88896
  count: r.count,
88651
88897
  partial: r.partial,
88652
88898
  failedMailboxes: r.failedMailboxes,
88653
- failedMailboxReasons: r.failedMailboxReasons
88899
+ failedMailboxReasons: r.failedMailboxReasons,
88900
+ omittedMessages: r.omittedMessages
88654
88901
  });
88655
88902
  }
88656
88903
  const fan = await fanOutImapMessages({ subject: base, mailbox, limit }, "search");
@@ -88675,20 +88922,21 @@ ${r.text}`, {
88675
88922
  const orderedRows = mergedNewestFirst.slice().reverse().sort(
88676
88923
  (a, b) => (a.dateReceived ? new Date(a.dateReceived).getTime() : 0) - (b.dateReceived ? new Date(b.dateReceived).getTime() : 0)
88677
88924
  );
88678
- const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0;
88925
+ const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0;
88679
88926
  const coverage = partialCoverageBlock({
88680
88927
  ...apple.diagnostics,
88681
88928
  partial: partial2,
88682
88929
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88683
88930
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88684
- });
88931
+ }) + omittedNote(fan.omittedMessages, true);
88685
88932
  const structured2 = {
88686
88933
  subject: base,
88687
88934
  messages: orderedRows,
88688
88935
  count: orderedRows.length,
88689
88936
  partial: partial2,
88690
88937
  failedMailboxes: fan.failedMailboxes,
88691
- failedMailboxReasons: fan.failedMailboxReasons
88938
+ failedMailboxReasons: fan.failedMailboxReasons,
88939
+ omittedMessages: fan.omittedMessages
88692
88940
  };
88693
88941
  if (orderedRows.length === 0) {
88694
88942
  return successResponse(`No messages found in thread "${base}".${coverage}`, structured2);
@@ -88753,7 +89001,8 @@ registerTool(
88753
89001
  count: r.count,
88754
89002
  partial: r.partial,
88755
89003
  failedMailboxes: r.failedMailboxes,
88756
- failedMailboxReasons: r.failedMailboxReasons
89004
+ failedMailboxReasons: r.failedMailboxReasons,
89005
+ omittedMessages: r.omittedMessages
88757
89006
  });
88758
89007
  }
88759
89008
  const fan = await fanOutImapMessages({ mailbox, limit, offset, from, unreadOnly }, "list");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.19.16",
3
+ "version": "2.19.18",
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",