proton-mail-bridge-client 2.7.2 → 2.8.0

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.
Files changed (59) hide show
  1. package/README.md +16 -9
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +87 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +368 -27
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/setup-claude-desktop.d.ts +12 -0
  11. package/dist/scripts/setup-claude-desktop.d.ts.map +1 -1
  12. package/dist/scripts/setup-claude-desktop.js +43 -2
  13. package/dist/scripts/setup-claude-desktop.js.map +1 -1
  14. package/dist/services/account-manager.d.ts +2 -0
  15. package/dist/services/account-manager.d.ts.map +1 -1
  16. package/dist/services/account-manager.js +3 -0
  17. package/dist/services/account-manager.js.map +1 -1
  18. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  19. package/dist/services/delivery-queue-service.js +7 -6
  20. package/dist/services/delivery-queue-service.js.map +1 -1
  21. package/dist/services/draft-store-service.d.ts.map +1 -1
  22. package/dist/services/draft-store-service.js +10 -9
  23. package/dist/services/draft-store-service.js.map +1 -1
  24. package/dist/services/local-index-service.d.ts +1 -0
  25. package/dist/services/local-index-service.d.ts.map +1 -1
  26. package/dist/services/local-index-service.js +24 -7
  27. package/dist/services/local-index-service.js.map +1 -1
  28. package/dist/services/reply-reminder-service.d.ts +43 -0
  29. package/dist/services/reply-reminder-service.d.ts.map +1 -0
  30. package/dist/services/reply-reminder-service.js +117 -0
  31. package/dist/services/reply-reminder-service.js.map +1 -0
  32. package/dist/services/simple-imap-service.d.ts +2 -0
  33. package/dist/services/simple-imap-service.d.ts.map +1 -1
  34. package/dist/services/simple-imap-service.js +33 -12
  35. package/dist/services/simple-imap-service.js.map +1 -1
  36. package/dist/services/smtp-service.d.ts.map +1 -1
  37. package/dist/services/smtp-service.js +1 -0
  38. package/dist/services/smtp-service.js.map +1 -1
  39. package/dist/services/snooze-service.d.ts.map +1 -1
  40. package/dist/services/snooze-service.js +5 -4
  41. package/dist/services/snooze-service.js.map +1 -1
  42. package/dist/services/template-service.d.ts.map +1 -1
  43. package/dist/services/template-service.js +3 -2
  44. package/dist/services/template-service.js.map +1 -1
  45. package/dist/types/index.d.ts +5 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/utils/helpers.d.ts +1 -0
  48. package/dist/utils/helpers.d.ts.map +1 -1
  49. package/dist/utils/helpers.js +59 -13
  50. package/dist/utils/helpers.js.map +1 -1
  51. package/dist/utils/ical-reply.d.ts +46 -0
  52. package/dist/utils/ical-reply.d.ts.map +1 -0
  53. package/dist/utils/ical-reply.js +236 -0
  54. package/dist/utils/ical-reply.js.map +1 -0
  55. package/dist/utils/own-record.d.ts +2 -0
  56. package/dist/utils/own-record.d.ts.map +1 -0
  57. package/dist/utils/own-record.js +6 -0
  58. package/dist/utils/own-record.js.map +1 -0
  59. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail,
18
18
  import { AccountManager } from "./services/account-manager.js";
19
19
  import { logger } from "./utils/logger.js";
20
20
  import { ensureDestructiveConfirmed, ensureEmailActionAllowed, ensureFlagChangeAllowed, ensureMailboxWriteAllowed, ensureOutboundRecipientsAllowed, ensureRemoteDraftSyncAllowed, ensureSendAllowed, resolveRemoteDraftSync, sanitizeRuntimeConfig, } from "./utils/runtime-policy.js";
21
+ import { InviteError, buildInviteReply, findMyAttendee, parseInvite } from "./utils/ical-reply.js";
21
22
  const require = createRequire(import.meta.url);
22
23
  const packageJson = require("../package.json");
23
24
  const PACKAGE_VERSION = packageJson.version ?? process.env.npm_package_version ?? "0.0.0";
@@ -33,6 +34,11 @@ const ALL_EMAIL_ACTIONS = [
33
34
  "move",
34
35
  "delete",
35
36
  ];
