apple-mail-mcp 2.19.16 → 2.19.17

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
@@ -826,7 +826,7 @@ Reply to an existing message.
826
826
  | Parameter | Type | Required | Description |
827
827
  | ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------- |
828
828
  | `id` | string | Yes | Message ID to reply to |
829
- | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
829
+ | `body` | string | Yes | Reply body (plain text; HTML tags such as `<br>` are not rendered) |
830
830
  | `replyAll` | boolean | No | Reply to all recipients (default: false) |
831
831
  | `send` | boolean | No | Send immediately (default: true, false = save as draft) |
832
832
  | `transport` | string | No | `smtp` or `applescript`; omitted prefers configured SMTP when sending. Drafts use AppleScript. |
@@ -1081,17 +1081,31 @@ Matching ignores letter case **and Unicode normalization form**: a typed
1081
1081
  precomposed `México` (NFC) finds a mailbox the server stores decomposed (NFD —
1082
1082
  common for folders created on a Mac, and what iCloud keeps), and the server's
1083
1083
  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
1084
+ _only_ in case or normalization look identical in any listing, so a name that
1085
1085
  matches both is refused with an error saying so — rename one of them. For the
1086
1086
  same reason `create-mailbox` treats a normalization-equivalent existing name as
1087
1087
  already existing, and `rename-mailbox` refuses to create such a twin.
1088
1088
 
1089
1089
  **Messages awaiting expunge are not listed.** On IMAP accounts, search,
1090
- list, thread and mail-stats queries only match messages *not* flagged
1090
+ list, thread and mail-stats queries only match messages _not_ flagged
1091
1091
  `\Deleted` (IMAP `UNDELETED`). iCloud keeps flagged-but-never-expunged
1092
1092
  messages out of its message counts yet still returns them from `UID SEARCH`,
1093
1093
  which made a filter-less `list-messages` fail on such a mailbox before 2.19.16.
1094
1094
 
