apple-mail-mcp 2.20.2 → 2.20.4

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
@@ -309,15 +309,17 @@ inferred from the top-level `Content-Type`.
309
309
 
310
310
  Get the full content of a message.
311
311
 
312
- | Parameter | Type | Required | Description |
313
- | ------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
- | `id` | string | Yes | Message ID |
315
- | `preferHtml` | boolean | No | Return HTML source instead of plain text |
316
- | `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders |
317
- | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
312
+ | Parameter | Type | Required | Description |
313
+ | ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
+ | `id` | string | Yes | Message ID |
315
+ | `preferHtml` | boolean | No | Return HTML source instead of plain text |
316
+ | `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders. A miss there is reported as "not found in <account> / <mailbox>"; it never falls back to the cross-mailbox scan |
317
+ | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
318
318
 
319
319
  **Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available). `structuredContent` also carries `rfcMessageId` and, since 2.19.0, two dates: `dateSent` (the message's `Date:` header — Mail's `date sent`) and `dateReceived` (arrival in the mailbox — Mail's `date received` / IMAP `INTERNALDATE`). They differ legitimately by transit time; when they differ by **years**, the mailbox was migrated or re-imported and the arrival timestamp was reset — trust `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)). Since 2.19.6 `dateSent` is **omitted** when it is more than 7 days later than `dateReceived`: a message cannot be sent after it arrived, and Mail.app substitutes a timestamp of its own for a `Date:` header it cannot parse ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)). `isHtml` reports what was actually returned — a message with no `text/plain` part returns its HTML part with `isHtml: true`. Bodies are decoded by each part's declared `charset`, falling back to windows-1252 for bytes that are not valid UTF-8.
320
320
 
321
+ **Numeric ids — how the message is found ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)):** with `account` + `mailbox` the read opens that one mailbox and stops there. Otherwise it opens the mailbox the id was last listed or searched from (a remembered location that turns out stale — the message was moved, e.g. by another client — is forgotten), and only then scans every mailbox. That cross-mailbox scan skips any mailbox holding more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default `50000`; `0` disables the guard) and stops after ~9s, because Mail's `whose id is` probe costs seconds per hundred thousand messages; a miss then names the mailboxes it did not scan. Pass `account` + `mailbox` to read from one of those, or use the message's `imap:` id, which never scans. The same resolution backs `get-message-headers`, `get-thread`'s seed lookup, and the original-message read of `reply-to-message` / `forward-message` and the attachment tools.
322
+
321
323
  > **Large messages / attachments:** reading a full message routes through
322
324
  > `osascript`, whose captured output buffer defaults to **64 MB**. Override it
323
325
  > with the `APPLE_MAIL_MCP_MAX_BUFFER` environment variable (in **bytes**) if you
@@ -2127,6 +2129,10 @@ In a JSON string literal, `\\` — two characters — denotes **one** literal ba
2127
2129
  - Message may have been deleted or moved
2128
2130
  - Message IDs change if the message is moved between mailboxes
2129
2131
  - Use `search-messages` to find the current message ID
