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 +12 -6
- package/build/index.js +287 -155
- 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
|
@@ -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
|
|
83107
|
-
* 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).
|
|
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.
|
|
84208
|
-
*
|
|
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
|
|
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
|
-
|
|
84235
|
-
|
|
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
|
-
|
|
84244
|
-
|
|
84245
|
-
|
|
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
|
|
84279
|
-
*
|
|
84280
|
-
*
|
|
84281
|
-
*
|
|
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
|
-
|
|
84291
|
-
|
|
84292
|
-
|
|
84293
|
-
|
|
84294
|
-
|
|
84295
|
-
|
|
84296
|
-
|
|
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
|
|
84303
|
-
|
|
84304
|
-
|
|
84305
|
-
|
|
84306
|
-
|
|
84307
|
-
|
|
84308
|
-
|
|
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
|
-
|
|
84311
|
-
|
|
84312
|
-
if
|
|
84313
|
-
if (count of _hits)
|
|
84314
|
-
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
|
|
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`
|
|
84328
|
-
* so a message in a late-iterated large folder resolves
|
|
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
|
-
*
|
|
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
|
|
84367
|
-
|
|
84368
|
-
|
|
84369
|
-
|
|
84370
|
-
|
|
84371
|
-
|
|
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
|
|
84406
|
-
|
|
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
|
|
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,
|
|
84428
|
-
|
|
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
|
|
84462
|
-
*
|
|
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
|
|
84475
|
-
|
|
84476
|
-
|
|
84477
|
-
|
|
84478
|
-
|
|
84479
|
-
|
|
84480
|
-
|
|
84481
|
-
|
|
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
|
-
*
|
|
84507
|
-
* the entire raw message
|
|
84508
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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}"`);
|
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