apple-mail-mcp 2.20.3 → 2.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -19994,6 +19994,84 @@ var init_nodemailer = __esm({
19994
19994
  }
19995
19995
  });
19996
19996
 
19997
+ // src/utils/callStop.ts
19998
+ function searchDeadlineMs(env = process.env) {
19999
+ const raw = env[SEARCH_DEADLINE_ENV];
20000
+ if (raw === void 0 || raw.trim() === "") return DEFAULT_SEARCH_DEADLINE_MS;
20001
+ const n = Number(raw);
20002
+ if (!Number.isFinite(n)) return DEFAULT_SEARCH_DEADLINE_MS;
20003
+ return Math.max(MIN_SEARCH_DEADLINE_MS, Math.floor(n));
20004
+ }
20005
+ function createCallStop(timing, deadlineMs, envVar = SEARCH_DEADLINE_ENV) {
20006
+ const controller = new AbortController();
20007
+ const upstream = timing?.signal;
20008
+ const onCancel = () => {
20009
+ if (!controller.signal.aborted) controller.abort({ kind: "cancelled" });
20010
+ };
20011
+ if (upstream?.aborted) onCancel();
20012
+ else upstream?.addEventListener("abort", onCancel, { once: true });
20013
+ const startedAt = timing?.arrivedAt ?? Date.now();
20014
+ const remaining = Math.max(0, deadlineMs - (Date.now() - startedAt));
20015
+ const timer = setTimeout(() => {
20016
+ if (!controller.signal.aborted) {
20017
+ controller.abort({ kind: "deadline", deadlineMs, envVar });
20018
+ }
20019
+ }, remaining);
20020
+ timer.unref?.();
20021
+ return {
20022
+ signal: controller.signal,
20023
+ dispose() {
20024
+ clearTimeout(timer);
20025
+ upstream?.removeEventListener("abort", onCancel);
20026
+ }
20027
+ };
20028
+ }
20029
+ function describeStop(signal) {
20030
+ const reason = signal?.reason;
20031
+ if (reason && typeof reason === "object" && reason.kind === "deadline") {
20032
+ return `the ${Math.round(reason.deadlineMs / 1e3)}s search deadline (${reason.envVar})`;
20033
+ }
20034
+ return "the request being cancelled";
20035
+ }
20036
+ function stoppedByDeadline(signal) {
20037
+ const reason = signal?.reason;
20038
+ return !!reason && typeof reason === "object" && reason.kind === "deadline";
20039
+ }
20040
+ function raceStop(work, signal) {
20041
+ if (!signal) return work;
20042
+ if (signal.aborted) {
20043
+ work.catch(() => void 0);
20044
+ return Promise.resolve(STOPPED);
20045
+ }
20046
+ return new Promise((resolve5, reject) => {
20047
+ const onAbort = () => {
20048
+ work.catch(() => void 0);
20049
+ resolve5(STOPPED);
20050
+ };
20051
+ signal.addEventListener("abort", onAbort, { once: true });
20052
+ work.then(
20053
+ (value) => {
20054
+ signal.removeEventListener("abort", onAbort);
20055
+ resolve5(value);
20056
+ },
20057
+ (error3) => {
20058
+ signal.removeEventListener("abort", onAbort);
20059
+ reject(error3);
20060
+ }
20061
+ );
20062
+ });
20063
+ }
20064
+ var DEFAULT_SEARCH_DEADLINE_MS, MIN_SEARCH_DEADLINE_MS, SEARCH_DEADLINE_ENV, STOPPED;
20065
+ var init_callStop = __esm({
20066
+ "src/utils/callStop.ts"() {
20067
+ "use strict";
20068
+ DEFAULT_SEARCH_DEADLINE_MS = 45e3;
20069
+ MIN_SEARCH_DEADLINE_MS = 2e3;
20070
+ SEARCH_DEADLINE_ENV = "APPLE_MAIL_MCP_SEARCH_DEADLINE_MS";
20071
+ STOPPED = /* @__PURE__ */ Symbol("stopped");
20072
+ }
20073
+ });
20074
+
19997
20075
  // node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js