1095
+ **Very large IMAP mailboxes are read newest-first in bounded windows**
1096
+ ([#256](https://github.com/sweetrb/apple-mail-mcp/issues/256)). Above 10,000
1097
+ 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`.
1102
+ A filtered search there runs the same criteria over newest-first sequence
1103
+ windows (5,000 messages, growing to 50,000) and stops once the page is full;
1104
+ 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
1106
+ the server's own reason (or a likely timeout) instead of reporting "no
1107
+ messages".
1108
+
1095
1109
  **Mail's local "On My Mac" mailboxes** are not children of any account — they
1096
1110
  hang off the application — so they are reported under the synthetic account label
1097
1111
  **`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,
@@ -58428,44 +58429,139 @@ function messageIdentity(entry) {
58428
58429
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
58429
58430
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
58430
58431
  }
58431
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
58432
- const lock = await client.getMailboxLock(path);
58432
+ function isUnfiltered(criteria) {
58433
+ const keys = Object.keys(criteria);
58434
+ return keys.length === 1 && criteria.deleted === false;
58435
+ }
58436
+ async function statusMessageCount(client, path) {
58433
58437
  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) {
58438
+ const st = await client.status(path, { messages: true });
58439
+ return typeof st.messages === "number" && st.messages >= 0 ? st.messages : void 0;
58440
+ } catch {
58441
+ return void 0;
58442
+ }
58443
+ }
58444
+ async function searchUids(client, path, criteria, size) {
58445
+ client.takeLastCommandError?.();
58446
+ const found = await client.search(criteria, { uid: true });
58447
+ if (Array.isArray(found)) return found;
58448
+ const cause = client.takeLastCommandError?.();
58449
+ const sized = size === void 0 ? "" : ` (${size.toLocaleString("en-US")} messages)`;
58450
+ throw new Error(
58451
+ `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
+ );
58453
+ }
58454
+ async function fetchRows(client, uids) {
58455
+ if (uids.length === 0) return [];
58456
+ 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);
58478
+ }
58479
+ async function walkWindows(exists, page, firstWindow, readWindow) {
58480
+ const wanted = page.skip + page.take;
58481
+ const uids = [];
58482
+ let seen = 0;
58483
+ let matched = 0;
58484
+ let hi = exists;
58485
+ let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
58486
+ while (hi >= 1 && seen < wanted) {
58487
+ 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);
58489
+ matched += live.length;
58490
+ for (const uid of live) {
58491
+ if (seen >= wanted) break;
58492
+ if (seen >= page.skip) uids.push(uid);
58493
+ seen++;
58494
+ }
58495
+ hi = lo - 1;
58496
+ width = Math.min(width * 4, MAX_WINDOW);
58497
+ }
58498
+ return { uids, matched, exhausted: hi < 1 };
58499
+ }
58500
+ async function listLargeMailbox(client, exists, page) {
58501
+ let deletedSeen = 0;
58502
+ 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,
58507
+ async (lo, hi) => {
58508
+ const live = [];
58509
+ for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
58510
+ if (msg.flags?.has("\\Deleted")) deletedSeen++;
58511
+ else live.push(msg.uid);
58512
+ }
58513
+ return live;
58514
+ }
58515
+ );
58516
+ return {
58517
+ messages: await fetchRows(client, walk.uids),
58518
+ total: Math.max(0, exists - deletedSeen),
58519
+ totalExact: true
58520
+ };
58521
+ }
58522
+ async function searchLargeMailbox(client, path, criteria, exists, page) {
58523
+ const walk = await walkWindows(exists, page, FIRST_SEARCH_WINDOW, async (lo, hi, width) => {
58524
+ const found = await searchUids(client, path, { ...criteria, seq: `${lo}:${hi}` }, exists);
58525
+ if (found.length > width) {
58439
58526
  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).`
58527
+ `IMAP SEARCH on "${path}" reported ${found.length} matches in a ${width}-message window \u2014 discarding as corrupted rather than trusting it (see #246).`
58441
58528
  );
58442
58529
  }
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);
58530
+ return found;
58531
+ });
58532
+ return {
58533
+ messages: await fetchRows(client, walk.uids),
58534
+ total: walk.matched,
58535
+ totalExact: walk.exhausted
58536
+ };
58537
+ }
58538
+ async function fetchMailboxMatches(client, path, criteria, page) {
58539
+ const lock = await client.getMailboxLock(path);
58540
+ try {
58541
+ const exists = await statusMessageCount(client, path);
58542
+ if (exists === 0) return { messages: [], total: 0, totalExact: true };
58543
+ if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
58544
+ try {
58545
+ return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
58546
+ } catch (error) {
58547
+ const detail = errText(error);
58548
+ throw new Error(
58549
+ detail.includes(`"${path}" (`) ? detail : `reading the newest messages of "${path}" (${exists.toLocaleString("en-US")} messages) failed: ${detail}`
58550
+ );
58551
+ }
58464
58552
  }
58465
- return {
58466
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
58467
- total: uids.length
58468
- };
58553
+ const uids = await searchUids(client, path, criteria, exists);
58554
+ if (uids.length === 0 || page.take === 0) {
58555
+ return { messages: [], total: uids.length, totalExact: true };
58556
+ }
58557
+ const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
58558
+ if (typeof messages === "number" && uids.length > messages) {
58559
+ throw new Error(
58560
+ `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).`
58561
+ );
58562
+ }
58563
+ const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
58564
+ return { messages: await fetchRows(client, newest), total: uids.length, totalExact: true };
58469
58565
  } finally {
58470
58566
  lock.release();
58471
58567
  }