2132
+ - "**not found in the mailboxes scanned**" with a list of mailboxes means the numeric-id scan
2133
+ skipped those because they hold more than `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` messages (default
2134
+ `50000`). The message may be in one of them: pass `account` + `mailbox` to read it from there, or
2135
+ use its `imap:` id ([#270](https://github.com/sweetrb/apple-mail-mcp/issues/270)).
2130
2136
 
2131
2137
  ### "... is present in more than one mailbox"
2132
2138
 
package/build/index.js CHANGED
@@ -82325,7 +82325,8 @@ function executeAppleScript(script, options = {}) {
82325
82325
  lastError = {
82326
82326
  success: false,
82327
82327
  output: "",
82328
- error: errorMessage
82328
+ error: errorMessage,
82329
+ ...isTimeout ? { timedOut: true } : {}
82329
82330
  };
82330
82331
  const canRetry = isTimeout || isRetryableError(errorMessage);
82331
82332
  const hasAttemptsLeft = attempt < maxRetries;
@@ -82598,6 +82599,48 @@ function getMailboxScanThreshold() {
82598
82599
  }
82599
82600
  var SEARCH_ACCOUNT_BUDGET_SECONDS = 30;
82600
82601
  var SEARCH_ACCOUNT_TIMEOUT_MS = 45e3;
82602
+ var READ_ONLY_BYID_TIMEOUT_MS = 15e3;
82603
+ function timedOutResolvingId(id) {
82604
+ return `AppleScript timed out resolving id ${Number(id)} \u2014 Mail.app's scripting bridge may be wedged; try health-check.`;
82605
+ }
82606
+ function timedOutScanningForId(id) {
82607
+ 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.`;
82608
+ }
82609
+ function getByIdScanThreshold() {
82610
+ const raw = process.env.APPLE_MAIL_MAX_BYID_SCAN_MAILBOX;
82611
+ if (raw !== void 0 && raw.trim() !== "") {
82612
+ const n = Number(raw);
82613
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
82614
+ }
82615
+ return 5e4;
82616
+ }
82617
+ var BYID_SCAN_BUDGET_SECONDS = 9;
82618
+ var BYID_COVERAGE_MARKER = "COV";
82619
+ var SCOPED_MISS_MARKER = "SCOPED";
82620
+ function describeUnscopedMiss(id, raw) {
82621
+ const covIdx = raw.indexOf(BYID_COVERAGE_MARKER);
82622
+ if (covIdx === -1) return raw.trim();
82623
+ const base2 = raw.slice(0, covIdx).trim();
82624
+ const [skippedRaw = "", budgetRaw = ""] = raw.slice(covIdx + BYID_COVERAGE_MARKER.length).split(DIAG_FIELD_SEP);
82625
+ const skipped = skippedRaw.split(DIAG_ITEM_SEP).map((s) => s.trim()).filter(Boolean);
82626
+ const budgetHit = budgetRaw.trim() === "true";
82627
+ if (skipped.length === 0 && !budgetHit) return base2;
82628
+ const parts = [`Message ${Number(id)} not found in the mailboxes scanned.`];
82629
+ if (skipped.length > 0) {
82630
+ parts.push(
82631
+ `Not scanned \u2014 too large for a cross-mailbox id scan (over ${getByIdScanThreshold()} messages; APPLE_MAIL_MAX_BYID_SCAN_MAILBOX): ${skipped.join(", ")}.`
82632
+ );
82633
+ }
82634
+ if (budgetHit) {
82635
+ parts.push(
82636
+ `The scan also stopped after its ${BYID_SCAN_BUDGET_SECONDS}s budget before reaching every mailbox.`
82637
+ );
82638
+ }
82639
+ parts.push(
82640
+ "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."
82641
+ );
82642
+ return parts.join(" ");
82643
+ }
82601
82644
  var GROUP_SEP = "";
82602
82645
  var FIELD_SEP = "";
82603
82646
  var RECORD_SEP = "";
@@ -83103,8 +83146,9 @@ var AppleMailManager = class {
83103
83146
  * Items") isn't reached before the AppleScript timeout fires, so the fetch
83104
83147
  * returns a false "not found" (only INBOX ids, reached early, worked). Every
83105
83148
  * search/list/by-id result records its id→location here so a subsequent fetch
83106
- * opens the one right mailbox directly. A stale entry (message moved) simply
83107
- * misses and falls back to the full scan, so it can never wedge a lookup.
83149
+ * opens the one right mailbox directly. A stale entry (message moved by
83150
+ * another client) misses, is EVICTED, and the read falls back to the bounded
83151
+ * cross-mailbox scan — see `resolveScopedById` (#270).
83108
83152
  */
83109
83153
  idLocationIndex = /* @__PURE__ */ new Map();
83110
83154
  /** Error from the most recent numeric message read, if it was refused. */
@@ -84204,24 +84248,22 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84204
84248
  /**
84205
84249
  * Get a message by ID.
84206
84250
  *
84207
- * Note: Mail.app message IDs are unique per mailbox. This method searches
84208
- * all mailboxes in all accounts to find the message.
84251
+ * Note: Mail.app message IDs are unique per mailbox. Resolution goes through
84252
+ * `resolveById` — the remembered location first, then the bounded
84253
+ * cross-mailbox scan — taking the FIRST copy found rather than refusing an id
84254
+ * a label store repeats across INBOX / "All Mail": every caller of this reads
84255
+ * per-message metadata (get-thread's seed subject, a mutation error's mailbox
84256
+ * hint) that is identical across those copies.
84209
84257
  */
84210
84258
  getMessageById(id, deepAttachmentCheck = false) {
84259
+ this.lastMessageLookupError = void 0;
84211
84260
  const deepScan = deepAttachmentCheck ? `if hasAtt is "false" then
84212
84261
  try
84213
84262
  set rawSrc to source of msg
84214
84263
  if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
84215
84264
  end try
84216
84265
  end if` : "";
84217
- const script = buildAppLevelScript(`
84218
- try
84219
- repeat with acct in accounts
84220
- repeat with mb in mailboxes of acct
84221
- try
84222
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84223
- if (count of matchingMsgs) > 0 then
84224
- set msg to item 1 of matchingMsgs
84266
+ const innerFetch = `
84225
84267
  set msgSubject to subject of msg
84226
84268
  set msgSender to sender of msg
84227
84269
  set d to date received of msg
@@ -84231,30 +84273,20 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84231
84273
  set msgJunk to junk mail status of msg as string
84232
84274
  set msgDeleted to deleted status of msg as string
84233
84275
  set msgMailbox to ""
84234
- ${mailboxPathFragment("mb", "msgMailbox")}
84235
- set msgAccount to name of acct
84276
+ try
84277
+ ${mailboxPathFragment("_hitMb", "msgMailbox")}
84278
+ end try
84279
+ set msgAccount to _hitAcct
84236
84280
  set hasAtt to "false"
84237
84281
  try
84238
84282
  set attCount to count of mail attachments of msg
84239
84283
  if attCount > 0 then set hasAtt to "true"
84240
84284
  end try
84241
84285
  ${deepScan}
84242
- 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
84243
- end if
84244
- end try
84245
- end repeat
84246
- end repeat
84247
- return ""
84248
- on error errMsg
84249
- return ""
84250
- end try
84251
- `);
84252
- const result = executeAppleScript(script, { timeoutMs: 6e4 });
84253
- if (!result.success || !result.output.trim()) {
84254
- console.error(`Failed to get message ${id}: ${result.error}`);
84255
- return null;
84256
- }
84257
- const parts = result.output.split(FIELD_SEP);
84286
+ 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`;
84287
+ const output = this.resolveById(id, innerFetch, void 0, { firstHit: true });
84288
+ if (output === null) return null;
84289
+ const parts = output.split(FIELD_SEP);
84258
84290
  if (parts.length < 9) return null;
84259
84291
  this.rememberLocation(id.toString(), parts[8], parts[7]);
84260
84292
  return {
@@ -84272,47 +84304,194 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84272
84304
  hasAttachments: parts.length > 9 ? parts[9] === "true" : false
84273
84305
  };
84274
84306
  }
84307
+ /**
84308
+ * Resolve numeric `id` and run `innerAction` against it — the ONE resolution
84309
+ * path behind every read-only by-id lookup (getMessageById,
84310
+ * getMessageContent, getMessageHeaders, getRawSource), so the four cannot
84311
+ * drift apart again. Returns the action's output, or null with
84312
+ * `lastMessageLookupError` set when there is something to say (#270):
84313
+ *
84314
+ * 1. Scoped, when a location is known — the caller's explicit `account` +
84315
+ * `mailbox`, else the `idLocationIndex` entry (see `resolveScopedById`):
84316
+ * - explicit hint, miss → a clean "not found in <account> / <mailbox>".
84317
+ * NO fallback: the caller said where to look, and the cross-mailbox
84318
+ * scan it would fall back to is the 20-50s operation @j5pu timed.
84319
+ * - index entry, miss → the entry is stale (the message was moved, by
84320
+ * another client or in Mail itself): evict it, then fall back to (2).
84321
+ * - timeout on either → report it; no second 15s scan on top.
84322
+ * 2. Unscoped: every mailbox of every account plus the local store, but
84323
+ * mailboxes above `getByIdScanThreshold()` are skipped and the walk stops
84324
+ * at `BYID_SCAN_BUDGET_SECONDS`; a miss names what was not covered.
84325
+ */
84326
+ resolveById(id, innerAction, hint, opts = {}) {
84327
+ const remembered = this.idLocationIndex.get(String(id));
84328
+ const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox, explicit: true } : remembered ? { ...remembered, explicit: false } : void 0;
84329
+ if (loc) {
84330
+ const scoped = this.resolveScopedById(id, loc, innerAction);
84331
+ if (scoped.kind === "hit") return scoped.output;
84332
+ if (scoped.kind === "done") return null;
84333
+ }
84334
+ const result = executeAppleScript(this.unscopedByIdScript(id, innerAction, opts), {
84335
+ timeoutMs: READ_ONLY_BYID_TIMEOUT_MS
84336
+ });
84337
+ if (!result.success || !result.output.trim()) {
84338
+ if (!result.success) {
84339
+ if (result.timedOut) this.lastMessageLookupError = timedOutScanningForId(id);
84340
+ console.error(`Failed to resolve message ${id}: ${result.error}`);
84341
+ }
84342
+ return null;
84343
+ }
84344
+ if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84345
+ this.lastMessageLookupError = describeUnscopedMiss(
84346
+ id,
84347
+ result.output.slice(LOOKUP_ERROR_MARKER.length)
84348
+ );
84349
+ return null;
84350
+ }
84351
+ return result.output;
84352
+ }
84353
+ /**
84354
+ * The scoped half of `resolveById`: open exactly `loc`'s mailbox and look for
84355
+ * the id there. `hit` carries the action's output; `done` means stop with
84356
+ * `lastMessageLookupError` set; `fallback` means the remembered location was
84357
+ * stale and has been evicted, so the caller should try the bounded scan.
84358
+ */
84359
+ resolveScopedById(id, loc, innerAction) {
84360
+ const result = executeAppleScript(
84361
+ this.scopedByIdScript(loc.account, loc.mailbox, id, innerAction),
84362
+ { timeoutMs: READ_ONLY_BYID_TIMEOUT_MS }
84363
+ );
84364
+ const where = `${loc.account} / ${loc.mailbox}`;
84365
+ if (!result.success) {
84366
+ console.error(`Failed to read message ${id} from ${where}: ${result.error}`);
84367
+ if (result.timedOut) {
84368
+ this.lastMessageLookupError = timedOutResolvingId(id);
84369
+ return { kind: "done" };
84370
+ }
84371
+ if (loc.explicit) {
84372
+ this.lastMessageLookupError = `Could not read message ${Number(id)} from ${where}: ${result.error ?? "AppleScript error"}`;
84373
+ return { kind: "done" };
84374
+ }
84375
+ this.forgetLocation(id);
84376
+ return { kind: "fallback" };
84377
+ }
84378
+ const scopedPrefix = LOOKUP_ERROR_MARKER + SCOPED_MISS_MARKER;
84379
+ if (result.output.trim() && !result.output.startsWith(scopedPrefix)) {
84380
+ return { kind: "hit", output: result.output };
84381
+ }
84382
+ if (!loc.explicit) {
84383
+ this.forgetLocation(id);
84384
+ return { kind: "fallback" };
84385
+ }
84386
+ const reason = result.output.startsWith(scopedPrefix) ? result.output.slice(scopedPrefix.length) : "nomessage";
84387
+ if (reason === "nomailbox") {
84388
+ 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.`;
84389
+ } else if (reason.startsWith("error:")) {
84390
+ this.lastMessageLookupError = `Could not read message ${Number(id)} from ${where}: ${reason.slice("error:".length)}`;
84391
+ } else {
84392
+ 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.`;
84393
+ }
84394
+ return { kind: "done" };
84395
+ }
84396
+ /** Drop a stale id→location entry (#270) so the next read doesn't retry it. */
84397
+ forgetLocation(id) {
84398
+ this.idLocationIndex.delete(String(id));
84399
+ }
84275
84400
  /**
84276
84401
  * The unscoped by-id resolution: walk every mailbox of every account, then the
84277
84402
  * local store (#183), and run `innerAction` with `msg` bound when EXACTLY one
84278
- * mailbox holds the id — `{LOOKUP_ERROR_MARKER}` prefixes both the not-found
84279
- * and the ambiguous outcome. Shared by getMessageContent, getRawSource and
84280
- * getMessageHeaders so the three reads cannot drift apart (the pre-#224 code
84281
- * carried two byte-identical copies).
84403
+ * mailbox holds the id (or on the first hit, with `firstHit`) —
84404
+ * `{LOOKUP_ERROR_MARKER}` prefixes both the not-found and the ambiguous
84405
+ * outcome. `_hitMb` / `_hitAcct` are bound to the hit's mailbox and account
84406
+ * name alongside `msg`.
84407
+ *
84408
+ * Bounded (#270): a mailbox above `getByIdScanThreshold()` messages is
84409
+ * skipped rather than probed, and the walk stops once it has run for
84410
+ * `BYID_SCAN_BUDGET_SECONDS`. Either way the not-found outcome carries a
84411
+ * `BYID_COVERAGE_MARKER` trailer naming what was not covered, so the caller
84412
+ * can say "not found in the mailboxes scanned; these large ones were not"
84413
+ * instead of a false flat "not found". A copy that sits only in a skipped
84414
+ * mailbox therefore no longer counts toward the ambiguity check — harmless
84415
+ * for these read-only lookups, whose copies are the same message.
84282
84416
  */
84283
- unscopedByIdScript(id, innerAction) {
84417
+ unscopedByIdScript(id, innerAction, opts = {}) {
84418
+ const threshold = getByIdScanThreshold();
84419
+ const sizeGuard = (mbVar, acctNameExpr) => threshold > 0 ? `set _mbCount to 0
84420
+ try
84421
+ set _mbCount to count of messages of ${mbVar}
84422
+ end try
84423
+ if _mbCount > ${threshold} then
84424
+ set _skipPath to ""
84425
+ try
84426
+ ${mailboxPathFragment(mbVar, "_skipPath")}
84427
+ end try
84428
+ set _skipped to _skipped & ${acctNameExpr} & " / " & _skipPath & " (" & (_mbCount as string) & ")${DIAG_ITEM_SEP}"
84429
+ set _probe to false
84430
+ end if` : "";
84431
+ const budgetCheck = `if ((current date) - _startedAt) > ${BYID_SCAN_BUDGET_SECONDS} then
84432
+ set _budgetHit to true
84433
+ exit repeat
84434
+ end if`;
84435
+ const stopOnHit = opts.firstHit ? `if (count of _hits) > 0 then exit repeat` : "";
84284
84436
  return buildAppLevelScript(`
84285
84437
  try
84286
84438
  set _hits to {}
84439
+ set _hitMbs to {}
84440
+ set _hitAccts to {}
84287
84441
  set _names to ""
84442
+ set _skipped to ""
84443
+ set _budgetHit to false
84444
+ set _startedAt to current date
84288
84445
  repeat with acct in accounts
84446
+ if _budgetHit then exit repeat
84447
+ ${stopOnHit}
84289
84448
  repeat with mb in mailboxes of acct
84290
- try
84291
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84292
- if (count of matchingMsgs) > 0 then
84293
- set end of _hits to item 1 of matchingMsgs
84294
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84295
- end if
84296
- end try
84449
+ ${budgetCheck}
84450
+ set _probe to true
84451
+ ${sizeGuard("mb", "(name of acct)")}
84452
+ if _probe then
84453
+ try
84454
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
84455
+ if (count of matchingMsgs) > 0 then
84456
+ set end of _hits to item 1 of matchingMsgs
84457
+ set end of _hitMbs to (contents of mb)
84458
+ set end of _hitAccts to (name of acct)
84459
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
84460
+ end if
84461
+ end try
84462
+ end if
84463
+ ${stopOnHit}
84297
84464
  end repeat
84298
84465
  end repeat
84299
84466
  -- #183: local mailboxes belong to no account, so the walk above cannot
84300
84467
  -- reach them. Collect into the SAME _hits/_names, which means an id
84301
84468
  -- present both in an account and locally is now correctly reported as
84302
- -- ambiguous rather than silently resolving to the account copy.${localMailboxBindingFragment()}
84303
- repeat with mb in _mbs
84304
- try
84305
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
84306
- if (count of matchingMsgs) > 0 then
84307
- set end of _hits to item 1 of matchingMsgs
84308
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84469
+ -- ambiguous rather than silently resolving to the account copy.
84470
+ if (not _budgetHit) and ${opts.firstHit ? "(count of _hits) is 0" : "true"} then${localMailboxBindingFragment()}
84471
+ repeat with mb in _mbs
84472
+ ${budgetCheck}
84473
+ set _probe to true
84474
+ ${sizeGuard("mb", `"${LOCAL_STORE_LABEL}"`)}
84475
+ if _probe then
84476
+ try
84477
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
84478
+ if (count of matchingMsgs) > 0 then
84479
+ set end of _hits to item 1 of matchingMsgs
84480
+ set end of _hitMbs to (contents of mb)
84481
+ set end of _hitAccts to "${LOCAL_STORE_LABEL}"
84482
+ set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
84483
+ end if
84484
+ end try
84309
84485
  end if
84310
- end try
84311
- end repeat
84312
- if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found"
84313
- 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"
84314
- if (count of _hits) is 1 then
84486
+ ${stopOnHit}
84487
+ end repeat
84488
+ end if
84489
+ if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found${BYID_COVERAGE_MARKER}" & _skipped & "${DIAG_FIELD_SEP}" & (_budgetHit as string)
84490
+ ${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"`}
84491
+ if (count of _hits) ${opts.firstHit ? "> 0" : "is 1"} then
84315
84492
  set msg to item 1 of _hits
84493
+ set _hitMb to item 1 of _hitMbs
84494
+ set _hitAcct to item 1 of _hitAccts
84316
84495
  ${innerAction}
84317
84496
  end if
84318
84497
  return ""
@@ -84324,20 +84503,25 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84324
84503
  /**
84325
84504
  * Build an app-level AppleScript that opens exactly one account+mailbox, finds
84326
84505
  * the message with numeric `id` in it, and runs `innerAction` (which may assume
84327
- * `msg` is bound). Used by the by-id fast paths (getMessageContent/getRawSource)
84328
- * so a message in a late-iterated large folder resolves directly instead of via
84329
- * the timeout-prone full-mailbox scan.
84506
+ * `msg`, `_hitMb` and `_hitAcct` are bound). Used by the by-id fast path
84507
+ * (`resolveScopedById`) so a message in a late-iterated large folder resolves
84508
+ * directly instead of via the timeout-prone full-mailbox scan.
84330
84509
  *
84331
84510
  * The mailbox name is resolved through `resolveMailbox` (so an alias like
84332
84511
  * "Sent"→"Sent Items" or a casing mismatch like "INBOX"→"Inbox" still opens the
84333
84512
  * right folder), and matched case-insensitively by iterating the account's
84334
84513
  * mailboxes — `mailbox "INBOX" of account …` throws on accounts whose inbox is
84335
84514
  * actually named "Inbox", which would silently drop us back to the slow scan.
84336
- * Returns "" (found nothing) on any error, so the caller falls back safely.
84515
+ *
84516
+ * A miss is reported, not swallowed (#270): `{LOOKUP_ERROR_MARKER}
84517
+ * {SCOPED_MISS_MARKER}` followed by `nomailbox`, `nomessage` or
84518
+ * `error:<Mail's text>`, so the caller can return a clean "not found in
84519
+ * <mailbox>" for an explicit hint instead of guessing.
84337
84520
  */
84338
84521
  scopedByIdScript(account, mailbox, id, innerAction) {
84339
84522
  const resolved = this.resolveMailbox(mailbox, account);
84340
84523
  const bind = isLocalStoreLabel(account) ? `${localMailboxBindingFragment()}
84524
+ set _hitAcct to "${LOCAL_STORE_LABEL}"
84341
84525
  set targetMb to missing value
84342
84526
  ignoring case
84343
84527
  repeat with mb in _mbs
@@ -84349,6 +84533,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84349
84533
  end if
84350
84534
  end repeat
84351
84535
  end ignoring` : `set acct to (first account whose name is "${escapeForAppleScript(account)}")
84536
+ set _hitAcct to (name of acct)
84352
84537
  set targetMb to missing value
84353
84538
  ignoring case
84354
84539
  repeat with mb in mailboxes of acct
@@ -84360,19 +84545,19 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84360
84545
  end if
84361
84546
  end repeat
84362
84547
  end ignoring`;
84548
+ const miss = `${LOOKUP_ERROR_MARKER}${SCOPED_MISS_MARKER}`;
84363
84549
  return buildAppLevelScript(`
84364
84550
  try
84365
84551
  ${bind}
84366
- if targetMb is not missing value then
84367
- set matchingMsgs to (messages of targetMb whose id is ${Number(id)})
84368
- if (count of matchingMsgs) > 0 then
84369
- set msg to item 1 of matchingMsgs
84370
- ${innerAction}
84371
- end if
84372
- end if
84552
+ if targetMb is missing value then return "${miss}nomailbox"
84553
+ set _hitMb to (contents of targetMb)
84554
+ set matchingMsgs to (messages of targetMb whose id is ${Number(id)})
84555
+ if (count of matchingMsgs) is 0 then return "${miss}nomessage"
84556
+ set msg to item 1 of matchingMsgs
84557
+ ${innerAction}
84373
84558
  return ""
84374
84559
  on error errMsg
84375
- return ""
84560
+ return "${miss}error:" & errMsg
84376
84561
  end try
84377
84562
  `);
84378
84563
  }
@@ -84385,6 +84570,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84385
84570
  * source can be MB-sized (it includes base64 attachments) and the plain-text
84386
84571
  * path doesn't need it; fetching it unconditionally was both slow and, worse,
84387
84572
  * returned the entire raw MIME blob mislabeled as HTML (#32).
84573
+ * @param hint - Explicit account + mailbox (both required to take effect):
84574
+ * the read opens that one mailbox and, on a miss, reports "not found in
84575
+ * <account> / <mailbox>" without scanning anywhere else (#270).
84388
84576
  */
84389
84577
  getMessageContent(id, includeHtml = false, hint) {
84390
84578
  this.lastMessageLookupError = void 0;
@@ -84402,38 +84590,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84402
84590
  ${sourceFetch}
84403
84591
  ${AS_MESSAGE_DATES_FRAGMENT}
84404
84592
  return msgSubject & "${MSGID_MARKER}" & msgRfcId & "${DATES_MARKER}" & msgDates & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource`;
84405
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84406
- if (loc) {
84407
- const scopedScript = this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch);
84408
- const scoped = this.parseMessageContent(
84409
- id,
84410
- executeAppleScript(scopedScript, { timeoutMs: 6e4 }),
84411
- includeHtml
84412
- );
84413
- if (scoped) return scoped;
84414
- }
84415
- const script = this.unscopedByIdScript(id, innerFetch);
84416
- return this.parseMessageContent(
84417
- id,
84418
- executeAppleScript(script, { timeoutMs: 6e4 }),
84419
- includeHtml
84420
- );
84593
+ const output = this.resolveById(id, innerFetch, hint);
84594
+ return output === null ? null : this.parseMessageContent(id, output, includeHtml);
84421
84595
  }
84422
84596
  /**
84423
84597
  * Parse the marker-delimited output of a getMessageContent AppleScript into a
84424
- * MessageContent, or null when nothing was found / the fetch failed. Shared by
84425
- * the scoped fast path and the full-mailbox-scan fallback.
84598
+ * MessageContent, or null when the payload is malformed.
84426
84599
  */
84427
- parseMessageContent(id, result, includeHtml) {
84428
- if (!result.success || !result.output.trim()) {
84429
- if (!result.success) console.error(`Failed to get message content: ${result.error}`);
84430
- return null;
84431
- }
84432
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84433
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84434
- return null;
84435
- }
84436
- const htmlSplit = result.output.split(HTML_MARKER);
84600
+ parseMessageContent(id, output, includeHtml) {
84601
+ const htmlSplit = output.split(HTML_MARKER);
84437
84602
  const contentPart = htmlSplit[0];
84438
84603
  const rawSource = htmlSplit.length > 1 ? htmlSplit[1] : "";
84439
84604
  const parts = contentPart.split(CONTENT_MARKER);
@@ -84458,8 +84623,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84458
84623
  /**
84459
84624
  * Fetch ONLY the raw RFC 5322 header block of a message (#224) — Mail's
84460
84625
  * `all headers` property — plus its `date received`, so the caller can show the
84461
- * arrival timestamp beside the author's `Date:` header. Same scoped-fast-path /
84462
- * full-scan resolution as getMessageContent; never reads the body or source.
84626
+ * arrival timestamp beside the author's `Date:` header. Same resolution as
84627
+ * getMessageContent (resolveById); never reads the body or source.
84463
84628
  * Returns null (with `lastMessageLookupError` set when Mail said why) on a miss.
84464
84629
  */
84465
84630
  getMessageHeaders(id, hint) {
@@ -84471,70 +84636,32 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84471
84636
  end try
84472
84637
  ${AS_MESSAGE_DATES_FRAGMENT}
84473
84638
  return msgDates & "${DATES_MARKER}" & msgHeaders`;
84474
- const parse4 = (result) => {
84475
- if (!result.success || !result.output.trim()) {
84476
- if (!result.success) console.error(`Failed to get message headers: ${result.error}`);
84477
- return null;
84478
- }
84479
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84480
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84481
- return null;
84482
- }
84483
- const idx = result.output.indexOf(DATES_MARKER);
84484
- if (idx === -1) return null;
84485
- const { dateReceived } = parseMessageDates(result.output.slice(0, idx));
84486
- const raw = result.output.slice(idx + DATES_MARKER.length);
84487
- if (!raw.trim()) return null;
84488
- return { raw, ...dateReceived ? { dateReceived } : {} };
84489
- };
84490
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84491
- if (loc) {
84492
- const scoped = parse4(
84493
- executeAppleScript(this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch), {
84494
- timeoutMs: 6e4
84495
- })
84496
- );
84497
- if (scoped) return scoped;
84498
- }
84499
- return parse4(executeAppleScript(this.unscopedByIdScript(id, innerFetch), { timeoutMs: 6e4 }));
84639
+ const output = this.resolveById(id, innerFetch, hint);
84640
+ if (output === null) return null;
84641
+ const idx = output.indexOf(DATES_MARKER);
84642
+ if (idx === -1) return null;
84643
+ const { dateReceived } = parseMessageDates(output.slice(0, idx));
84644
+ const raw = output.slice(idx + DATES_MARKER.length);
84645
+ if (!raw.trim()) return null;
84646
+ return { raw, ...dateReceived ? { dateReceived } : {} };
84500
84647
  }
84501
84648
  /**
84502
84649
  * Get the raw MIME source of a message.
84503
84650
  * Used as fallback for attachment extraction when AppleScript
84504
84651
  * mail attachments returns empty.
84505
84652
  *
84506
- * Timeout is 2x the default (120s) because `source of msg` returns
84507
- * the entire raw message including base64-encoded attachments —
84508
- * a 20MB attachment can take several seconds over Exchange/IMAP.
84653
+ * Uses the same short `READ_ONLY_BYID_TIMEOUT_MS` as the other by-id reads
84654
+ * (#270) — `source of msg` returning the entire raw message, base64
84655
+ * attachments included, is documented elsewhere as taking "several
84656
+ * seconds" for a 20MB attachment over Exchange/IMAP, comfortably inside
84657
+ * that budget; a call still running past it is a wedged scripting bridge,
84658
+ * not legitimate transfer time, so there is no case where the old 120s
84659
+ * ceiling was buying a real result a shorter one would have missed.
84660
+ * Same resolution as getMessageContent (resolveById).
84509
84661
  */
84510
84662
  getRawSource(id, hint) {
84511
84663
  this.lastMessageLookupError = void 0;
84512
- const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
84513
- if (loc) {
84514
- const scopedScript = this.scopedByIdScript(
84515
- loc.account,
84516
- loc.mailbox,
84517
- id,
84518
- "return source of msg"
84519
- );
84520
- const scoped = executeAppleScript(scopedScript, { timeoutMs: 12e4 });
84521
- if (scoped.success && scoped.output.trim() && !scoped.output.startsWith(LOOKUP_ERROR_MARKER)) {
84522
- return scoped.output;
84523
- }
84524
- if (scoped.success && scoped.output.startsWith(LOOKUP_ERROR_MARKER)) {
84525
- this.lastMessageLookupError = scoped.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84526
- }
84527
- }
84528
- const script = this.unscopedByIdScript(id, "return source of msg");
84529
- const result = executeAppleScript(script, { timeoutMs: 12e4 });
84530
- if (!result.success || !result.output.trim()) {
84531
- return null;
84532
- }
84533
- if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
84534
- this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
84535
- return null;
84536
- }
84537
- return result.output;
84664
+ return this.resolveById(id, "return source of msg", hint);
84538
84665
  }
84539
84666
  /**
84540
84667
  * List messages in a mailbox.
@@ -87506,10 +87633,12 @@ async function readOriginal(deps, id, cfg) {
87506
87633
  return { original, plainText: extractTextBody(source.raw) };
87507
87634
  }
87508
87635
  const raw = deps.mail.getRawSource(id);
87509
- if (!raw)
87636
+ if (!raw) {
87637
+ const why = deps.mail.consumeLastMessageLookupError?.();
87510
87638
  throw new Error(
87511
- "Cannot read the original message source. Re-list the intended mailbox and retry with its message id."
87639
+ `Cannot read the original message source${why ? ` (${why})` : ""}. Re-list the intended mailbox and retry with its message id.`
87512
87640
  );
87641
+ }
87513
87642
  const content = deps.mail.getMessageContent(id);
87514
87643
  return { original: parseOriginalHeaders(raw), plainText: content?.plainText ?? null };
87515
87644
  }
@@ -89151,7 +89280,10 @@ registerTool(
89151
89280
  seedSubject = subjectFromGetMessage(r.info);
89152
89281
  } else {
89153
89282
  const msg = mailManager.getMessageById(id);
89154
- if (!msg) return errorResponse(`Message with ID "${id}" not found`);
89283
+ if (!msg) {
89284
+ const lookupError = mailManager.consumeLastMessageLookupError();
89285
+ return errorResponse(lookupError ?? `Message with ID "${id}" not found`);
89286
+ }
89155
89287
  seedSubject = msg.subject;
89156
89288
  }
89157
89289
  if (!seedSubject) return errorResponse(`Could not determine the subject of message "${id}"`);
@@ -371,3 +371,4 @@ GUI is ignoring.
371
371
  | `APPLE_MAIL_MCP_TEMPLATES_FILE` | Email-templates store path (default `~/Library/Application Support/apple-mail-mcp/templates.json`). |
372
372
  | `APPLE_MAIL_MCP_MAX_BUFFER` | Max AppleScript (`osascript`) output buffer in bytes (default 64 MiB). |
373
373
  | `APPLE_MAIL_MAX_SEARCH_MAILBOX` | Per-mailbox message-count guard for unscoped AppleScript search (default `5000`; `0` disables). Note: no `_MCP` in the name. |
374
+ | `APPLE_MAIL_MAX_BYID_SCAN_MAILBOX` | Per-mailbox message-count guard for the cross-mailbox scan a numeric message id falls back to (default `50000`; `0` disables). Skipped mailboxes are named in the not-found error. Note: no `_MCP` in the name. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.20.2",
3
+ "version": "2.20.4",
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",