apple-mail-mcp 2.19.15 → 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,11 +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
+ **Messages awaiting expunge are not listed.** On IMAP accounts, search,
1090
+ list, thread and mail-stats queries only match messages _not_ flagged
1091
+ `\Deleted` (IMAP `UNDELETED`). iCloud keeps flagged-but-never-expunged
1092
+ messages out of its message counts yet still returns them from `UID SEARCH`,
1093
+ which made a filter-less `list-messages` fail on such a mailbox before 2.19.16.
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
+
1089
1109
  **Mail's local "On My Mac" mailboxes** are not children of any account — they
1090
1110
  hang off the application — so they are reported under the synthetic account label
1091
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,
@@ -58323,7 +58324,7 @@ async function resolveMailboxPath(client, mailbox, _mode) {
58323
58324
  return staticMailboxAlias(mailbox);
58324
58325
  }
58325
58326
  function buildCriteria(a, listMode) {
58326
- const c = {};
58327
+ const c = { ...NOT_DELETED };
58327
58328
  if (a.query) c.or = [{ subject: a.query }, { from: a.query }];
58328
58329
  if (a.body) c.body = a.body;
58329
58330
  if (a.from) c.from = a.from;
@@ -58335,7 +58336,6 @@ function buildCriteria(a, listMode) {
58335
58336
  if (a.isFlagged === false) c.unflagged = true;
58336
58337
  if (a.dateFrom) c.since = new Date(a.dateFrom);
58337
58338
  if (a.dateTo) c.before = new Date(a.dateTo);
58338
- if (Object.keys(c).length === 0) c.all = true;
58339
58339
  return c;
58340
58340
  }
58341
58341
  function validDate(d) {
@@ -58429,44 +58429,139 @@ function messageIdentity(entry) {
58429
58429
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
58430
58430
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
58431
58431
  }
58432
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
58433
- 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) {
58434
58437
  try {
58435
- const found = await client.search(criteria, { uid: true });
58436
- const uids = Array.isArray(found) ? found : [];
58437
- if (uids.length === 0 || newestCount === 0) return { messages: [], total: uids.length };
58438
- const status = await client.status(path, { messages: true });
58439
- 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) {
58440
58526
  throw new Error(
58441
- `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).`
58442
58528
  );
58443
58529
  }
58444
- const newest = uids.slice().reverse().slice(0, newestCount);
58445
- const byUid = /* @__PURE__ */ new Map();
58446
- for await (const msg of client.fetch(
58447
- newest.join(","),
58448
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
58449
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
58450
- // the fetch (~17%), same single round trip, no extra request.
58451
- //
58452
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
58453
- // can finally mean what it says: imapflow's `envelope.date` is built from
58454
- // the header block, so it IS the `Date:` header, not arrival time.
58455
- //
58456
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
58457
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
58458
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
58459
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
58460
- // bytes per message. Too cheap to hide behind an opt-in.
58461
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
58462
- { uid: true }
58463
- )) {
58464
- 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
+ }
58465
58552
  }
58466
- return {
58467
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
58468
- total: uids.length
58469
- };
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 };
58470
58565
  } finally {
58471
58566
  lock.release();
58472
58567
  }
