apple-mail-mcp 2.19.17 → 2.19.19

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
@@ -240,6 +240,7 @@ Search for messages matching criteria. Searches all accounts by default.
240
240
  | `dateFrom` | string | No | Start date filter (e.g., "January 1, 2026") |
241
241
  | `dateTo` | string | No | End date filter (e.g., "March 1, 2026") |
242
242
  | `limit` | number | No | Max results, 1–500 (default: 50) |
243
+ | `offset` | number | No | Newest matches to skip, ≥ 0 (for pagination). IMAP accounts only; without a `mailbox`, `offset` + `limit` may not exceed 5,000 |
243
244
 
244
245
  **Returns:** List of matching messages with ID, date, subject, sender, and read state.
245
246
 
@@ -280,19 +281,27 @@ human reader; the same information is also returned as structured fields on
280
281
  `search-messages` and `list-messages`, so a caller can tell _"nothing matched"_
281
282
  apart from _"I did not look everywhere"_ without parsing the text:
282
283
 
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. |
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)) |
290
293
 
291
294
  Treat a non-empty `skippedLargeMailboxes` as actionable rather than
292
295
  informational: re-run scoped to the named mailbox with a `dateFrom`/`dateTo`
293
296
  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.
297
+ those mailboxes server-side. The list fields are optional and are omitted (or
298
+ empty) when coverage was complete.
299
+
300
+ A row the IMAP backend could only read with a reduced item set (its full
301
+ `BODYSTRUCTURE`, or its whole `FETCH` response, was unparseable — e.g. a message
302
+ forwarded as an attachment many levels deep) is still returned, with a
303
+ `metadataIncomplete` note saying what was missing; its `hasAttachments` is then
304
+ inferred from the top-level `Content-Type`.
296
305
 
297
306
  ---
298
307
 
@@ -1095,14 +1104,23 @@ which made a filter-less `list-messages` fail on such a mailbox before 2.19.16.
1095
1104
  **Very large IMAP mailboxes are read newest-first in bounded windows**
1096
1105
  ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)). Above 10,000
1097
1106
  messages, a filter-less `list-messages`/`search-messages` pages by message
1098
- sequence number from the top of the mailbox — it fetches UIDs and flags for
1099
- just enough of the newest messages to fill `limit` + `offset` (skipping
1100
- `\Deleted` ones) and full rows for the page alone, so `limit: 1` on a
1101
- 793,614-message mailbox costs one small `FETCH`, not a whole-mailbox `SEARCH`.
1107
+ sequence number: page `offset` of `limit` is sequence numbers
1108
+ `N-offset-limit+1 .. N-offset`, so it fetches UIDs and flags for that range
1109
+ alone (plus a small margin; `\Deleted` ones are skipped and the page topped up
1110
+ from below) and full rows for the page. The cost is the page, not the offset:
1111
+ `limit: 1` at offset 790,000 of a 793,614-message mailbox is one ~33-message
1112
+ `FETCH`, not a whole-mailbox `SEARCH` or a walk over every skipped message.
1113
+ Offsets count sequence positions. iCloud keeps messages awaiting expunge out of
1114
+ the sequence space, so there they are exact; on a server that keeps them in it,
1115
+ a page shifts by the number of such messages above it (they are never
1116
+ returned).
1102
1117
  A filtered search there runs the same criteria over newest-first sequence
1103
1118
  windows (5,000 messages, growing to 50,000) and stops once the page is full;
1104
1119
  the reported total then reads `at least N` unless the walk reached the bottom
1105
- of the mailbox. A `SEARCH` or `FETCH` that fails names the mailbox, its size and
1120
+ of the mailbox. A filtered search's deep `offset` has no such shortcut — which
1121
+ messages match is only known by searching the skipped windows — so its cost
1122
+ still grows with the offset (in 50,000-message `SEARCH` windows); narrow it
1123
+ with `dateTo` instead. A `SEARCH` or `FETCH` that fails names the mailbox, its size and
1106
1124
  the server's own reason (or a likely timeout) instead of reporting "no
1107
1125
  messages".
1108
1126
 
