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 +12 -6
- package/build/index.js +269 -164
- package/docs/IMAP-SETUP.md +1 -0
- package/package.json +1 -1
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
|
|
83112
|
-
* misses and falls back to the
|
|
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.
|
|
84213
|
-
*
|
|
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
|
|
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
|
-
|
|
84241
|
-
|
|
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
|
-
|
|
84250
|
-
|
|
84251
|
-
|
|
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
|
|
84286
|
-
*
|
|
84287
|
-
*
|
|
84288
|
-
*
|
|
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
|
-
|
|
84298
|
-
|
|
84299
|
-
|
|
84300
|
-
|
|
84301
|
-
|
|
84302
|
-
|
|
84303
|
-
|
|
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
|
|
84310
|
-
|
|
84311
|
-
|
|
84312
|
-
|
|
84313
|
-
|
|
84314
|
-
|
|
84315
|
-
|
|
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
|
-
|
|
84318
|
-
|
|
84319
|
-
if
|
|
84320
|
-
if (count of _hits)
|
|
84321
|
-
if (count of _hits)
|
|
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`
|
|
84335
|
-
* so a message in a late-iterated large folder resolves
|
|
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
|
-
*
|
|
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
|
|
84374
|
-
|
|
84375
|
-
|
|
84376
|
-
|
|
84377
|
-
|
|
84378
|
-
|
|
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
|
|
84413
|
-
|
|
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
|
|
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,
|
|
84435
|
-
|
|
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
|
|
84472
|
-
*
|
|
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
|
|
84485
|
-
|
|
84486
|
-
|
|
84487
|
-
|
|
84488
|
-
|
|
84489
|
-
|
|
84490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/docs/IMAP-SETUP.md
CHANGED
|
@@ -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