@@ -58495,15 +58590,17 @@ async function run(args, listMode, deps) {
58495
58590
  const limit = args.limit ?? 50;
58496
58591
  const offset = args.offset ?? 0;
58497
58592
  const criteria = buildCriteria(args, listMode);
58498
- const newestPerMailbox = offset + limit;
58593
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
58499
58594
  const fetched = [];
58500
58595
  const failedMailboxes = [];
58501
58596
  const failedMailboxReasons = {};
58502
58597
  let totalMatched = 0;
58598
+ let totalExact = true;
58503
58599
  for (const path of paths) {
58504
58600
  try {
58505
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
58601
+ const result = await fetchMailboxMatches(client, path, criteria, page);
58506
58602
  totalMatched += result.total;
58603
+ totalExact &&= result.totalExact;
58507
58604
  fetched.push(...result.messages.map((message) => ({ message, path })));
58508
58605
  } catch (error) {
58509
58606
  failedMailboxes.push(path);
@@ -58528,8 +58625,6 @@ async function run(args, listMode, deps) {
58528
58625
  if (!unique.has(key)) unique.set(key, entry);
58529
58626
  }
58530
58627
  ordered = [...unique.values()].slice(offset, offset + limit);
58531
- } else {
58532
- ordered = fetched.slice(offset, offset + limit);
58533
58628
  }
58534
58629
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
58535
58630
  const messages = ordered.map(
@@ -58540,6 +58635,7 @@ async function run(args, listMode, deps) {
58540
58635
 
58541
58636
  Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
58542
58637
  const verb = listMode ? "listed" : "matched";
58638
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
58543
58639
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
58544
58640
  if (messages.length === 0) {
58545
58641
  return {
@@ -58551,7 +58647,7 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
58551
58647
  failedMailboxReasons
58552
58648
  };
58553
58649
  }
58554
- 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}):
58555
58651
  ` + rows.join("\n") + `
58556
58652
 
58557
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;
@@ -58629,7 +58725,10 @@ function imapMailStats(deps = {}) {
58629
58725
  try {
58630
58726
  const lock = await client.getMailboxLock("INBOX");
58631
58727
  try {
58632
- const found = await client.search({ since: since(days) }, { uid: true });
58728
+ const found = await client.search(
58729
+ { since: since(days), ...NOT_DELETED },
58730
+ { uid: true }
58731
+ );
58633
58732
  return Array.isArray(found) ? found.length : 0;
58634
58733
  } finally {
58635
58734
  lock.release();
@@ -59498,11 +59597,23 @@ async function imapThread(id, deps = {}, limit = 50) {
59498
59597
  if (Array.isArray(found)) found.forEach((u) => uidSet.add(u));
59499
59598
  };
59500
59599
  if (seedMsgId) {
59501
- addFound(await client.search({ header: { references: seedMsgId } }, { uid: true }));
59502
- addFound(await client.search({ header: { "in-reply-to": seedMsgId } }, { uid: true }));
59600
+ addFound(
59601
+ await client.search(
59602
+ { header: { references: seedMsgId }, ...NOT_DELETED },
59603
+ { uid: true }
59604
+ )
59605
+ );
59606
+ addFound(
59607
+ await client.search(
59608
+ { header: { "in-reply-to": seedMsgId }, ...NOT_DELETED },
59609
+ { uid: true }
59610
+ )
59611
+ );
59503
59612
  }
59504
59613
  for (const mid of [...refIds].slice(0, 20)) {
59505
- addFound(await client.search({ header: { "message-id": mid } }, { uid: true }));
59614
+ addFound(
59615
+ await client.search({ header: { "message-id": mid }, ...NOT_DELETED }, { uid: true })
59616
+ );
59506
59617
  }
59507
59618
  if (uidSet.size <= 1) return null;
59508
59619
  const uids = [...uidSet].slice(0, limit);
@@ -59554,7 +59665,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59554
59665
  true
59555
59666
  );
59556
59667
  }
59557
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, 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;
59558
59669
  var init_imapClient = __esm({
59559
59670
  "src/services/imapClient.ts"() {
59560
59671
  "use strict";
@@ -59580,7 +59691,24 @@ var init_imapClient = __esm({
59580
59691
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
59581
59692
  };
59582
59693
  defaultConnect = async (cfg) => {
59583
- 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
+ });
59584
59712
  client.on("error", () => {
59585
59713
  });
59586
59714
  try {
@@ -59607,6 +59735,10 @@ var init_imapClient = __esm({
59607
59735
  junk: "\\junk",
59608
59736
  starred: "\\flagged"
59609
59737
  };
59738
+ NOT_DELETED = { deleted: false };
59739
+ LARGE_MAILBOX_MESSAGES = 1e4;
59740
+ FIRST_SEARCH_WINDOW = 5e3;
59741
+ MAX_WINDOW = 5e4;
59610
59742
  poolConnect = defaultConnect;
59611
59743
  pools = /* @__PURE__ */ new Map();
59612
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,
@@ -65652,7 +65653,7 @@ async function resolveMailboxPath(client, mailbox, _mode) {
65652
65653
  return staticMailboxAlias(mailbox);
65653
65654
  }
65654
65655
  function buildCriteria(a, listMode) {
65655
- const c = {};
65656
+ const c = { ...NOT_DELETED };
65656
65657
  if (a.query) c.or = [{ subject: a.query }, { from: a.query }];
65657
65658
  if (a.body) c.body = a.body;
65658
65659
  if (a.from) c.from = a.from;
@@ -65664,7 +65665,6 @@ function buildCriteria(a, listMode) {
65664
65665
  if (a.isFlagged === false) c.unflagged = true;
65665
65666
  if (a.dateFrom) c.since = new Date(a.dateFrom);
65666
65667
  if (a.dateTo) c.before = new Date(a.dateTo);
65667
- if (Object.keys(c).length === 0) c.all = true;
65668
65668
  return c;
65669
65669
  }
65670
65670
  function validDate(d) {
@@ -65758,44 +65758,139 @@ function messageIdentity(entry) {
65758
65758
  const messageId = raw.replace(/^<+|>+$/g, "").trim().toLowerCase();
65759
65759
  return messageId ? `mid:${messageId}` : `${entry.path}\0${entry.message.uid}`;
65760
65760
  }
65761
- async function fetchMailboxMatches(client, path, criteria, newestCount) {
65762
- 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) {
65763
65766
  try {
65764
- const found = await client.search(criteria, { uid: true });
65765
- const uids = Array.isArray(found) ? found : [];
65766
- if (uids.length === 0 || newestCount === 0) return { messages: [], total: uids.length };
65767
- const status = await client.status(path, { messages: true });
65768
- 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) {
65769
65855
  throw new Error(
65770
- `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).`
65771
65857
  );
65772
65858
  }
65773
- const newest = uids.slice().reverse().slice(0, newestCount);
65774
- const byUid = /* @__PURE__ */ new Map();
65775
- for await (const msg of client.fetch(
65776
- newest.join(","),
65777
- // BODYSTRUCTURE rides along so `hasAttachments` is computed rather
65778
- // than assumed. Measured on 50 real messages: ~390ms -> ~465ms for
65779
- // the fetch (~17%), same single round trip, no extra request.
65780
- //
65781
- // INTERNALDATE rides along for the same reason, and is why `dateReceived`
65782
- // can finally mean what it says: imapflow's `envelope.date` is built from
65783
- // the header block, so it IS the `Date:` header, not arrival time.
65784
- //
65785
- // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
65786
- // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
65787
- // still be recovered (#234). Measured on 50 real messages, 6 alternating
65788
- // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
65789
- // bytes per message. Too cheap to hide behind an opt-in.
65790
- { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
65791
- { uid: true }
65792
- )) {
65793
- 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
+ }
65794
65881
  }
65795
- return {
65796
- messages: newest.map((uid) => byUid.get(uid)).filter((message) => message !== void 0),
65797
- total: uids.length
65798
- };
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 };
65799
65894
  } finally {
65800
65895
  lock.release();
65801
65896
  }
@@ -65824,15 +65919,17 @@ async function run(args, listMode, deps) {
65824
65919
  const limit = args.limit ?? 50;
65825
65920
  const offset = args.offset ?? 0;
65826
65921
  const criteria = buildCriteria(args, listMode);
65827
- const newestPerMailbox = offset + limit;
65922
+ const page = unscopedSearch ? { skip: 0, take: offset + limit } : { skip: offset, take: limit };
65828
65923
  const fetched = [];
65829
65924
  const failedMailboxes = [];
65830
65925
  const failedMailboxReasons = {};
65831
65926
  let totalMatched = 0;
65927
+ let totalExact = true;
65832
65928
  for (const path of paths) {
65833
65929
  try {
65834
- const result = await fetchMailboxMatches(client, path, criteria, newestPerMailbox);
65930
+ const result = await fetchMailboxMatches(client, path, criteria, page);
65835
65931
  totalMatched += result.total;
65932
+ totalExact &&= result.totalExact;
65836
65933
  fetched.push(...result.messages.map((message) => ({ message, path })));
65837
65934
  } catch (error2) {
65838
65935
  failedMailboxes.push(path);
@@ -65857,8 +65954,6 @@ async function run(args, listMode, deps) {
65857
65954
  if (!unique.has(key)) unique.set(key, entry);
65858
65955
  }
65859
65956
  ordered = [...unique.values()].slice(offset, offset + limit);
65860
- } else {
65861
- ordered = fetched.slice(offset, offset + limit);
65862
65957
  }
65863
65958
  const rows = ordered.map(({ message, path }) => formatRow(message, cfg.accountLabel, path));
65864
65959
  const messages = ordered.map(
@@ -65869,6 +65964,7 @@ async function run(args, listMode, deps) {
65869
65964
 
65870
65965
  Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"${path}" (${failedMailboxReasons[path]})`).join(", ")}.` : "";
65871
65966
  const verb = listMode ? "listed" : "matched";
65967
+ const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
65872
65968
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
65873
65969
  if (messages.length === 0) {
65874
65970
  return {
@@ -65880,7 +65976,7 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path) => `"
65880
65976
  failedMailboxReasons
65881
65977
  };
65882
65978
  }
65883
- 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}):
65884
65980
  ` + rows.join("\n") + `
65885
65981
 
65886
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;
@@ -65958,7 +66054,10 @@ function imapMailStats(deps = {}) {
65958
66054
  try {
65959
66055
  const lock = await client.getMailboxLock("INBOX");
65960
66056
  try {
65961
- const found = await client.search({ since: since(days) }, { uid: true });
66057
+ const found = await client.search(
66058
+ { since: since(days), ...NOT_DELETED },
66059
+ { uid: true }
66060
+ );
65962
66061
  return Array.isArray(found) ? found.length : 0;
65963
66062
  } finally {
65964
66063
  lock.release();
@@ -66827,11 +66926,23 @@ async function imapThread(id, deps = {}, limit = 50) {
66827
66926
  if (Array.isArray(found)) found.forEach((u) => uidSet.add(u));
66828
66927
  };
66829
66928
  if (seedMsgId) {
66830
- addFound(await client.search({ header: { references: seedMsgId } }, { uid: true }));
66831
- addFound(await client.search({ header: { "in-reply-to": seedMsgId } }, { uid: true }));
66929
+ addFound(
66930
+ await client.search(
66931
+ { header: { references: seedMsgId }, ...NOT_DELETED },
66932
+ { uid: true }
66933
+ )
66934
+ );
66935
+ addFound(
66936
+ await client.search(
66937
+ { header: { "in-reply-to": seedMsgId }, ...NOT_DELETED },
66938
+ { uid: true }
66939
+ )
66940
+ );
66832
66941
  }
66833
66942
  for (const mid of [...refIds].slice(0, 20)) {
66834
- addFound(await client.search({ header: { "message-id": mid } }, { uid: true }));
66943
+ addFound(
66944
+ await client.search({ header: { "message-id": mid }, ...NOT_DELETED }, { uid: true })
66945
+ );
66835
66946
  }
66836
66947
  if (uidSet.size <= 1) return null;
66837
66948
  const uids = [...uidSet].slice(0, limit);
@@ -66883,7 +66994,7 @@ async function imapThread(id, deps = {}, limit = 50) {
66883
66994
  true
66884
66995
  );
66885
66996
  }
66886
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, 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;
66887
66998
  var init_imapClient = __esm({
66888
66999
  "src/services/imapClient.ts"() {
66889
67000
  "use strict";
@@ -66909,7 +67020,24 @@ var init_imapClient = __esm({
66909
67020
  accounts: "APPLE_MAIL_MCP_IMAP_ACCOUNTS"
66910
67021
  };
66911
67022
  defaultConnect = async (cfg) => {
66912
- 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
+ });
66913
67041
  client.on("error", () => {
66914
67042
  });
66915
67043
  try {
@@ -66936,6 +67064,10 @@ var init_imapClient = __esm({
66936
67064
  junk: "\\junk",
66937
67065
  starred: "\\flagged"
66938
67066
  };
67067
+ NOT_DELETED = { deleted: false };
67068
+ LARGE_MAILBOX_MESSAGES = 1e4;
67069
+ FIRST_SEARCH_WINDOW = 5e3;
67070
+ MAX_WINDOW = 5e4;
66939
67071
  poolConnect = defaultConnect;
66940
67072
  pools = /* @__PURE__ */ new Map();
66941
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.15",
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",