package/build/cli.js CHANGED
@@ -58049,6 +58049,7 @@ __export(imapClient_exports, {
58049
58049
  MAX_COMPOSE_SOURCE_BYTES: () => MAX_COMPOSE_SOURCE_BYTES,
58050
58050
  MAX_RFC822_FILE_BYTES: () => MAX_RFC822_FILE_BYTES,
58051
58051
  MAX_RFC822_INLINE_BYTES: () => MAX_RFC822_INLINE_BYTES,
58052
+ MAX_UNSCOPED_SEARCH_DEPTH: () => MAX_UNSCOPED_SEARCH_DEPTH,
58052
58053
  __resetPool: () => __resetPool,
58053
58054
  __setPoolConnect: () => __setPoolConnect,
58054
58055
  bodyStructureHasAttachments: () => bodyStructureHasAttachments,
@@ -58092,10 +58093,12 @@ __export(imapClient_exports, {
58092
58093
  mailFlagColorIndex: () => mailFlagColorIndex,
58093
58094
  matchMailbox: () => matchMailbox,
58094
58095
  normalizeMessageId: () => normalizeMessageId,
58096
+ omittedNote: () => omittedNote,
58095
58097
  resolveImapConfig: () => resolveImapConfig,
58096
58098
  resolveImapConfigs: () => resolveImapConfigs,
58097
58099
  resolveMailboxPath: () => resolveMailboxPath,
58098
- shouldUseImap: () => shouldUseImap
58100
+ shouldUseImap: () => shouldUseImap,
58101
+ unscopedSearchOffsetError: () => unscopedSearchOffsetError
58099
58102
  });
58100
58103
  import { createHash } from "node:crypto";
58101
58104
  function encodeImapId(account, path, uid) {
@@ -58402,7 +58405,12 @@ function structuredRow(m, account, path) {
58402
58405
  // a caller from "no attachments", so every IMAP-sourced message claimed to
58403
58406
  // have none. Falls back to false only when the fetch carried no
58404
58407
  // BODYSTRUCTURE at all.
58405
- hasAttachments: bodyStructureHasAttachments(m.bodyStructure),
58408
+ // A row read without BODYSTRUCTURE (#256 follow-up) judges by its top-level
58409
+ // Content-Type instead, and says so in `metadataIncomplete`.
58410
+ hasAttachments: m.bodyStructure ? bodyStructureHasAttachments(m.bodyStructure) : m.degraded ? contentTypeSuggestsAttachments(m) : false,
58411
+ ...m.degraded ? {
58412
+ metadataIncomplete: m.degraded === "bodystructure" ? "BODYSTRUCTURE unreadable; hasAttachments inferred from Content-Type" : "FETCH response unreadable; row rebuilt from raw headers, hasAttachments inferred from Content-Type"
58413
+ } : {},
58406
58414
  // Message-ID (when the envelope carries it) is the strongest cross-/intra-
58407
58415
  // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
58408
58416
  // AppleScript path does not expose it, so cross-backend dedup falls back to
@@ -58410,6 +58418,13 @@ function structuredRow(m, account, path) {
58410
58418
  ...env.messageId ? { messageId: env.messageId } : {}
58411
58419
  };
58412
58420
  }
58421
+ function omittedNote(omitted, merged) {
58422
+ if (omitted.length === 0) return "";
58423
+ const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
58424
+ return `
58425
+
58426
+ 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.`;
58427
+ }
58413
58428
  function describeMailboxFailure(error) {
58414
58429
  const raw = errText(error);
58415
58430
  const oneLine = raw.split("\n")[0].trim();
@@ -58429,6 +58444,10 @@ function messageIdentity(entry) {
58429
58444
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
58430
58445
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
58431
58446
  }
58447
+ function unscopedSearchOffsetError(offset, limit) {
58448
+ if (offset <= 0 || offset + limit <= MAX_UNSCOPED_SEARCH_DEPTH) return void 0;
58449
+ return `search-messages without a mailbox merges every mailbox's newest offset+limit matches, so it pages at most ${MAX_UNSCOPED_SEARCH_DEPTH.toLocaleString("en-US")} deep (offset ${offset} + limit ${limit} asked). Name a mailbox (e.g. the account's All Mail or Archive) to page deeper, or narrow the search with dateTo.`;
58450
+ }
58432
58451
  function isUnfiltered(criteria) {
58433
58452
  const keys = Object.keys(criteria);
58434
58453
  return keys.length === 1 && criteria.deleted === false;
@@ -58451,41 +58470,117 @@ async function searchUids(client, path, criteria, size) {
58451
58470
  `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)")
58452
58471
  );
58453
58472
  }
58473
+ function mergeFetched(into, msg) {
58474
+ const prev = into.get(msg.uid);
58475
+ if (!prev) {
58476
+ into.set(msg.uid, msg);
58477
+ return;
58478
+ }
58479
+ const merged = { ...prev };
58480
+ for (const [key, value] of Object.entries(msg)) {
58481
+ if (value !== void 0 && value !== null)
58482
+ merged[key] = value;
58483
+ }
58484
+ into.set(msg.uid, merged);
58485
+ }
58486
+ async function fetchInto(client, uids, query, into) {
58487
+ const wanted = new Set(uids);
58488
+ for await (const msg of client.fetch(uids.join(","), query, { uid: true })) {
58489
+ if (typeof msg?.uid === "number" && wanted.has(msg.uid)) mergeFetched(into, msg);
58490
+ }
58491
+ }
58492
+ function fetchedHeaders(m) {
58493
+ return m.headers ? parseHeaderBlock(decodeHeaderBytes(asBuffer(m.headers))) : void 0;
58494
+ }
58495
+ function envelopeFromHeaders(m) {
58496
+ const h = fetchedHeaders(m);
58497
+ if (!h || h.headers.length === 0) return void 0;
58498
+ const env = {};
58499
+ if (h.subject) env.subject = h.subject;
58500
+ if (h.from) {
58501
+ const angle = h.from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
58502
+ env.from = angle ? [{ ...angle[1].trim() ? { name: angle[1].trim() } : {}, address: angle[2].trim() }] : [{ address: h.from.trim() }];
58503
+ }
58504
+ if (h.messageId) env.messageId = `<${h.messageId}>`;
58505
+ if (h.inReplyTo) env.inReplyTo = `<${h.inReplyTo}>`;
58506
+ if (h.dateHeader) env.date = h.dateHeader;
58507
+ return env;
58508
+ }
58509
+ function contentTypeSuggestsAttachments(m) {
58510
+ const type = fetchedHeaders(m)?.headers.find((h) => h.name.toLowerCase() === "content-type");
58511
+ return /^\s*multipart\/mixed\b/i.test(type?.value ?? "");
58512
+ }
58454
58513
  async function fetchRows(client, uids) {
58455
- if (uids.length === 0) return [];
58514
+ if (uids.length === 0) return { messages: [], omitted: [] };
58456
58515
  const byUid = /* @__PURE__ */ new Map();
58457
- for await (const msg of client.fetch(
58458
- uids.join(","),
58459
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
58460
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
58461
- // the fetch (~17%), same single round trip, no extra request.
58462
- //
58463
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
58464
- // can finally mean what it says: imapflow's `envelope.date` is built from
58465
- // the header block, so it IS the `Date:` header, not arrival time.
58466
- //
58467
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
58468
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
58469
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
58470
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
58471
- // bytes per message. Too cheap to hide behind an opt-in.
58472
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
58473
- { uid: true }
58474
- )) {
58475
- byUid.set(msg.uid, msg);
58476
- }
58477
- return uids.map((uid) => byUid.get(uid)).filter((message) => message !== void 0);
58516
+ const causes = [];
58517
+ const noteCause = () => {
58518
+ const err = client.takeLastCommandError?.();
58519
+ if (err !== void 0) {
58520
+ const text = describeMailboxFailure(err);
58521
+ if (!causes.includes(text)) causes.push(text);
58522
+ }
58523
+ };
58524
+ const missing = () => uids.filter((uid) => !byUid.get(uid)?.envelope);
58525
+ client.takeLastCommandError?.();
58526
+ await fetchInto(client, uids, ROW_QUERY, byUid);
58527
+ noteCause();
58528
+ let gap = missing();
58529
+ if (gap.length > 0) {
58530
+ await fetchInto(client, gap, ROW_QUERY, byUid);
58531
+ noteCause();
58532
+ gap = missing();
58533
+ }
58534
+ if (gap.length > 0) {
58535
+ const reduced = /* @__PURE__ */ new Map();
58536
+ await fetchInto(client, gap, ROW_QUERY_NO_STRUCTURE, reduced);
58537
+ noteCause();
58538
+ for (const msg of reduced.values()) {
58539
+ if (!msg.envelope) continue;
58540
+ mergeFetched(byUid, { ...msg, degraded: "bodystructure" });
58541
+ }
58542
+ gap = missing();
58543
+ }
58544
+ if (gap.length > 0) {
58545
+ for (const uid of gap) {
58546
+ const bare = /* @__PURE__ */ new Map();
58547
+ await fetchInto(client, [uid], ROW_QUERY_HEADERS_ONLY, bare);
58548
+ noteCause();
58549
+ const msg = bare.get(uid);
58550
+ const envelope = msg ? envelopeFromHeaders(msg) : void 0;
58551
+ if (msg && envelope) {
58552
+ mergeFetched(byUid, { ...msg, envelope, degraded: "envelope" });
58553
+ }
58554
+ }
58555
+ gap = missing();
58556
+ }
58557
+ const omitted = new Set(gap);
58558
+ return {
58559
+ messages: uids.filter((uid) => !omitted.has(uid)).map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
58560
+ omitted: gap,
58561
+ ...causes.length > 0 ? { cause: causes.join("; ") } : {}
58562
+ };
58563
+ }
58564
+ function pageRows(fetch) {
58565
+ return {
58566
+ messages: fetch.messages,
58567
+ omitted: fetch.omitted,
58568
+ ...fetch.omitted.length > 0 ? { omittedReason: omissionReason(fetch) } : {}
58569
+ };
58570
+ }
58571
+ function omissionReason(fetch) {
58572
+ return "the server's FETCH response for this message could not be read, even without BODYSTRUCTURE or ENVELOPE" + (fetch.cause ? ` (${fetch.cause})` : "");
58478
58573
  }
58479
- async function walkWindows(exists, page, firstWindow, readWindow) {
58574
+ async function walkWindows(top, page, firstWindow, readWindow, openTop = true) {
58480
58575
  const wanted = page.skip + page.take;
58481
58576
  const uids = [];
58482
58577
  let seen = 0;
58483
58578
  let matched = 0;
58484
- let hi = exists;
58579
+ let hi = top;
58485
58580
  let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
58486
58581
  while (hi >= 1 && seen < wanted) {
58487
58582
  const lo = Math.max(1, hi - width + 1);
58488
- const live = (await readWindow(lo, hi === exists ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
58583
+ const live = (await readWindow(lo, hi === top && openTop ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
58489
58584
  matched += live.length;
58490
58585
  for (const uid of live) {
58491
58586
  if (seen >= wanted) break;
@@ -58497,13 +58592,21 @@ async function walkWindows(exists, page, firstWindow, readWindow) {
58497
58592
  }
58498
58593
  return { uids, matched, exhausted: hi < 1 };
58499
58594
  }
58595
+ function sequenceTop(client, statusCount) {
58596
+ const mb = client.mailbox;
58597
+ const exists = mb && typeof mb.exists === "number" && mb.exists >= 0 ? mb.exists : void 0;
58598
+ return exists ?? statusCount;
58599
+ }
58500
58600
  async function listLargeMailbox(client, exists, page) {
58601
+ const top = sequenceTop(client, exists) - page.skip;
58602
+ if (top < 1 || page.take === 0) {
58603
+ return { messages: [], omitted: [], total: exists, totalExact: true };
58604
+ }
58501
58605
  let deletedSeen = 0;
58502
58606
  const walk = await walkWindows(
58503
- exists,
58504
- page,
58505
- // Room for a few ghosts on the first read without a second round trip.
58506
- page.skip + page.take + 64,
58607
+ top,
58608
+ { skip: 0, take: page.take },
58609
+ page.take + DELETED_MARGIN,
58507
58610
  async (lo, hi) => {
58508
58611
  const live = [];
58509
58612
  for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
@@ -58511,10 +58614,13 @@ async function listLargeMailbox(client, exists, page) {
58511
58614
  else live.push(msg.uid);
58512
58615
  }
58513
58616
  return live;
58514
- }
58617
+ },
58618
+ // From the very top the first window is `lo:*`, as before; below it the
58619
+ // range is exact.
58620
+ page.skip === 0
58515
58621
  );
58516
58622
  return {
58517
- messages: await fetchRows(client, walk.uids),
58623
+ ...pageRows(await fetchRows(client, walk.uids)),
58518
58624
  total: Math.max(0, exists - deletedSeen),
58519
58625
  totalExact: true
58520
58626
  };
@@ -58530,7 +58636,7 @@ async function searchLargeMailbox(client, path, criteria, exists, page) {
58530
58636
  return found;
58531
58637
  });
58532
58638
  return {
58533
- messages: await fetchRows(client, walk.uids),
58639
+ ...pageRows(await fetchRows(client, walk.uids)),
58534
58640
  total: walk.matched,
58535
58641
  totalExact: walk.exhausted
58536
58642
  };
@@ -58539,7 +58645,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
58539
58645
  const lock = await client.getMailboxLock(path);
58540
58646
  try {
58541
58647
  const exists = await statusMessageCount(client, path);
58542
- if (exists === 0) return { messages: [], total: 0, totalExact: true };
58648
+ if (exists === 0) return { messages: [], omitted: [], total: 0, totalExact: true };
58543
58649
  if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
58544
58650
  try {
58545
58651
  return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
@@ -58552,7 +58658,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
58552
58658
  }
58553
58659
  const uids = await searchUids(client, path, criteria, exists);
58554
58660
  if (uids.length === 0 || page.take === 0) {
58555
- return { messages: [], total: uids.length, totalExact: true };
58661
+ return { messages: [], omitted: [], total: uids.length, totalExact: true };
58556
58662
  }
58557
58663
  const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
58558
58664
  if (typeof messages === "number" && uids.length > messages) {
@@ -58561,7 +58667,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
58561
58667
  );
58562
58668
  }
58563
58669
  const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
58564
- return { messages: await fetchRows(client, newest), total: uids.length, totalExact: true };
58670
+ return { ...pageRows(await fetchRows(client, newest)), total: uids.length, totalExact: true };
58565
58671
  } finally {
58566
58672
  lock.release();
58567
58673
  }
@@ -58589,11 +58695,16 @@ async function run(args, listMode, deps) {
58589
58695
  }
58590
58696
  const limit = args.limit ?? 50;
58591
58697
  const offset = args.offset ?? 0;
58698
+ if (unscopedSearch) {
58699
+ const tooDeep = unscopedSearchOffsetError(offset, limit);
58700
+ if (tooDeep) throw new Error(tooDeep);
58701
+ }
58592
58702
  const criteria = buildCriteria(args, listMode);
58593
58703
  const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
58594
58704
  const fetched = [];
58595
58705
  const failedMailboxes = [];
58596
58706
  const failedMailboxReasons = {};
58707
+ const omittedMessages = [];
58597
58708
  let totalMatched = 0;
58598
58709
  let totalExact = true;
58599
58710
  for (const path of paths) {
@@ -58602,6 +58713,14 @@ async function run(args, listMode, deps) {
58602
58713
  totalMatched += result.total;
58603
58714
  totalExact &&= result.totalExact;
58604
58715
  fetched.push(...result.messages.map((message) => ({ message, path })));
58716
+ for (const uid of result.omitted) {
58717
+ omittedMessages.push({
58718
+ id: encodeImapId(cfg.accountLabel, path, uid),
58719
+ mailbox: path,
58720
+ uid,
58721
+ reason: result.omittedReason ?? "the server's FETCH response could not be read"
58722
+ });
58723
+ }
58605
58724
  } catch (error) {
58606
58725
  failedMailboxes.push(path);
58607
58726
  failedMailboxReasons[path] = describeMailboxFailure(error);
@@ -58630,10 +58749,10 @@ async function run(args, listMode, deps) {
58630
58749
  const messages = ordered.map(
58631
58750
  ({ message, path }) => structuredRow(message, cfg.accountLabel, path)
58632
58751
  );
58633
- const partial = failedMailboxes.length > 0;
58634
- const failureNote = partial ? `
58752
+ const partial = failedMailboxes.length > 0 || omittedMessages.length > 0;
58753
+ const failureNote = (failedMailboxes.length > 0 ? `
58635
58754
 
58636
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
58755
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
58637
58756
  const verb = listMode ? "listed" : "matched";
58638
58757
  const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
58639
58758
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
@@ -58644,7 +58763,8 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
58644
58763
  count: 0,
58645
58764
  partial,
58646
58765
  failedMailboxes,
58647
- failedMailboxReasons
58766
+ failedMailboxReasons,
58767
+ omittedMessages
58648
58768
  };
58649
58769
  }
58650
58770
  const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
@@ -58657,7 +58777,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
58657
58777
  count: messages.length,
58658
58778
  partial,
58659
58779
  failedMailboxes,
58660
- failedMailboxReasons
58780
+ failedMailboxReasons,
58781
+ omittedMessages
58661
58782
  };
58662
58783
  },
58663
58784
  true
@@ -59617,31 +59738,21 @@ async function imapThread(id, deps = {}, limit = 50) {
59617
59738
  }
59618
59739
  if (uidSet.size <= 1) return null;
59619
59740
  const uids = [...uidSet].slice(0, limit);
59620
- const msgs = [];
59621
- for await (const msg of client.fetch(
59622
- uids.join(","),
59623
- // Same reason as the list/search fetch: get-thread emits structured
59624
- // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
59625
- // silently disagree with the same message seen via search.
59626
- // INTERNALDATE and the Date: header ride along for the same reason as
59627
- // the list/search fetch: the per-message date is recovered and
59628
- // sanity-checked exactly as a row's dateSent is (#234).
59629
- {
59630
- envelope: true,
59631
- flags: true,
59632
- bodyStructure: true,
59633
- internalDate: true,
59634
- headers: ["date"]
59635
- },
59636
- { uid: true }
59637
- )) {
59638
- msgs.push(msg);
59639
- }
59741
+ const rows = await fetchRows(client, uids);
59742
+ const msgs = rows.messages.slice();
59743
+ const omittedMessages = rows.omitted.map((uid) => ({
59744
+ id: encodeImapId(ref.account, ref.path, uid),
59745
+ mailbox: ref.path,
59746
+ uid,
59747
+ reason: omissionReason(rows)
59748
+ }));
59640
59749
  msgs.sort((a, b) => dateMs(a) - dateMs(b));
59641
59750
  const subject = seed.envelope?.subject || "(no subject)";
59642
59751
  const structured = {
59643
59752
  subject,
59644
59753
  count: msgs.length,
59754
+ partial: omittedMessages.length > 0,
59755
+ omittedMessages,
59645
59756
  messages: msgs.map((m) => ({
59646
59757
  id: encodeImapId(ref.account, ref.path, m.uid),
59647
59758
  subject: m.envelope?.subject || "(no subject)",
@@ -59656,7 +59767,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59656
59767
  }))
59657
59768
  };
59658
59769
  const text = `Thread "${subject}" \u2014 ${msgs.length} message(s) via IMAP (References-linked, oldest first):
59659
- ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
59770
+ ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n") + omittedNote(omittedMessages, false);
59660
59771
  return { count: msgs.length, text, structured };
59661
59772
  } finally {
59662
59773
  lock.release();
@@ -59665,7 +59776,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59665
59776
  true
59666
59777
  );
59667
59778
  }
59668
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, 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;
59779
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, MAX_UNSCOPED_SEARCH_DEPTH, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, DELETED_MARGIN, 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;
59669
59780
  var init_imapClient = __esm({
59670
59781
  "src/services/imapClient.ts"() {
59671
59782
  "use strict";
@@ -59739,6 +59850,26 @@ var init_imapClient = __esm({
59739
59850
  LARGE_MAILBOX_MESSAGES = 1e4;
59740
59851
  FIRST_SEARCH_WINDOW = 5e3;
59741
59852
  MAX_WINDOW = 5e4;
59853
+ MAX_UNSCOPED_SEARCH_DEPTH = 5e3;
59854
+ ROW_QUERY = {
59855
+ envelope: true,
59856
+ flags: true,
59857
+ bodyStructure: true,
59858
+ internalDate: true,
59859
+ headers: ["date"]
59860
+ };
59861
+ ROW_QUERY_NO_STRUCTURE = {
59862
+ envelope: true,
59863
+ flags: true,
59864
+ internalDate: true,
59865
+ headers: ["date", "content-type"]
59866
+ };
59867
+ ROW_QUERY_HEADERS_ONLY = {
59868
+ flags: true,
59869
+ internalDate: true,
59870
+ headers: ["date", "from", "subject", "message-id", "in-reply-to", "content-type"]
59871
+ };
59872
+ DELETED_MARGIN = 32;
59742
59873
  poolConnect = defaultConnect;
59743
59874
  pools = /* @__PURE__ */ new Map();
59744
59875
  connecting = /* @__PURE__ */ new Map();
package/build/index.js CHANGED
@@ -65378,6 +65378,7 @@ __export(imapClient_exports, {
65378
65378
  MAX_COMPOSE_SOURCE_BYTES: () => MAX_COMPOSE_SOURCE_BYTES,
65379
65379
  MAX_RFC822_FILE_BYTES: () => MAX_RFC822_FILE_BYTES,
65380
65380
  MAX_RFC822_INLINE_BYTES: () => MAX_RFC822_INLINE_BYTES,
65381
+ MAX_UNSCOPED_SEARCH_DEPTH: () => MAX_UNSCOPED_SEARCH_DEPTH,
65381
65382
  __resetPool: () => __resetPool,
65382
65383
  __setPoolConnect: () => __setPoolConnect,
65383
65384
  bodyStructureHasAttachments: () => bodyStructureHasAttachments,
@@ -65421,10 +65422,12 @@ __export(imapClient_exports, {
65421
65422
  mailFlagColorIndex: () => mailFlagColorIndex,
65422
65423
  matchMailbox: () => matchMailbox,
65423
65424
  normalizeMessageId: () => normalizeMessageId,
65425
+ omittedNote: () => omittedNote,
65424
65426
  resolveImapConfig: () => resolveImapConfig,
65425
65427
  resolveImapConfigs: () => resolveImapConfigs,
65426
65428
  resolveMailboxPath: () => resolveMailboxPath,
65427
- shouldUseImap: () => shouldUseImap
65429
+ shouldUseImap: () => shouldUseImap,
65430
+ unscopedSearchOffsetError: () => unscopedSearchOffsetError
65428
65431
  });
65429
65432
  import { createHash } from "node:crypto";
65430
65433
  function encodeImapId(account, path, uid) {
@@ -65731,7 +65734,12 @@ function structuredRow(m, account, path) {
65731
65734
  // a caller from "no attachments", so every IMAP-sourced message claimed to
65732
65735
  // have none. Falls back to false only when the fetch carried no
65733
65736
  // BODYSTRUCTURE at all.
65734
- hasAttachments: bodyStructureHasAttachments(m.bodyStructure),
65737
+ // A row read without BODYSTRUCTURE (#256 follow-up) judges by its top-level
65738
+ // Content-Type instead, and says so in `metadataIncomplete`.
65739
+ hasAttachments: m.bodyStructure ? bodyStructureHasAttachments(m.bodyStructure) : m.degraded ? contentTypeSuggestsAttachments(m) : false,
65740
+ ...m.degraded ? {
65741
+ metadataIncomplete: m.degraded === "bodystructure" ? "BODYSTRUCTURE unreadable; hasAttachments inferred from Content-Type" : "FETCH response unreadable; row rebuilt from raw headers, hasAttachments inferred from Content-Type"
65742
+ } : {},
65735
65743
  // Message-ID (when the envelope carries it) is the strongest cross-/intra-
65736
65744
  // backend dedup key for the multi-account merge (imapMultiAccount.ts). The
65737
65745
  // AppleScript path does not expose it, so cross-backend dedup falls back to
@@ -65739,6 +65747,13 @@ function structuredRow(m, account, path) {
65739
65747
  ...env.messageId ? { messageId: env.messageId } : {}
65740
65748
  };
65741
65749
  }
65750
+ function omittedNote(omitted, merged) {
65751
+ if (omitted.length === 0) return "";
65752
+ const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
65753
+ return `
65754
+
65755
+ 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.`;
65756
+ }
65742
65757
  function describeMailboxFailure(error2) {
65743
65758
  const raw = errText(error2);
65744
65759
  const oneLine = raw.split("\n")[0].trim();
@@ -65758,6 +65773,10 @@ function messageIdentity(entry) {
65758
65773
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
65759
65774
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
65760
65775
  }
65776
+ function unscopedSearchOffsetError(offset, limit) {
65777
+ if (offset <= 0 || offset + limit <= MAX_UNSCOPED_SEARCH_DEPTH) return void 0;
65778
+ return `search-messages without a mailbox merges every mailbox's newest offset+limit matches, so it pages at most ${MAX_UNSCOPED_SEARCH_DEPTH.toLocaleString("en-US")} deep (offset ${offset} + limit ${limit} asked). Name a mailbox (e.g. the account's All Mail or Archive) to page deeper, or narrow the search with dateTo.`;
65779
+ }
65761
65780
  function isUnfiltered(criteria) {
65762
65781
  const keys = Object.keys(criteria);
65763
65782
  return keys.length === 1 && criteria.deleted === false;
@@ -65780,41 +65799,117 @@ async function searchUids(client, path, criteria, size) {
65780
65799
  `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)")
65781
65800
  );
65782
65801
  }
65802
+ function mergeFetched(into, msg) {
65803
+ const prev = into.get(msg.uid);
65804
+ if (!prev) {
65805
+ into.set(msg.uid, msg);
65806
+ return;
65807
+ }
65808
+ const merged = { ...prev };
65809
+ for (const [key, value] of Object.entries(msg)) {
65810
+ if (value !== void 0 && value !== null)
65811
+ merged[key] = value;
65812
+ }
65813
+ into.set(msg.uid, merged);
65814
+ }
65815
+ async function fetchInto(client, uids, query, into) {
65816
+ const wanted = new Set(uids);
65817
+ for await (const msg of client.fetch(uids.join(","), query, { uid: true })) {
65818
+ if (typeof msg?.uid === "number" && wanted.has(msg.uid)) mergeFetched(into, msg);
65819
+ }
65820
+ }
65821
+ function fetchedHeaders(m) {
65822
+ return m.headers ? parseHeaderBlock(decodeHeaderBytes(asBuffer(m.headers))) : void 0;
65823
+ }
65824
+ function envelopeFromHeaders(m) {
65825
+ const h = fetchedHeaders(m);
65826
+ if (!h || h.headers.length === 0) return void 0;
65827
+ const env = {};
65828
+ if (h.subject) env.subject = h.subject;
65829
+ if (h.from) {
65830
+ const angle = h.from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
65831
+ env.from = angle ? [{ ...angle[1].trim() ? { name: angle[1].trim() } : {}, address: angle[2].trim() }] : [{ address: h.from.trim() }];
65832
+ }
65833
+ if (h.messageId) env.messageId = `<${h.messageId}>`;
65834
+ if (h.inReplyTo) env.inReplyTo = `<${h.inReplyTo}>`;
65835
+ if (h.dateHeader) env.date = h.dateHeader;
65836
+ return env;
65837
+ }
65838
+ function contentTypeSuggestsAttachments(m) {
65839
+ const type = fetchedHeaders(m)?.headers.find((h) => h.name.toLowerCase() === "content-type");
65840
+ return /^\s*multipart\/mixed\b/i.test(type?.value ?? "");
65841
+ }
65783
65842
  async function fetchRows(client, uids) {
65784
- if (uids.length === 0) return [];
65843
+ if (uids.length === 0) return { messages: [], omitted: [] };
65785
65844
  const byUid = /* @__PURE__ */ new Map();
65786
- for await (const msg of client.fetch(
65787
- uids.join(","),
65788
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
65789
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
65790
- // the fetch (~17%), same single round trip, no extra request.
65791
- //
65792
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
65793
- // can finally mean what it says: imapflow's `envelope.date` is built from
65794
- // the header block, so it IS the `Date:` header, not arrival time.
65795
- //
65796
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
65797
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
65798
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
65799
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
65800
- // bytes per message. Too cheap to hide behind an opt-in.
65801
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
65802
- { uid: true }
65803
- )) {
65804
- byUid.set(msg.uid, msg);
65805
- }
65806
- return uids.map((uid) => byUid.get(uid)).filter((message) => message !== void 0);
65807
- }
65808
- async function walkWindows(exists, page, firstWindow, readWindow) {
65845
+ const causes = [];
65846
+ const noteCause = () => {
65847
+ const err = client.takeLastCommandError?.();
65848
+ if (err !== void 0) {
65849
+ const text = describeMailboxFailure(err);
65850
+ if (!causes.includes(text)) causes.push(text);
65851
+ }
65852
+ };
65853
+ const missing = () => uids.filter((uid) => !byUid.get(uid)?.envelope);
65854
+ client.takeLastCommandError?.();
65855
+ await fetchInto(client, uids, ROW_QUERY, byUid);
65856
+ noteCause();
65857
+ let gap = missing();
65858
+ if (gap.length > 0) {
65859
+ await fetchInto(client, gap, ROW_QUERY, byUid);
65860
+ noteCause();
65861
+ gap = missing();
65862
+ }
65863
+ if (gap.length > 0) {
65864
+ const reduced = /* @__PURE__ */ new Map();
65865
+ await fetchInto(client, gap, ROW_QUERY_NO_STRUCTURE, reduced);
65866
+ noteCause();
65867
+ for (const msg of reduced.values()) {
65868
+ if (!msg.envelope) continue;
65869
+ mergeFetched(byUid, { ...msg, degraded: "bodystructure" });
65870
+ }
65871
+ gap = missing();
65872
+ }
65873
+ if (gap.length > 0) {
65874
+ for (const uid of gap) {
65875
+ const bare = /* @__PURE__ */ new Map();
65876
+ await fetchInto(client, [uid], ROW_QUERY_HEADERS_ONLY, bare);
65877
+ noteCause();
65878
+ const msg = bare.get(uid);
65879
+ const envelope = msg ? envelopeFromHeaders(msg) : void 0;
65880
+ if (msg && envelope) {
65881
+ mergeFetched(byUid, { ...msg, envelope, degraded: "envelope" });
65882
+ }
65883
+ }
65884
+ gap = missing();
65885
+ }
65886
+ const omitted = new Set(gap);
65887
+ return {
65888
+ messages: uids.filter((uid) => !omitted.has(uid)).map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
65889
+ omitted: gap,
65890
+ ...causes.length > 0 ? { cause: causes.join("; ") } : {}
65891
+ };
65892
+ }
65893
+ function pageRows(fetch) {
65894
+ return {
65895
+ messages: fetch.messages,
65896
+ omitted: fetch.omitted,
65897
+ ...fetch.omitted.length > 0 ? { omittedReason: omissionReason(fetch) } : {}
65898
+ };
65899
+ }
65900
+ function omissionReason(fetch) {
65901
+ return "the server's FETCH response for this message could not be read, even without BODYSTRUCTURE or ENVELOPE" + (fetch.cause ? ` (${fetch.cause})` : "");
65902
+ }
65903
+ async function walkWindows(top, page, firstWindow, readWindow, openTop = true) {
65809
65904
  const wanted = page.skip + page.take;
65810
65905
  const uids = [];
65811
65906
  let seen = 0;
65812
65907
  let matched = 0;
65813
- let hi = exists;
65908
+ let hi = top;
65814
65909
  let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
65815
65910
  while (hi >= 1 && seen < wanted) {
65816
65911
  const lo = Math.max(1, hi - width + 1);
65817
- const live = (await readWindow(lo, hi === exists ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
65912
+ const live = (await readWindow(lo, hi === top && openTop ? "*" : hi, hi - lo + 1)).slice().sort((a, b) => b - a);
65818
65913
  matched += live.length;
65819
65914
  for (const uid of live) {
65820
65915
  if (seen >= wanted) break;
@@ -65826,13 +65921,21 @@ async function walkWindows(exists, page, firstWindow, readWindow) {
65826
65921
  }
65827
65922
  return { uids, matched, exhausted: hi < 1 };
65828
65923
  }
65924
+ function sequenceTop(client, statusCount) {
65925
+ const mb = client.mailbox;
65926
+ const exists = mb && typeof mb.exists === "number" && mb.exists >= 0 ? mb.exists : void 0;
65927
+ return exists ?? statusCount;
65928
+ }
65829
65929
  async function listLargeMailbox(client, exists, page) {
65930
+ const top = sequenceTop(client, exists) - page.skip;
65931
+ if (top < 1 || page.take === 0) {
65932
+ return { messages: [], omitted: [], total: exists, totalExact: true };
65933
+ }
65830
65934
  let deletedSeen = 0;
65831
65935
  const walk = await walkWindows(
65832
- exists,
65833
- page,
65834
- // Room for a few ghosts on the first read without a second round trip.
65835
- page.skip + page.take + 64,
65936
+ top,
65937
+ { skip: 0, take: page.take },
65938
+ page.take + DELETED_MARGIN,
65836
65939
  async (lo, hi) => {
65837
65940
  const live = [];
65838
65941
  for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
@@ -65840,10 +65943,13 @@ async function listLargeMailbox(client, exists, page) {
65840
65943
  else live.push(msg.uid);
65841
65944
  }
65842
65945
  return live;
65843
- }
65946
+ },
65947
+ // From the very top the first window is `lo:*`, as before; below it the
65948
+ // range is exact.
65949
+ page.skip === 0
65844
65950
  );
65845
65951
  return {
65846
- messages: await fetchRows(client, walk.uids),
65952
+ ...pageRows(await fetchRows(client, walk.uids)),
65847
65953
  total: Math.max(0, exists - deletedSeen),
65848
65954
  totalExact: true
65849
65955
  };
@@ -65859,7 +65965,7 @@ async function searchLargeMailbox(client, path, criteria, exists, page) {
65859
65965
  return found;
65860
65966
  });
65861
65967
  return {
65862
- messages: await fetchRows(client, walk.uids),
65968
+ ...pageRows(await fetchRows(client, walk.uids)),
65863
65969
  total: walk.matched,
65864
65970
  totalExact: walk.exhausted
65865
65971
  };
@@ -65868,7 +65974,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
65868
65974
  const lock = await client.getMailboxLock(path);
65869
65975
  try {
65870
65976
  const exists = await statusMessageCount(client, path);
65871
- if (exists === 0) return { messages: [], total: 0, totalExact: true };
65977
+ if (exists === 0) return { messages: [], omitted: [], total: 0, totalExact: true };
65872
65978
  if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
65873
65979
  try {
65874
65980
  return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
@@ -65881,7 +65987,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
65881
65987
  }
65882
65988
  const uids = await searchUids(client, path, criteria, exists);
65883
65989
  if (uids.length === 0 || page.take === 0) {
65884
- return { messages: [], total: uids.length, totalExact: true };
65990
+ return { messages: [], omitted: [], total: uids.length, totalExact: true };
65885
65991
  }
65886
65992
  const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
65887
65993
  if (typeof messages === "number" && uids.length > messages) {
@@ -65890,7 +65996,7 @@ async function fetchMailboxMatches(client, path, criteria, page) {
65890
65996
  );
65891
65997
  }
65892
65998
  const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
65893
- return { messages: await fetchRows(client, newest), total: uids.length, totalExact: true };
65999
+ return { ...pageRows(await fetchRows(client, newest)), total: uids.length, totalExact: true };
65894
66000
  } finally {
65895
66001
  lock.release();
65896
66002
  }
@@ -65918,11 +66024,16 @@ async function run(args, listMode, deps) {
65918
66024
  }
65919
66025
  const limit = args.limit ?? 50;
65920
66026
  const offset = args.offset ?? 0;
66027
+ if (unscopedSearch) {
66028
+ const tooDeep = unscopedSearchOffsetError(offset, limit);
66029
+ if (tooDeep) throw new Error(tooDeep);
66030
+ }
65921
66031
  const criteria = buildCriteria(args, listMode);
65922
66032
  const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
65923
66033
  const fetched = [];
65924
66034
  const failedMailboxes = [];
65925
66035
  const failedMailboxReasons = {};
66036
+ const omittedMessages = [];
65926
66037
  let totalMatched = 0;
65927
66038
  let totalExact = true;
65928
66039
  for (const path of paths) {
@@ -65931,6 +66042,14 @@ async function run(args, listMode, deps) {
65931
66042
  totalMatched += result.total;
65932
66043
  totalExact &&= result.totalExact;
65933
66044
  fetched.push(...result.messages.map((message) => ({ message, path })));
66045
+ for (const uid of result.omitted) {
66046
+ omittedMessages.push({
66047
+ id: encodeImapId(cfg.accountLabel, path, uid),
66048
+ mailbox: path,
66049
+ uid,
66050
+ reason: result.omittedReason ?? "the server's FETCH response could not be read"
66051
+ });
66052
+ }
65934
66053
  } catch (error2) {
65935
66054
  failedMailboxes.push(path);
65936
66055
  failedMailboxReasons[path] = describeMailboxFailure(error2);
@@ -65959,10 +66078,10 @@ async function run(args, listMode, deps) {
65959
66078
  const messages = ordered.map(
65960
66079
  ({ message, path }) => structuredRow(message, cfg.accountLabel, path)
65961
66080
  );
65962
- const partial2 = failedMailboxes.length > 0;
65963
- const failureNote = partial2 ? `
66081
+ const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0;
66082
+ const failureNote = (failedMailboxes.length > 0 ? `
65964
66083
 
65965
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
66084
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
65966
66085
  const verb = listMode ? "listed" : "matched";
65967
66086
  const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
65968
66087
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
@@ -65973,7 +66092,8 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
65973
66092
  count: 0,
65974
66093
  partial: partial2,
65975
66094
  failedMailboxes,
65976
- failedMailboxReasons
66095
+ failedMailboxReasons,
66096
+ omittedMessages
65977
66097
  };
65978
66098
  }
65979
66099
  const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
@@ -65986,7 +66106,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
65986
66106
  count: messages.length,
65987
66107
  partial: partial2,
65988
66108
  failedMailboxes,
65989
- failedMailboxReasons
66109
+ failedMailboxReasons,
66110
+ omittedMessages
65990
66111
  };
65991
66112
  },
65992
66113
  true
@@ -66946,31 +67067,21 @@ async function imapThread(id, deps = {}, limit = 50) {
66946
67067
  }
66947
67068
  if (uidSet.size <= 1) return null;
66948
67069
  const uids = [...uidSet].slice(0, limit);
66949
- const msgs = [];
66950
- for await (const msg of client.fetch(
66951
- uids.join(","),
66952
- // Same reason as the list/search fetch: get-thread emits structured
66953
- // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
66954
- // silently disagree with the same message seen via search.
66955
- // INTERNALDATE and the Date: header ride along for the same reason as
66956
- // the list/search fetch: the per-message date is recovered and
66957
- // sanity-checked exactly as a row's dateSent is (#234).
66958
- {
66959
- envelope: true,
66960
- flags: true,
66961
- bodyStructure: true,
66962
- internalDate: true,
66963
- headers: ["date"]
66964
- },
66965
- { uid: true }
66966
- )) {
66967
- msgs.push(msg);
66968
- }
67070
+ const rows = await fetchRows(client, uids);
67071
+ const msgs = rows.messages.slice();
67072
+ const omittedMessages = rows.omitted.map((uid) => ({
67073
+ id: encodeImapId(ref.account, ref.path, uid),
67074
+ mailbox: ref.path,
67075
+ uid,
67076
+ reason: omissionReason(rows)
67077
+ }));
66969
67078
  msgs.sort((a, b) => dateMs(a) - dateMs(b));
66970
67079
  const subject = seed.envelope?.subject || "(no subject)";
66971
67080
  const structured = {
66972
67081
  subject,
66973
67082
  count: msgs.length,
67083
+ partial: omittedMessages.length > 0,
67084
+ omittedMessages,
66974
67085
  messages: msgs.map((m) => ({
66975
67086
  id: encodeImapId(ref.account, ref.path, m.uid),
66976
67087
  subject: m.envelope?.subject || "(no subject)",
@@ -66985,7 +67096,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66985
67096
  }))
66986
67097
  };
66987
67098
  const text = `Thread "${subject}" \u2014 ${msgs.length} message(s) via IMAP (References-linked, oldest first):
66988
- ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n");
67099
+ ` + msgs.map((m) => formatRow(m, ref.account, ref.path)).join("\n") + omittedNote(omittedMessages, false);
66989
67100
  return { count: msgs.length, text, structured };
66990
67101
  } finally {
66991
67102
  lock.release();
@@ -66994,7 +67105,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66994
67105
  true
66995
67106
  );
66996
67107
  }
66997
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, 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;
67108
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED, LARGE_MAILBOX_MESSAGES, FIRST_SEARCH_WINDOW, MAX_WINDOW, MAX_UNSCOPED_SEARCH_DEPTH, ROW_QUERY, ROW_QUERY_NO_STRUCTURE, ROW_QUERY_HEADERS_ONLY, DELETED_MARGIN, 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;
66998
67109
  var init_imapClient = __esm({
66999
67110
  "src/services/imapClient.ts"() {
67000
67111
  "use strict";
@@ -67068,6 +67179,26 @@ var init_imapClient = __esm({
67068
67179
  LARGE_MAILBOX_MESSAGES = 1e4;
67069
67180
  FIRST_SEARCH_WINDOW = 5e3;
67070
67181
  MAX_WINDOW = 5e4;
67182
+ MAX_UNSCOPED_SEARCH_DEPTH = 5e3;
67183
+ ROW_QUERY = {
67184
+ envelope: true,
67185
+ flags: true,
67186
+ bodyStructure: true,
67187
+ internalDate: true,
67188
+ headers: ["date"]
67189
+ };
67190
+ ROW_QUERY_NO_STRUCTURE = {
67191
+ envelope: true,
67192
+ flags: true,
67193
+ internalDate: true,
67194
+ headers: ["date", "content-type"]
67195
+ };
67196
+ ROW_QUERY_HEADERS_ONLY = {
67197
+ flags: true,
67198
+ internalDate: true,
67199
+ headers: ["date", "from", "subject", "message-id", "in-reply-to", "content-type"]
67200
+ };
67201
+ DELETED_MARGIN = 32;
67071
67202
  poolConnect = defaultConnect;
67072
67203
  pools = /* @__PURE__ */ new Map();
67073
67204
  connecting = /* @__PURE__ */ new Map();
@@ -87569,6 +87700,7 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87569
87700
  const accountsFailed = [];
87570
87701
  const failedMailboxes = [];
87571
87702
  const failedMailboxReasons = {};
87703
+ const omittedMessages = [];
87572
87704
  for (const config2 of configs) {
87573
87705
  const perAccountArgs = { ...args, account: void 0 };
87574
87706
  try {
@@ -87581,12 +87713,25 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87581
87713
  for (const [mailbox, reason] of Object.entries(res.failedMailboxReasons)) {
87582
87714
  failedMailboxReasons[`${config2.accountLabel} / ${mailbox}`] = reason;
87583
87715
  }
87716
+ omittedMessages.push(
87717
+ ...(res.omittedMessages ?? []).map((o) => ({
87718
+ ...o,
87719
+ mailbox: `${config2.accountLabel} / ${o.mailbox}`
87720
+ }))
87721
+ );
87584
87722
  } catch (e) {
87585
87723
  accountsFailed.push(config2.accountLabel);
87586
87724
  console.error(`IMAP fan-out failed for account "${config2.accountLabel}": ${String(e)}`);
87587
87725
  }
87588
87726
  }
87589
- return { rows, accountsQueried, accountsFailed, failedMailboxes, failedMailboxReasons };
87727
+ return {
87728
+ rows,
87729
+ accountsQueried,
87730
+ accountsFailed,
87731
+ failedMailboxes,
87732
+ failedMailboxReasons,
87733
+ omittedMessages
87734
+ };
87590
87735
  }
87591
87736
  function configMatchesAccount(config2, account) {
87592
87737
  const name = account.name.trim().toLowerCase();
@@ -88189,7 +88334,10 @@ var LIST_OUTPUT_SCHEMA = {
88189
88334
  failedMailboxes: external_exports.array(external_exports.string()).optional(),
88190
88335
  // Underlying error text per entry in `failedMailboxes`, same keys (#246
88191
88336
  // follow-up) — declared so a client can rely on it rather than parse text.
88192
- failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional()
88337
+ failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional(),
88338
+ // Messages that belong on the page but could not be read, each with its
88339
+ // imap: id and why (#256 follow-up). Non-empty implies `partial: true`.
88340
+ omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional()
88193
88341
  };
88194
88342
  var BATCH_COUNT_OUTPUT_SCHEMA = {
88195
88343
  ok: external_exports.boolean().optional(),
@@ -88268,7 +88416,7 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88268
88416
  const merged = mergeMessages(fan.rows, apple.rows, limit);
88269
88417
  const diagnostics = {
88270
88418
  ...apple.diagnostics,
88271
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0,
88419
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0,
88272
88420
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88273
88421
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88274
88422
  };
@@ -88280,9 +88428,10 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88280
88428
  notSearchedMailboxes: diagnostics.notSearchedMailboxes,
88281
88429
  timedOutAccounts: diagnostics.timedOutAccounts,
88282
88430
  failedMailboxes: fan.failedMailboxes,
88283
- failedMailboxReasons: fan.failedMailboxReasons
88431
+ failedMailboxReasons: fan.failedMailboxReasons,
88432
+ omittedMessages: fan.omittedMessages
88284
88433
  };
88285
- const coverageBlock = partialCoverageBlock(diagnostics);
88434
+ const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true);
88286
88435
  if (merged.length === 0) {
88287
88436
  const base = diagnostics.partial ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.` : "No messages found";
88288
88437
  return successResponse(`${base}${coverageBlock}`, structured);
@@ -88345,7 +88494,10 @@ registerTool(
88345
88494
  isFlagged: external_exports.boolean().optional().describe("Filter by flagged status"),
88346
88495
  dateFrom: DATE_FILTER_SCHEMA.describe("Start date filter (e.g., 'January 1, 2026')"),
88347
88496
  dateTo: DATE_FILTER_SCHEMA.describe("End date filter (e.g., 'March 1, 2026')"),
88348
- limit: external_exports.number().int().min(1).max(500).optional().describe("Maximum number of results (default: 50, max: 500)")
88497
+ limit: external_exports.number().int().min(1).max(500).optional().describe("Maximum number of results (default: 50, max: 500)"),
88498
+ offset: external_exports.number().int().min(0).optional().describe(
88499
+ "Number of newest matches to skip (for pagination; IMAP accounts only). Without a mailbox, offset + limit may not exceed 5,000 \u2014 name a mailbox to page deeper."
88500
+ )
88349
88501
  },
88350
88502
  outputSchema: LIST_OUTPUT_SCHEMA
88351
88503
  },
@@ -88356,6 +88508,7 @@ registerTool(
88356
88508
  mailbox,
88357
88509
  account,
88358
88510
  limit = 50,
88511
+ offset = 0,
88359
88512
  dateFrom,
88360
88513
  dateTo,
88361
88514
  from,
@@ -88363,12 +88516,17 @@ registerTool(
88363
88516
  isRead,
88364
88517
  isFlagged
88365
88518
  }) => {
88519
+ if (offset > 0 && !mailbox) {
88520
+ const tooDeep = unscopedSearchOffsetError(offset, limit);
88521
+ if (tooDeep) return errorResponse(tooDeep);
88522
+ }
88366
88523
  if (shouldUseImap(account)) {
88367
88524
  const imapArgs = {
88368
88525
  query,
88369
88526
  body,
88370
88527
  mailbox,
88371
88528
  limit,
88529
+ offset,
88372
88530
  dateFrom,
88373
88531
  dateTo,
88374
88532
  from,
@@ -88383,14 +88541,20 @@ registerTool(
88383
88541
  count: r.count,
88384
88542
  partial: r.partial,
88385
88543
  failedMailboxes: r.failedMailboxes,
88386
- failedMailboxReasons: r.failedMailboxReasons
88544
+ failedMailboxReasons: r.failedMailboxReasons,
88545
+ omittedMessages: r.omittedMessages
88387
88546
  });
88388
88547
  }
88389
- const fan = await fanOutImapMessages(imapArgs, "search");
88390
88548
  const { appleScriptOnly } = partitionAccountsForCounts(
88391
88549
  mailManager.listAccounts(),
88392
88550
  resolveImapConfigs()
88393
88551
  );
88552
+ if (offset > 0 && appleScriptOnly.length > 0 && !body) {
88553
+ return errorResponse(
88554
+ `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.`
88555
+ );
88556
+ }
88557
+ const fan = await fanOutImapMessages(imapArgs, "search");
88394
88558
  if (body) {
88395
88559
  const apple2 = {
88396
88560
  rows: [],
@@ -88421,6 +88585,11 @@ registerTool(
88421
88585
  );
88422
88586
  return mergedMessageResponse(fan, apple, limit, "matched");
88423
88587
  }
88588
+ if (offset > 0) {
88589
+ return errorResponse(
88590
+ `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.`
88591
+ );
88592
+ }
88424
88593
  if (body) {
88425
88594
  return errorResponse(
88426
88595
  `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.`
@@ -88737,7 +88906,9 @@ registerTool(
88737
88906
  messages: external_exports.array(MESSAGE_ROW_SCHEMA).optional(),
88738
88907
  count: external_exports.number().optional(),
88739
88908
  partial: external_exports.boolean().optional(),
88740
- failedMailboxes: external_exports.array(external_exports.string()).optional()
88909
+ failedMailboxes: external_exports.array(external_exports.string()).optional(),
88910
+ failedMailboxReasons: LIST_OUTPUT_SCHEMA.failedMailboxReasons,
88911
+ omittedMessages: LIST_OUTPUT_SCHEMA.omittedMessages
88741
88912
  }
88742
88913
  },
88743
88914
  withErrorHandling(async ({ id, account, mailbox, limit = 50 }) => {
@@ -88767,7 +88938,8 @@ ${r.text}`, {
88767
88938
  count: r.count,
88768
88939
  partial: r.partial,
88769
88940
  failedMailboxes: r.failedMailboxes,
88770
- failedMailboxReasons: r.failedMailboxReasons
88941
+ failedMailboxReasons: r.failedMailboxReasons,
88942
+ omittedMessages: r.omittedMessages
88771
88943
  });
88772
88944
  }
88773
88945
  const fan = await fanOutImapMessages({ subject: base, mailbox, limit }, "search");
@@ -88792,20 +88964,21 @@ ${r.text}`, {
88792
88964
  const orderedRows = mergedNewestFirst.slice().reverse().sort(
88793
88965
  (a, b) => (a.dateReceived ? new Date(a.dateReceived).getTime() : 0) - (b.dateReceived ? new Date(b.dateReceived).getTime() : 0)
88794
88966
  );
88795
- const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0;
88967
+ const partial2 = apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0;
88796
88968
  const coverage = partialCoverageBlock({
88797
88969
  ...apple.diagnostics,
88798
88970
  partial: partial2,
88799
88971
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88800
88972
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88801
- });
88973
+ }) + omittedNote(fan.omittedMessages, true);
88802
88974
  const structured2 = {
88803
88975
  subject: base,
88804
88976
  messages: orderedRows,
88805
88977
  count: orderedRows.length,
88806
88978
  partial: partial2,
88807
88979
  failedMailboxes: fan.failedMailboxes,
88808
- failedMailboxReasons: fan.failedMailboxReasons
88980
+ failedMailboxReasons: fan.failedMailboxReasons,
88981
+ omittedMessages: fan.omittedMessages
88809
88982
  };
88810
88983
  if (orderedRows.length === 0) {
88811
88984
  return successResponse(`No messages found in thread "${base}".${coverage}`, structured2);
@@ -88870,7 +89043,8 @@ registerTool(
88870
89043
  count: r.count,
88871
89044
  partial: r.partial,
88872
89045
  failedMailboxes: r.failedMailboxes,
88873
- failedMailboxReasons: r.failedMailboxReasons
89046
+ failedMailboxReasons: r.failedMailboxReasons,
89047
+ omittedMessages: r.omittedMessages
88874
89048
  });
88875
89049
  }
88876
89050
  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.17",
3
+ "version": "2.19.19",
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",