@@ -58494,15 +58590,17 @@ async function run(args, listMode, deps) {
58494
58590
  const limit = args.limit ?? 50;
58495
58591
  const offset = args.offset ?? 0;
58496
58592
  const criteria = buildCriteria(args, listMode);
58497
- const newestPerMailbox = offset + limit;
58593
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
58498
58594
  const fetched = [];
58499
58595
  const failedMailboxes = [];
58500
58596
  const failedMailboxReasons = {};
58501
58597
  let totalMatched = 0;
58598
+ let totalExact = true;
58502
58599
  for (const path of paths) {
58503
58600
  try {
58504
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
58601
+ const result = await fetchMailboxMatches(client, path, criteria, page);
58505
58602
  totalMatched += result.total;
58603
+ totalExact &&= result.totalExact;
58506
58604
  fetched.push(...result.messages.map((message) => ({ message, path })));
58507
58605
  } catch (error) {
58508
58606
  failedMailboxes.push(path);
@@ -58527,8 +58625,6 @@ async function run(args, listMode, deps) {
58527
58625
  if (!unique.has(key)) unique.set(key, entry);
58528
58626
  }
58529
58627
  ordered = [...unique.values()].slice(offset, offset + limit);
58530
- } else {
58531
- ordered = fetched.slice(offset, offset + limit);
58532
58628
  }
58533
58629
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
58534
58630
  const messages = ordered.map(
@@ -58539,6 +58635,7 @@ async function run(args, listMode, deps) {
58539
58635
 
58540
58636
  Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
58541
58637
  const verb = listMode ? "listed" : "matched";
58638
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
58542
58639
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
58543
58640
  if (messages.length === 0) {
58544
58641
  return {
@@ -58550,7 +58647,7 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
58550
58647
  failedMailboxReasons
58551
58648
  };
58552
58649
  }
58553
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalMatched} total ${verb}):
58650
+ const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
58554
58651
  ` + rows.join("\n") + `
58555
58652
 
58556
58653
  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;
@@ -59568,7 +59665,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59568
59665
  true
59569
59666
  );
59570
59667
  }
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;
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;
59572
59669
  var init_imapClient = __esm({
59573
59670
  "src/services/imapClient.ts"() {
59574
59671
  "use strict";
@@ -59594,7 +59691,24 @@ var init_imapClient = __esm({
59594
59691
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
59595
59692
  };
59596
59693
  defaultConnect = async (cfg) => {
59597
- const client = new import_imapflow.ImapFlow(buildImapConnectionOptions(cfg));
59694
+ let lastCommandError;
59695
+ const keepErr = (entry) => {
59696
+ if (entry && typeof entry === "object" && "err" in entry) {
59697
+ lastCommandError = entry.err;
59698
+ }
59699
+ };
59700
+ const noop = () => void 0;
59701
+ const client = new import_imapflow.ImapFlow({
59702
+ ...buildImapConnectionOptions(cfg),
59703
+ logger: { trace: noop, debug: noop, info: noop, warn: keepErr, error: keepErr, fatal: keepErr }
59704
+ });
59705
+ Object.assign(client, {
59706
+ takeLastCommandError: () => {
59707
+ const err = lastCommandError;
59708
+ lastCommandError = void 0;
59709
+ return err;
59710
+ }
59711
+ });
59598
59712
  client.on("error", () => {
59599
59713
  });
59600
59714
  try {
@@ -59622,6 +59736,9 @@ var init_imapClient = __esm({
59622
59736
  starred: "\\flagged"
59623
59737
  };
59624
59738
  NOT_DELETED = { deleted: false };
59739
+ LARGE_MAILBOX_MESSAGES = 1e4;
59740
+ FIRST_SEARCH_WINDOW = 5e3;
59741
+ MAX_WINDOW = 5e4;
59625
59742
  poolConnect = defaultConnect;
59626
59743
  pools = /* @__PURE__ */ new Map();
59627
59744
  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,
@@ -65757,44 +65758,139 @@ function messageIdentity(entry) {
65757
65758
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
65758
65759
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
65759
65760
  }
65760
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
65761
- const lock = await client.getMailboxLock(path);
65761
+ function isUnfiltered(criteria) {
65762
+ const keys = Object.keys(criteria);
65763
+ return keys.length === 1 && criteria.deleted === false;
65764
+ }
65765
+ async function statusMessageCount(client, path) {
65762
65766
  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) {
65767
+ const st = await client.status(path, { messages: true });
65768
+ return typeof st.messages === "number" && st.messages >= 0 ? st.messages : void 0;
65769
+ } catch {
65770
+ return void 0;
65771
+ }
65772
+ }
65773
+ async function searchUids(client, path, criteria, size) {
65774
+ client.takeLastCommandError?.();
65775
+ const found = await client.search(criteria, { uid: true });
65776
+ if (Array.isArray(found)) return found;
65777
+ const cause = client.takeLastCommandError?.();
65778
+ const sized = size === void 0 ? "" : ` (${size.toLocaleString("en-US")} messages)`;
65779
+ throw new Error(
65780
+ `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
+ );
65782
+ }
65783
+ async function fetchRows(client, uids) {
65784
+ if (uids.length === 0) return [];
65785
+ 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) {
65809
+ const wanted = page.skip + page.take;
65810
+ const uids = [];
65811
+ let seen = 0;
65812
+ let matched = 0;
65813
+ let hi = exists;
65814
+ let width = Math.max(1, Math.min(firstWindow, MAX_WINDOW));
65815
+ while (hi >= 1 && seen < wanted) {
65816
+ 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);
65818
+ matched += live.length;
65819
+ for (const uid of live) {
65820
+ if (seen >= wanted) break;
65821
+ if (seen >= page.skip) uids.push(uid);
65822
+ seen++;
65823
+ }
65824
+ hi = lo - 1;
65825
+ width = Math.min(width * 4, MAX_WINDOW);
65826
+ }
65827
+ return { uids, matched, exhausted: hi < 1 };
65828
+ }
65829
+ async function listLargeMailbox(client, exists, page) {
65830
+ let deletedSeen = 0;
65831
+ 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,
65836
+ async (lo, hi) => {
65837
+ const live = [];
65838
+ for await (const msg of client.fetch(`${lo}:${hi}`, { uid: true, flags: true })) {
65839
+ if (msg.flags?.has("\\Deleted")) deletedSeen++;
65840
+ else live.push(msg.uid);
65841
+ }
65842
+ return live;
65843
+ }
65844
+ );
65845
+ return {
65846
+ messages: await fetchRows(client, walk.uids),
65847
+ total: Math.max(0, exists - deletedSeen),
65848
+ totalExact: true
65849
+ };
65850
+ }
65851
+ async function searchLargeMailbox(client, path, criteria, exists, page) {
65852
+ const walk = await walkWindows(exists, page, FIRST_SEARCH_WINDOW, async (lo, hi, width) => {
65853
+ const found = await searchUids(client, path, { ...criteria, seq: `${lo}:${hi}` }, exists);
65854
+ if (found.length > width) {
65768
65855
  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).`
65856
+ `IMAP SEARCH on "${path}" reported ${found.length} matches in a ${width}-message window \u2014 discarding as corrupted rather than trusting it (see #246).`
65770
65857
  );
65771
65858
  }
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);
65859
+ return found;
65860
+ });
65861
+ return {
65862
+ messages: await fetchRows(client, walk.uids),
65863
+ total: walk.matched,
65864
+ totalExact: walk.exhausted
65865
+ };
65866
+ }
65867
+ async function fetchMailboxMatches(client, path, criteria, page) {
65868
+ const lock = await client.getMailboxLock(path);
65869
+ try {
65870
+ const exists = await statusMessageCount(client, path);
65871
+ if (exists === 0) return { messages: [], total: 0, totalExact: true };
65872
+ if (exists !== void 0 && exists > LARGE_MAILBOX_MESSAGES) {
65873
+ try {
65874
+ return isUnfiltered(criteria) ? await listLargeMailbox(client, exists, page) : await searchLargeMailbox(client, path, criteria, exists, page);
65875
+ } catch (error2) {
65876
+ const detail = errText(error2);
65877
+ throw new Error(
65878
+ detail.includes(`"${path}" (`) ? detail : `reading the newest messages of "${path}" (${exists.toLocaleString("en-US")} messages) failed: ${detail}`
65879
+ );
65880
+ }
65793
65881
  }
65794
- return {
65795
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
65796
- total: uids.length
65797
- };
65882
+ const uids = await searchUids(client, path, criteria, exists);
65883
+ if (uids.length === 0 || page.take === 0) {
65884
+ return { messages: [], total: uids.length, totalExact: true };
65885
+ }
65886
+ const messages = exists ?? (await client.status(path, { messages: true })).messages ?? void 0;
65887
+ if (typeof messages === "number" && uids.length > messages) {
65888
+ throw new Error(
65889
+ `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).`
65890
+ );
65891
+ }
65892
+ const newest = uids.slice().reverse().slice(page.skip, page.skip + page.take);
65893
+ return { messages: await fetchRows(client, newest), total: uids.length, totalExact: true };
65798
65894
  } finally {
65799
65895
  lock.release();
65800
65896
  }
@@ -65823,15 +65919,17 @@ async function run(args, listMode, deps) {
65823
65919
  const limit = args.limit ?? 50;
65824
65920
  const offset = args.offset ?? 0;
65825
65921
  const criteria = buildCriteria(args, listMode);
65826
- const newestPerMailbox = offset + limit;
65922
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
65827
65923
  const fetched = [];
65828
65924
  const failedMailboxes = [];
65829
65925
  const failedMailboxReasons = {};
65830
65926
  let totalMatched = 0;
65927
+ let totalExact = true;
65831
65928
  for (const path of paths) {
65832
65929
  try {
65833
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
65930
+ const result = await fetchMailboxMatches(client, path, criteria, page);
65834
65931
  totalMatched += result.total;
65932
+ totalExact &&= result.totalExact;
65835
65933
  fetched.push(...result.messages.map((message) => ({ message, path })));
65836
65934
  } catch (error2) {
65837
65935
  failedMailboxes.push(path);
@@ -65856,8 +65954,6 @@ async function run(args, listMode, deps) {
65856
65954
  if (!unique.has(key)) unique.set(key, entry);
65857
65955
  }
65858
65956
  ordered = [...unique.values()].slice(offset, offset + limit);
65859
- } else {
65860
- ordered = fetched.slice(offset, offset + limit);
65861
65957
  }
65862
65958
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
65863
65959
  const messages = ordered.map(
@@ -65868,6 +65964,7 @@ async function run(args, listMode, deps) {
65868
65964
 
65869
65965
  Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
65870
65966
  const verb = listMode ? "listed" : "matched";
65967
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
65871
65968
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
65872
65969
  if (messages.length === 0) {
65873
65970
  return {
@@ -65879,7 +65976,7 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
65879
65976
  failedMailboxReasons
65880
65977
  };
65881
65978
  }
65882
- const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalMatched} total ${verb}):
65979
+ const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
65883
65980
  ` + rows.join("\n") + `
65884
65981
 
65885
65982
  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;
@@ -66897,7 +66994,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66897
66994
  true
66898
66995
  );
66899
66996
  }
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;
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;
66901
66998
  var init_imapClient = __esm({
66902
66999
  "src/services/imapClient.ts"() {
66903
67000
  "use strict";
@@ -66923,7 +67020,24 @@ var init_imapClient = __esm({
66923
67020
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
66924
67021
  };
66925
67022
  defaultConnect = async (cfg) => {
66926
- const client = new import_imapflow.ImapFlow(buildImapConnectionOptions(cfg));
67023
+ let lastCommandError;
67024
+ const keepErr = (entry) => {
67025
+ if (entry && typeof entry === "object" && "err" in entry) {
67026
+ lastCommandError = entry.err;
67027
+ }
67028
+ };
67029
+ const noop = () => void 0;
67030
+ const client = new import_imapflow.ImapFlow({
67031
+ ...buildImapConnectionOptions(cfg),
67032
+ logger: { trace: noop, debug: noop, info: noop, warn: keepErr, error: keepErr, fatal: keepErr }
67033
+ });
67034
+ Object.assign(client, {
67035
+ takeLastCommandError: () => {
67036
+ const err = lastCommandError;
67037
+ lastCommandError = void 0;
67038
+ return err;
67039
+ }
67040
+ });
66927
67041
  client.on("error", () => {
66928
67042
  });
66929
67043
  try {
@@ -66951,6 +67065,9 @@ var init_imapClient = __esm({
66951
67065
  starred: "\\flagged"
66952
67066
  };
66953
67067
  NOT_DELETED = { deleted: false };
67068
+ LARGE_MAILBOX_MESSAGES = 1e4;
67069
+ FIRST_SEARCH_WINDOW = 5e3;
67070
+ MAX_WINDOW = 5e4;
66954
67071
  poolConnect = defaultConnect;
66955
67072
  pools = /* @__PURE__ */ new Map();
66956
67073
  connecting = /* @__PURE__ */ new Map();
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.17",
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",