37
+ // Triage and statistics tools cover every configured account unless told which one.
38
+ const ACCOUNT_FILTER_PROPERTY = {
39
+ type: "string",
40
+ description: "Only this account (its address or slug). Without it the tool covers every configured account.",
41
+ };
36
42
  const TOOLS = [
37
43
  {
38
44
  name: "send_email",
@@ -100,6 +106,7 @@ const TOOLS = [
100
106
  inputSchema: {
101
107
  type: "object",
102
108
  properties: {
109
+ account: ACCOUNT_FILTER_PROPERTY,
103
110
  status: { type: "string", enum: ["pending", "sending", "sent", "canceled", "failed"], description: "Filter to one status. Omit to list everything." },
104
111
  limit: { type: "number", description: "Records per page, newest first. Default 50.", default: 50 },
105
112
  offset: { type: "number", description: "Skip this many records (paging). Check hasMore for more." },
@@ -146,6 +153,61 @@ const TOOLS = [
146
153
  required: ["to"],
147
154
  },
148
155
  },
156
+ {
157
+ name: "set_reply_reminder",
158
+ description: "Remind yourself to follow up if nobody answers a message by a date. Usually used on a message you sent: it is kept as a local note, and get_inbox_digest and list_reply_reminders show it as due once the date has passed and nobody but you has written in the thread. A reply cancels the wait by itself. Setting a reminder again for the same message moves it to the new date. Changes nothing in the mailbox. Use get_follow_up_candidates instead to find sent threads that have been unanswered for a while without setting anything.",
159
+ annotations: { destructiveHint: false },
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ emailId: { type: "string", description: "The message to be answered (typically one from Sent)." },
164
+ afterDays: { type: "number", description: "Remind after this many days (1-365). Default 3. Give this or remindAt." },
165
+ remindAt: { type: "string", description: "Remind at this date and time (ISO 8601, in the future). Give this or afterDays." },
166
+ note: { type: "string", description: "Optional note shown with the reminder (up to 500 characters)." },
167
+ },
168
+ required: ["emailId"],
169
+ },
170
+ },
171
+ {
172
+ name: "list_reply_reminders",
173
+ description: "List your reply reminders with their state: waiting (the date has not come), due (the date has passed and nobody has answered) or answered (someone other than you wrote in the thread after the reminder was set). By default only waiting and due ones are listed, due first. The state is worked out from the local index when you ask; run sync_emails first if the index may be stale.",
174
+ annotations: { readOnlyHint: true },
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ account: ACCOUNT_FILTER_PROPERTY,
179
+ status: { type: "string", enum: ["open", "due", "answered", "all"], description: "open = waiting and due (default).", default: "open" },
180
+ limit: { type: "number", description: "Maximum reminders to return.", default: 50 },
181
+ },
182
+ },
183
+ },
184
+ {
185
+ name: "cancel_reply_reminder",
186
+ description: "Delete a reply reminder by the id set_reply_reminder or list_reply_reminders gave.",
187
+ annotations: { destructiveHint: false },
188
+ inputSchema: {
189
+ type: "object",
190
+ properties: { id: { type: "string", description: "Reminder id." } },
191
+ required: ["id"],
192
+ },
193
+ },
194
+ {
195
+ name: "respond_to_invite",
196
+ description: "Answer a calendar invitation in a message: accept, decline or tentatively accept. Reads the invitation (the text/calendar attachment of emailId), then sends the organizer a standard calendar reply (iTIP METHOD:REPLY) from the address the invitation was sent to, so their calendar updates your status. Refuses a cancelled event, a message that is not an invitation, and an invitation without an organizer. Use dryRun to see who would be answered and what the reply says before sending. Subject to the same send settings as reply_to_email (PROTONMAIL_ALLOW_SEND, confirmation, RESTRICT_OUTBOUND_TO_SELF, send delay). Use get_email_by_id or list_attachments first if unsure whether a message holds an invitation.",
197
+ annotations: { destructiveHint: true },
198
+ inputSchema: {
199
+ type: "object",
200
+ properties: {
201
+ emailId: { type: "string", description: "The message that holds the invitation." },
202
+ response: { type: "string", enum: ["accept", "decline", "tentative"], description: "Your answer." },
203
+ comment: { type: "string", description: "Optional note to the organizer (up to 1000 characters)." },
204
+ dryRun: { type: "boolean", description: "Show who would be answered and what the reply says, without sending.", default: false },
205
+ undoWindowSeconds: { type: "number", description: "Override PROTONMAIL_SEND_DELAY_SECONDS for this one send: queue it for this many seconds (cancelable via cancel_send). 0 sends immediately." },
206
+ confirmed: { type: "boolean", description: "Set to true to confirm this irreversible send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
207
+ },
208
+ required: ["emailId", "response"],
209
+ },
210
+ },
149
211
  {
150
212
  name: "reply_to_email",
151
213
  description: "Immediately send a reply to an existing email, threading it correctly via In-Reply-To and References headers. Use when you have an emailId and want to send the reply right away. Prefer create_reply_draft to save the reply for review first, or create_thread_reply_draft when replying from a threadId. Use reply_all_email to reply to all original recipients. Provide body (plain text, or HTML with isHtml) or markdownBody. Requires PROTONMAIL_ALLOW_SEND.",
@@ -396,6 +458,7 @@ const TOOLS = [
396
458
  inputSchema: {
397
459
  type: "object",
398
460
  properties: {
461
+ account: ACCOUNT_FILTER_PROPERTY,
399
462
  includeSent: { type: "boolean", description: "Include drafts already sent.", default: false },
400
463
  limit: { type: "number", description: "Maximum drafts to return. Defaults to 20; check hasMore and page with offset for more." },
401
464
  offset: { type: "number", description: "Number of drafts to skip, newest-updated first.", default: 0 },
@@ -409,6 +472,7 @@ const TOOLS = [
409
472
  inputSchema: {
410
473
  type: "object",
411
474
  properties: {
475
+ account: ACCOUNT_FILTER_PROPERTY,
412
476
  limit: { type: "number", description: "Maximum drafts to return.", default: 50 },
413
477
  offset: { type: "number", description: "Pagination offset.", default: 0 },
414
478
  },
@@ -542,6 +606,7 @@ const TOOLS = [
542
606
  inputSchema: {
543
607
  type: "object",
544
608
  properties: {
609
+ account: ACCOUNT_FILTER_PROPERTY,
545
610
  folder: { type: "string", description: "Folder name.", default: "INBOX" },
546
611
  limit: { type: "number", description: "Number of emails to return (1-250; larger values are capped at 250, use hasMore and offset/beforeUid to page).", default: 50, maximum: 250 },
547
612
  offset: { type: "number", description: "Pagination offset from newest first.", default: 0 },
@@ -623,7 +688,7 @@ const TOOLS = [
623
688
  name: "get_folders",
624
689
  description: "Return all mailbox folders with message counts and unseen counts from the live IMAP session. Use to discover available folder names before targeting get_emails, move_email, or create_folder. Prefer sync_folders to force a fresh fetch when the folder list appears stale. Folders with noselect:true cannot be used for IMAP operations.",
625
690
  annotations: { readOnlyHint: true },
626
- inputSchema: { type: "object", properties: {} },
691
+ inputSchema: { type: "object", properties: { account: ACCOUNT_FILTER_PROPERTY } },
627
692
  },
628
693
  {
629
694
  name: "sync_folders",
@@ -785,6 +850,7 @@ const TOOLS = [
785
850
  inputSchema: {
786
851
  type: "object",
787
852
  properties: {
853
+ account: ACCOUNT_FILTER_PROPERTY,
788
854
  status: { type: "string", enum: ["pending", "waking", "woken", "canceled", "failed"], description: "Filter to one status. Omit to list everything (waking = being moved back right now)." },
789
855
  },
790
856
  },
@@ -915,6 +981,7 @@ const TOOLS = [
915
981
  inputSchema: {
916
982
  type: "object",
917
983
  properties: {
984
+ account: ACCOUNT_FILTER_PROPERTY,
918
985
  folder: { type: "string", description: "Folder to count in. Defaults to INBOX." },
919
986
  query: { type: "string", description: "Free-text filter." },
920
987
  from: { type: "string", description: "Sender filter." },
@@ -939,6 +1006,7 @@ const TOOLS = [
939
1006
  inputSchema: {
940
1007
  type: "object",
941
1008
  properties: {
1009
+ account: ACCOUNT_FILTER_PROPERTY,
942
1010
  folder: { type: "string", description: "Folder path. Defaults to INBOX.", default: "INBOX" },
943
1011
  },
944
1012
  },
@@ -1032,6 +1100,7 @@ const TOOLS = [
1032
1100
  inputSchema: {
1033
1101
  type: "object",
1034
1102
  properties: {
1103
+ account: ACCOUNT_FILTER_PROPERTY,
1035
1104
  folder: { type: "string", description: "Folder to analyse. Defaults to INBOX." },
1036
1105
  since: { type: "string", description: "ISO date lower bound." },
1037
1106
  before: { type: "string", description: "ISO date upper bound." },
@@ -1225,6 +1294,7 @@ const TOOLS = [
1225
1294
  inputSchema: {
1226
1295
  type: "object",
1227
1296
  properties: {
1297
+ account: ACCOUNT_FILTER_PROPERTY,
1228
1298
  days: { type: "number", description: "Trailing days window.", default: 30 },
1229
1299
  limit: { type: "number", description: "Maximum messages to sample.", default: 2000 },
1230
1300
  },
@@ -1237,6 +1307,7 @@ const TOOLS = [
1237
1307
  inputSchema: {
1238
1308
  type: "object",
1239
1309
  properties: {
1310
+ account: ACCOUNT_FILTER_PROPERTY,
1240
1311
  days: { type: "number", description: "Trailing days window.", default: 30 },
1241
1312
  limit: { type: "number", description: "Maximum messages to sample.", default: 2000 },
1242
1313
  },
@@ -1249,6 +1320,7 @@ const TOOLS = [
1249
1320
  inputSchema: {
1250
1321
  type: "object",
1251
1322
  properties: {
1323
+ account: ACCOUNT_FILTER_PROPERTY,
1252
1324
  limit: { type: "number", description: "Maximum contacts to return.", default: 100 },
1253
1325
  },
1254
1326
  },
@@ -1260,6 +1332,7 @@ const TOOLS = [
1260
1332
  inputSchema: {
1261
1333
  type: "object",
1262
1334
  properties: {
1335
+ account: ACCOUNT_FILTER_PROPERTY,
1263
1336
  days: { type: "number", description: "Number of trailing days to include.", default: 30 },
1264
1337
  },
1265
1338
  },
@@ -1356,7 +1429,7 @@ const TOOLS = [
1356
1429
  name: "get_index_status",
1357
1430
  description: "Return metadata about the local SQLite email index: row count, last sync timestamp, index schema version, and per-folder coverage. Use to verify the index is fresh and complete before querying it with search_indexed_emails or get_threads. If the index is empty or stale, call sync_emails first.",
1358
1431
  annotations: { readOnlyHint: true },
1359
- inputSchema: { type: "object", properties: {} },
1432
+ inputSchema: { type: "object", properties: { account: ACCOUNT_FILTER_PROPERTY } },
1360
1433
  },
1361
1434
  {
1362
1435
  name: "search_indexed_emails",
@@ -1393,6 +1466,7 @@ const TOOLS = [
1393
1466
  inputSchema: {
1394
1467
  type: "object",
1395
1468
  properties: {
1469
+ account: ACCOUNT_FILTER_PROPERTY,
1396
1470
  limit: { type: "number", description: "Maximum labels to return. Capped at 250; use get_folders for the complete live folder list.", default: 250 },
1397
1471
  },
1398
1472
  },
@@ -1404,6 +1478,7 @@ const TOOLS = [
1404
1478
  inputSchema: {
1405
1479
  type: "object",
1406
1480
  properties: {
1481
+ account: ACCOUNT_FILTER_PROPERTY,
1407
1482
  query: { type: "string", description: "Free-text filter across subject, participants, and labels." },
1408
1483
  label: { type: "string", description: "Require a normalized label on the thread." },
1409
1484
  folder: { type: "string", description: "Only threads with a message in this folder, by its exact path as get_folders lists it (e.g. INBOX, Archive, Trash, Folders/Receipts). With several accounts it applies to each." },
@@ -1419,6 +1494,7 @@ const TOOLS = [
1419
1494
  inputSchema: {
1420
1495
  type: "object",
1421
1496
  properties: {
1497
+ account: ACCOUNT_FILTER_PROPERTY,
1422
1498
  query: { type: "string", description: "Free-text filter across subject, latest preview, senders, and labels." },
1423
1499
  label: { type: "string", description: "Require a normalized label on the thread." },
1424
1500
  pendingOn: {
@@ -1449,6 +1525,7 @@ const TOOLS = [
1449
1525
  inputSchema: {
1450
1526
  type: "object",
1451
1527
  properties: {
1528
+ account: ACCOUNT_FILTER_PROPERTY,
1452
1529
  limit: { type: "number", description: "Maximum threads per digest section.", default: 10 },
1453
1530
  offset: { type: "number", description: "Skip this many threads in each section (use paging.<section>.nextOffset from the previous response to continue).", default: 0 },
1454
1531
  minAgeHours: {
@@ -1471,6 +1548,7 @@ const TOOLS = [
1471
1548
  inputSchema: {
1472
1549
  type: "object",
1473
1550
  properties: {
1551
+ account: ACCOUNT_FILTER_PROPERTY,
1474
1552
  limit: { type: "number", description: "Maximum candidate threads to return.", default: 25 },
1475
1553
  offset: { type: "number", description: "Skip this many candidates (use nextOffset from the previous response to page).", default: 0 },
1476
1554
  minAgeHours: { type: "number", description: "Minimum thread age in hours.", default: 24 },
@@ -1495,6 +1573,7 @@ const TOOLS = [
1495
1573
  inputSchema: {
1496
1574
  type: "object",
1497
1575
  properties: {
1576
+ account: ACCOUNT_FILTER_PROPERTY,
1498
1577
  category: {
1499
1578
  type: "string",
1500
1579
  enum: ["document", "invoice", "contract", "travel", "calendar"],
@@ -1518,6 +1597,7 @@ const TOOLS = [
1518
1597
  inputSchema: {
1519
1598
  type: "object",
1520
1599
  properties: {
1600
+ account: ACCOUNT_FILTER_PROPERTY,
1521
1601
  person: { type: "string", description: "Person name or email fragment to match." },
1522
1602
  domain: { type: "string", description: "Domain to match, such as example.com." },
1523
1603
  limit: { type: "number", description: "Maximum threads to include.", default: 10 },
@@ -1853,6 +1933,8 @@ const AUDIT_MAX_DEPTH = 8;
1853
1933
  const AUDIT_REDACTED_KEYS = new Set([
1854
1934
  "body", "html", "text", "htmlBody", "textBody", "markdownBody", "customMessage", "notes", "bodyEdits",
1855
1935
  "raw", "rawBase64", "base64", "content", "find", "replace", "signature",
1936
+ // Parts of a received message that a tool result could carry: a preview or attachment text can hold a code or a secret.
1937
+ "preview", "snippet", "attachmentText", "latestPreview",
1856
1938
  ]);
1857
1939
  const AUDIT_SECRET_KEY = /pass(word|wd|phrase)?$|secret|token|api[-_]?key|authoriz|credential|cookie|private[-_]?key/i;
1858
1940
  function sanitizeAuditValue(value, depth = 0, seen = new WeakSet()) {
@@ -1963,6 +2045,58 @@ function parseBodyEdits(value) {
1963
2045
  return { find: edit.find, replace: edit.replace, ...(edit.all === true ? { all: true } : {}) };
1964
2046
  });
1965
2047
  }
2048
+ // Moving a message into the Trash folder is trashing it: trash_email is literally a move there. The move tools
2049
+ // therefore need the "trash" permission as well as "move" when the destination is the Trash folder, or an
2050
+ // ALLOWED_ACTIONS list that leaves out "trash" could be sidestepped by moving to Trash instead.
2051
+ async function ensureMoveTargetAllowed(runtime, imap, targetFolder) {
2052
+ if (!targetFolder)
2053
+ return;
2054
+ // A call that can span accounts is checked against every account's own Trash folder: their names may differ.
2055
+ let unknown = false;
2056
+ for (const candidate of Array.isArray(imap) ? imap : [imap]) {
2057
+ const isTrash = await candidate.isTrashFolder(targetFolder);
2058
+ if (isTrash) {
2059
+ ensureEmailActionAllowed(runtime, "trash");
2060
+ return;
2061
+ }
2062
+ if (isTrash === undefined)
2063
+ unknown = true;
2064
+ }
2065
+ // Not being able to tell must not open a way round the permission: with "trash" not allowed, refuse.
2066
+ if (unknown && !runtime.allowedActions.includes("trash")) {
2067
+ throw new McpError(ErrorCode.InternalError, `Cannot tell whether ${targetFolder} is the Trash folder (the folder list could not be read), and the trash action is not allowed. Try again when Bridge answers.`);
2068
+ }
2069
+ }
2070
+ // Arguments a tool does not declare are ignored (clients send extra fields, and refusing them would break
2071
+ // those clients), but silently: `folder` on get_threads did nothing for a long time and nobody was told. The
2072
+ // result now carries a note naming them, so the caller (usually a model) learns the argument had no effect.
2073
+ export function ignoredArguments(toolName, args) {
2074
+ const tool = TOOLS.find((candidate) => candidate.name === toolName);
2075
+ if (!tool || !args || typeof args !== "object" || Array.isArray(args)) {
2076
+ return [];
2077
+ }
2078
+ const declared = new Set(Object.keys(tool.inputSchema.properties ?? {}));
2079
+ return Object.keys(args).filter((key) => !declared.has(key));
2080
+ }
2081
+ function withIgnoredArgumentsNote(handler) {
2082
+ return async (request) => {
2083
+ const result = await handler(request);
2084
+ const ignored = ignoredArguments(request.params.name, request.params.arguments);
2085
+ if (ignored.length === 0) {
2086
+ return result;
2087
+ }
2088
+ // The names come from the caller; keep the note short however many or however long they are.
2089
+ const shown = ignored.slice(0, 10).map((key) => `"${key.length > 64 ? `${key.slice(0, 64)}...` : key}"`);
2090
+ if (ignored.length > shown.length)
2091
+ shown.push(`and ${ignored.length - shown.length} more`);
2092
+ logger.warn("Tool call had arguments the tool does not accept", "MCPServer", { name: request.params.name, ignored: shown });
2093
+ const tool = TOOLS.find((candidate) => candidate.name === request.params.name);
2094
+ const accepted = Object.keys(tool?.inputSchema?.properties ?? {});
2095
+ const note = `Note: ${request.params.name} does not accept ${shown.join(", ")}; ignored. ` +
2096
+ (accepted.length > 0 ? `It accepts: ${accepted.join(", ")}.` : "It takes no arguments.");
2097
+ return { ...result, content: [...result.content, { type: "text", text: note }] };
2098
+ };
2099
+ }
1966
2100
  function asObject(value) {
1967
2101
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1968
2102
  return {};
@@ -3728,6 +3862,37 @@ export function createServer(config, options = {}) {
3728
3862
  }
3729
3863
  return found;
3730
3864
  }
3865
+ // The accounts a triage or statistics tool covers: the one named by `account`, else all of them.
3866
+ function selectedBundles(args) {
3867
+ const one = resolveAccountArg(args);
3868
+ return one ? [one] : accountManager.all();
3869
+ }
3870
+ // A reminder as a caller sees it: ids carry the account's prefix, like every other id.
3871
+ function presentReminder(bundle, record) {
3872
+ const slug = responseSlug(bundle);
3873
+ return { ...record, id: withAccountPrefix(slug, record.id), emailId: withAccountPrefix(slug, record.emailId) };
3874
+ }
3875
+ // Each open reminder with its state, from the local index: answered (somebody else wrote in the thread since it
3876
+ // was set), due (the date has passed) or waiting.
3877
+ async function evaluateReminders(bundles) {
3878
+ const own = allAccountAddresses();
3879
+ const now = Date.now();
3880
+ const perAccount = await Promise.all(bundles.map(async (bundle) => {
3881
+ const records = await bundle.replyReminderService.list();
3882
+ return Promise.all(records.map(async (record) => {
3883
+ const replies = await bundle.localIndexService.getRepliesSince(record.messageId, record.createdAt, own);
3884
+ const first = [...replies].sort((left, right) => (left.internalDate || left.date || "").localeCompare(right.internalDate || right.date || ""))[0];
3885
+ const state = first ? "answered" : new Date(record.remindAt).getTime() <= now ? "due" : "waiting";
3886
+ return {
3887
+ ...presentReminder(bundle, record),
3888
+ state,
3889
+ ...(state === "due" ? { daysOverdue: Math.floor((now - new Date(record.remindAt).getTime()) / 86_400_000) } : {}),
3890
+ ...(first ? { answeredAt: first.internalDate || first.date, answeredBy: first.from[0]?.address } : {}),
3891
+ };
3892
+ }));
3893
+ }));
3894
+ return perAccount.flat();
3895
+ }
3731
3896
  // Every configured account address — reply helpers treat all of them as "self".
3732
3897
  function allAccountAddresses() {
3733
3898
  return accountManager.all().map((bundle) => bundle.account.address);
@@ -3980,7 +4145,7 @@ export function createServer(config, options = {}) {
3980
4145
  }
3981
4146
  }
3982
4147
  });
3983
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4148
+ server.setRequestHandler(CallToolRequestSchema, withIgnoredArgumentsNote(async (request) => {
3984
4149
  const name = request.params.name;
3985
4150
  const args = asObject(request.params.arguments);
3986
4151
  logger.debug("Handling tool call", "MCPServer", { name, argKeys: Object.keys(args || {}) });
@@ -4114,7 +4279,7 @@ export function createServer(config, options = {}) {
4114
4279
  // a draft — see redactQueueRecordAttachments' comment. Redacted for every
4115
4280
  // record regardless of status (including canceled ones, which otherwise
4116
4281
  // stayed just as bloated once queued).
4117
- const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
4282
+ const perAccount = await withAudit(auditService, name, args, async () => Promise.all(selectedBundles(args).map(async (bundle) => ({
4118
4283
  bundle,
4119
4284
  items: await bundle.deliveryQueueService.list(),
4120
4285
  }))));
@@ -4397,6 +4562,151 @@ export function createServer(config, options = {}) {
4397
4562
  sentCopy: sentCopyTokenRa,
4398
4563
  }, false, [emailSource({ ...detailRa, id: prefixedIdFor(readBundleRa, detailRa.id) })]);
4399
4564
  }
4565
+ case "set_reply_reminder": {
4566
+ ensureMailboxWriteAllowed(config.runtime);
4567
+ const { bundle: reminderBundle, rest: reminderEmailId } = resolveAccountForEmailId(requireString(args, "emailId"));
4568
+ const afterDays = optionalNumber(args.afterDays, 1, 365);
4569
+ const remindAtArg = optionalString(args, "remindAt");
4570
+ if (afterDays !== undefined && remindAtArg !== undefined) {
4571
+ throw new McpError(ErrorCode.InvalidParams, "Give afterDays or remindAt, not both.");
4572
+ }
4573
+ const now = new Date();
4574
+ const remindAt = remindAtArg !== undefined ? new Date(remindAtArg) : new Date(now.getTime() + (afterDays ?? 3) * 24 * 60 * 60 * 1000);
4575
+ if (Number.isNaN(remindAt.getTime())) {
4576
+ throw new McpError(ErrorCode.InvalidParams, "remindAt must be a date and time such as 2026-10-20T09:00:00Z.");
4577
+ }
4578
+ const reminderDetail = await reminderBundle.imapService.getEmailById(reminderEmailId);
4579
+ if (!reminderDetail.messageId) {
4580
+ throw new McpError(ErrorCode.InvalidParams, "This message has no Message-ID header, so replies to it cannot be recognised.");
4581
+ }
4582
+ const record = await withAudit(reminderBundle.auditService, name, args, () => reminderBundle.replyReminderService.set({
4583
+ emailId: reminderEmailId,
4584
+ messageId: reminderDetail.messageId,
4585
+ subject: reminderDetail.subject,
4586
+ to: reminderDetail.to.map((address) => address.address ?? "").filter(Boolean),
4587
+ remindAt,
4588
+ note: optionalString(args, "note"),
4589
+ now,
4590
+ }));
4591
+ return createTextResult({ ...presentReminder(reminderBundle, record), state: "waiting" });
4592
+ }
4593
+ case "list_reply_reminders": {
4594
+ const status = optionalString(args, "status") ?? "open";
4595
+ if (!["open", "due", "answered", "all"].includes(status)) {
4596
+ throw new McpError(ErrorCode.InvalidParams, "status must be open, due, answered or all.");
4597
+ }
4598
+ const limit = normalizeLimit(args.limit, 50, 1, 500);
4599
+ const bundles = selectedBundles(args);
4600
+ await Promise.all(bundles.map((bundle) => maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, { force: false, folder: "INBOX", limitPerFolder: 100 })));
4601
+ const all = await evaluateReminders(bundles);
4602
+ const wanted = all.filter((reminder) => (status === "all" ? true : status === "open" ? reminder.state !== "answered" : reminder.state === status));
4603
+ const rank = { due: 0, waiting: 1, answered: 2 };
4604
+ wanted.sort((left, right) => rank[left.state] - rank[right.state] || left.remindAt.localeCompare(right.remindAt));
4605
+ return createTextResult({
4606
+ total: wanted.length,
4607
+ due: all.filter((reminder) => reminder.state === "due").length,
4608
+ reminders: wanted.slice(0, limit),
4609
+ });
4610
+ }
4611
+ case "cancel_reply_reminder": {
4612
+ ensureMailboxWriteAllowed(config.runtime);
4613
+ const { bundle: cancelBundle, rest: reminderId } = resolveAccountForEmailId(requireString(args, "id"));
4614
+ const result = await withAudit(cancelBundle.auditService, name, args, () => cancelBundle.replyReminderService.cancel(reminderId));
4615
+ return createTextResult({ id: requireString(args, "id"), canceled: result.canceled });
4616
+ }
4617
+ case "respond_to_invite": {
4618
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Answer calendar invitation ${String(args.emailId ?? "?")}`);
4619
+ ensureSendAllowed(config.runtime);
4620
+ const { bundle: inviteBundle, rest: inviteEmailId } = resolveAccountForEmailId(requireString(args, "emailId"));
4621
+ const responseArg = requireString(args, "response");
4622
+ if (responseArg !== "accept" && responseArg !== "decline" && responseArg !== "tentative") {
4623
+ throw new McpError(ErrorCode.InvalidParams, "response must be accept, decline or tentative.");
4624
+ }
4625
+ const inviteComment = optionalString(args, "comment");
4626
+ if (inviteComment !== undefined && inviteComment.length > 1000) {
4627
+ throw new McpError(ErrorCode.InvalidParams, "comment must be at most 1000 characters.");
4628
+ }
4629
+ const inviteDetail = await inviteBundle.imapService.getEmailById(inviteEmailId);
4630
+ const candidates = (inviteDetail.attachments ?? []).filter((attachment) => Boolean(attachment.id) &&
4631
+ (attachment.isCalendarInvite || attachment.kind === "calendar" || /text\/calendar/i.test(attachment.contentType ?? "") || /\.ics$/i.test(attachment.filename ?? "")));
4632
+ if (candidates.length === 0) {
4633
+ throw new McpError(ErrorCode.InvalidParams, "This message has no calendar invitation attachment.");
4634
+ }
4635
+ let invite;
4636
+ let inviteProblem;
4637
+ for (const candidate of candidates) {
4638
+ const content = await inviteBundle.imapService.getAttachmentContent(inviteEmailId, candidate.id, { includeBase64: true });
4639
+ try {
4640
+ invite = parseInvite(Buffer.from(content.base64 ?? "", "base64").toString("utf8"));
4641
+ break;
4642
+ }
4643
+ catch (error) {
4644
+ if (!(error instanceof InviteError))
4645
+ throw error;
4646
+ inviteProblem ??= error.message;
4647
+ }
4648
+ }
4649
+ if (!invite) {
4650
+ throw new McpError(ErrorCode.InvalidParams, inviteProblem ?? "No invitation could be read from this message.");
4651
+ }
4652
+ // Answer as the account that holds the message. Only an attendee address that is exactly one of our
4653
+ // accounts is used; a "+tag" or alias address in a hostile invite never picks the sender.
4654
+ const myAddresses = allAccountAddresses();
4655
+ const myAttendee = findMyAttendee(invite, (address) => myAddresses.some((own) => own.toLowerCase() === address.toLowerCase()));
4656
+ const attendeeBundle = myAttendee ? accountManager.byAddress(myAttendee.address) : undefined;
4657
+ const inviteSendBundle = attendeeBundle ?? inviteBundle;
4658
+ const answeringAs = inviteSendBundle.config.smtp.username;
4659
+ const inviteReply = buildInviteReply(invite, {
4660
+ attendeeAddress: answeringAs,
4661
+ attendeeName: myAttendee?.name,
4662
+ response: responseArg,
4663
+ comment: inviteComment,
4664
+ });
4665
+ const inviteTo = [inviteReply.to];
4666
+ ensureValidEmails(inviteTo, "to");
4667
+ ensureOutboundRecipientsAllowed(config.runtime, inviteSendBundle.config.smtp.username, inviteTo);
4668
+ const senderDomains = (inviteDetail.from ?? []).map((entry) => (entry.address ?? "").split("@").pop()?.toLowerCase());
4669
+ const organizerDomain = invite.organizer.address.split("@").pop()?.toLowerCase();
4670
+ const organizerMismatch = !senderDomains.includes(organizerDomain);
4671
+ const inviteSummary = {
4672
+ event: invite.summary ?? "(no title)",
4673
+ ...(organizerMismatch
4674
+ ? { warning: `The organizer (${invite.organizer.address}) is not the sender of this message; the answer goes to the organizer, so check that you trust them.` }
4675
+ : {}),
4676
+ organizer: invite.organizer.address,
4677
+ answeringAs,
4678
+ response: responseArg,
4679
+ previousStatus: myAttendee?.partstat,
4680
+ ...(invite.hasRecurrence ? { note: "This invitation repeats; the answer applies to the whole series." } : {}),
4681
+ };
4682
+ if (normalizeBoolean(args.dryRun, false)) {
4683
+ return createTextResult({ dryRun: true, wouldSendTo: inviteTo, subject: inviteReply.subject, ...inviteSummary, replyCalendar: inviteReply.ics.split("\r\n").filter(Boolean), note: "No email was sent." });
4684
+ }
4685
+ const invitePayload = {
4686
+ to: inviteTo,
4687
+ subject: inviteReply.subject,
4688
+ body: inviteReply.body,
4689
+ from: inviteSendBundle.config.smtp.username === answeringAs ? undefined : answeringAs,
4690
+ inReplyTo: inviteDetail.messageId,
4691
+ references: replyReferences(inviteDetail),
4692
+ icalEvent: { method: "REPLY", content: inviteReply.ics },
4693
+ appendSignature: false,
4694
+ };
4695
+ const inviteUndo = resolveUndoWindowSeconds(args);
4696
+ if (inviteUndo > 0) {
4697
+ return queueUndoSend(inviteSendBundle, invitePayload, inviteUndo, name, args, { to: inviteTo, ...inviteSummary });
4698
+ }
4699
+ const inviteResult = await withAudit(auditService, name, args, async () => inviteSendBundle.smtpService.sendEmail(invitePayload));
4700
+ return createTextResult({
4701
+ ...inviteSummary,
4702
+ to: inviteTo,
4703
+ subject: inviteReply.subject,
4704
+ messageId: inviteResult.messageId,
4705
+ accepted: inviteResult.accepted,
4706
+ rejected: inviteResult.rejected,
4707
+ response_: inviteResult.response,
4708
+ }, false, [emailSource({ ...inviteDetail, id: prefixedIdFor(inviteBundle, inviteDetail.id) })]);
4709
+ }
4400
4710
  case "forward_email": {
4401
4711
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
4402
4712
  ensureSendAllowed(config.runtime);
@@ -4760,9 +5070,10 @@ export function createServer(config, options = {}) {
4760
5070
  // account's own list already arrives that way). Single-account guard
4761
5071
  // keeps byte-identical output for the common case.
4762
5072
  const includeSent = normalizeBoolean(args.includeSent, false);
5073
+ resolveAccountArg(args);
4763
5074
  const allDrafts = accountManager.all().length === 1
4764
5075
  ? (await draftStore.listDrafts(includeSent)).map((draft) => ({ bundle: primaryBundle, draft }))
4765
- : (await Promise.all(accountManager.all().map(async (bundle) => {
5076
+ : (await Promise.all(selectedBundles(args).map(async (bundle) => {
4766
5077
  const drafts = await bundle.draftStore.listDrafts(includeSent);
4767
5078
  return drafts.map((draft) => ({ bundle, draft: presentDraft(bundle, draft) }));
4768
5079
  })))
@@ -4789,7 +5100,11 @@ export function createServer(config, options = {}) {
4789
5100
  case "list_remote_drafts": {
4790
5101
  // Scope limitation (follow-up round): stays scoped to the primary account's
4791
5102
  // remote Drafts mailbox only — see list_drafts' identical note.
4792
- const result = await imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
5103
+ // `account` picks another account's Drafts mailbox; its ids carry that account's prefix like everywhere else.
5104
+ const draftsBundle = resolveAccountArg(args) ?? primaryBundle;
5105
+ const draftsSlug = responseSlug(draftsBundle);
5106
+ const rawResult = await draftsBundle.imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
5107
+ const result = { ...rawResult, emails: rawResult.emails.map((email) => ({ ...email, id: withAccountPrefix(draftsSlug, email.id) })) };
4793
5108
  // projectFields (called with no fields arg) still trims each email's
4794
5109
  // attachments to the essentials — same fix as every other list-style
4795
5110
  // tool, this one just didn't route through projectFields at all before.
@@ -5267,6 +5582,7 @@ export function createServer(config, options = {}) {
5267
5582
  includeSnippet: normalizeBoolean(args.includeSnippet, false),
5268
5583
  };
5269
5584
  if (accountManager.all().length === 1) {
5585
+ resolveAccountArg(args);
5270
5586
  const result = await imapService.getEmails(getEmailsInput);
5271
5587
  return createTextResult({
5272
5588
  ...result,
@@ -5308,7 +5624,7 @@ export function createServer(config, options = {}) {
5308
5624
  // than inferring it from a count that can be either capped-by-fetch or
5309
5625
  // filter-blind depending on which one you pick.
5310
5626
  const perAccountFetchLimit = requestedOffset + effectiveLimit + 1;
5311
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5627
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
5312
5628
  const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
5313
5629
  return { bundle, result };
5314
5630
  }));
@@ -5528,6 +5844,7 @@ export function createServer(config, options = {}) {
5528
5844
  sizeSmaller: optionalNumber(args.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
5529
5845
  };
5530
5846
  if (accountManager.all().length === 1) {
5847
+ resolveAccountArg(args);
5531
5848
  const result = await imapService.countMessages(countInput);
5532
5849
  return createTextResult(result);
5533
5850
  }
@@ -5536,7 +5853,7 @@ export function createServer(config, options = {}) {
5536
5853
  // folder name), with a per-account breakdown alongside the total.
5537
5854
  // One account lacking the folder or label (or unreachable) must not fail the whole count, as in
5538
5855
  // search_emails: report it and return the rest. Every account failing still throws.
5539
- const settled = await Promise.allSettled(accountManager.all().map(async (bundle) => ({
5856
+ const settled = await Promise.allSettled(selectedBundles(args).map(async (bundle) => ({
5540
5857
  slug: bundle.account.slug,
5541
5858
  ...(await bundle.imapService.countMessages(countInput)),
5542
5859
  })));
@@ -5545,7 +5862,7 @@ export function createServer(config, options = {}) {
5545
5862
  throw settled[0].reason;
5546
5863
  }
5547
5864
  const failedAccounts = settled.flatMap((entry, index) => entry.status === "rejected"
5548
- ? [{ account: accountManager.all()[index].account.slug, error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason) }]
5865
+ ? [{ account: selectedBundles(args)[index].account.slug, error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason) }]
5549
5866
  : []);
5550
5867
  return createTextResult({
5551
5868
  folder: countInput.folder ?? "INBOX",
@@ -5558,13 +5875,14 @@ export function createServer(config, options = {}) {
5558
5875
  case "folder_stats": {
5559
5876
  const folderStatsFolder = optionalString(args, "folder");
5560
5877
  if (accountManager.all().length === 1) {
5878
+ resolveAccountArg(args);
5561
5879
  const result = await imapService.getFolderStats(folderStatsFolder);
5562
5880
  return createTextResult(result);
5563
5881
  }
5564
5882
  // Merge strategy: fetch stats for the same folder in every account and
5565
5883
  // sum the numeric fields (total/unseen); uidNext/uidValidity are
5566
5884
  // per-account concepts and only make sense in the breakdown.
5567
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
5885
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
5568
5886
  slug: bundle.account.slug,
5569
5887
  ...(await bundle.imapService.getFolderStats(folderStatsFolder)),
5570
5888
  })));
@@ -5610,6 +5928,7 @@ export function createServer(config, options = {}) {
5610
5928
  throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
5611
5929
  const folder = optionalString(args, "folder") ?? "INBOX";
5612
5930
  const targetFolder = requireString(args, "targetFolder");
5931
+ await ensureMoveTargetAllowed(config.runtime, accountManager.all().map((entry) => entry.imapService), targetFolder);
5613
5932
  const max = getBulkMaxBatchSize(args);
5614
5933
  const dryRun = normalizeBoolean(args.dryRun, false);
5615
5934
  // emailIds can span multiple configured accounts (a "<slug>::"
@@ -5883,6 +6202,7 @@ export function createServer(config, options = {}) {
5883
6202
  excludeSelf: normalizeBoolean(args.excludeSelf, true),
5884
6203
  };
5885
6204
  if (accountManager.all().length === 1) {
6205
+ resolveAccountArg(args);
5886
6206
  const result = await imapService.topSenders(topSendersInput);
5887
6207
  return createTextResult(result);
5888
6208
  }
@@ -5891,7 +6211,7 @@ export function createServer(config, options = {}) {
5891
6211
  // re-sort and re-apply the requested top-N limit over the merged set.
5892
6212
  // Each account is asked for its WHOLE table, not just its own top N: a sender in the middle of every
5893
6213
  // account's table can still be first in total, and cutting each table first made it disappear.
5894
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
6214
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
5895
6215
  bundle,
5896
6216
  result: await bundle.imapService.topSenders({ ...topSendersInput, limit: 10_000 }),
5897
6217
  })));
@@ -5927,6 +6247,7 @@ export function createServer(config, options = {}) {
5927
6247
  // (e.g. a match/threading key that could resolve differently per
5928
6248
  // account) is out of scope for this pass.
5929
6249
  const { bundle, rest: messageId } = resolveAccountForEmailId(requireString(args, "messageId"));
6250
+ await ensureMoveTargetAllowed(config.runtime, bundle.imapService, requireString(args, "destination"));
5930
6251
  const result = await withAudit(bundle.auditService, name, args, () => bundle.imapService.moveThread({
5931
6252
  messageId,
5932
6253
  destination: requireString(args, "destination"),
@@ -6009,6 +6330,7 @@ export function createServer(config, options = {}) {
6009
6330
  }
6010
6331
  case "get_folders": {
6011
6332
  if (accountManager.all().length === 1) {
6333
+ resolveAccountArg(args);
6012
6334
  return createTextResult(await imapService.getFolders());
6013
6335
  }
6014
6336
  // Merge strategy: fetch each account's own folder list (LIVE IMAP) and
@@ -6016,7 +6338,7 @@ export function createServer(config, options = {}) {
6016
6338
  // which is left bare) so the merged list stays unambiguous while a
6017
6339
  // single-account setup's own folder paths are completely untouched.
6018
6340
  const primarySlug = accountManager.primary().account.slug;
6019
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({ bundle, folders: await bundle.imapService.getFolders() })));
6341
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({ bundle, folders: await bundle.imapService.getFolders() })));
6020
6342
  const merged = perAccount.flatMap(({ bundle, folders }) => folders.map((folder) => {
6021
6343
  const slug = accountSlugForTag(bundle, primarySlug);
6022
6344
  return slug ? { ...folder, path: `${slug}::${folder.path}`, account: slug } : folder;
@@ -6055,6 +6377,7 @@ export function createServer(config, options = {}) {
6055
6377
  const rawEmailId = requireString(args, "emailId");
6056
6378
  const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6057
6379
  const targetFolder = requireString(args, "targetFolder");
6380
+ await ensureMoveTargetAllowed(config.runtime, bundle.imapService, targetFolder);
6058
6381
  const uidValidity = parseEmailId(emailId).uidValidity;
6059
6382
  const result = await withAudit(auditService, name, args, async () => {
6060
6383
  try {
@@ -6128,6 +6451,7 @@ export function createServer(config, options = {}) {
6128
6451
  ensureEmailActionAllowed(config.runtime, "restore");
6129
6452
  const rawEmailId = requireString(args, "emailId");
6130
6453
  const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6454
+ await ensureMoveTargetAllowed(config.runtime, bundle.imapService, optionalString(args, "targetFolder"));
6131
6455
  const uidValidity = parseEmailId(emailId).uidValidity;
6132
6456
  const result = await withAudit(auditService, name, args, async () => bundle.imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
6133
6457
  const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
@@ -6190,11 +6514,12 @@ export function createServer(config, options = {}) {
6190
6514
  // independent snoozeService/JSON file. Single-account guard keeps
6191
6515
  // byte-identical output for the common case.
6192
6516
  if (accountManager.all().length === 1) {
6517
+ resolveAccountArg(args);
6193
6518
  const all = await withAudit(auditService, name, args, async () => snoozeService.list());
6194
6519
  const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
6195
6520
  return createTextResult(filtered);
6196
6521
  }
6197
- const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
6522
+ const perAccount = await withAudit(auditService, name, args, async () => Promise.all(selectedBundles(args).map(async (bundle) => ({
6198
6523
  bundle,
6199
6524
  items: await bundle.snoozeService.list(),
6200
6525
  }))));
@@ -6276,6 +6601,9 @@ export function createServer(config, options = {}) {
6276
6601
  const continueOnError = normalizeBoolean(args.continueOnError, true);
6277
6602
  const dryRun = normalizeBoolean(args.dryRun, false);
6278
6603
  const targetFolder = optionalString(args, "targetFolder");
6604
+ if (action === "move" || action === "restore") {
6605
+ await ensureMoveTargetAllowed(config.runtime, accountManager.all().map((entry) => entry.imapService), targetFolder);
6606
+ }
6279
6607
  const groups = groupEmailIdsByAccount(accountManager, emailIds);
6280
6608
  const groupResults = [];
6281
6609
  for (const [slug, group] of groups) {
@@ -6316,6 +6644,7 @@ export function createServer(config, options = {}) {
6316
6644
  const statsDays = normalizeLimit(args.days, 30, 1, 365);
6317
6645
  const statsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
6318
6646
  if (accountManager.all().length === 1) {
6647
+ resolveAccountArg(args);
6319
6648
  const folders = await imapService.getFolders();
6320
6649
  const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, statsDays, statsLimit);
6321
6650
  return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
@@ -6325,7 +6654,7 @@ export function createServer(config, options = {}) {
6325
6654
  // numeric mailbox/sample fields and merge folder lists (non-primary
6326
6655
  // folder paths tagged with their account slug to avoid collisions).
6327
6656
  const primarySlugForStats = accountManager.primary().account.slug;
6328
- const statsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6657
+ const statsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6329
6658
  const folders = await bundle.imapService.getFolders();
6330
6659
  const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, statsDays, statsLimit);
6331
6660
  return {
@@ -6357,6 +6686,7 @@ export function createServer(config, options = {}) {
6357
6686
  const analyticsDays = normalizeLimit(args.days, 30, 1, 365);
6358
6687
  const analyticsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
6359
6688
  if (accountManager.all().length === 1) {
6689
+ resolveAccountArg(args);
6360
6690
  const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, analyticsDays, analyticsLimit);
6361
6691
  return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username, analyticsDays));
6362
6692
  }
@@ -6365,7 +6695,7 @@ export function createServer(config, options = {}) {
6365
6695
  // top-N breakdowns (hours/senders/domains) by summing counts per key
6366
6696
  // and re-sorting, and recompute busiestDay/insights from the merged
6367
6697
  // results.
6368
- const analyticsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6698
+ const analyticsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6369
6699
  const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, analyticsDays, analyticsLimit);
6370
6700
  return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username, analyticsDays);
6371
6701
  }));
@@ -6404,6 +6734,7 @@ export function createServer(config, options = {}) {
6404
6734
  // meant limit: 5 silently ranked contacts from only 5 messages total.
6405
6735
  const limit = normalizeLimit(args.limit, 100);
6406
6736
  if (accountManager.all().length === 1) {
6737
+ resolveAccountArg(args);
6407
6738
  const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
6408
6739
  return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
6409
6740
  }
@@ -6412,7 +6743,7 @@ export function createServer(config, options = {}) {
6412
6743
  // address across accounts by summing incoming/outgoing/totalMessages
6413
6744
  // and keeping the most recent lastContactAt, and re-apply the
6414
6745
  // requested limit over the merged, re-sorted set.
6415
- const contactsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6746
+ const contactsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6416
6747
  const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, 30, 3000);
6417
6748
  return analyticsService.getContacts(sample, Number.MAX_SAFE_INTEGER, bundle.config.smtp.username);
6418
6749
  }));
@@ -6422,13 +6753,14 @@ export function createServer(config, options = {}) {
6422
6753
  const days = normalizeLimit(args.days, 30, 1, 365);
6423
6754
  // Counted in SQL over the whole index (a sample of the newest 3000 messages cut the older days short).
6424
6755
  if (accountManager.all().length === 1) {
6756
+ resolveAccountArg(args);
6425
6757
  await maybeRefreshLocalIndex(imapService, localIndexService, {});
6426
6758
  return createTextResult(await localIndexService.getDailyVolume(days));
6427
6759
  }
6428
6760
  // Merge strategy: compute each account's own daily trend points
6429
6761
  // independently, then sum count/unreadCount/starredCount/attachmentCount
6430
6762
  // for matching dates across accounts.
6431
- const trendsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6763
+ const trendsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6432
6764
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {});
6433
6765
  return bundle.localIndexService.getDailyVolume(days);
6434
6766
  }));
@@ -6829,8 +7161,9 @@ export function createServer(config, options = {}) {
6829
7161
  }
6830
7162
  case "get_index_status":
6831
7163
  {
6832
- const indexStatus = await localIndexService.getStatus();
6833
- const syncCfg = backgroundSyncService.getStatus();
7164
+ const statusBundle = resolveAccountArg(args) ?? primaryBundle;
7165
+ const indexStatus = await statusBundle.localIndexService.getStatus();
7166
+ const syncCfg = statusBundle.backgroundSyncService.getStatus();
6834
7167
  return createTextResult({
6835
7168
  ...indexStatus,
6836
7169
  // What keeps this index current, so a stale/missing message can be explained:
@@ -6901,6 +7234,7 @@ export function createServer(config, options = {}) {
6901
7234
  {
6902
7235
  const labelsLimit = normalizeLimit(args.limit, 250);
6903
7236
  if (accountManager.all().length === 1) {
7237
+ resolveAccountArg(args);
6904
7238
  const labels = await localIndexService.getLabels(labelsLimit);
6905
7239
  return createTextResult({
6906
7240
  total: labels.length,
@@ -6911,7 +7245,7 @@ export function createServer(config, options = {}) {
6911
7245
  // messageCount/unreadCount/threadCount for labels that share the same
6912
7246
  // name+type across accounts, so e.g. "INBOX" folders from two accounts
6913
7247
  // combine into one row rather than appearing as unrelated duplicates.
6914
- const perAccountLabels = await Promise.all(accountManager.all().map((bundle) => bundle.localIndexService.getLabels(labelsLimit)));
7248
+ const perAccountLabels = await Promise.all(selectedBundles(args).map((bundle) => bundle.localIndexService.getLabels(labelsLimit)));
6915
7249
  const mergedLabels = mergeMailboxLabels(perAccountLabels).slice(0, labelsLimit);
6916
7250
  return createTextResult({
6917
7251
  total: mergedLabels.length,
@@ -6928,6 +7262,7 @@ export function createServer(config, options = {}) {
6928
7262
  offset: optionalInteger(args.offset, 0, 1_000_000),
6929
7263
  };
6930
7264
  if (accountManager.all().length === 1) {
7265
+ resolveAccountArg(args);
6931
7266
  await maybeRefreshLocalIndex(imapService, localIndexService, {
6932
7267
  folder: "INBOX",
6933
7268
  limitPerFolder: 100,
@@ -6941,7 +7276,7 @@ export function createServer(config, options = {}) {
6941
7276
  // re-sort newest-first by latestDate, then re-apply the requested
6942
7277
  // limit across the merged set and sum totals/hasMore.
6943
7278
  const primarySlugForThreads = accountManager.primary().account.slug;
6944
- const threadsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7279
+ const threadsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6945
7280
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
6946
7281
  folder: "INBOX",
6947
7282
  limitPerFolder: 100,
@@ -6985,7 +7320,7 @@ export function createServer(config, options = {}) {
6985
7320
  // makes this correct: any thread in the true global top-`limit` must already
6986
7321
  // rank within its own account's top-`limit`, since a lower rank there would mean
6987
7322
  // at least `limit` better-or-equal threads exist ahead of it account-wide alone.
6988
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7323
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
6989
7324
  const refresh = await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
6990
7325
  force: normalizeBoolean(args.syncBefore, false),
6991
7326
  folder: "INBOX",
@@ -7041,7 +7376,7 @@ export function createServer(config, options = {}) {
7041
7376
  // top `limit` of the MERGED list (never per-account — see get_actionable_threads
7042
7377
  // for why requesting `limit`, not limit/accounts, per account is what makes this
7043
7378
  // correct). `counts` is summed across accounts field-by-field.
7044
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7379
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
7045
7380
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
7046
7381
  force: normalizeBoolean(args.syncBefore, false),
7047
7382
  folder: "INBOX",
@@ -7076,6 +7411,8 @@ export function createServer(config, options = {}) {
7076
7411
  for (const key of countKeys) {
7077
7412
  counts[key] = perAccount.reduce((sum, entry) => sum + (entry.counts[key] ?? 0), 0);
7078
7413
  }
7414
+ // Reply reminders whose date has passed with no answer: they belong in a triage summary.
7415
+ const dueReminders = (await evaluateReminders(selectedBundles(args))).filter((reminder) => reminder.state === "due");
7079
7416
  const result = {
7080
7417
  generatedAt: new Date().toISOString(),
7081
7418
  indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
@@ -7085,6 +7422,7 @@ export function createServer(config, options = {}) {
7085
7422
  : {}),
7086
7423
  topThreads,
7087
7424
  staleAwaitingYou,
7425
+ ...(dueReminders.length > 0 ? { repliesDue: dueReminders.slice(0, 10), repliesDueTotal: dueReminders.length } : {}),
7088
7426
  // Each section pages independently with the same offset/limit: pass nextOffset back as
7089
7427
  // `offset` to continue (a section with hasMore:false is exhausted).
7090
7428
  paging: {
@@ -7104,7 +7442,7 @@ export function createServer(config, options = {}) {
7104
7442
  // score), tag ids with the account's slug, concatenate, re-sort by that same
7105
7443
  // (ageHours, then score) order, and take the top `limit` of the MERGED set —
7106
7444
  // same correctness argument as get_actionable_threads' fan-out.
7107
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7445
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
7108
7446
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
7109
7447
  force: normalizeBoolean(args.syncBefore, false),
7110
7448
  folder: "INBOX",
@@ -7163,7 +7501,7 @@ export function createServer(config, options = {}) {
7163
7501
  args.category === "calendar"
7164
7502
  ? args.category
7165
7503
  : "document";
7166
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7504
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
7167
7505
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
7168
7506
  force: normalizeBoolean(args.syncBefore, false),
7169
7507
  folder: "INBOX",
@@ -7207,7 +7545,7 @@ export function createServer(config, options = {}) {
7207
7545
  // local-index-service.ts), tag ids with the account's slug, concatenate,
7208
7546
  // re-sort by that same recency order, and take the top `limit` of the
7209
7547
  // MERGED set — same correctness argument as get_actionable_threads' fan-out.
7210
- const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
7548
+ const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
7211
7549
  await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
7212
7550
  force: normalizeBoolean(args.syncBefore, false),
7213
7551
  folder: "INBOX",
@@ -7433,6 +7771,9 @@ export function createServer(config, options = {}) {
7433
7771
  const thread = await bundle.localIndexService.getThreadById(threadId);
7434
7772
  const action = requireEmailAction(args);
7435
7773
  ensureEmailActionAllowed(config.runtime, action);
7774
+ if (action === "move" || action === "restore") {
7775
+ await ensureMoveTargetAllowed(config.runtime, bundle.imapService, optionalString(args, "targetFolder"));
7776
+ }
7436
7777
  const unreadOnly = normalizeBoolean(args.unreadOnly, false);
7437
7778
  const emailIds = [...new Set(thread.messages
7438
7779
  .filter((message) => !unreadOnly || !message.isRead)
@@ -7674,7 +8015,7 @@ export function createServer(config, options = {}) {
7674
8015
  ?? "An internal error occurred. Check get_logs for details, or run run_doctor for a full connectivity check.";
7675
8016
  throw new McpError(ErrorCode.InternalError, message);
7676
8017
  }
7677
- });
8018
+ }));
7678
8019
  return {
7679
8020
  server,
7680
8021
  smtpService,