apple-mail-mcp 2.20.3 → 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
@@ -82603,6 +82603,44 @@ var READ_ONLY_BYID_TIMEOUT_MS = 15e3;
82603
82603
  function timedOutResolvingId(id) {
82604
82604
  return `AppleScript timed out resolving id ${Number(id)} \u2014 Mail.app's scripting bridge may be wedged; try health-check.`;
82605
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
+ }
82606
82644
  var GROUP_SEP = "";
82607
82645
  var FIELD_SEP = "";
82608
82646
  var RECORD_SEP = "";
@@ -83108,8 +83146,9 @@ var AppleMailManager = class {
83108
83146
  * Items") isn't reached before the AppleScript timeout fires, so the fetch
83109
83147
  * returns a false "not found" (only INBOX ids, reached early, worked). Every
83110
83148
  * 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.
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).
83113
83152
  */
83114
83153
  idLocationIndex = /* @__PURE__ */ new Map();
83115
83154
  /** Error from the most recent numeric message read, if it was refused. */
@@ -84209,8 +84248,12 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84209
84248
  /**
84210
84249
  * Get a message by ID.
84211
84250
  *
84212
- * Note: Mail.app message IDs are unique per mailbox. This method searches
84213
- * 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.
84214
84257
  */
84215
84258
  getMessageById(id, deepAttachmentCheck = false) {
84216
84259
  this.lastMessageLookupError = void 0;
@@ -84220,14 +84263,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84220
84263
  if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
84221
84264
  end try
84222
84265
  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
84266
+ const innerFetch = `
84231
84267
  set msgSubject to subject of msg
84232
84268
  set msgSender to sender of msg
84233
84269
  set d to date received of msg
@@ -84237,31 +84273,20 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84237
84273
  set msgJunk to junk mail status of msg as string
84238
84274
  set msgDeleted to deleted status of msg as string
84239
84275
  set msgMailbox to ""
84240
- ${mailboxPathFragment("mb", "msgMailbox")}
84241
- set msgAccount to name of acct
84276
+ try
84277
+ ${mailboxPathFragment("_hitMb", "msgMailbox")}
84278
+ end try
84279
+ set msgAccount to _hitAcct
84242
84280
  set hasAtt to "false"
84243
84281
  try
84244
84282
  set attCount to count of mail attachments of msg
84245
84283
  if attCount > 0 then set hasAtt to "true"
84246
84284
  end try
84247
84285
  ${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);
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);
84265
84290
  if (parts.length < 9) return null;
84266
84291
  this.rememberLocation(id.toString(), parts[8], parts[7]);
84267
84292
  return {
@@ -84279,47 +84304,194 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84279
84304
  hasAttachments: parts.length > 9 ? parts[9] === "true" : false
84280
84305
  };
84281
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
+ }
84282
84400
  /**
84283
84401
  * The unscoped by-id resolution: walk every mailbox of every account, then the
84284
84402
  * 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).
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.
84289
84416
  */
84290
- 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` : "";
84291
84436
  return buildAppLevelScript(`
84292
84437
  try
84293
84438
  set _hits to {}
84439
+ set _hitMbs to {}
84440
+ set _hitAccts to {}
84294
84441
  set _names to ""
84442
+ set _skipped to ""
84443
+ set _budgetHit to false
84444
+ set _startedAt to current date
84295
84445
  repeat with acct in accounts
84446
+ if _budgetHit then exit repeat
84447
+ ${stopOnHit}
84296
84448
  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
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}
84304
84464
  end repeat
84305
84465
  end repeat
84306
84466
  -- #183: local mailboxes belong to no account, so the walk above cannot
84307
84467
  -- reach them. Collect into the SAME _hits/_names, which means an id
84308
84468
  -- 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) & ", "
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
84316
84485
  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
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
84322
84492
  set msg to item 1 of _hits
84493
+ set _hitMb to item 1 of _hitMbs
84494
+ set _hitAcct to item 1 of _hitAccts
84323
84495
  ${innerAction}
84324
84496
  end if
84325
84497
  return ""
@@ -84331,20 +84503,25 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84331
84503
  /**
84332
84504
  * Build an app-level AppleScript that opens exactly one account+mailbox, finds
84333
84505
  * 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.
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.
84337
84509
  *
84338
84510
  * The mailbox name is resolved through `resolveMailbox` (so an alias like
84339
84511
  * "Sent"→"Sent Items" or a casing mismatch like "INBOX"→"Inbox" still opens the
84340
84512
  * right folder), and matched case-insensitively by iterating the account's
84341
84513
  * mailboxes — `mailbox "INBOX" of account …` throws on accounts whose inbox is
84342
84514
  * 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.
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.
84344
84520
  */
84345
84521
  scopedByIdScript(account, mailbox, id, innerAction) {
84346
84522
  const resolved = this.resolveMailbox(mailbox, account);
84347
84523
  const bind = isLocalStoreLabel(account) ? `${localMailboxBindingFragment()}
84524
+ set _hitAcct to "${LOCAL_STORE_LABEL}"
84348
84525
  set targetMb to missing value
84349
84526
  ignoring case
84350
84527
  repeat with mb in _mbs
@@ -84356,6 +84533,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84356
84533
  end if
84357
84534
  end repeat
84358
84535
  end ignoring` : `set acct to (first account whose name is "${escapeForAppleScript(account)}")
84536
+ set _hitAcct to (name of acct)
84359
84537
  set targetMb to missing value
84360
84538
  ignoring case
84361
84539
  repeat with mb in mailboxes of acct
@@ -84367,19 +84545,19 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84367
84545
  end if
84368
84546
  end repeat
84369
84547
  end ignoring`;
84548
+ const miss = `${LOOKUP_ERROR_MARKER}${SCOPED_MISS_MARKER}`;
84370
84549
  return buildAppLevelScript(`
84371
84550
  try
84372
84551
  ${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
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}
84380
84558
  return ""
84381
84559
  on error errMsg
84382
- return ""
84560
+ return "${miss}error:" & errMsg
84383
84561
  end try
84384
84562
  `);
84385
84563
  }
@@ -84392,6 +84570,9 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84392
84570
  * source can be MB-sized (it includes base64 attachments) and the plain-text
84393
84571
  * path doesn't need it; fetching it unconditionally was both slow and, worse,
84394
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).
84395
84576
  */
84396
84577
  getMessageContent(id, includeHtml = false, hint) {
84397
84578
  this.lastMessageLookupError = void 0;
@@ -84409,41 +84590,15 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84409
84590
  ${sourceFetch}
84410
84591
  ${AS_MESSAGE_DATES_FRAGMENT}
84411
84592
  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
- );
84593
+ const output = this.resolveById(id, innerFetch, hint);
84594
+ return output === null ? null : this.parseMessageContent(id, output, includeHtml);
84428
84595
  }
84429
84596
  /**
84430
84597
  * 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.
84598
+ * MessageContent, or null when the payload is malformed.
84433
84599
  */
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);
84600
+ parseMessageContent(id, output, includeHtml) {
84601
+ const htmlSplit = output.split(HTML_MARKER);
84447
84602
  const contentPart = htmlSplit[0];
84448
84603
  const rawSource = htmlSplit.length > 1 ? htmlSplit[1] : "";
84449
84604
  const parts = contentPart.split(CONTENT_MARKER);
@@ -84468,8 +84623,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84468
84623
  /**
84469
84624
  * Fetch ONLY the raw RFC 5322 header block of a message (#224) — Mail's
84470
84625
  * `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.
84626
+ * arrival timestamp beside the author's `Date:` header. Same resolution as
84627
+ * getMessageContent (resolveById); never reads the body or source.
84473
84628
  * Returns null (with `lastMessageLookupError` set when Mail said why) on a miss.
84474
84629
  */
84475
84630
  getMessageHeaders(id, hint) {
@@ -84481,39 +84636,14 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84481
84636
  end try
84482
84637
  ${AS_MESSAGE_DATES_FRAGMENT}
84483
84638
  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
- );
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 } : {} };
84517
84647
  }
