apple-mail-mcp 2.10.7 → 2.10.9
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 +30 -1
- package/build/index.js +171 -38
- package/docs/IMAP-SETUP.md +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -486,6 +486,7 @@ for an explicitly-named IMAP account, never on an omitted account.
|
|
|
486
486
|
| `APPLE_MAIL_MCP_IMAP_IDLE` | No | `0` | Set `1` to enable IMAP IDLE push notifications (new-mail alerts) for every configured account |
|
|
487
487
|
| `APPLE_MAIL_MCP_IMAP_IDLE_MS` | No | `30000` | Idle timeout (ms) before a pooled IMAP connection is closed (`0` = never close) |
|
|
488
488
|
| `APPLE_MAIL_MCP_STATS_BUDGET_MS` | No | `25000` | Per-account wall-clock budget for `get-mail-stats` (minimum `1000`). Raise it for very large accounts |
|
|
489
|
+
| `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | No | `50000` | Overall wall-clock deadline for one `get-mail-stats` call (minimum `2000`), measured from when the request arrived and covering time queued behind other tool calls, account enumeration **and** every per-account read. Keep it below your client's request timeout |
|
|
489
490
|
|
|
490
491
|
**Multiple IMAP accounts (C2):** set `APPLE_MAIL_MCP_IMAP_ACCOUNTS` to a JSON array, e.g.
|
|
491
492
|
`[{"account":"Work","user":"me@co.com","host":"imap.co.com","keychainService":"imap.co.com"}]`.
|
|
@@ -775,9 +776,15 @@ Move a message to a different mailbox.
|
|
|
775
776
|
| Parameter | Type | Required | Description |
|
|
776
777
|
|-----------|------|----------|-------------|
|
|
777
778
|
| `id` | string | Yes | Message ID |
|
|
778
|
-
| `mailbox` | string | Yes | Destination mailbox |
|
|
779
|
+
| `mailbox` | string | Yes | Destination mailbox — full path (`Work/Archive`) or a leaf name that is unique on the account |
|
|
779
780
|
| `account` | string | No | Account containing mailbox |
|
|
780
781
|
|
|
782
|
+
A destination is matched first as a full path, then as a leaf name. If a leaf
|
|
783
|
+
name matches **more than one** mailbox (e.g. `Archive` under both `Work` and
|
|
784
|
+
`Thornlands`), the move is refused with an error naming every candidate — pass
|
|
785
|
+
the full path. The same applies to `batch-move-messages`, `delete-mailbox` and
|
|
786
|
+
`rename-mailbox`.
|
|
787
|
+
|
|
781
788
|
---
|
|
782
789
|
|
|
783
790
|
#### `list-attachments`
|
|
@@ -1157,6 +1164,28 @@ overruns is reported via `partial: true` + `failedAccounts` rather than being
|
|
|
1157
1164
|
folded in as a silent zero; a scoped call to a single account returns an error
|
|
1158
1165
|
naming the budget instead. Raise the budget if you have a very large account.
|
|
1159
1166
|
|
|
1167
|
+
The whole call is additionally bounded by one wall-clock deadline,
|
|
1168
|
+
`APPLE_MAIL_MCP_STATS_DEADLINE_MS` (default `50000`), which covers the Mail.app
|
|
1169
|
+
account enumeration as well as every per-account read. Per-step budgets alone
|
|
1170
|
+
were not enough: their worst cases **add up**, and the sum could exceed a
|
|
1171
|
+
client's request timeout, so the call died with nothing returned instead of
|
|
1172
|
+
degrading. Keep the deadline below your MCP client's request timeout — whatever
|
|
1173
|
+
cannot be read inside it is named in `failedAccounts`, so you always get a
|
|
1174
|
+
partial answer rather than a dead call.
|
|
1175
|
+
|
|
1176
|
+
**Concurrent `get-mail-stats` calls do not run concurrently.** Tool calls are
|
|
1177
|
+
serialized so they cannot race into Mail.app's single-threaded AppleScript
|
|
1178
|
+
dispatch, so each call waits for the ones ahead of it and per-call latency grows
|
|
1179
|
+
with queue depth — N concurrent calls take about N × the single-call cost. Since
|
|
1180
|
+
this is the most expensive read tool, that is very visible here: measured on 3
|
|
1181
|
+
IMAP accounts, three concurrent calls returned at 5.5s / 10.3s / 15.6s against a
|
|
1182
|
+
~5.2s solo cost. The deadline is measured from when the request **arrived**, so
|
|
1183
|
+
that wait is spent from the same budget as the work: a call that waited ≥1s
|
|
1184
|
+
reports `queueWaitMs`, and one that arrives with its deadline already spent
|
|
1185
|
+
returns straight away naming the queue rather than starting work whose answer
|
|
1186
|
+
would land after your client has given up. Issue these calls one at a time, and
|
|
1187
|
+
prefer `get-unread-count` when a single number will do.
|
|
1188
|
+
|
|
1160
1189
|
---
|
|
1161
1190
|
|
|
1162
1191
|
#### `get-sync-status`
|
package/build/index.js
CHANGED
|
@@ -78388,6 +78388,12 @@ var AppleMailManager = class {
|
|
|
78388
78388
|
* presenting a fallback zero/empty as a real answer. (#130)
|
|
78389
78389
|
*/
|
|
78390
78390
|
lastAccountsError = null;
|
|
78391
|
+
/**
|
|
78392
|
+
* Same, for the mailbox listing — read via `listMailboxesChecked()`. Kept
|
|
78393
|
+
* separate from `lastAccountsError` so a failed mailbox read on one account
|
|
78394
|
+
* can't be misread as the account enumeration having failed. (#135)
|
|
78395
|
+
*/
|
|
78396
|
+
lastMailboxesError = null;
|
|
78391
78397
|
/**
|
|
78392
78398
|
* Remembers where each message id was last seen: id → {account, mailbox}.
|
|
78393
78399
|
*
|
|
@@ -78417,12 +78423,12 @@ var AppleMailManager = class {
|
|
|
78417
78423
|
/**
|
|
78418
78424
|
* Returns cached accounts or fetches fresh data if cache is expired/empty.
|
|
78419
78425
|
*/
|
|
78420
|
-
getCachedAccounts() {
|
|
78426
|
+
getCachedAccounts(options = {}) {
|
|
78421
78427
|
const now = Date.now();
|
|
78422
78428
|
if (this.cache.accounts && now < this.cache.accounts.expiry) {
|
|
78423
78429
|
return this.cache.accounts.data;
|
|
78424
78430
|
}
|
|
78425
|
-
const accounts = this.fetchAccounts();
|
|
78431
|
+
const accounts = this.fetchAccounts(options);
|
|
78426
78432
|
if (accounts === null) {
|
|
78427
78433
|
return this.cache.accounts?.data ?? [];
|
|
78428
78434
|
}
|
|
@@ -80187,7 +80193,7 @@ var AppleMailManager = class {
|
|
|
80187
80193
|
/**
|
|
80188
80194
|
* List all mailboxes for an account.
|
|
80189
80195
|
*/
|
|
80190
|
-
listMailboxes(account) {
|
|
80196
|
+
listMailboxes(account, options = {}) {
|
|
80191
80197
|
const targetAccount = this.resolveAccount(account);
|
|
80192
80198
|
const listCommand = `
|
|
80193
80199
|
set mailboxList to {}
|
|
@@ -80201,9 +80207,10 @@ var AppleMailManager = class {
|
|
|
80201
80207
|
return mailboxList as text
|
|
80202
80208
|
`;
|
|
80203
80209
|
const script = buildAccountScopedScript(targetAccount, listCommand);
|
|
80204
|
-
const result = executeAppleScript(script, { timeoutMs: 6e4 });
|
|
80210
|
+
const result = executeAppleScript(script, { timeoutMs: options.timeoutMs ?? 6e4 });
|
|
80205
80211
|
if (!result.success) {
|
|
80206
80212
|
console.error(`Failed to list mailboxes: ${result.error}`);
|
|
80213
|
+
this.lastMailboxesError = result.error ?? "AppleScript transport failed";
|
|
80207
80214
|
return [];
|
|
80208
80215
|
}
|
|
80209
80216
|
if (!result.output.trim()) return [];
|
|
@@ -80221,6 +80228,20 @@ var AppleMailManager = class {
|
|
|
80221
80228
|
}
|
|
80222
80229
|
return mailboxes;
|
|
80223
80230
|
}
|
|
80231
|
+
/**
|
|
80232
|
+
* listMailboxes() plus whether the underlying AppleScript read actually worked.
|
|
80233
|
+
*
|
|
80234
|
+
* An empty list is ambiguous on its own — Mail with no mailboxes and a timed-out
|
|
80235
|
+
* transport both produce `[]`, and folding the second into a total as 0 is the
|
|
80236
|
+
* silent-zero class #130 fixed elsewhere. A caller summing counts across
|
|
80237
|
+
* accounts needs to tell them apart. (#135)
|
|
80238
|
+
*/
|
|
80239
|
+
listMailboxesChecked(account, options = {}) {
|
|
80240
|
+
this.lastMailboxesError = null;
|
|
80241
|
+
const mailboxes = this.listMailboxes(account, options);
|
|
80242
|
+
const error2 = this.lastMailboxesError;
|
|
80243
|
+
return error2 ? { mailboxes, failed: true, error: error2 } : { mailboxes, failed: false };
|
|
80244
|
+
}
|
|
80224
80245
|
/**
|
|
80225
80246
|
* Get unread count for a mailbox.
|
|
80226
80247
|
*/
|
|
@@ -80756,18 +80777,22 @@ end tell`;
|
|
|
80756
80777
|
/**
|
|
80757
80778
|
* List all mail accounts (uses cache).
|
|
80758
80779
|
*/
|
|
80759
|
-
listAccounts() {
|
|
80760
|
-
return this.getCachedAccounts();
|
|
80780
|
+
listAccounts(options = {}) {
|
|
80781
|
+
return this.getCachedAccounts(options);
|
|
80761
80782
|
}
|
|
80762
80783
|
/**
|
|
80763
80784
|
* listAccounts() plus whether the underlying AppleScript read actually worked.
|
|
80764
80785
|
*
|
|
80765
80786
|
* `failed: true` means the list is a fallback (stale cache or empty) because the
|
|
80766
80787
|
* transport errored — NOT that Mail has no accounts. (#130)
|
|
80788
|
+
*
|
|
80789
|
+
* `timeoutMs` bounds the AppleScript read when the cache is cold, so a caller
|
|
80790
|
+
* working to an overall deadline can spend a known slice here instead of the
|
|
80791
|
+
* blanket 30s. A cache hit costs nothing and ignores it. (#135)
|
|
80767
80792
|
*/
|
|
80768
|
-
listAccountsChecked() {
|
|
80793
|
+
listAccountsChecked(options = {}) {
|
|
80769
80794
|
this.lastAccountsError = null;
|
|
80770
|
-
const accounts = this.getCachedAccounts();
|
|
80795
|
+
const accounts = this.getCachedAccounts(options);
|
|
80771
80796
|
const error2 = this.lastAccountsError;
|
|
80772
80797
|
return error2 ? { accounts, failed: true, error: error2 } : { accounts, failed: false };
|
|
80773
80798
|
}
|
|
@@ -80792,7 +80817,7 @@ end tell`;
|
|
|
80792
80817
|
* "Mail answered, and there genuinely are no accounts" — collapsing the two is
|
|
80793
80818
|
* what let a wedged transport report a confident "No Mail accounts found". (#130)
|
|
80794
80819
|
*/
|
|
80795
|
-
fetchAccounts() {
|
|
80820
|
+
fetchAccounts(options = {}) {
|
|
80796
80821
|
const script = buildAppLevelScript(`
|
|
80797
80822
|
set accountList to {}
|
|
80798
80823
|
repeat with acct in accounts
|
|
@@ -80808,7 +80833,10 @@ end tell`;
|
|
|
80808
80833
|
set AppleScript's text item delimiters to "${RECORD_SEP}"
|
|
80809
80834
|
return accountList as text
|
|
80810
80835
|
`);
|
|
80811
|
-
const result = executeAppleScript(
|
|
80836
|
+
const result = executeAppleScript(
|
|
80837
|
+
script,
|
|
80838
|
+
options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
|
|
80839
|
+
);
|
|
80812
80840
|
if (!result.success) {
|
|
80813
80841
|
console.error(`Failed to list accounts: ${result.error}`);
|
|
80814
80842
|
this.lastAccountsError = result.error ?? "AppleScript transport failed";
|
|
@@ -82113,13 +82141,26 @@ async function useClient(deps, fn, retryOnDrop = false) {
|
|
|
82113
82141
|
function withClient(deps, fn) {
|
|
82114
82142
|
return useClient(deps, fn);
|
|
82115
82143
|
}
|
|
82116
|
-
async function
|
|
82144
|
+
async function resolveMailbox(client, name) {
|
|
82117
82145
|
const wanted = name.trim().toLowerCase();
|
|
82118
82146
|
const boxes = await client.list();
|
|
82119
82147
|
const byPath = boxes.find((b) => b.path.toLowerCase() === wanted);
|
|
82120
|
-
if (byPath) return byPath.path;
|
|
82121
|
-
const byName = boxes.
|
|
82122
|
-
|
|
82148
|
+
if (byPath) return { kind: "found", path: byPath.path };
|
|
82149
|
+
const byName = boxes.filter((b) => b.name.toLowerCase() === wanted);
|
|
82150
|
+
if (byName.length === 1) return { kind: "found", path: byName[0].path };
|
|
82151
|
+
if (byName.length > 1) {
|
|
82152
|
+
return { kind: "ambiguous", candidates: byName.map((b) => b.path).sort() };
|
|
82153
|
+
}
|
|
82154
|
+
return { kind: "none" };
|
|
82155
|
+
}
|
|
82156
|
+
function ambiguousMailboxError(name, candidates, accountLabel) {
|
|
82157
|
+
const where = accountLabel ? ` on IMAP account ${accountLabel}` : "";
|
|
82158
|
+
return `Mailbox "${name}" is ambiguous${where} \u2014 it matches ${candidates.map((c) => `"${c}"`).join(" and ")}. Pass the full path.`;
|
|
82159
|
+
}
|
|
82160
|
+
async function findMailboxPathOrThrow(client, name) {
|
|
82161
|
+
const res = await resolveMailbox(client, name);
|
|
82162
|
+
if (res.kind === "ambiguous") throw new Error(ambiguousMailboxError(name, res.candidates));
|
|
82163
|
+
return res.kind === "found" ? res.path : null;
|
|
82123
82164
|
}
|
|
82124
82165
|
function imapCreateMailbox(name, deps = {}) {
|
|
82125
82166
|
return withClient(deps, async (client) => {
|
|
@@ -82133,13 +82174,20 @@ function imapCreateMailbox(name, deps = {}) {
|
|
|
82133
82174
|
}
|
|
82134
82175
|
function imapDeleteMailbox(name, deps = {}) {
|
|
82135
82176
|
return withClient(deps, async (client, cfg) => {
|
|
82136
|
-
const
|
|
82137
|
-
if (
|
|
82177
|
+
const res = await resolveMailbox(client, name);
|
|
82178
|
+
if (res.kind === "ambiguous") {
|
|
82179
|
+
return {
|
|
82180
|
+
success: false,
|
|
82181
|
+
error: ambiguousMailboxError(name, res.candidates, cfg.accountLabel)
|
|
82182
|
+
};
|
|
82183
|
+
}
|
|
82184
|
+
if (res.kind === "none") {
|
|
82138
82185
|
return {
|
|
82139
82186
|
success: false,
|
|
82140
82187
|
error: `Mailbox "${name}" not found on IMAP account ${cfg.accountLabel}.`
|
|
82141
82188
|
};
|
|
82142
82189
|
}
|
|
82190
|
+
const path = res.path;
|
|
82143
82191
|
try {
|
|
82144
82192
|
await client.mailboxDelete(path);
|
|
82145
82193
|
return {
|
|
@@ -82153,13 +82201,20 @@ function imapDeleteMailbox(name, deps = {}) {
|
|
|
82153
82201
|
}
|
|
82154
82202
|
function imapRenameMailbox(oldName, newName, deps = {}) {
|
|
82155
82203
|
return withClient(deps, async (client, cfg) => {
|
|
82156
|
-
const
|
|
82157
|
-
if (
|
|
82204
|
+
const found = await resolveMailbox(client, oldName);
|
|
82205
|
+
if (found.kind === "ambiguous") {
|
|
82206
|
+
return {
|
|
82207
|
+
success: false,
|
|
82208
|
+
error: ambiguousMailboxError(oldName, found.candidates, cfg.accountLabel)
|
|
82209
|
+
};
|
|
82210
|
+
}
|
|
82211
|
+
if (found.kind === "none") {
|
|
82158
82212
|
return {
|
|
82159
82213
|
success: false,
|
|
82160
82214
|
error: `Mailbox "${oldName}" not found on IMAP account ${cfg.accountLabel}.`
|
|
82161
82215
|
};
|
|
82162
82216
|
}
|
|
82217
|
+
const path = found.path;
|
|
82163
82218
|
try {
|
|
82164
82219
|
const res = await client.mailboxRename(path, newName);
|
|
82165
82220
|
return { success: true, info: `Renamed "${res.path}" to "${res.newPath}" via IMAP.` };
|
|
@@ -82292,8 +82347,15 @@ function imapUnflagMessage(id, deps = {}) {
|
|
|
82292
82347
|
async function imapMoveMessageById(id, destMailbox, deps = {}) {
|
|
82293
82348
|
const ref = decodeImapId(id);
|
|
82294
82349
|
if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
|
|
82295
|
-
return withClient(depsForMessageRef(ref, deps), async (client) => {
|
|
82296
|
-
const
|
|
82350
|
+
return withClient(depsForMessageRef(ref, deps), async (client, cfg) => {
|
|
82351
|
+
const dest = await resolveMailbox(client, destMailbox);
|
|
82352
|
+
if (dest.kind === "ambiguous") {
|
|
82353
|
+
return {
|
|
82354
|
+
success: false,
|
|
82355
|
+
error: ambiguousMailboxError(destMailbox, dest.candidates, cfg.accountLabel)
|
|
82356
|
+
};
|
|
82357
|
+
}
|
|
82358
|
+
const destPath = dest.kind === "found" ? dest.path : resolveMailboxPath(destMailbox, "list");
|
|
82297
82359
|
const lock = await client.getMailboxLock(ref.path);
|
|
82298
82360
|
try {
|
|
82299
82361
|
await client.messageMove([ref.uid], destPath, { uid: true });
|
|
@@ -82468,7 +82530,7 @@ var imapBatchDelete = (ids, deps = {}) => imapBatch(ids, deps, async (c, uids, p
|
|
|
82468
82530
|
});
|
|
82469
82531
|
function imapBatchMove(ids, destMailbox, deps = {}) {
|
|
82470
82532
|
return imapBatch(ids, deps, async (c, uids) => {
|
|
82471
|
-
const dest = await
|
|
82533
|
+
const dest = await findMailboxPathOrThrow(c, destMailbox) ?? resolveMailboxPath(destMailbox, "list");
|
|
82472
82534
|
await c.messageMove(uids, dest, { uid: true });
|
|
82473
82535
|
});
|
|
82474
82536
|
}
|
|
@@ -82544,6 +82606,9 @@ async function imapThread(id, deps = {}, limit = 50) {
|
|
|
82544
82606
|
);
|
|
82545
82607
|
}
|
|
82546
82608
|
|
|
82609
|
+
// src/tools/respond.ts
|
|
82610
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
82611
|
+
|
|
82547
82612
|
// src/utils/serialize.ts
|
|
82548
82613
|
function createSerialGate(settleMs = 50) {
|
|
82549
82614
|
let tail = Promise.resolve();
|
|
@@ -82612,15 +82677,23 @@ function partialCoverageBlock(diagnostics) {
|
|
|
82612
82677
|
${notes.map((n) => ` - ${n}`).join("\n")}`;
|
|
82613
82678
|
}
|
|
82614
82679
|
var serializeAppleScript = createSerialGate();
|
|
82680
|
+
var callTiming = new AsyncLocalStorage();
|
|
82681
|
+
function currentCallTiming() {
|
|
82682
|
+
return callTiming.getStore();
|
|
82683
|
+
}
|
|
82615
82684
|
function withErrorHandling(handler, errorPrefix) {
|
|
82616
82685
|
return async (params) => {
|
|
82686
|
+
const arrivedAt = Date.now();
|
|
82617
82687
|
return serializeAppleScript(async () => {
|
|
82618
|
-
|
|
82619
|
-
|
|
82620
|
-
|
|
82621
|
-
|
|
82622
|
-
|
|
82623
|
-
|
|
82688
|
+
const timing = { arrivedAt, queueWaitMs: Date.now() - arrivedAt };
|
|
82689
|
+
return callTiming.run(timing, async () => {
|
|
82690
|
+
try {
|
|
82691
|
+
return await handler(params);
|
|
82692
|
+
} catch (error2) {
|
|
82693
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
82694
|
+
return errorResponse(`${errorPrefix}: ${message}`);
|
|
82695
|
+
}
|
|
82696
|
+
});
|
|
82624
82697
|
});
|
|
82625
82698
|
};
|
|
82626
82699
|
}
|
|
@@ -85092,21 +85165,36 @@ registerTool(
|
|
|
85092
85165
|
// tolerated by the permissive advertisement (#135).
|
|
85093
85166
|
perMailbox: external_exports.array(external_exports.object({}).passthrough()).optional(),
|
|
85094
85167
|
partial: external_exports.boolean().optional(),
|
|
85095
|
-
failedAccounts: external_exports.array(external_exports.string()).optional()
|
|
85168
|
+
failedAccounts: external_exports.array(external_exports.string()).optional(),
|
|
85169
|
+
// Present only when this call waited behind other tool calls. Without it,
|
|
85170
|
+
// queue wait is invisible from the outside and a caller timing the call
|
|
85171
|
+
// sees a duration no per-account budget explains (#135).
|
|
85172
|
+
queueWaitMs: external_exports.number().optional()
|
|
85096
85173
|
}
|
|
85097
85174
|
},
|
|
85098
85175
|
withErrorHandling(async ({ account }) => {
|
|
85099
85176
|
const budgetMs = Math.max(1e3, Number(process.env.APPLE_MAIL_MCP_STATS_BUDGET_MS ?? 25e3));
|
|
85177
|
+
const deadlineMs = Math.max(
|
|
85178
|
+
2e3,
|
|
85179
|
+
Number(process.env.APPLE_MAIL_MCP_STATS_DEADLINE_MS ?? 5e4)
|
|
85180
|
+
);
|
|
85181
|
+
const timing = currentCallTiming();
|
|
85182
|
+
const queueWaitMs = timing?.queueWaitMs ?? 0;
|
|
85183
|
+
const startedAt = timing?.arrivedAt ?? Date.now();
|
|
85184
|
+
const remainingMs = () => Math.max(0, deadlineMs - (Date.now() - startedAt));
|
|
85185
|
+
if (queueWaitMs > 0 && remainingMs() < 1e3) {
|
|
85186
|
+
return errorResponse(
|
|
85187
|
+
`Could not read mail statistics: the ${deadlineMs}ms overall deadline was spent waiting ${queueWaitMs}ms in the tool-call queue before this call could start. Tool calls are serialized so they cannot race into Mail.app, so concurrent get-mail-stats calls each wait for the ones ahead of them \u2014 and it is the most expensive read tool (one IMAP STATUS per mailbox, and Gmail lists every label). Issue one at a time, prefer "get-unread-count" when a single number will do, or raise APPLE_MAIL_MCP_STATS_DEADLINE_MS.`
|
|
85188
|
+
);
|
|
85189
|
+
}
|
|
85100
85190
|
const withBudget = async (work, label) => {
|
|
85191
|
+
const ms = Math.min(budgetMs, remainingMs());
|
|
85101
85192
|
let timer;
|
|
85102
85193
|
try {
|
|
85103
85194
|
return await Promise.race([
|
|
85104
85195
|
work,
|
|
85105
85196
|
new Promise((_, reject) => {
|
|
85106
|
-
timer = setTimeout(
|
|
85107
|
-
() => reject(new Error(`${label} timed out after ${budgetMs}ms`)),
|
|
85108
|
-
budgetMs
|
|
85109
|
-
);
|
|
85197
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
85110
85198
|
})
|
|
85111
85199
|
]);
|
|
85112
85200
|
} finally {
|
|
@@ -85119,7 +85207,9 @@ registerTool(
|
|
|
85119
85207
|
s = await withBudget(imapMailStats({ account }), `IMAP mail-stats for "${account}"`);
|
|
85120
85208
|
} catch (e) {
|
|
85121
85209
|
return errorResponse(
|
|
85122
|
-
`Could not read mail statistics for "${account}": ${String(e)}. Gathering stats costs one IMAP STATUS per mailbox, so a very large account can exceed the ${budgetMs}ms budget \u2014 raise APPLE_MAIL_MCP_STATS_BUDGET_MS, or run the "doctor" tool if the connection itself is the problem.`
|
|
85210
|
+
`Could not read mail statistics for "${account}": ${String(e)}. Gathering stats costs one IMAP STATUS per mailbox, so a very large account can exceed the ${budgetMs}ms budget \u2014 raise APPLE_MAIL_MCP_STATS_BUDGET_MS, or run the "doctor" tool if the connection itself is the problem.` + // Don't send someone tuning a budget when the queue is what ran the
|
|
85211
|
+
// clock down: the remaining deadline caps the budget (#135).
|
|
85212
|
+
(queueWaitMs >= 1e3 ? ` Note: this call waited ${queueWaitMs}ms behind other tool calls before starting, which counts against the ${deadlineMs}ms overall deadline and so caps the effective budget \u2014 issue get-mail-stats calls one at a time.` : ``)
|
|
85123
85213
|
);
|
|
85124
85214
|
}
|
|
85125
85215
|
const lines2 = [
|
|
@@ -85133,15 +85223,38 @@ registerTool(
|
|
|
85133
85223
|
` Last 7 days: ${s.recent.last7d}`,
|
|
85134
85224
|
` Last 30 days: ${s.recent.last30d}`
|
|
85135
85225
|
];
|
|
85136
|
-
|
|
85226
|
+
if (queueWaitMs >= 1e3) {
|
|
85227
|
+
lines2.push(
|
|
85228
|
+
``,
|
|
85229
|
+
`\u23F3 Waited ${(queueWaitMs / 1e3).toFixed(1)}s in the tool-call queue before this call started (calls are serialized so they cannot race into Mail.app), so the time you measured is queue wait plus work.`
|
|
85230
|
+
);
|
|
85231
|
+
}
|
|
85232
|
+
return successResponse(lines2.join("\n"), {
|
|
85233
|
+
account,
|
|
85234
|
+
...s,
|
|
85235
|
+
...queueWaitMs >= 1e3 ? { queueWaitMs } : {}
|
|
85236
|
+
});
|
|
85137
85237
|
}
|
|
85138
85238
|
if (account === void 0 && shouldUseImap(account)) {
|
|
85139
85239
|
let totalMessages = 0;
|
|
85140
85240
|
let totalUnread = 0;
|
|
85141
85241
|
const recent = { last24h: 0, last7d: 0, last30d: 0 };
|
|
85142
85242
|
const perAccount = [];
|
|
85143
|
-
const sources = planCountSources(mailManager.listAccounts(), resolveImapConfigs());
|
|
85144
85243
|
const failedAccounts = [];
|
|
85244
|
+
const imapConfigs = resolveImapConfigs();
|
|
85245
|
+
const enumerateMs = Math.max(1e3, Math.min(1e4, Math.floor(remainingMs() * 0.3)));
|
|
85246
|
+
const enumerated = mailManager.listAccountsChecked({ timeoutMs: enumerateMs });
|
|
85247
|
+
const sources = enumerated.failed ? imapConfigs.map((config2) => ({
|
|
85248
|
+
kind: "imap",
|
|
85249
|
+
config: config2,
|
|
85250
|
+
label: config2.accountLabel
|
|
85251
|
+
})) : planCountSources(enumerated.accounts, imapConfigs);
|
|
85252
|
+
if (enumerated.failed) {
|
|
85253
|
+
console.error(
|
|
85254
|
+
`Mail.app account enumeration failed for get-mail-stats: ${enumerated.error}`
|
|
85255
|
+
);
|
|
85256
|
+
failedAccounts.push("Mail.app accounts (AppleScript enumeration)");
|
|
85257
|
+
}
|
|
85145
85258
|
const settled = await Promise.all(
|
|
85146
85259
|
sources.filter((s) => s.kind === "imap").map(async (src) => {
|
|
85147
85260
|
try {
|
|
@@ -85176,9 +85289,20 @@ registerTool(
|
|
|
85176
85289
|
}
|
|
85177
85290
|
for (const src of sources) {
|
|
85178
85291
|
if (src.kind === "imap") continue;
|
|
85292
|
+
const left = remainingMs();
|
|
85293
|
+
if (left < 1e3) {
|
|
85294
|
+
failedAccounts.push(src.label);
|
|
85295
|
+
continue;
|
|
85296
|
+
}
|
|
85297
|
+
const read = mailManager.listMailboxesChecked(src.account.name, { timeoutMs: left });
|
|
85298
|
+
if (read.failed) {
|
|
85299
|
+
console.error(`AppleScript mail-stats failed for "${src.label}": ${read.error}`);
|
|
85300
|
+
failedAccounts.push(src.label);
|
|
85301
|
+
continue;
|
|
85302
|
+
}
|
|
85179
85303
|
let m = 0;
|
|
85180
85304
|
let u = 0;
|
|
85181
|
-
for (const mb of
|
|
85305
|
+
for (const mb of read.mailboxes) {
|
|
85182
85306
|
m += mb.messageCount;
|
|
85183
85307
|
u += mb.unreadCount;
|
|
85184
85308
|
}
|
|
@@ -85210,7 +85334,15 @@ registerTool(
|
|
|
85210
85334
|
if (failedAccounts.length > 0) {
|
|
85211
85335
|
lines2.push(
|
|
85212
85336
|
``,
|
|
85213
|
-
`\u26A0\uFE0F PARTIAL: ${failedAccounts.length} account(s) could not be read (${failedAccounts.join(", ")}), so the real totals are higher. They either failed
|
|
85337
|
+
`\u26A0\uFE0F PARTIAL: ${failedAccounts.length} account(s) could not be read (${failedAccounts.join(", ")}), so the real totals are higher. They either failed, exceeded the ${budgetMs}ms per-account budget, or ran out of the ${deadlineMs}ms overall deadline \u2014 raise APPLE_MAIL_MCP_STATS_BUDGET_MS and/or APPLE_MAIL_MCP_STATS_DEADLINE_MS if an account is simply large, or run the "doctor" tool to check the connection.` + // Attribute the shortfall to the queue when the queue is what ate the
|
|
85338
|
+
// deadline, instead of leaving it to look like a slow account (#135).
|
|
85339
|
+
(queueWaitMs >= 1e3 ? ` Note: ${queueWaitMs}ms of that deadline was spent queued behind other tool calls, not reading mail \u2014 issuing get-mail-stats calls one at a time will recover it.` : ``)
|
|
85340
|
+
);
|
|
85341
|
+
}
|
|
85342
|
+
if (queueWaitMs >= 1e3) {
|
|
85343
|
+
lines2.push(
|
|
85344
|
+
``,
|
|
85345
|
+
`\u23F3 Waited ${(queueWaitMs / 1e3).toFixed(1)}s in the tool-call queue before this call started (calls are serialized so they cannot race into Mail.app), so the time you measured is queue wait plus work.`
|
|
85214
85346
|
);
|
|
85215
85347
|
}
|
|
85216
85348
|
return successResponse(lines2.join("\n"), {
|
|
@@ -85218,7 +85350,8 @@ registerTool(
|
|
|
85218
85350
|
totalUnread,
|
|
85219
85351
|
accounts: perAccount,
|
|
85220
85352
|
recent,
|
|
85221
|
-
...failedAccounts.length > 0 ? { partial: true, failedAccounts } : {}
|
|
85353
|
+
...failedAccounts.length > 0 ? { partial: true, failedAccounts } : {},
|
|
85354
|
+
...queueWaitMs >= 1e3 ? { queueWaitMs } : {}
|
|
85222
85355
|
});
|
|
85223
85356
|
}
|
|
85224
85357
|
const stats = mailManager.getMailStats();
|
package/docs/IMAP-SETUP.md
CHANGED
|
@@ -358,6 +358,7 @@ GUI is ignoring.
|
|
|
358
358
|
| `APPLE_MAIL_MCP_IMAP_IDLE` | `1` to enable IMAP IDLE new-mail push. |
|
|
359
359
|
| `APPLE_MAIL_MCP_IMAP_IDLE_MS` | Pooled-connection idle timeout in ms (default `30000`; `0` = never close). |
|
|
360
360
|
| `APPLE_MAIL_MCP_STATS_BUDGET_MS` | Per-account wall-clock budget for `get-mail-stats` in ms (default `25000`, minimum `1000`). |
|
|
361
|
+
| `APPLE_MAIL_MCP_STATS_DEADLINE_MS` | Overall wall-clock deadline for one `get-mail-stats` call in ms (default `50000`, minimum `2000`) — measured from when the request arrived, and covering time spent queued behind other tool calls as well as the Mail.app account enumeration and every per-account read. Keep it below your MCP client's request timeout. |
|
|
361
362
|
| `APPLE_MAIL_MCP_SMTP_HOST` | SMTP host; setting it enables `transport:"smtp"`. |
|
|
362
363
|
| `APPLE_MAIL_MCP_SMTP_PORT` | SMTP port (`465` if secure, else `587`). |
|
|
363
364
|
| `APPLE_MAIL_MCP_SMTP_SECURE` | `true` for implicit TLS (465); else STARTTLS. |
|
package/package.json
CHANGED