19998
20076
  var require_err_helpers = __commonJS({
19999
20077
  "node_modules/.pnpm/pino-std-serializers@7.1.0/node_modules/pino-std-serializers/lib/err-helpers.js"(exports, module) {
@@ -65379,6 +65457,7 @@ __export(imapClient_exports, {
65379
65457
  resolveImapConfigs: () => resolveImapConfigs,
65380
65458
  resolveMailboxPath: () => resolveMailboxPath,
65381
65459
  shouldUseImap: () => shouldUseImap,
65460
+ stoppedNote: () => stoppedNote,
65382
65461
  unscopedSearchOffsetError: () => unscopedSearchOffsetError
65383
65462
  });
65384
65463
  import { createHash } from "node:crypto";
@@ -65699,6 +65778,12 @@ function structuredRow(m, account, path3) {
65699
65778
  ...env.messageId ? { messageId: env.messageId } : {}
65700
65779
  };
65701
65780
  }
65781
+ function stoppedNote(timedOut, signal) {
65782
+ if (timedOut.length === 0) return "";
65783
+ return `
65784
+
65785
+ Partial result. Stopped by ${describeStop(signal)} before finishing ${timedOut.length} mailbox(es): ${timedOut.map((m) => `"${m}"`).join(", ")}. This is NOT a confirmed "no such mail" \u2014 narrow the search with \`mailbox\` and/or \`dateFrom\`, or raise ${SEARCH_DEADLINE_ENV}.`;
65786
+ }
65702
65787
  function omittedNote(omitted, merged) {
65703
65788
  if (omitted.length === 0) return "";
65704
65789
  const list = omitted.map((o) => `UID ${o.uid} in "${o.mailbox}" (${o.id})`).join(", ");
@@ -65988,9 +66073,23 @@ async function run(args, listMode, deps) {
65988
66073
  const omittedMessages = [];
65989
66074
  let totalMatched = 0;
65990
66075
  let totalExact = true;
65991
- for (const path3 of paths) {
66076
+ const timedOutMailboxes = [];
66077
+ for (const [index, path3] of paths.entries()) {
66078
+ if (deps.signal?.aborted) {
66079
+ timedOutMailboxes.push(...paths.slice(index));
66080
+ break;
66081
+ }
65992
66082
  try {
65993
- const result = await fetchMailboxMatches(client, path3, criteria, page);
66083
+ const outcome = await raceStop(
66084
+ fetchMailboxMatches(client, path3, criteria, page),
66085
+ deps.signal
66086
+ );
66087
+ if (outcome === STOPPED) {
66088
+ abandonClient(client, cfg, deps);
66089
+ timedOutMailboxes.push(...paths.slice(index));
66090
+ break;
66091
+ }
66092
+ const result = outcome;
65994
66093
  totalMatched += result.total;
65995
66094
  totalExact &&= result.totalExact;
65996
66095
  fetched.push(...result.messages.map((message) => ({ message, path: path3 })));
@@ -66030,10 +66129,14 @@ async function run(args, listMode, deps) {
66030
66129
  const messages = ordered.map(
66031
66130
  ({ message, path: path3 }) => structuredRow(message, cfg.accountLabel, path3)
66032
66131
  );
66033
- const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0;
66132
+ const partial2 = failedMailboxes.length > 0 || omittedMessages.length > 0 || timedOutMailboxes.length > 0;
66133
+ const stopped = timedOutMailboxes.length > 0 ? {
66134
+ timedOutMailboxes,
66135
+ stoppedBy: stoppedByDeadline(deps.signal) ? "deadline" : "cancelled"
66136
+ } : {};
66034
66137
  const failureNote = (failedMailboxes.length > 0 ? `
66035
66138
 
66036
- Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch);
66139
+ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `"${path3}" (${failedMailboxReasons[path3]})`).join(", ")}.` : "") + omittedNote(omittedMessages, unscopedSearch) + stoppedNote(timedOutMailboxes, deps.signal);
66037
66140
  const verb = listMode ? "listed" : "matched";
66038
66141
  const totalText = totalExact ? `${totalMatched} total` : `at least ${totalMatched} total`;
66039
66142
  const scope = unscopedSearch ? allMailboxCount === 1 ? `mailbox "${paths[0]}"` : `${allMailboxCount} selectable mailboxes` : `mailbox "${paths[0]}"`;
@@ -66045,7 +66148,8 @@ Partial result. Could not search mailbox(es): ${failedMailboxes.map((path3) => `
66045
66148
  partial: partial2,
66046
66149
  failedMailboxes,
66047
66150
  failedMailboxReasons,
66048
- omittedMessages
66151
+ omittedMessages,
66152
+ ...stopped
66049
66153
  };
66050
66154
  }
66051
66155
  const text = `Found ${rows.length} message(s) via IMAP (server-side, account ${cfg.accountLabel}, ${scope}; ${totalText} ${verb}):
@@ -66059,7 +66163,8 @@ Note: these IMAP IDs (imap:\u2026) work with get-message and the message mutatio
66059
66163
  partial: partial2,
66060
66164
  failedMailboxes,
66061
66165
  failedMailboxReasons,
66062
- omittedMessages
66166
+ omittedMessages,
66167
+ ...stopped
66063
66168
  };
66064
66169
  },
66065
66170
  true
@@ -66202,6 +66307,20 @@ async function dropPool(key) {
66202
66307
  await e.client.logout().catch(() => void 0);
66203
66308
  e.client.close?.();
66204
66309
  }
66310
+ function abandonClient(client, cfg, deps) {
66311
+ if (!deps.connect) {
66312
+ const key = poolKey(cfg);
66313
+ const entry = pools.get(key);
66314
+ if (entry && entry.client === client) {
66315
+ if (entry.idle) clearTimeout(entry.idle);
66316
+ pools.delete(key);
66317
+ }
66318
+ }
66319
+ try {
66320
+ client.close?.();
66321
+ } catch {
66322
+ }
66323
+ }
66205
66324
  async function dropAllPools() {
66206
66325
  await Promise.all([...pools.keys()].map((k) => dropPool(k)));
66207
66326
  }
@@ -67200,6 +67319,7 @@ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, NOT_DELETED,
67200
67319
  var init_imapClient = __esm({
67201
67320
  "src/services/imapClient.ts"() {
67202
67321
  "use strict";
67322
+ init_callStop();
67203
67323
  import_imapflow = __toESM(require_imap_flow(), 1);
67204
67324
  init_smtpMailer();
67205
67325
  init_docsUrls();
@@ -82603,6 +82723,44 @@ var READ_ONLY_BYID_TIMEOUT_MS = 15e3;
82603
82723
  function timedOutResolvingId(id) {
82604
82724
  return `AppleScript timed out resolving id ${Number(id)} \u2014 Mail.app's scripting bridge may be wedged; try health-check.`;
82605
82725
  }
82726
+ function timedOutScanningForId(id) {
82727
+ return `AppleScript timed out scanning every mailbox for id ${Number(id)} \u2014 the cross-mailbox scan did not finish within ${READ_ONLY_BYID_TIMEOUT_MS / 1e3}s (very large mailboxes; if health-check also fails, Mail.app's scripting bridge may be wedged). Pass account + mailbox to read it from one mailbox, or use the message's imap: id from list-messages/search-messages.`;
82728
+ }
82729
+ function getByIdScanThreshold() {
82730
+ const raw = process.env.APPLE_MAIL_MAX_BYID_SCAN_MAILBOX;
82731
+ if (raw !== void 0 && raw.trim() !== "") {
82732
+ const n = Number(raw);
82733
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
82734
+ }
82735
+ return 5e4;
82736
+ }
82737
+ var BYID_SCAN_BUDGET_SECONDS = 9;
82738
+ var BYID_COVERAGE_MARKER = "COV";
82739
+ var SCOPED_MISS_MARKER = "SCOPED";
82740
+ function describeUnscopedMiss(id, raw) {
82741
+ const covIdx = raw.indexOf(BYID_COVERAGE_MARKER);
82742
+ if (covIdx === -1) return raw.trim();
82743
+ const base2 = raw.slice(0, covIdx).trim();
82744
+ const [skippedRaw = "", budgetRaw = ""] = raw.slice(covIdx + BYID_COVERAGE_MARKER.length).split(DIAG_FIELD_SEP);
82745
+ const skipped = skippedRaw.split(DIAG_ITEM_SEP).map((s) => s.trim()).filter(Boolean);
82746
+ const budgetHit = budgetRaw.trim() === "true";
82747
+ if (skipped.length === 0 && !budgetHit) return base2;
82748
+ const parts = [`Message ${Number(id)} not found in the mailboxes scanned.`];
82749
+ if (skipped.length > 0) {
82750
+ parts.push(
82751
+ `Not scanned \u2014 too large for a cross-mailbox id scan (over ${getByIdScanThreshold()} messages; APPLE_MAIL_MAX_BYID_SCAN_MAILBOX): ${skipped.join(", ")}.`
82752
+ );
82753
+ }
82754
+ if (budgetHit) {
82755
+ parts.push(
82756
+ `The scan also stopped after its ${BYID_SCAN_BUDGET_SECONDS}s budget before reaching every mailbox.`
82757
+ );
82758
+ }
82759
+ parts.push(
82760
+ "If the message is in a mailbox that was not scanned, pass account + mailbox to read it from that mailbox directly, or use its imap: id from list-messages/search-messages."
82761
+ );
82762
+ return parts.join(" ");
82763
+ }
82606
82764
  var GROUP_SEP = "";
82607
82765
  var FIELD_SEP = "";
82608
82766
  var RECORD_SEP = "";
@@ -83108,8 +83266,9 @@ var AppleMailManager = class {
83108
83266
  * Items") isn't reached before the AppleScript timeout fires, so the fetch
83109
83267
  * returns a false "not found" (only INBOX ids, reached early, worked). Every
83110
83268
  * search/list/by-id result records its id→location here so a subsequent fetch
83111
- * opens the one right mailbox directly. A stale entry (message moved) simply
83112
- * misses and falls back to the full scan, so it can never wedge a lookup.
83269
+ * opens the one right mailbox directly. A stale entry (message moved by
83270
+ * another client) misses, is EVICTED, and the read falls back to the bounded
83271
+ * cross-mailbox scan — see `resolveScopedById` (#270).
83113
83272
  */
83114
83273
  idLocationIndex = /* @__PURE__ */ new Map();
83115
83274
  /** Error from the most recent numeric message read, if it was refused. */
@@ -84209,8 +84368,12 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84209
84368
  /**
84210
84369
  * Get a message by ID.
84211
84370
  *
84212
- * Note: Mail.app message IDs are unique per mailbox. This method searches
84213
- * all mailboxes in all accounts to find the message.
84371
+ * Note: Mail.app message IDs are unique per mailbox. Resolution goes through
84372
+ * `resolveById` — the remembered location first, then the bounded
84373
+ * cross-mailbox scan — taking the FIRST copy found rather than refusing an id
84374
+ * a label store repeats across INBOX / "All Mail": every caller of this reads
84375
+ * per-message metadata (get-thread's seed subject, a mutation error's mailbox
84376
+ * hint) that is identical across those copies.
84214
84377
  */
84215
84378
  getMessageById(id, deepAttachmentCheck = false) {
84216
84379
  this.lastMessageLookupError = void 0;
@@ -84220,14 +84383,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84220
84383
  if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
84221
84384
  end try
84222
84385
  end if` : "";
84223
- const script = buildAppLevelScript(`
84224
- try
84225
- repeat with acct in accounts
84226
- repeat with mb in mailboxes of acct
84227
- try
84228
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84229
- if (count of matchingMsgs) > 0 then
84230
- set msg to item 1 of matchingMsgs
84386
+ const innerFetch = `
84231
84387
  set msgSubject to subject of msg
84232
84388
  set msgSender to sender of msg
84233
84389
  set d to date received of msg
@@ -84237,31 +84393,20 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84237
84393
  set msgJunk to junk mail status of msg as string
84238
84394
  set msgDeleted to deleted status of msg as string
84239
84395
  set msgMailbox to ""
84240
- ${mailboxPathFragment("mb", "msgMailbox")}
84241
- set msgAccount to name of acct
84396
+ try
84397
+ ${mailboxPathFragment("_hitMb", "msgMailbox")}
84398
+ end try
84399
+ set msgAccount to _hitAcct
84242
84400
  set hasAtt to "false"
84243
84401
  try
84244
84402
  set attCount to count of mail attachments of msg
84245
84403
  if attCount > 0 then set hasAtt to "true"
84246
84404
  end try
84247
84405
  ${deepScan}
84248
- return msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & msgJunk & "${FIELD_SEP}" & msgDeleted & "${FIELD_SEP}" & msgMailbox & "${FIELD_SEP}" & msgAccount & "${FIELD_SEP}" & hasAtt
84249
- end if
84250
- end try
84251
- end repeat
84252
- end repeat
84253
- return ""
84254
- on error errMsg
84255
- return ""
84256
- end try
84257
- `);
84258
- const result = executeAppleScript(script, { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS });
84259
- if (!result.success || !result.output.trim()) {
84260
- if (result.timedOut) this.lastMessageLookupError = timedOutResolvingId(id);
84261
- console.error(`Failed to get message ${id}: ${result.error}`);
84262
- return null;
84263
- }
84264
- const parts = result.output.split(FIELD_SEP);
84406
+ return msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & msgJunk & "${FIELD_SEP}" & msgDeleted & "${FIELD_SEP}" & msgMailbox & "${FIELD_SEP}" & msgAccount & "${FIELD_SEP}" & hasAtt`;
84407
+ const output = this.resolveById(id, innerFetch, void 0, { firstHit: true });
84408
+ if (output === null) return null;
84409
+ const parts = output.split(FIELD_SEP);
84265
84410
  if (parts.length < 9) return null;
84266
84411
  this.rememberLocation(id.toString(), parts[8], parts[7]);
84267
84412
  return {
@@ -84279,47 +84424,194 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84279
84424
  hasAttachments: parts.length > 9 ? parts[9] === "true" : false
84280
84425
  };
84281
84426
  }
84427
+ /**
84428
+ * Resolve numeric `id` and run `innerAction` against it — the ONE resolution
84429
+ * path behind every read-only by-id lookup (getMessageById,
84430
+ * getMessageContent, getMessageHeaders, getRawSource), so the four cannot
84431
+ * drift apart again. Returns the action's output, or null with
84432
+ * `lastMessageLookupError` set when there is something to say (#270):
84433
+ *
84434
+ * 1. Scoped, when a location is known — the caller's explicit `account` +
84435
+ * `mailbox`, else the `idLocationIndex` entry (see `resolveScopedById`):
84436
+ * - explicit hint, miss → a clean "not found in <account> / <mailbox>".
84437
+ * NO fallback: the caller said where to look, and the cross-mailbox
84438
+ * scan it would fall back to is the 20-50s operation @j5pu timed.
84439
+ * - index entry, miss → the entry is stale (the message was moved, by
84440
+ * another client or in Mail itself): evict it, then fall back to (2).
84441
+ * - timeout on either → report it; no second 15s scan on top.
84442
+ * 2. Unscoped: every mailbox of every account plus the local store, but
84443
+ * mailboxes above `getByIdScanThreshold()` are skipped and the walk stops
84444
+ * at `BYID_SCAN_BUDGET_SECONDS`; a miss names what was not covered.
84445
+ */
84446
+ resolveById(id, innerAction, hint, opts = {}) {
84447
+ const remembered = this.idLocationIndex.get(String(id));
84448
+ const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox, explicit: true } : remembered ? { ...remembered, explicit: false } : void 0;
84449
+ if (loc) {
84450
+ const scoped = this.resolveScopedById(id, loc, innerAction);
84451
+ if (scoped.kind === "hit") return scoped.output;
84452
+ if (scoped.kind === "done") return null;
84453
+ }
84454
+ const result = executeAppleScript(this.unscopedByIdScript(id, innerAction, opts), {
84455
+ timeoutMs: READ_ONLY_BYID_TIMEOUT_MS
84456
+ });
84457
+ if (!result.success || !result.output.trim()) {
84458
+ if (!result.success) {
84459
+ if (result.timedOut) this.lastMessageLookupError = timedOutScanningForId(id);
84460
+ console.error(`Failed to resolve message ${id}: ${result.error}`);
84461
+ }
84462
+ return null;
84463
+ }
84464
+ if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84465
+ this.lastMessageLookupError = describeUnscopedMiss(
84466
+ id,
84467
+ result.output.slice(LOOKUP_ERROR_MARKER.length)
84468
+ );
84469
+ return null;
84470
+ }
84471
+ return result.output;
84472
+ }
84473
+ /**
84474
+ * The scoped half of `resolveById`: open exactly `loc`'s mailbox and look for
84475
+ * the id there. `hit` carries the action's output; `done` means stop with
84476
+ * `lastMessageLookupError` set; `fallback` means the remembered location was
84477
+ * stale and has been evicted, so the caller should try the bounded scan.
84478
+ */
84479
+ resolveScopedById(id, loc, innerAction) {
84480
+ const result = executeAppleScript(
84481
+ this.scopedByIdScript(loc.account, loc.mailbox, id, innerAction),
84482
+ { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }
84483
+ );
84484
+ const where = `${loc.account} / ${loc.mailbox}`;
84485
+ if (!result.success) {
84486
+ console.error(`Failed to read message ${id} from ${where}: ${result.error}`);
84487
+ if (result.timedOut) {
84488
+ this.lastMessageLookupError = timedOutResolvingId(id);
84489
+ return { kind: "done" };
84490
+ }
84491
+ if (loc.explicit) {
84492
+ this.lastMessageLookupError = `Could not read message ${Number(id)} from ${where}: ${result.error ?? "AppleScript error"}`;
84493
+ return { kind: "done" };
84494
+ }
84495
+ this.forgetLocation(id);
84496
+ return { kind: "fallback" };
84497
+ }
84498
+ const scopedPrefix = LOOKUP_ERROR_MARKER + SCOPED_MISS_MARKER;
84499
+ if (result.output.trim() && !result.output.startsWith(scopedPrefix)) {
84500
+ return { kind: "hit", output: result.output };
84501
+ }
84502
+ if (!loc.explicit) {
84503
+ this.forgetLocation(id);
84504
+ return { kind: "fallback" };
84505
+ }
84506
+ const reason = result.output.startsWith(scopedPrefix) ? result.output.slice(scopedPrefix.length) : "nomessage";
84507
+ if (reason === "nomailbox") {
84508
+ this.lastMessageLookupError = `Mailbox "${loc.mailbox}" not found in account "${loc.account}" \u2014 message ${Number(id)} was not looked up. Check the names with list-mailboxes, or omit account/mailbox to scan every mailbox.`;
84509
+ } else if (reason.startsWith("error:")) {
84510
+ this.lastMessageLookupError = `Could not read message ${Number(id)} from ${where}: ${reason.slice("error:".length)}`;
84511
+ } else {
84512
+ this.lastMessageLookupError = `Message ${Number(id)} not found in ${where} \u2014 it may have been moved or deleted. Re-list or search to get its current id, or omit account/mailbox to scan every mailbox.`;
84513
+ }
84514
+ return { kind: "done" };
84515
+ }
84516
+ /** Drop a stale id→location entry (#270) so the next read doesn't retry it. */
84517
+ forgetLocation(id) {
84518
+ this.idLocationIndex.delete(String(id));
84519
+ }
84282
84520
  /**
84283
84521
  * The unscoped by-id resolution: walk every mailbox of every account, then the
84284
84522
  * local store (#183), and run `innerAction` with `msg` bound when EXACTLY one
84285
- * mailbox holds the id — `{LOOKUP_ERROR_MARKER}` prefixes both the not-found
84286
- * and the ambiguous outcome. Shared by getMessageContent, getRawSource and
84287
- * getMessageHeaders so the three reads cannot drift apart (the pre-#224 code
84288
- * carried two byte-identical copies).
84523
+ * mailbox holds the id (or on the first hit, with `firstHit`) —
84524
+ * `{LOOKUP_ERROR_MARKER}` prefixes both the not-found and the ambiguous
84525
+ * outcome. `_hitMb` / `_hitAcct` are bound to the hit's mailbox and account
84526
+ * name alongside `msg`.
84527
+ *
84528
+ * Bounded (#270): a mailbox above `getByIdScanThreshold()` messages is
84529
+ * skipped rather than probed, and the walk stops once it has run for
84530
+ * `BYID_SCAN_BUDGET_SECONDS`. Either way the not-found outcome carries a
84531
+ * `BYID_COVERAGE_MARKER` trailer naming what was not covered, so the caller
84532
+ * can say "not found in the mailboxes scanned; these large ones were not"
84533
+ * instead of a false flat "not found". A copy that sits only in a skipped
84534
+ * mailbox therefore no longer counts toward the ambiguity check — harmless
84535
+ * for these read-only lookups, whose copies are the same message.
84289
84536
  */
84290
- unscopedByIdScript(id, innerAction) {
84537
+ unscopedByIdScript(id, innerAction, opts = {}) {
84538
+ const threshold = getByIdScanThreshold();
84539
+ const sizeGuard = (mbVar, acctNameExpr) => threshold > 0 ? `set _mbCount to 0
84540
+ try
84541
+ set _mbCount to count of messages of ${mbVar}
84542
+ end try
84543
+ if _mbCount > ${threshold} then
84544
+ set _skipPath to ""
84545
+ try
84546
+ ${mailboxPathFragment(mbVar, "_skipPath")}
84547
+ end try
84548
+ set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & (_mbCount as string) & ")${DIAG_ITEM_SEP}"
84549
+ set _probe to false
84550
+ end if` : "";
84551
+ const budgetCheck = `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84552
+ set _budgetHit to true
84553
+ exit repeat
84554
+ end if`;
84555
+ const stopOnHit = opts.firstHit ? `if (count of _hits) > 0 then exit repeat` : "";
84291
84556
  return buildAppLevelScript(`
84292
84557
  try
84293
84558
  set _hits to {}
84559
+ set _hitMbs to {}
84560
+ set _hitAccts to {}
84294
84561
  set _names to ""
84562
+ set _skipped to ""
84563
+ set _budgetHit to false
84564
+ set _startedAt to current date
84295
84565
  repeat with acct in accounts
84566
+ if _budgetHit then exit repeat
84567
+ ${stopOnHit}
84296
84568
  repeat with mb in mailboxes of acct
84297
- try
84298
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84299
- if (count of matchingMsgs) > 0 then
84300
- set end of _hits to item 1 of matchingMsgs
84301
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84302
- end if
84303
- end try
84569
+ ${budgetCheck}
84570
+ set _probe to true
84571
+ ${sizeGuard("mb", "(name of acct)")}
84572
+ if _probe then
84573
+ try
84574
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
84575
+ if (count of matchingMsgs) > 0 then
84576
+ set end of _hits to item 1 of matchingMsgs
84577
+ set end of _hitMbs to (contents of mb)
84578
+ set end of _hitAccts to (name of acct)
84579
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84580
+ end if
84581
+ end try
84582
+ end if
84583
+ ${stopOnHit}
84304
84584
  end repeat
84305
84585
  end repeat
84306
84586
  -- #183: local mailboxes belong to no account, so the walk above cannot
84307
84587
  -- reach them. Collect into the SAME _hits/_names, which means an id
84308
84588
  -- present both in an account and locally is now correctly reported as
84309
- -- ambiguous rather than silently resolving to the account copy.${localMailboxBindingFragment()}
84310
- repeat with mb in _mbs
84311
- try
84312
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84313
- if (count of matchingMsgs) > 0 then
84314
- set end of _hits to item 1 of matchingMsgs
84315
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84589
+ -- ambiguous rather than silently resolving to the account copy.
84590
+ if (not _budgetHit) and ${opts.firstHit ? "(count of _hits) is 0" : "true"} then${localMailboxBindingFragment()}
84591
+ repeat with mb in _mbs
84592
+ ${budgetCheck}
84593
+ set _probe to true
84594
+ ${sizeGuard("mb", `"${LOCAL_STORE_LABEL}"`)}
84595
+ if _probe then
84596
+ try
84597
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
84598
+ if (count of matchingMsgs) > 0 then
84599
+ set end of _hits to item 1 of matchingMsgs
84600
+ set end of _hitMbs to (contents of mb)
84601
+ set end of _hitAccts to "${LOCAL_STORE_LABEL}"
84602
+ set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84603
+ end if
84604
+ end try
84316
84605
  end if
84317
- end try
84318
- end repeat
84319
- if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found"
84320
- if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"
84321
- if (count of _hits) is 1 then
84606
+ ${stopOnHit}
84607
+ end repeat
84608
+ end if
84609
+ if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found${BYID_COVERAGE_MARKER}" & _skipped & "${DIAG_FIELD_SEP}" & (_budgetHit as string)
84610
+ ${opts.firstHit ? "" : `if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"`}
84611
+ if (count of _hits) ${opts.firstHit ? "> 0" : "is 1"} then
84322
84612
  set msg to item 1 of _hits
84613
+ set _hitMb to item 1 of _hitMbs
84614
+ set _hitAcct to item 1 of _hitAccts
84323
84615
  ${innerAction}
84324
84616
  end if
84325
84617
  return ""
@@ -84331,20 +84623,25 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84331
84623
  /**
84332
84624
  * Build an app-level AppleScript that opens exactly one account+mailbox, finds
84333
84625
  * the message with numeric `id` in it, and runs `innerAction` (which may assume
84334
- * `msg` is bound). Used by the by-id fast paths (getMessageContent/getRawSource)
84335
- * so a message in a late-iterated large folder resolves directly instead of via
84336
- * the timeout-prone full-mailbox scan.
84626
+ * `msg`, `_hitMb` and `_hitAcct` are bound). Used by the by-id fast path
84627
+ * (`resolveScopedById`) so a message in a late-iterated large folder resolves
84628
+ * directly instead of via the timeout-prone full-mailbox scan.
84337
84629
  *
84338
84630
  * The mailbox name is resolved through `resolveMailbox` (so an alias like
84339
84631
  * "Sent"→"Sent Items" or a casing mismatch like "INBOX"→"Inbox" still opens the
84340
84632
  * right folder), and matched case-insensitively by iterating the account's
84341
84633
  * mailboxes — `mailbox "INBOX" of account …` throws on accounts whose inbox is
84342
84634
  * actually named "Inbox", which would silently drop us back to the slow scan.
84343
- * Returns "" (found nothing) on any error, so the caller falls back safely.
84635
+ *
84636
+ * A miss is reported, not swallowed (#270): `{LOOKUP_ERROR_MARKER}
84637
+ * {SCOPED_MISS_MARKER}` followed by `nomailbox`, `nomessage` or
84638
+ * `error:<Mail's text>`, so the caller can return a clean "not found in
84639
+ * <mailbox>" for an explicit hint instead of guessing.
84344
84640
  */
84345
84641
  scopedByIdScript(account, mailbox, id, innerAction) {
84346
84642
  const resolved = this.resolveMailbox(mailbox, account);
84347
84643
  const bind = isLocalStoreLabel(account) ? `${localMailboxBindingFragment()}
84644
+ set _hitAcct to "${LOCAL_STORE_LABEL}"
84348
84645
  set targetMb to missing value
84349
84646
  ignoring case
84350
84647
  repeat with mb in _mbs
@@ -84356,6 +84653,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84356
84653
  end if
84357
84654
  end repeat
84358
84655
  end ignoring` : `set acct to (first account whose name is "${escapeForAppleScript(account)}")
84656
+ set _hitAcct to (name of acct)
84359
84657
  set targetMb to missing value
84360
84658
  ignoring case
84361
84659
  repeat with mb in mailboxes of acct
@@ -84367,19 +84665,19 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84367
84665
  end if
84368
84666
  end repeat
84369
84667
  end ignoring`;
84668
+ const miss = `${LOOKUP_ERROR_MARKER}${SCOPED_MISS_MARKER}`;
84370
84669
  return buildAppLevelScript(`
84371
84670
  try
84372
84671
  ${bind}
84373
- if targetMb is not missing value then
84374
- set matchingMsgs to (messages of targetMb whose id is ${Number(id)})
84375
- if (count of matchingMsgs) > 0 then
84376
- set msg to item 1 of matchingMsgs
84377
- ${innerAction}
84378
- end if
84379
- end if
84672
+ if targetMb is missing value then return "${miss}nomailbox"
84673
+ set _hitMb to (contents of targetMb)
84674
+ set matchingMsgs to (messages of targetMb whose id is ${Number(id)})
84675
+ if (count of matchingMsgs) is 0 then return "${miss}nomessage"
84676
+ set msg to item 1 of matchingMsgs
84677
+ ${innerAction}
84380
84678
  return ""
84381
84679
  on error errMsg
84382
- return ""
84680
+ return "${miss}error:" & errMsg
84383
84681
  end try
84384
84682
  `);
84385
84683
  }
@@ -84392,6 +84690,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84392
84690
  * source can be MB-sized (it includes base64 attachments) and the plain-text
84393
84691
  * path doesn't need it; fetching it unconditionally was both slow and, worse,
84394
84692
  * returned the entire raw MIME blob mislabeled as HTML (#32).
84693
+ * @param hint - Explicit account + mailbox (both required to take effect):
84694
+ * the read opens that one mailbox and, on a miss, reports "not found in
84695
+ * <account> / <mailbox>" without scanning anywhere else (#270).
84395
84696
  */
84396
84697
  getMessageContent(id, includeHtml = false, hint) {
84397
84698
  this.lastMessageLookupError = void 0;
@@ -84409,41 +84710,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84409
84710
  ${sourceFetch}
84410
84711
  ${AS_MESSAGE_DATES_FRAGMENT}
84411
84712
  return msgSubject & "${MSGID_MARKER}" & msgRfcId & "${DATES_MARKER}" & msgDates & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource`;
84412
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84413
- if (loc) {
84414
- const scopedScript = this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch);
84415
- const scoped = this.parseMessageContent(
84416
- id,
84417
- executeAppleScript(scopedScript, { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }),
84418
- includeHtml
84419
- );
84420
- if (scoped) return scoped;
84421
- }
84422
- const script = this.unscopedByIdScript(id, innerFetch);
84423
- return this.parseMessageContent(
84424
- id,
84425
- executeAppleScript(script, { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }),
84426
- includeHtml
84427
- );
84713
+ const output = this.resolveById(id, innerFetch, hint);
84714
+ return output === null ? null : this.parseMessageContent(id, output, includeHtml);
84428
84715
  }
84429
84716
  /**
84430
84717
  * Parse the marker-delimited output of a getMessageContent AppleScript into a
84431
- * MessageContent, or null when nothing was found / the fetch failed. Shared by
84432
- * the scoped fast path and the full-mailbox-scan fallback.
84718
+ * MessageContent, or null when the payload is malformed.
84433
84719
  */
84434
- parseMessageContent(id, result, includeHtml) {
84435
- if (!result.success || !result.output.trim()) {
84436
- if (!result.success) {
84437
- if (result.timedOut) this.lastMessageLookupError = timedOutResolvingId(id);
84438
- console.error(`Failed to get message content: ${result.error}`);
84439
- }
84440
- return null;
84441
- }
84442
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84443
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84444
- return null;
84445
- }
84446
- const htmlSplit = result.output.split(HTML_MARKER);
84720
+ parseMessageContent(id, output, includeHtml) {
84721
+ const htmlSplit = output.split(HTML_MARKER);
84447
84722
  const contentPart = htmlSplit[0];
84448
84723
  const rawSource = htmlSplit.length > 1 ? htmlSplit[1] : "";
84449
84724
  const parts = contentPart.split(CONTENT_MARKER);
@@ -84468,8 +84743,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84468
84743
  /**
84469
84744
  * Fetch ONLY the raw RFC 5322 header block of a message (#224) — Mail's
84470
84745
  * `all headers` property — plus its `date received`, so the caller can show the
84471
- * arrival timestamp beside the author's `Date:` header. Same scoped-fast-path /
84472
- * full-scan resolution as getMessageContent; never reads the body or source.
84746
+ * arrival timestamp beside the author's `Date:` header. Same resolution as
84747
+ * getMessageContent (resolveById); never reads the body or source.
84473
84748
  * Returns null (with `lastMessageLookupError` set when Mail said why) on a miss.
84474
84749
  */
84475
84750
  getMessageHeaders(id, hint) {
@@ -84481,39 +84756,14 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84481
84756
  end try
84482
84757
  ${AS_MESSAGE_DATES_FRAGMENT}
84483
84758
  return msgDates & "${DATES_MARKER}" & msgHeaders`;
84484
- const parse4 = (result) => {
84485
- if (!result.success || !result.output.trim()) {
84486
- if (!result.success) {
84487
- if (result.timedOut) this.lastMessageLookupError = timedOutResolvingId(id);
84488
- console.error(`Failed to get message headers: ${result.error}`);
84489
- }
84490
- return null;
84491
- }
84492
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84493
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84494
- return null;
84495
- }
84496
- const idx = result.output.indexOf(DATES_MARKER);
84497
- if (idx === -1) return null;
84498
- const { dateReceived } = parseMessageDates(result.output.slice(0, idx));
84499
- const raw = result.output.slice(idx + DATES_MARKER.length);
84500
- if (!raw.trim()) return null;
84501
- return { raw, ...dateReceived ? { dateReceived } : {} };
84502
- };
84503
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84504
- if (loc) {
84505
- const scoped = parse4(
84506
- executeAppleScript(this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch), {
84507
- timeoutMs: READ_ONLY_BYID_TIMEOUT_MS
84508
- })
84509
- );
84510
- if (scoped) return scoped;
84511
- }
84512
- return parse4(
84513
- executeAppleScript(this.unscopedByIdScript(id, innerFetch), {
84514
- timeoutMs: READ_ONLY_BYID_TIMEOUT_MS
84515
- })
84516
- );
84759
+ const output = this.resolveById(id, innerFetch, hint);
84760
+ if (output === null) return null;
84761
+ const idx = output.indexOf(DATES_MARKER);
84762
+ if (idx === -1) return null;
84763
+ const { dateReceived } = parseMessageDates(output.slice(0, idx));
84764
+ const raw = output.slice(idx + DATES_MARKER.length);
84765
+ if (!raw.trim()) return null;
84766
+ return { raw, ...dateReceived ? { dateReceived } : {} };
84517
84767
  }
84518
84768
  /**
84519
84769
  * Get the raw MIME source of a message.
@@ -84527,38 +84777,11 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84527
84777
  * that budget; a call still running past it is a wedged scripting bridge,
84528
84778
  * not legitimate transfer time, so there is no case where the old 120s
84529
84779
  * ceiling was buying a real result a shorter one would have missed.
84780
+ * Same resolution as getMessageContent (resolveById).
84530
84781
  */
84531
84782
  getRawSource(id, hint) {
84532
84783
  this.lastMessageLookupError = void 0;
84533
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84534
- if (loc) {
84535
- const scopedScript = this.scopedByIdScript(
84536
- loc.account,
84537
- loc.mailbox,
84538
- id,
84539
- "return source of msg"
84540
- );
84541
- const scoped = executeAppleScript(scopedScript, { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS });
84542
- if (scoped.success && scoped.output.trim() && !scoped.output.startsWith(LOOKUP_ERROR_MARKER)) {
84543
- return scoped.output;
84544
- }
84545
- if (scoped.success && scoped.output.startsWith(LOOKUP_ERROR_MARKER)) {
84546
- this.lastMessageLookupError = scoped.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84547
- } else if (!scoped.success && scoped.timedOut) {
84548
- this.lastMessageLookupError = timedOutResolvingId(id);
84549
- }
84550
- }
84551
- const script = this.unscopedByIdScript(id, "return source of msg");
84552
- const result = executeAppleScript(script, { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS });
84553
- if (!result.success || !result.output.trim()) {
84554
- if (result.timedOut) this.lastMessageLookupError = timedOutResolvingId(id);
84555
- return null;
84556
- }
84557
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84558
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84559
- return null;
84560
- }
84561
- return result.output;
84784
+ return this.resolveById(id, "return source of msg", hint);
84562
84785
  }
84563
84786
  /**
84564
84787
  * List messages in a mailbox.
@@ -87497,10 +87720,14 @@ function currentCallTiming() {
87497
87720
  return callTiming.getStore();
87498
87721
  }
87499
87722
  function withErrorHandling(handler, errorPrefix) {
87500
- return async (params) => {
87723
+ return async (params, extra) => {
87501
87724
  const arrivedAt = Date.now();
87725
+ const signal = extra?.signal;
87502
87726
  return serializeAppleScript(async () => {
87503
- const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt };
87727
+ if (signal?.aborted) {
87728
+ return errorResponse(`${errorPrefix}: request was cancelled before it started`);
87729
+ }
87730
+ const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt, signal };
87504
87731
  return callTiming.run(timing, async () => {
87505
87732
  try {
87506
87733
  return await handler(params);
@@ -87530,10 +87757,12 @@ async function readOriginal(deps, id, cfg) {
87530
87757
  return { original, plainText: extractTextBody(source.raw) };
87531
87758
  }
87532
87759
  const raw = deps.mail.getRawSource(id);
87533
- if (!raw)
87760
+ if (!raw) {
87761
+ const why = deps.mail.consumeLastMessageLookupError?.();
87534
87762
  throw new Error(
87535
- "Cannot read the original message source. Re-list the intended mailbox and retry with its message id."
87763
+ `Cannot read the original message source${why ? ` (${why})` : ""}. Re-list the intended mailbox and retry with its message id.`
87536
87764
  );
87765
+ }
87537
87766
  const content = deps.mail.getMessageContent(id);
87538
87767
  return { original: parseOriginalHeaders(raw), plainText: content?.plainText ?? null };
87539
87768
  }
@@ -87759,6 +87988,9 @@ function unlistableStoreError(account, error3, knownAccounts) {
87759
87988
  return parts.join("\n\n");
87760
87989
  }
87761
87990
 
87991
+ // src/index.ts
87992
+ init_callStop();
87993
+
87762
87994
  // src/tools/batchResults.ts
87763
87995
  async function hybridBatchCounts(ids, appleFn, imapFn) {
87764
87996
  const distinctIds = [...new Set(ids)];
@@ -87937,12 +88169,17 @@ function mergeMessages(imapRows, appleRows, limit) {
87937
88169
  }
87938
88170
  async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapConfigs()) {
87939
88171
  const rows = [];
88172
+ const timedOutMailboxes = [];
87940
88173
  const accountsQueried = [];
87941
88174
  const accountsFailed = [];
87942
88175
  const failedMailboxes = [];
87943
88176
  const failedMailboxReasons = {};
87944
88177
  const omittedMessages = [];
87945
88178
  for (const config2 of configs) {
88179
+ if (deps.signal?.aborted) {
88180
+ timedOutMailboxes.push(`${config2.accountLabel} / *`);
88181
+ continue;
88182
+ }
87946
88183
  const perAccountArgs = { ...args, account: void 0 };
87947
88184
  try {
87948
88185
  const res = kind === "search" ? await imapSearchMessages(perAccountArgs, { ...deps, config: config2 }) : await imapListMessages(perAccountArgs, { ...deps, config: config2 });
@@ -87954,6 +88191,9 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87954
88191
  for (const [mailbox, reason] of Object.entries(res.failedMailboxReasons)) {
87955
88192
  failedMailboxReasons[`${config2.accountLabel} / ${mailbox}`] = reason;
87956
88193
  }
88194
+ timedOutMailboxes.push(
88195
+ ...(res.timedOutMailboxes ?? []).map((mailbox) => `${config2.accountLabel} / ${mailbox}`)
88196
+ );
87957
88197
  omittedMessages.push(
87958
88198
  ...(res.omittedMessages ?? []).map((o) => ({
87959
88199
  ...o,
@@ -87971,7 +88211,8 @@ async function fanOutImapMessages(args, kind, deps = {}, configs = resolveImapCo
87971
88211
  accountsFailed,
87972
88212
  failedMailboxes,
87973
88213
  failedMailboxReasons,
87974
- omittedMessages
88214
+ omittedMessages,
88215
+ timedOutMailboxes
87975
88216
  };
87976
88217
  }
87977
88218
  function configMatchesAccount(config2, account) {
@@ -88589,7 +88830,11 @@ var LIST_OUTPUT_SCHEMA = {
88589
88830
  failedMailboxReasons: external_exports.record(external_exports.string(), external_exports.string()).optional(),
88590
88831
  // Messages that belong on the page but could not be read, each with its
88591
88832
  // imap: id and why (#256 follow-up). Non-empty implies `partial: true`.
88592
- omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional()
88833
+ omittedMessages: external_exports.array(external_exports.object({ id: external_exports.string(), mailbox: external_exports.string(), uid: external_exports.number(), reason: external_exports.string() })).optional(),
88834
+ // Mailboxes a cancelled or out-of-time search-messages never finished (#276),
88835
+ // and why. Non-empty implies `partial: true`.
88836
+ timedOutMailboxes: external_exports.array(external_exports.string()).optional(),
88837
+ stoppedBy: external_exports.enum(["deadline", "cancelled"]).optional()
88593
88838
  };
88594
88839
  var BATCH_COUNT_OUTPUT_SCHEMA = {
88595
88840
  ok: external_exports.boolean().optional(),
@@ -88664,11 +88909,12 @@ function appleScanForAccounts(accounts, scan) {
88664
88909
  }
88665
88910
  return { rows, diagnostics };
88666
88911
  }
88667
- function mergedMessageResponse(fan, apple, limit, verb) {
88912
+ function mergedMessageResponse(fan, apple, limit, verb, stopSignal) {
88668
88913
  const merged = mergeMessages(fan.rows, apple.rows, limit);
88914
+ const timedOutMailboxes = fan.timedOutMailboxes ?? [];
88669
88915
  const diagnostics = {
88670
88916
  ...apple.diagnostics,
88671
- partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0,
88917
+ partial: apple.diagnostics.partial || fan.accountsFailed.length > 0 || fan.failedMailboxes.length > 0 || fan.omittedMessages.length > 0 || timedOutMailboxes.length > 0,
88672
88918
  timedOutAccounts: [...apple.diagnostics.timedOutAccounts, ...fan.accountsFailed],
88673
88919
  notSearchedMailboxes: [...apple.diagnostics.notSearchedMailboxes, ...fan.failedMailboxes]
88674
88920
  };
@@ -88681,9 +88927,13 @@ function mergedMessageResponse(fan, apple, limit, verb) {
88681
88927
  timedOutAccounts: diagnostics.timedOutAccounts,
88682
88928
  failedMailboxes: fan.failedMailboxes,
88683
88929
  failedMailboxReasons: fan.failedMailboxReasons,
88684
- omittedMessages: fan.omittedMessages
88930
+ omittedMessages: fan.omittedMessages,
88931
+ ...timedOutMailboxes.length > 0 ? {
88932
+ timedOutMailboxes,
88933
+ stoppedBy: stoppedByDeadline(stopSignal) ? "deadline" : "cancelled"
88934
+ } : {}
88685
88935
  };
88686
- const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true);
88936
+ const coverageBlock = partialCoverageBlock(diagnostics) + omittedNote(fan.omittedMessages, true) + stoppedNote(timedOutMailboxes, stopSignal);
88687
88937
  if (merged.length === 0) {
88688
88938
  const base2 = diagnostics.partial ? `No messages found in the portions that were ${verb === "matched" ? "searched" : "listed"}.` : "No messages found";
88689
88939
  return successResponse(`${base2}${coverageBlock}`, structured);
@@ -88730,7 +88980,7 @@ function collectForensics(tool, args) {
88730
88980
  registerTool(
88731
88981
  "search-messages",
88732
88982
  {
88733
- description: "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped).\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.",
88983
+ description: "Use when: finding messages by query/sender/subject/date/read/flag filters and you need their ids for follow-up operations.\nReturns: matching messages with id, date, subject, sender, and read state (plus partial-coverage diagnostics when some mailboxes were skipped). An IMAP search stops at its deadline (APPLE_MAIL_MCP_SEARCH_DEADLINE_MS, default 45s) or when the request is cancelled, returning the matches found so far with partial: true and timedOutMailboxes \u2014 narrow with mailbox/dateFrom rather than repeating it.\nDo not use when: you want a plain mailbox listing without filters (use list-messages), already have an id and want the body (use get-message), or want a whole conversation (use get-thread).\nPrefer this first to obtain the message ids that get-message/mark-as-read/delete-message/move-message and the batch tools require.",
88734
88984
  inputSchema: {
88735
88985
  query: external_exports.string().optional().describe("Text to search for in subject or sender"),
88736
88986
  body: external_exports.string().optional().describe(
@@ -88773,6 +89023,15 @@ registerTool(
88773
89023
  if (tooDeep) return errorResponse(tooDeep);
88774
89024
  }
88775
89025
  if (shouldUseImap(account)) {
89026
+ const stop = createCallStop(currentCallTiming(), searchDeadlineMs());
89027
+ try {
89028
+ return await searchViaImap(stop.signal);
89029
+ } finally {
89030
+ stop.dispose();
89031
+ }
89032
+ }
89033
+ return searchViaAppleScript();
89034
+ async function searchViaImap(signal) {
88776
89035
  const imapArgs = {
88777
89036
  query,
88778
89037
  body,
@@ -88787,14 +89046,15 @@ registerTool(
88787
89046
  isFlagged
88788
89047
  };
88789
89048
  if (account !== void 0) {
88790
- const r = await imapSearchMessages({ ...imapArgs, account });
89049
+ const r = await imapSearchMessages({ ...imapArgs, account }, { signal });
88791
89050
  return successResponse(r.text, {
88792
89051
  messages: r.messages,
88793
89052
  count: r.count,
88794
89053
  partial: r.partial,
88795
89054
  failedMailboxes: r.failedMailboxes,
88796
89055
  failedMailboxReasons: r.failedMailboxReasons,
88797
- omittedMessages: r.omittedMessages
89056
+ omittedMessages: r.omittedMessages,
89057
+ ...r.timedOutMailboxes?.length ? { timedOutMailboxes: r.timedOutMailboxes, stoppedBy: r.stoppedBy } : {}
88798
89058
  });
88799
89059
  }
88800
89060
  const { appleScriptOnly } = partitionAccountsForCounts(
@@ -88806,7 +89066,7 @@ registerTool(
88806
89066
  `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.`
88807
89067
  );
88808
89068
  }
88809
- const fan = await fanOutImapMessages(imapArgs, "search");
89069
+ const fan = await fanOutImapMessages(imapArgs, "search", { signal });
88810
89070
  if (body) {
88811
89071
  const apple2 = {
88812
89072
  rows: [],
@@ -88818,7 +89078,18 @@ registerTool(
88818
89078
  )
88819
89079
  }
88820
89080
  };
88821
- return mergedMessageResponse(fan, apple2, limit, "matched");
89081
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
89082
+ }
89083
+ if (signal.aborted && appleScriptOnly.length > 0) {
89084
+ const apple2 = {
89085
+ rows: [],
89086
+ diagnostics: {
89087
+ ...emptyDiagnostics(),
89088
+ partial: true,
89089
+ timedOutAccounts: appleScriptOnly.map((a) => a.name)
89090
+ }
89091
+ };
89092
+ return mergedMessageResponse(fan, apple2, limit, "matched", signal);
88822
89093
  }
88823
89094
  const apple = appleScanForAccounts(
88824
89095
  appleScriptOnly,
@@ -88835,51 +89106,53 @@ registerTool(
88835
89106
  isFlagged
88836
89107
  )
88837
89108
  );
88838
- return mergedMessageResponse(fan, apple, limit, "matched");
89109
+ return mergedMessageResponse(fan, apple, limit, "matched", signal);
88839
89110
  }
88840
- if (offset > 0) {
88841
- return errorResponse(
88842
- `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.`
89111
+ function searchViaAppleScript() {
89112
+ if (offset > 0) {
89113
+ return errorResponse(
89114
+ `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.`
89115
+ );
89116
+ }
89117
+ if (body) {
89118
+ return errorResponse(
89119
+ `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.`
89120
+ );
89121
+ }
89122
+ const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(
89123
+ query,
89124
+ mailbox,
89125
+ account,
89126
+ limit,
89127
+ dateFrom,
89128
+ dateTo,
89129
+ from,
89130
+ subject,
89131
+ isRead,
89132
+ isFlagged
88843
89133
  );
88844
- }
88845
- if (body) {
88846
- return errorResponse(
88847
- `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.`
89134
+ const coverageBlock = partialCoverageBlock(diagnostics);
89135
+ const structured = {
89136
+ messages: messages.map(messageSummary),
89137
+ count: messages.length,
89138
+ partial: diagnostics.partial,
89139
+ skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
89140
+ notSearchedMailboxes: diagnostics.notSearchedMailboxes,
89141
+ timedOutAccounts: diagnostics.timedOutAccounts
89142
+ };
89143
+ if (messages.length === 0) {
89144
+ const base2 = diagnostics.partial ? "No messages found in the portions that were searched." : "No messages found matching criteria";
89145
+ return successResponse(`${base2}${coverageBlock}`, structured);
89146
+ }
89147
+ const messageList = messages.map(
89148
+ (m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
89149
+ ).join("\n");
89150
+ return successResponse(
89151
+ `Found ${messages.length} message(s):
89152
+ ${messageList}${coverageBlock}`,
89153
+ structured
88848
89154
  );
88849
89155
  }
88850
- const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(
88851
- query,
88852
- mailbox,
88853
- account,
88854
- limit,
88855
- dateFrom,
88856
- dateTo,
88857
- from,
88858
- subject,
88859
- isRead,
88860
- isFlagged
88861
- );
88862
- const coverageBlock = partialCoverageBlock(diagnostics);
88863
- const structured = {
88864
- messages: messages.map(messageSummary),
88865
- count: messages.length,
88866
- partial: diagnostics.partial,
88867
- skippedLargeMailboxes: diagnostics.skippedLargeMailboxes,
88868
- notSearchedMailboxes: diagnostics.notSearchedMailboxes,
88869
- timedOutAccounts: diagnostics.timedOutAccounts
88870
- };
88871
- if (messages.length === 0) {
88872
- const base2 = diagnostics.partial ? "No messages found in the portions that were searched." : "No messages found matching criteria";
88873
- return successResponse(`${base2}${coverageBlock}`, structured);
88874
- }
88875
- const messageList = messages.map(
88876
- (m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender}) [${m.isRead ? "read" : "unread"}]`
88877
- ).join("\n");
88878
- return successResponse(
88879
- `Found ${messages.length} message(s):
88880
- ${messageList}${coverageBlock}`,
88881
- structured
88882
- );
88883
89156
  },
88884
89157
  "Error searching messages"
88885
89158
  )