84518
84648
  /**
84519
84649
  * Get the raw MIME source of a message.
@@ -84527,38 +84657,11 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
84527
84657
  * that budget; a call still running past it is a wedged scripting bridge,
84528
84658
  * not legitimate transfer time, so there is no case where the old 120s
84529
84659
  * ceiling was buying a real result a shorter one would have missed.
84660
+ * Same resolution as getMessageContent (resolveById).
84530
84661
  */
84531
84662
  getRawSource(id, hint) {
84532
84663
  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;
84664
+ return this.resolveById(id, "return source of msg", hint);
84562
84665
  }
84563
84666
  /**
84564
84667
  * List messages in a mailbox.
@@ -87530,10 +87633,12 @@ async function readOriginal(deps, id, cfg) {
87530
87633
  return { original, plainText: extractTextBody(source.raw) };
87531
87634
  }
87532
87635
  const raw = deps.mail.getRawSource(id);
87533
- if (!raw)
87636
+ if (!raw) {
87637
+ const why = deps.mail.consumeLastMessageLookupError?.();
87534
87638
  throw new Error(
87535
- "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.`
87536
87640
  );
87641
+ }
87537
87642
  const content = deps.mail.getMessageContent(id);
87538
87643
  return { original: parseOriginalHeaders(raw), plainText: content?.plainText ?? null };
87539
87644
  }
@@ -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.3",
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",