proton-mail-bridge-client 2.0.8 → 2.1.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 (33) hide show
  1. package/dist/index.d.ts +18 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1586 -416
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/account-manager.d.ts +37 -0
  6. package/dist/services/account-manager.d.ts.map +1 -0
  7. package/dist/services/account-manager.js +98 -0
  8. package/dist/services/account-manager.js.map +1 -0
  9. package/dist/services/background-sync-service.d.ts.map +1 -1
  10. package/dist/services/background-sync-service.js +8 -0
  11. package/dist/services/background-sync-service.js.map +1 -1
  12. package/dist/services/draft-store-service.d.ts +2 -0
  13. package/dist/services/draft-store-service.d.ts.map +1 -1
  14. package/dist/services/draft-store-service.js +2 -0
  15. package/dist/services/draft-store-service.js.map +1 -1
  16. package/dist/services/local-index-service.d.ts.map +1 -1
  17. package/dist/services/local-index-service.js +13 -4
  18. package/dist/services/local-index-service.js.map +1 -1
  19. package/dist/services/simple-imap-service.d.ts +1 -0
  20. package/dist/services/simple-imap-service.d.ts.map +1 -1
  21. package/dist/services/simple-imap-service.js +33 -1
  22. package/dist/services/simple-imap-service.js.map +1 -1
  23. package/dist/services/smtp-service.d.ts +1 -1
  24. package/dist/services/smtp-service.d.ts.map +1 -1
  25. package/dist/services/smtp-service.js +9 -3
  26. package/dist/services/smtp-service.js.map +1 -1
  27. package/dist/types/index.d.ts +13 -0
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/dist/utils/helpers.d.ts +6 -0
  30. package/dist/utils/helpers.d.ts.map +1 -1
  31. package/dist/utils/helpers.js +33 -0
  32. package/dist/utils/helpers.js.map +1 -1
  33. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,16 +11,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { isMainModule } from "./is-main.js";
13
13
  import { AnalyticsService } from "./services/analytics-service.js";
14
- import { AuditService } from "./services/audit-service.js";
15
- import { BackgroundSyncService } from "./services/background-sync-service.js";
16
- import { DeliveryQueueService } from "./services/delivery-queue-service.js";
17
- import { DraftStoreService } from "./services/draft-store-service.js";
18
- import { LocalIndexService } from "./services/local-index-service.js";
19
- import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, SimpleIMAPService, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
20
- import { applySignature, SMTPService } from "./services/smtp-service.js";
21
- import { SnoozeService } from "./services/snooze-service.js";
22
- import { TemplateService } from "./services/template-service.js";
23
- import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmailId, parseEmails, projectFields, renderMarkdown, stringifyForJson, } from "./utils/helpers.js";
14
+ import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
15
+ import { applySignature } from "./services/smtp-service.js";
16
+ import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmailId, parseEmails, projectFields, renderMarkdown, slugifyAccountAddress, splitAccountPrefix, stringifyForJson, withAccountPrefix, } from "./utils/helpers.js";
17
+ import { AccountManager } from "./services/account-manager.js";
24
18
  import { logger } from "./utils/logger.js";
25
19
  import { ensureDestructiveConfirmed, ensureEmailActionAllowed, ensureMailboxWriteAllowed, ensureOutboundRecipientsAllowed, ensureRemoteDraftSyncAllowed, ensureSendAllowed, resolveRemoteDraftSync, sanitizeRuntimeConfig, } from "./utils/runtime-policy.js";
26
20
  const require = createRequire(import.meta.url);
@@ -59,6 +53,7 @@ const TOOLS = [
59
53
  description: "SMTP priority header.",
60
54
  },
61
55
  fromName: { type: "string", description: "Optional display name for the From header (e.g. 'Alice'). Does not change the sending address." },
56
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
62
57
  sanitizeHtml: { type: "boolean", description: "Strip scripts, event handlers, and remote image beacons from HTML before delivery. Defaults to true when body is HTML.", default: true },
63
58
  replyTo: { type: "string", description: "Optional reply-to email address." },
64
59
  requestReadReceipt: { type: "boolean", description: "Request a read receipt (MDN) via a Disposition-Notification-To header. Most mail clients ask the recipient before honoring it — this is a request, not a guarantee.", default: false },
@@ -142,6 +137,7 @@ const TOOLS = [
142
137
  properties: {
143
138
  to: { type: "string", description: "Recipient email address." },
144
139
  customMessage: { type: "string", description: "Optional custom test body." },
140
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
145
141
  confirmed: { type: "boolean", description: "Set to true to confirm this send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
146
142
  },
147
143
  required: ["to"],
@@ -162,6 +158,7 @@ const TOOLS = [
162
158
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
163
159
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
164
160
  fromName: { type: "string", description: "Optional display name for the From header." },
161
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
165
162
  sanitizeHtml: { type: "boolean", description: "Strip scripts and remote image beacons from HTML before delivery. Defaults to true.", default: true },
166
163
  attachments: {
167
164
  type: "array",
@@ -200,6 +197,7 @@ const TOOLS = [
200
197
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
201
198
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
202
199
  fromName: { type: "string", description: "Optional display name for the From header." },
200
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
203
201
  sanitizeHtml: { type: "boolean", description: "Strip scripts and remote image beacons from HTML before delivery. Defaults to true.", default: true },
204
202
  attachments: {
205
203
  type: "array",
@@ -239,6 +237,7 @@ const TOOLS = [
239
237
  cc: { type: "string", description: "CC recipients, comma-separated." },
240
238
  bcc: { type: "string", description: "BCC recipients, comma-separated." },
241
239
  fromName: { type: "string", description: "Optional display name for the From header." },
240
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
242
241
  sanitizeHtml: { type: "boolean", description: "Strip scripts and remote image beacons from HTML before delivery. Defaults to true.", default: true },
243
242
  attachments: {
244
243
  type: "array",
@@ -279,6 +278,7 @@ const TOOLS = [
279
278
  isHtml: { type: "boolean", description: "Whether the body should be HTML.", default: false },
280
279
  priority: { type: "string", enum: ["high", "normal", "low"] },
281
280
  replyTo: { type: "string", description: "Optional reply-to email address." },
281
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default when the draft is sent (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
282
282
  notes: { type: "string", description: "Optional local note for the draft." },
283
283
  syncToRemote: {
284
284
  type: "boolean",
@@ -318,6 +318,7 @@ const TOOLS = [
318
318
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
319
319
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
320
320
  notes: { type: "string", description: "Optional local note for the draft." },
321
+ from: { type: "string", description: "Store this draft under this address instead of the Bridge login's default (e.g. an additional configured account address). Falls back to the primary account if the address isn't a configured account." },
321
322
  syncToRemote: {
322
323
  type: "boolean",
323
324
  description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
@@ -356,6 +357,7 @@ const TOOLS = [
356
357
  cc: { type: "string", description: "CC recipients, comma-separated." },
357
358
  bcc: { type: "string", description: "BCC recipients, comma-separated." },
358
359
  notes: { type: "string", description: "Optional local note for the draft." },
360
+ from: { type: "string", description: "Store this draft under this address instead of the Bridge login's default (e.g. an additional configured account address). Falls back to the primary account if the address isn't a configured account." },
359
361
  syncToRemote: {
360
362
  type: "boolean",
361
363
  description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
@@ -432,6 +434,7 @@ const TOOLS = [
432
434
  isHtml: { type: "boolean", description: "Whether the body should be HTML." },
433
435
  priority: { type: "string", enum: ["high", "normal", "low"] },
434
436
  replyTo: { type: "string", description: "Optional reply-to email address." },
437
+ from: { type: "string", description: "Send as this address instead of the Bridge login's default when the draft is sent (e.g. an alias or additional address on the same Proton account). Proton accepts any address verified on the account; an address not on the account is rejected by Proton at send time." },
435
438
  notes: { type: "string", description: "Optional local note for the draft." },
436
439
  syncToRemote: {
437
440
  type: "boolean",
@@ -1235,6 +1238,17 @@ const TOOLS = [
1235
1238
  annotations: { readOnlyHint: true },
1236
1239
  inputSchema: { type: "object", properties: {} },
1237
1240
  },
1241
+ {
1242
+ name: "list_accounts",
1243
+ description: "List every configured Proton address (the primary Bridge login plus any additional addresses from PROTONMAIL_ACCOUNTS_JSON), each with its own IMAP/SMTP connection status and local index freshness. Use to see which accounts this server is managing, and each account's `slug` — prefix an emailId/draftId with `<slug>::` to target that account explicitly in any other tool (a plain, unprefixed id always means the primary account). Set checkConnections:true to verify each account's IMAP/SMTP reachability live (slower).",
1244
+ annotations: { readOnlyHint: true },
1245
+ inputSchema: {
1246
+ type: "object",
1247
+ properties: {
1248
+ checkConnections: { type: "boolean", description: "Verify IMAP/SMTP connectivity for every account (adds a real connection attempt per account).", default: false },
1249
+ },
1250
+ },
1251
+ },
1238
1252
  {
1239
1253
  name: "run_doctor",
1240
1254
  description: "Run a comprehensive production health check covering SMTP auth, IMAP auth, optional IMAP IDLE probe, SQLite index integrity, sync-failed drafts, runtime policy validation, and what this server can/cannot do (capabilities). Also reports this server's own version and entrypoint path — worth checking first when behavior doesn't match the changelog, since an old/orphaned install elsewhere on disk can silently shadow an upgrade and every other field here would still report healthy. Connection failures include a classified diagnosis (authentication_failed vs bridge_unreachable) with a specific fix. Use to fully diagnose or validate the setup. Prefer get_connection_status for a quick protocol-only reachability check.",
@@ -1508,6 +1522,7 @@ const TOOLS = [
1508
1522
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
1509
1523
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
1510
1524
  notes: { type: "string", description: "Optional local note for the draft." },
1525
+ from: { type: "string", description: "Store this draft under this address instead of the Bridge login's default (e.g. an additional configured account address). Falls back to the primary account if the address isn't a configured account." },
1511
1526
  syncBefore: {
1512
1527
  type: "boolean",
1513
1528
  description: "Refresh the local mailbox index from IMAP before resolving the thread.",
@@ -2128,6 +2143,67 @@ function threadSource(thread) {
2128
2143
  },
2129
2144
  };
2130
2145
  }
2146
+ // Recursively prefixes every id-bearing field on a thread (or array of threads) with
2147
+ // the owning account's slug via withAccountPrefix, so ids returned by a tool fanned
2148
+ // out across accounts (see accountManager.all() usages below) stay globally unique
2149
+ // and resolvable back to the right account by splitAccountPrefix. slug is undefined
2150
+ // for the primary account, and withAccountPrefix(undefined, id) === id, so the
2151
+ // primary's own threads/messages are returned byte-for-byte unchanged — zero
2152
+ // behavior change for single-account setups. Deep-clones via JSON round-trip since
2153
+ // every field tagged here is plain, already-JSON-serializable data.
2154
+ export function tagAccountIds(slug, value) {
2155
+ if (!slug || value === undefined || value === null) {
2156
+ return value;
2157
+ }
2158
+ const clone = JSON.parse(JSON.stringify(value));
2159
+ const prefix = (id) => (typeof id === "string" ? withAccountPrefix(slug, id) : id);
2160
+ const tagThread = (thread) => {
2161
+ if (!thread || typeof thread !== "object") {
2162
+ return;
2163
+ }
2164
+ const t = thread;
2165
+ if (typeof t.id === "string")
2166
+ t.id = prefix(t.id);
2167
+ if (typeof t.latestEmailId === "string")
2168
+ t.latestEmailId = prefix(t.latestEmailId);
2169
+ if (Array.isArray(t.messageIds))
2170
+ t.messageIds = t.messageIds.map(prefix);
2171
+ if (Array.isArray(t.documents)) {
2172
+ for (const doc of t.documents) {
2173
+ if (doc && typeof doc === "object" && typeof doc.emailId === "string") {
2174
+ doc.emailId = prefix(doc.emailId);
2175
+ }
2176
+ }
2177
+ }
2178
+ if (Array.isArray(t.messages)) {
2179
+ for (const message of t.messages) {
2180
+ if (!message || typeof message !== "object")
2181
+ continue;
2182
+ const m = message;
2183
+ if (typeof m.id === "string")
2184
+ m.id = prefix(m.id);
2185
+ if (typeof m.primaryEmailId === "string")
2186
+ m.primaryEmailId = prefix(m.primaryEmailId);
2187
+ if (typeof m.canonicalId === "string")
2188
+ m.canonicalId = prefix(m.canonicalId);
2189
+ if (Array.isArray(m.locations)) {
2190
+ for (const loc of m.locations) {
2191
+ if (loc && typeof loc === "object" && typeof loc.emailId === "string") {
2192
+ loc.emailId = prefix(loc.emailId);
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+ }
2198
+ };
2199
+ if (Array.isArray(clone)) {
2200
+ clone.forEach(tagThread);
2201
+ }
2202
+ else {
2203
+ tagThread(clone);
2204
+ }
2205
+ return clone;
2206
+ }
2131
2207
  // list_drafts has no filter and returns every draft unconditionally — unlike get_draft/create_draft,
2132
2208
  // which each touch exactly one draft the caller already knows the content of. DraftRecord.attachments
2133
2209
  // carries full base64 `content`, and createTextResult() serializes the whole payload TWICE (once into
@@ -2396,6 +2472,109 @@ async function getAnalyticsSampleFromIndex(imapService, localIndexService, days,
2396
2472
  const result = await localIndexService.search({ dateFrom, limit });
2397
2473
  return result.emails;
2398
2474
  }
2475
+ // --- Multi-account fan-out helpers (2.2.0) -------------------------------
2476
+ //
2477
+ // Every helper below is only ever invoked from the ">1 configured account"
2478
+ // branch of its call site; the "exactly 1 account" branch always keeps
2479
+ // running the original single-bundle code path unchanged, so a
2480
+ // single-account setup produces byte-for-byte the same response it always
2481
+ // did. These helpers exist purely to keep each case-block's merge logic
2482
+ // short and readable.
2483
+ function accountSlugForTag(bundle, primarySlug) {
2484
+ return bundle.account.slug === primarySlug ? undefined : bundle.account.slug;
2485
+ }
2486
+ // Re-sorts a merged, cross-account list of date-bearing items newest-first.
2487
+ // Each account's own list already arrives pre-sorted (by relevance, then
2488
+ // recency) — this is a reasonable global approximation once several
2489
+ // independently-sorted lists are interleaved, not a re-derivation of each
2490
+ // service's original relevance score.
2491
+ function sortByDateDesc(items, dateOf) {
2492
+ return [...items].sort((left, right) => {
2493
+ const leftTime = new Date(dateOf(left) || 0).getTime();
2494
+ const rightTime = new Date(dateOf(right) || 0).getTime();
2495
+ return rightTime - leftTime;
2496
+ });
2497
+ }
2498
+ function mergeMailboxLabels(perAccountLabels) {
2499
+ const merged = new Map();
2500
+ for (const labels of perAccountLabels) {
2501
+ for (const label of labels) {
2502
+ const key = `${label.type}:${label.name}`;
2503
+ const existing = merged.get(key);
2504
+ if (existing) {
2505
+ existing.messageCount += label.messageCount;
2506
+ existing.unreadCount += label.unreadCount;
2507
+ existing.threadCount += label.threadCount;
2508
+ }
2509
+ else {
2510
+ merged.set(key, { ...label });
2511
+ }
2512
+ }
2513
+ }
2514
+ return [...merged.values()];
2515
+ }
2516
+ function mergeContactStats(perAccountContacts, limit) {
2517
+ const merged = new Map();
2518
+ for (const contacts of perAccountContacts) {
2519
+ for (const contact of contacts) {
2520
+ const existing = merged.get(contact.address);
2521
+ if (existing) {
2522
+ existing.incoming += contact.incoming;
2523
+ existing.outgoing += contact.outgoing;
2524
+ existing.totalMessages += contact.totalMessages;
2525
+ if (contact.lastContactAt &&
2526
+ (!existing.lastContactAt || new Date(contact.lastContactAt).getTime() > new Date(existing.lastContactAt).getTime())) {
2527
+ existing.lastContactAt = contact.lastContactAt;
2528
+ }
2529
+ }
2530
+ else {
2531
+ merged.set(contact.address, { ...contact });
2532
+ }
2533
+ }
2534
+ }
2535
+ return [...merged.values()]
2536
+ .sort((left, right) => {
2537
+ if (right.totalMessages !== left.totalMessages) {
2538
+ return right.totalMessages - left.totalMessages;
2539
+ }
2540
+ return (right.lastContactAt ?? "").localeCompare(left.lastContactAt ?? "");
2541
+ })
2542
+ .slice(0, limit);
2543
+ }
2544
+ function mergeCountedEntries(perAccountEntries, keyOf, topN) {
2545
+ const merged = new Map();
2546
+ for (const entries of perAccountEntries) {
2547
+ for (const entry of entries) {
2548
+ const key = keyOf(entry);
2549
+ const existing = merged.get(key);
2550
+ if (existing) {
2551
+ existing.count += entry.count;
2552
+ }
2553
+ else {
2554
+ merged.set(key, { ...entry });
2555
+ }
2556
+ }
2557
+ }
2558
+ return [...merged.values()].sort((left, right) => right.count - left.count).slice(0, topN);
2559
+ }
2560
+ function mergeVolumeTrends(perAccountTrends) {
2561
+ const merged = new Map();
2562
+ for (const trends of perAccountTrends) {
2563
+ for (const point of trends) {
2564
+ const existing = merged.get(point.date);
2565
+ if (existing) {
2566
+ existing.count += point.count;
2567
+ existing.unreadCount += point.unreadCount;
2568
+ existing.starredCount += point.starredCount;
2569
+ existing.attachmentCount += point.attachmentCount;
2570
+ }
2571
+ else {
2572
+ merged.set(point.date, { ...point });
2573
+ }
2574
+ }
2575
+ }
2576
+ return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
2577
+ }
2399
2578
  async function runEmailAction(imapService, emailId, action, targetFolder) {
2400
2579
  // undefined for an old-format id (no embedded UIDVALIDITY) — every method
2401
2580
  // below's own assertMailboxUidValidity guard already treats that as
@@ -2540,7 +2719,7 @@ export function ensureBulkBatchSize(uidsLength, max) {
2540
2719
  // call reused for both this and resolveUidsForBulkOp) should pass it, so a
2541
2720
  // stale-generation id gets its own clear error instead of being lumped in
2542
2721
  // with a genuinely malformed one.
2543
- function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2722
+ export function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2544
2723
  if (!emailIds) {
2545
2724
  return [];
2546
2725
  }
@@ -2585,7 +2764,7 @@ function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2585
2764
  }
2586
2765
  return excluded;
2587
2766
  }
2588
- function withBulkNotFound(result, excludedEmailIds) {
2767
+ export function withBulkNotFound(result, excludedEmailIds) {
2589
2768
  if (excludedEmailIds.length === 0) {
2590
2769
  return result;
2591
2770
  }
@@ -2604,6 +2783,60 @@ function withBulkNotFound(result, excludedEmailIds) {
2604
2783
  ],
2605
2784
  };
2606
2785
  }
2786
+ // Groups a bulk/batch tool's emailIds array by the account each id's optional
2787
+ // "<slug>::" prefix names (see splitAccountPrefix/resolveAccountForEmailId),
2788
+ // so bulk_delete/bulk_move/bulk_update_flags/bulk_update_labels/
2789
+ // batch_email_action can run each account's ids against that account's own
2790
+ // SimpleIMAPService instance instead of always hitting the primary bundle's
2791
+ // shared `imapService`. Map key is the resolved account slug (undefined for
2792
+ // the primary account — the same "no prefix" convention as everywhere else),
2793
+ // so results can be merged back with each entry's original, possibly
2794
+ // prefixed, emailId.
2795
+ export function groupEmailIdsByAccount(accountManager, emailIds) {
2796
+ const groups = new Map();
2797
+ for (const emailId of emailIds) {
2798
+ const { accountSlug, rest } = splitAccountPrefix(emailId, accountManager.additionalSlugs());
2799
+ let group = groups.get(accountSlug);
2800
+ if (!group) {
2801
+ group = { bundle: accountManager.bySlugOrPrimary(accountSlug), restIds: [] };
2802
+ groups.set(accountSlug, group);
2803
+ }
2804
+ group.restIds.push(rest);
2805
+ }
2806
+ return groups;
2807
+ }
2808
+ // Re-applies a group's account-slug prefix to every emailId a BulkOperationResult
2809
+ // or notFound entry references — those are built inside SimpleIMAPService (or from
2810
+ // the group's own restIds), which knows nothing about account slugs, so the prefix
2811
+ // stripped off when grouping (groupEmailIdsByAccount) must be put back before the
2812
+ // per-group results are merged into the single response the tool returns.
2813
+ export function prefixBulkResult(result, slug) {
2814
+ if (!slug) {
2815
+ return result;
2816
+ }
2817
+ return {
2818
+ ...result,
2819
+ results: result.results.map((entry) => ({ ...entry, emailId: withAccountPrefix(slug, entry.emailId) })),
2820
+ };
2821
+ }
2822
+ export function prefixNotFound(excluded, slug) {
2823
+ if (!slug) {
2824
+ return excluded;
2825
+ }
2826
+ return excluded.map((entry) => ({ ...entry, emailId: withAccountPrefix(slug, entry.emailId) }));
2827
+ }
2828
+ // Merges the per-account-group BulkOperationResults produced by the bulk_*
2829
+ // handlers into the single combined shape the tool actually returns.
2830
+ export function mergeBulkResults(results) {
2831
+ return results.reduce((acc, result) => ({
2832
+ dryRun: acc.dryRun || result.dryRun,
2833
+ total: acc.total + result.total,
2834
+ succeeded: acc.succeeded + result.succeeded,
2835
+ failed: acc.failed + result.failed,
2836
+ notFound: acc.notFound + result.notFound,
2837
+ results: [...acc.results, ...result.results],
2838
+ }), { dryRun: false, total: 0, succeeded: 0, failed: 0, notFound: 0, results: [] });
2839
+ }
2607
2840
  function paginateRecentRecords(records, limit, offset) {
2608
2841
  return records.slice(offset, offset + limit);
2609
2842
  }
@@ -2739,6 +2972,78 @@ function isMissingTargetFolderError(error) {
2739
2972
  const message = error instanceof Error ? error.message : String(error);
2740
2973
  return /TRYCREATE|NONEXISTENT/i.test(message);
2741
2974
  }
2975
+ // PROTONMAIL_ACCOUNTS_JSON: a JSON array of additional Proton addresses on the same
2976
+ // account, exposed by Bridge's Split Addresses feature as their own IMAP/SMTP logins.
2977
+ // Each entry only needs `address` and `password` — Bridge issues each split address its
2978
+ // own Bridge-generated password but keeps the SAME host/ports as the primary connection,
2979
+ // and the IMAP/SMTP username for a split address is the address itself. Any of
2980
+ // imapHost/imapPort/imapSecure/imapUsername/smtpHost/smtpPort/smtpSecure/smtpUsername may
2981
+ // still be overridden explicitly for setups that don't follow that default.
2982
+ // Example: [{"address":"me@pm.me","password":"..."},{"address":"me@example.com","password":"...","imapUsername":"me@example.com"}]
2983
+ function parseAdditionalAccountsEnv(primary) {
2984
+ const raw = process.env.PROTONMAIL_ACCOUNTS_JSON?.trim();
2985
+ if (!raw) {
2986
+ return [];
2987
+ }
2988
+ let parsed;
2989
+ try {
2990
+ parsed = JSON.parse(raw);
2991
+ }
2992
+ catch (error) {
2993
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2994
+ }
2995
+ if (!Array.isArray(parsed)) {
2996
+ throw new Error("PROTONMAIL_ACCOUNTS_JSON must be a JSON array.");
2997
+ }
2998
+ const seenAddresses = new Set([primary.address.toLowerCase()]);
2999
+ const seenSlugs = new Set([primary.slug]);
3000
+ return parsed.map((entry, index) => {
3001
+ if (!entry || typeof entry !== "object") {
3002
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON[${index}] must be an object.`);
3003
+ }
3004
+ const value = entry;
3005
+ const address = typeof value.address === "string" ? value.address.trim() : "";
3006
+ const password = typeof value.password === "string" ? value.password : "";
3007
+ if (!address || !isValidEmail(address)) {
3008
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON[${index}].address must be a valid email address.`);
3009
+ }
3010
+ if (!password) {
3011
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON[${index}].password is required.`);
3012
+ }
3013
+ const lowerAddress = address.toLowerCase();
3014
+ if (seenAddresses.has(lowerAddress)) {
3015
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON[${index}]: duplicate address "${address}".`);
3016
+ }
3017
+ seenAddresses.add(lowerAddress);
3018
+ const slug = slugifyAccountAddress(address);
3019
+ if (!slug || seenSlugs.has(slug)) {
3020
+ throw new Error(`PROTONMAIL_ACCOUNTS_JSON[${index}]: address "${address}" produces an empty or duplicate account slug ("${slug}").`);
3021
+ }
3022
+ seenSlugs.add(slug);
3023
+ const stringField = (key, fallback) => typeof value[key] === "string" && value[key].trim() ? value[key].trim() : fallback;
3024
+ const numberField = (key, fallback) => typeof value[key] === "number" && Number.isInteger(value[key]) ? value[key] : fallback;
3025
+ const boolField = (key, fallback) => typeof value[key] === "boolean" ? value[key] : fallback;
3026
+ return {
3027
+ address,
3028
+ slug,
3029
+ dataDir: join(primary.dataDir, "accounts", slug),
3030
+ imap: {
3031
+ host: stringField("imapHost", primary.imap.host),
3032
+ port: numberField("imapPort", primary.imap.port),
3033
+ secure: boolField("imapSecure", primary.imap.secure),
3034
+ username: stringField("imapUsername", address),
3035
+ password: stringField("imapPassword", password),
3036
+ },
3037
+ smtp: {
3038
+ host: stringField("smtpHost", primary.smtp.host),
3039
+ port: numberField("smtpPort", primary.smtp.port),
3040
+ secure: boolField("smtpSecure", primary.smtp.secure),
3041
+ username: stringField("smtpUsername", address),
3042
+ password: stringField("smtpPassword", password),
3043
+ },
3044
+ };
3045
+ });
3046
+ }
2742
3047
  export function buildConfigFromEnv() {
2743
3048
  const username = readEnvValue("PROTONMAIL_USERNAME");
2744
3049
  const password = readEnvValue("PROTONMAIL_PASSWORD");
@@ -2783,27 +3088,26 @@ export function buildConfigFromEnv() {
2783
3088
  if (isLocalBridgeHost(imapHost)) {
2784
3089
  logger.warn("TLS verification is disabled for local Bridge IMAP connections.", "MCPServer", { host: imapHost });
2785
3090
  }
3091
+ const primarySmtp = { host: smtpHost, port: smtpPort, secure: smtpSecure, username, password };
3092
+ const primaryImap = { host: imapHost, port: imapPort, secure: imapSecure, username: imapUsername, password: imapPassword };
3093
+ const primaryAccount = {
3094
+ address: username,
3095
+ slug: slugifyAccountAddress(username),
3096
+ imap: primaryImap,
3097
+ smtp: primarySmtp,
3098
+ dataDir,
3099
+ };
3100
+ const accounts = [primaryAccount, ...parseAdditionalAccountsEnv(primaryAccount)];
2786
3101
  return {
2787
- smtp: {
2788
- host: smtpHost,
2789
- port: smtpPort,
2790
- secure: smtpSecure,
2791
- username,
2792
- password,
2793
- },
2794
- imap: {
2795
- host: imapHost,
2796
- port: imapPort,
2797
- secure: imapSecure,
2798
- username: imapUsername,
2799
- password: imapPassword,
2800
- },
3102
+ smtp: primarySmtp,
3103
+ imap: primaryImap,
2801
3104
  dataDir,
2802
3105
  debug,
2803
3106
  cacheEnabled: true,
2804
3107
  analyticsEnabled: true,
2805
3108
  autoSync,
2806
3109
  syncInterval,
3110
+ accounts,
2807
3111
  runtime: {
2808
3112
  readOnly,
2809
3113
  allowSend,
@@ -2829,34 +3133,96 @@ export function buildConfigFromEnv() {
2829
3133
  };
2830
3134
  }
2831
3135
  export function createServer(config, options = {}) {
2832
- const smtpService = new SMTPService(config);
2833
- const imapService = new SimpleIMAPService(config, logger, config.runtime.opDelayMs);
3136
+ // AccountManager builds one full, isolated service stack per configured account
3137
+ // (config.accounts — primary plus any PROTONMAIL_ACCOUNTS_JSON entries). The bare
3138
+ // names below stay bound to the PRIMARY account's stack so every existing
3139
+ // single-account tool handler keeps working completely unchanged; only handlers that
3140
+ // need to act on a specific (possibly non-primary) account resolve through
3141
+ // accountManager instead — see resolveAccountForEmailId/resolveAccountForDraft below.
3142
+ const accountManager = new AccountManager(config, logger);
3143
+ const primaryBundle = accountManager.primary();
3144
+ const smtpService = primaryBundle.smtpService;
3145
+ const imapService = primaryBundle.imapService;
2834
3146
  const analyticsService = new AnalyticsService();
2835
- const auditService = new AuditService(config);
2836
- const localIndexService = new LocalIndexService(config, logger);
2837
- const draftStore = new DraftStoreService(config, logger);
2838
- const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
2839
- const deliveryQueueService = new DeliveryQueueService(config, smtpService, logger);
2840
- // Lets a fired scheduled_send close the loop back to its source draft —
2841
- // see DeliveryQueueService.checkDue()'s draftStore usage.
2842
- deliveryQueueService.setDraftStore(draftStore);
2843
- const snoozeService = new SnoozeService(config, imapService, logger);
2844
- const templateService = new TemplateService(config, logger);
3147
+ const auditService = primaryBundle.auditService;
3148
+ const localIndexService = primaryBundle.localIndexService;
3149
+ const draftStore = primaryBundle.draftStore;
3150
+ const backgroundSyncService = primaryBundle.backgroundSyncService;
3151
+ const deliveryQueueService = primaryBundle.deliveryQueueService;
3152
+ const snoozeService = primaryBundle.snoozeService;
3153
+ const templateService = primaryBundle.templateService;
3154
+ // Resolves an emailId's optional account-slug prefix (see splitAccountPrefix) to its
3155
+ // owning AccountBundle, defaulting to the primary account for a plain, unprefixed id —
3156
+ // which covers every id that existed before multi-account support, unchanged.
3157
+ function resolveAccountForEmailId(emailId) {
3158
+ const { accountSlug, rest } = splitAccountPrefix(emailId, accountManager.additionalSlugs());
3159
+ return { bundle: accountManager.bySlugOrPrimary(accountSlug), rest };
3160
+ }
3161
+ // Re-applies a bundle's account-slug prefix to an id that came back bare from that
3162
+ // bundle's own imapService (which only ever understands unprefixed ids) — so a
3163
+ // caller acting on a non-primary account gets back an id that resolveAccountForEmailId
3164
+ // will route to the same account next time. The primary account's ids stay unprefixed,
3165
+ // exactly as before multi-account support existed.
3166
+ function prefixedIdFor(bundle, rawId) {
3167
+ return withAccountPrefix(bundle.account.slug === accountManager.primary().account.slug ? undefined : bundle.account.slug, rawId);
3168
+ }
3169
+ // draftId and emailId share the exact same "<slug>::" prefixing scheme (plain
3170
+ // string-prefix stripping over the non-primary slugs), so draftId resolution reuses
3171
+ // resolveAccountForEmailId itself rather than a second, parallel mechanism.
3172
+ const resolveAccountForDraftId = resolveAccountForEmailId;
3173
+ // The slug to prefix a response id with for this bundle — undefined for the
3174
+ // primary account, so a primary-account id round-trips with NO prefix at all,
3175
+ // exactly as it did before multi-account support existed.
3176
+ function responseSlug(bundle) {
3177
+ return bundle.account.slug === accountManager.primary().account.slug ? undefined : bundle.account.slug;
3178
+ }
3179
+ // Alias for responseSlug — tagAccountIds (added alongside the digest/follow-up
3180
+ // multi-account fan-out) was written against this name; kept as a thin alias rather
3181
+ // than rewriting every call site to the other name.
3182
+ const slugForBundle = responseSlug;
3183
+ // A draft created/updated through a non-primary account's own draftStore needs its
3184
+ // id wrapped with that account's slug prefix in the RESPONSE (so get_draft/
3185
+ // update_draft/send_draft/delete_draft/list_drafts can find it again via
3186
+ // resolveAccountForDraftId) — but every internal draftStore/imapService call for
3187
+ // that account keeps using the bundle's own unprefixed id, exactly as stored on
3188
+ // disk under that account's own dataDir. remoteDraft.emailId is a real emailId (not
3189
+ // a draftId) for a remote copy living in that same account's mailbox, so it gets
3190
+ // the identical prefix treatment for the identical reason.
3191
+ function presentDraft(bundle, draft) {
3192
+ const slug = responseSlug(bundle);
3193
+ if (!slug) {
3194
+ return draft;
3195
+ }
3196
+ return {
3197
+ ...draft,
3198
+ id: withAccountPrefix(slug, draft.id),
3199
+ remoteDraft: draft.remoteDraft?.emailId
3200
+ ? { ...draft.remoteDraft, emailId: withAccountPrefix(slug, draft.remoteDraft.emailId) }
3201
+ : draft.remoteDraft,
3202
+ };
3203
+ }
2845
3204
  if (options.startBackgroundSync) {
2846
- backgroundSyncService.start();
2847
- // start()'s first real work is awaiting recoverInterruptedSends(), which
2848
- // can throw a lock-acquisition-timeout error (see file-lock.ts). Bare
2849
- // `void` here would turn that into an unhandled rejection, and the
2850
- // process-wide unhandledRejection handler below calls process.exit(1) —
2851
- // crashing the whole server before it serves a single request over
2852
- // nothing worse than startup lock contention. Log and degrade instead:
2853
- // the delivery queue just won't be running yet.
2854
- deliveryQueueService.start().catch((error) => {
2855
- logger.error("deliveryQueueService.start() failed", "MCPServer", error);
2856
- });
2857
- // snoozeService.start() is synchronous (returns void, not a Promise) —
2858
- // nothing to catch here.
2859
- snoozeService.start();
3205
+ // Every configured account gets its own independent sync/delivery-queue/snooze
3206
+ // loop — additional accounts are not just readable, they stay live-synced exactly
3207
+ // like the primary. The primary's own start() call below is intentionally
3208
+ // redundant with this loop (primaryBundle is included in accountManager.all()) so
3209
+ // this doesn't silently change primary-only behavior if that ever stops holding.
3210
+ for (const bundle of accountManager.all()) {
3211
+ bundle.backgroundSyncService.start();
3212
+ // start()'s first real work is awaiting recoverInterruptedSends(), which
3213
+ // can throw a lock-acquisition-timeout error (see file-lock.ts). Bare
3214
+ // `void` here would turn that into an unhandled rejection, and the
3215
+ // process-wide unhandledRejection handler below calls process.exit(1) —
3216
+ // crashing the whole server before it serves a single request over
3217
+ // nothing worse than startup lock contention. Log and degrade instead:
3218
+ // the delivery queue just won't be running yet.
3219
+ bundle.deliveryQueueService.start().catch((error) => {
3220
+ logger.error("deliveryQueueService.start() failed", "MCPServer", { account: bundle.account.slug, error });
3221
+ });
3222
+ // snoozeService.start() is synchronous (returns void, not a Promise) —
3223
+ // nothing to catch here.
3224
+ bundle.snoozeService.start();
3225
+ }
2860
3226
  }
2861
3227
  const server = new Server({
2862
3228
  name: "proton-mail-bridge-client",
@@ -3006,6 +3372,7 @@ export function createServer(config, options = {}) {
3006
3372
  const priority = optionalString(args, "priority");
3007
3373
  const replyTo = optionalString(args, "replyTo");
3008
3374
  const fromName = optionalString(args, "fromName");
3375
+ const from = optionalString(args, "from");
3009
3376
  const sanitizeHtml = normalizeBoolean(args.sanitizeHtml, true);
3010
3377
  const attachments = optionalAttachmentList(args.attachments);
3011
3378
  ensureValidEmails(to, "to");
@@ -3014,6 +3381,9 @@ export function createServer(config, options = {}) {
3014
3381
  if (replyTo && !isValidEmail(replyTo)) {
3015
3382
  throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
3016
3383
  }
3384
+ if (from && !isValidEmail(from)) {
3385
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3386
+ }
3017
3387
  // dryRun: preview without sending
3018
3388
  const dryRunSend = normalizeBoolean(args.dryRun, false);
3019
3389
  // RESTRICT_OUTBOUND_TO_SELF check
@@ -3037,6 +3407,7 @@ export function createServer(config, options = {}) {
3037
3407
  isHtml,
3038
3408
  htmlBody,
3039
3409
  fromName,
3410
+ from,
3040
3411
  sanitizeHtml,
3041
3412
  priority: priority === "high" || priority === "low" || priority === "normal"
3042
3413
  ? priority
@@ -3059,6 +3430,12 @@ export function createServer(config, options = {}) {
3059
3430
  undoWindowSeconds = requested;
3060
3431
  }
3061
3432
  if (undoWindowSeconds > 0) {
3433
+ // Scope limitation (follow-up round): the undo-send queue is always the
3434
+ // primary's deliveryQueueService, so a delayed send with a non-primary
3435
+ // `from` still only gets the header-override-on-primary fallback at fire
3436
+ // time, not this account's own SMTP connection — unlike the immediate-send
3437
+ // path above. Fine for now since `from` is still passed through in the
3438
+ // payload and Proton accepts any verified address on the account.
3062
3439
  const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
3063
3440
  const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
3064
3441
  return createTextResult({
@@ -3068,12 +3445,22 @@ export function createServer(config, options = {}) {
3068
3445
  note: `Not sent yet — will send in ~${undoWindowSeconds}s unless canceled with cancel_send. This server must stay running for the send to fire; if it's restarted before sendAt, the send fires on next startup instead.`,
3069
3446
  });
3070
3447
  }
3071
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail(emailPayload));
3448
+ // A `from` matching a configured additional account routes through that
3449
+ // account's own SMTP connection — required under Bridge's Split Addresses
3450
+ // feature, where each address is its own separate IMAP/SMTP login, not just
3451
+ // an alias on the primary's connection. A `from` that doesn't match any
3452
+ // configured account falls back to the pre-multi-account behavior: send via
3453
+ // the primary connection with `from` passed through as a plain header
3454
+ // override (still valid for a genuine alias only reachable that way).
3455
+ const sendBundle = from ? accountManager.byAddress(from) : undefined;
3456
+ const sendSmtp = sendBundle ? sendBundle.smtpService : smtpService;
3457
+ const sendImap = sendBundle ? sendBundle.imapService : imapService;
3458
+ const result = await withAudit(auditService, name, args, async () => sendSmtp.sendEmail(emailPayload));
3072
3459
  let sentCopyTokenSend = "[sent-copy:unverified]";
3073
3460
  try {
3074
3461
  const verifyMsgId = result.messageId;
3075
3462
  if (verifyMsgId) {
3076
- const scv = await verifySentCopy(imapService, verifyMsgId);
3463
+ const scv = await verifySentCopy(sendImap, verifyMsgId);
3077
3464
  if (scv.found)
3078
3465
  sentCopyTokenSend = "[sent-copy:verified]";
3079
3466
  }
@@ -3098,10 +3485,12 @@ export function createServer(config, options = {}) {
3098
3485
  return createTextResult(filtered);
3099
3486
  }
3100
3487
  case "get_unsubscribe_info": {
3101
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
3488
+ const rawEmailId = requireString(args, "emailId");
3489
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
3490
+ const detail = await bundle.imapService.getEmailById(emailId);
3102
3491
  const info = extractUnsubscribeInfo(detail);
3103
3492
  return createTextResult({
3104
- emailId: detail.id,
3493
+ emailId: prefixedIdFor(bundle, detail.id),
3105
3494
  hasUnsubscribeHeader: Boolean(info.mailto || info.url),
3106
3495
  mailto: info.mailto,
3107
3496
  url: info.url,
@@ -3111,7 +3500,9 @@ export function createServer(config, options = {}) {
3111
3500
  });
3112
3501
  }
3113
3502
  case "unsubscribe_sender": {
3114
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
3503
+ const rawEmailId = requireString(args, "emailId");
3504
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
3505
+ const detail = await bundle.imapService.getEmailById(emailId);
3115
3506
  const info = extractUnsubscribeInfo(detail);
3116
3507
  if (!info.mailto) {
3117
3508
  if (info.url) {
@@ -3124,14 +3515,14 @@ export function createServer(config, options = {}) {
3124
3515
  // info.mailto comes from an untrusted inbound header — subject to the
3125
3516
  // same outbound restriction as every other send path.
3126
3517
  ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [info.mailto]);
3127
- const result = await withAudit(auditService, name, args, () => smtpService.sendEmail({
3518
+ const result = await withAudit(auditService, name, args, () => bundle.smtpService.sendEmail({
3128
3519
  to: [info.mailto],
3129
3520
  subject: "unsubscribe",
3130
3521
  body: "unsubscribe",
3131
3522
  isHtml: false,
3132
3523
  }));
3133
3524
  return createTextResult({
3134
- emailId: detail.id,
3525
+ emailId: prefixedIdFor(bundle, detail.id),
3135
3526
  unsubscribedVia: info.mailto,
3136
3527
  messageId: result.messageId,
3137
3528
  accepted: result.accepted,
@@ -3143,10 +3534,17 @@ export function createServer(config, options = {}) {
3143
3534
  if (!isValidEmail(to)) {
3144
3535
  throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
3145
3536
  }
3537
+ const fromTest = optionalString(args, "from");
3538
+ if (fromTest && !isValidEmail(fromTest)) {
3539
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3540
+ }
3146
3541
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send test email to ${to}`);
3147
3542
  ensureSendAllowed(config.runtime);
3148
3543
  ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [to]);
3149
- const result = await withAudit(auditService, name, args, async () => smtpService.sendTestEmail(to, optionalString(args, "customMessage")));
3544
+ // Same account-routing-or-header-override fallback as send_email.
3545
+ const testSendBundle = fromTest ? accountManager.byAddress(fromTest) : undefined;
3546
+ const testSmtp = testSendBundle ? testSendBundle.smtpService : smtpService;
3547
+ const result = await withAudit(auditService, name, args, async () => testSmtp.sendTestEmail(to, optionalString(args, "customMessage"), testSendBundle ? undefined : fromTest));
3150
3548
  return createTextResult({
3151
3549
  messageId: result.messageId,
3152
3550
  accepted: result.accepted,
@@ -3157,18 +3555,30 @@ export function createServer(config, options = {}) {
3157
3555
  case "reply_to_email": {
3158
3556
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Reply to email ${String(args.emailId ?? "?")}`);
3159
3557
  ensureSendAllowed(config.runtime);
3160
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
3558
+ // The message being replied to might belong to a non-primary account (its
3559
+ // emailId carries that account's slug prefix) — resolve it the same way
3560
+ // every other emailId-taking tool does, and read AND send through that same
3561
+ // account by default (a `from` matching a DIFFERENT configured account still
3562
+ // wins and routes the send there instead — see sendBundleForReply below).
3563
+ const { bundle: readBundleReply, rest: emailIdReplyRest } = resolveAccountForEmailId(requireString(args, "emailId"));
3564
+ const detail = await readBundleReply.imapService.getEmailById(emailIdReplyRest);
3161
3565
  const markdownBodyReply = optionalString(args, "markdownBody");
3162
3566
  const body = markdownBodyReply ? markdownBodyReply : requireString(args, "body");
3163
3567
  const isHtml = markdownBodyReply ? false : normalizeBoolean(args.isHtml, false);
3164
3568
  const htmlBody = markdownBodyReply ? renderMarkdown(markdownBodyReply).html : undefined;
3165
3569
  const replyAll = normalizeBoolean(args.replyAll, false);
3166
3570
  const fromNameReply = optionalString(args, "fromName");
3571
+ const fromReply = optionalString(args, "from");
3572
+ if (fromReply && !isValidEmail(fromReply)) {
3573
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3574
+ }
3575
+ const sendBundleReply = fromReply ? accountManager.byAddress(fromReply) : undefined;
3576
+ const sendBundleForReply = sendBundleReply ?? readBundleReply;
3167
3577
  const sanitizeHtmlReply = normalizeBoolean(args.sanitizeHtml, true);
3168
3578
  const attachments = optionalAttachmentList(args.attachments);
3169
3579
  const extraCc = parseEmails(optionalString(args, "cc"));
3170
3580
  const extraBcc = parseEmails(optionalString(args, "bcc"));
3171
- const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
3581
+ const recipients = getReplyRecipients(detail, readBundleReply.config.smtp.username, replyAll);
3172
3582
  const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
3173
3583
  const to = uniqueAddresses(recipients.to);
3174
3584
  ensureValidEmails(to, "to");
@@ -3192,9 +3602,9 @@ export function createServer(config, options = {}) {
3192
3602
  }
3193
3603
  }
3194
3604
  if (dryRunReply) {
3195
- return createTextResult({ dryRun: true, wouldSendTo: { to, cc, bcc: extraBcc }, subject: prefixedSubject(detail.subject, "Re:"), note: "No email was sent." }, false, [emailSource(detail)]);
3605
+ return createTextResult({ dryRun: true, wouldSendTo: { to, cc, bcc: extraBcc }, subject: prefixedSubject(detail.subject, "Re:"), note: "No email was sent." }, false, [emailSource({ ...detail, id: prefixedIdFor(readBundleReply, detail.id) })]);
3196
3606
  }
3197
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3607
+ const result = await withAudit(auditService, name, args, async () => sendBundleForReply.smtpService.sendEmail({
3198
3608
  to,
3199
3609
  cc,
3200
3610
  bcc: extraBcc,
@@ -3203,6 +3613,7 @@ export function createServer(config, options = {}) {
3203
3613
  isHtml,
3204
3614
  htmlBody: signedReply.htmlBody,
3205
3615
  fromName: fromNameReply,
3616
+ from: fromReply,
3206
3617
  sanitizeHtml: sanitizeHtmlReply,
3207
3618
  inReplyTo: detail.messageId,
3208
3619
  references: detail.messageId ? [detail.messageId] : undefined,
@@ -3214,14 +3625,14 @@ export function createServer(config, options = {}) {
3214
3625
  try {
3215
3626
  const verifyMsgId = result.messageId;
3216
3627
  if (verifyMsgId) {
3217
- const scv = await verifySentCopy(imapService, verifyMsgId);
3628
+ const scv = await verifySentCopy(sendBundleForReply.imapService, verifyMsgId);
3218
3629
  if (scv.found)
3219
3630
  sentCopyTokenReply = "[sent-copy:verified]";
3220
3631
  }
3221
3632
  }
3222
3633
  catch (_) { }
3223
3634
  return createTextResult({
3224
- repliedTo: detail.id,
3635
+ repliedTo: prefixedIdFor(readBundleReply, detail.id),
3225
3636
  to,
3226
3637
  cc,
3227
3638
  messageId: result.messageId,
@@ -3229,22 +3640,30 @@ export function createServer(config, options = {}) {
3229
3640
  rejected: result.rejected,
3230
3641
  response: result.response,
3231
3642
  sentCopy: sentCopyTokenReply,
3232
- }, false, [emailSource(detail)]);
3643
+ }, false, [emailSource({ ...detail, id: prefixedIdFor(readBundleReply, detail.id) })]);
3233
3644
  }
3234
3645
  case "reply_all_email": {
3235
3646
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Reply-all to email ${String(args.emailId ?? "?")}`);
3236
3647
  ensureSendAllowed(config.runtime);
3237
- const detailRa = await imapService.getEmailById(requireString(args, "emailId"));
3648
+ // Same emailId account resolution as reply_to_email — see its comment.
3649
+ const { bundle: readBundleRa, rest: emailIdRaRest } = resolveAccountForEmailId(requireString(args, "emailId"));
3650
+ const detailRa = await readBundleRa.imapService.getEmailById(emailIdRaRest);
3238
3651
  const markdownBodyRa = optionalString(args, "markdownBody");
3239
3652
  const bodyRa = markdownBodyRa ? markdownBodyRa : requireString(args, "body");
3240
3653
  const isHtmlRa = markdownBodyRa ? false : normalizeBoolean(args.isHtml, false);
3241
3654
  const htmlBodyRa = markdownBodyRa ? renderMarkdown(markdownBodyRa).html : undefined;
3242
3655
  const fromNameRa = optionalString(args, "fromName");
3656
+ const fromRa = optionalString(args, "from");
3657
+ if (fromRa && !isValidEmail(fromRa)) {
3658
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3659
+ }
3660
+ const sendBundleRa = fromRa ? accountManager.byAddress(fromRa) : undefined;
3661
+ const sendBundleForRa = sendBundleRa ?? readBundleRa;
3243
3662
  const sanitizeHtmlRa = normalizeBoolean(args.sanitizeHtml, true);
3244
3663
  const attachmentsRa = optionalAttachmentList(args.attachments);
3245
3664
  const extraCcRa = parseEmails(optionalString(args, "cc"));
3246
3665
  const extraBccRa = parseEmails(optionalString(args, "bcc"));
3247
- const recipientsRa = getReplyRecipients(detailRa, config.smtp.username, true);
3666
+ const recipientsRa = getReplyRecipients(detailRa, readBundleRa.config.smtp.username, true);
3248
3667
  const ccRa = uniqueAddresses([...recipientsRa.cc, ...extraCcRa]);
3249
3668
  const toRa = uniqueAddresses(recipientsRa.to);
3250
3669
  ensureValidEmails(toRa, "to");
@@ -3270,7 +3689,7 @@ export function createServer(config, options = {}) {
3270
3689
  // quoted original — not at the very end, after the quote.
3271
3690
  const signedReplyRa = applySignature(bodyRa, htmlBodyRa, normalizeBoolean(args.appendSignature, true));
3272
3691
  const replyBodyRa = includeQuoteRa ? buildReplyText(detailRa, signedReplyRa.body) : signedReplyRa.body;
3273
- const resultRa = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3692
+ const resultRa = await withAudit(auditService, name, args, async () => sendBundleForRa.smtpService.sendEmail({
3274
3693
  to: toRa,
3275
3694
  cc: ccRa,
3276
3695
  bcc: extraBccRa,
@@ -3279,6 +3698,7 @@ export function createServer(config, options = {}) {
3279
3698
  isHtml: isHtmlRa,
3280
3699
  htmlBody: signedReplyRa.htmlBody,
3281
3700
  fromName: fromNameRa,
3701
+ from: fromRa,
3282
3702
  sanitizeHtml: sanitizeHtmlRa,
3283
3703
  inReplyTo: detailRa.messageId,
3284
3704
  references: detailRa.messageId ? [detailRa.messageId] : undefined,
@@ -3290,14 +3710,14 @@ export function createServer(config, options = {}) {
3290
3710
  try {
3291
3711
  const verifyMsgId = resultRa.messageId;
3292
3712
  if (verifyMsgId) {
3293
- const scv = await verifySentCopy(imapService, verifyMsgId);
3713
+ const scv = await verifySentCopy(sendBundleForRa.imapService, verifyMsgId);
3294
3714
  if (scv.found)
3295
3715
  sentCopyTokenRa = "[sent-copy:verified]";
3296
3716
  }
3297
3717
  }
3298
3718
  catch (_) { }
3299
3719
  return createTextResult({
3300
- repliedTo: detailRa.id,
3720
+ repliedTo: prefixedIdFor(readBundleRa, detailRa.id),
3301
3721
  to: toRa,
3302
3722
  cc: ccRa,
3303
3723
  messageId: resultRa.messageId,
@@ -3305,12 +3725,14 @@ export function createServer(config, options = {}) {
3305
3725
  rejected: resultRa.rejected,
3306
3726
  response: resultRa.response,
3307
3727
  sentCopy: sentCopyTokenRa,
3308
- }, false, [emailSource(detailRa)]);
3728
+ }, false, [emailSource({ ...detailRa, id: prefixedIdFor(readBundleRa, detailRa.id) })]);
3309
3729
  }
3310
3730
  case "forward_email": {
3311
3731
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
3312
3732
  ensureSendAllowed(config.runtime);
3313
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
3733
+ // Same emailId account resolution as reply_to_email — see its comment.
3734
+ const { bundle: readBundleFwd, rest: emailIdFwdRest } = resolveAccountForEmailId(requireString(args, "emailId"));
3735
+ const detail = await readBundleFwd.imapService.getEmailById(emailIdFwdRest);
3314
3736
  const to = parseEmails(requireString(args, "to"));
3315
3737
  const cc = parseEmails(optionalString(args, "cc"));
3316
3738
  const bcc = parseEmails(optionalString(args, "bcc"));
@@ -3319,6 +3741,12 @@ export function createServer(config, options = {}) {
3319
3741
  const isHtml = markdownBodyFwd ? false : normalizeBoolean(args.isHtml, false);
3320
3742
  const htmlBody = markdownBodyFwd ? renderMarkdown(markdownBodyFwd).html : undefined;
3321
3743
  const fromNameFwd = optionalString(args, "fromName");
3744
+ const fromFwd = optionalString(args, "from");
3745
+ if (fromFwd && !isValidEmail(fromFwd)) {
3746
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3747
+ }
3748
+ const sendBundleFwd = fromFwd ? accountManager.byAddress(fromFwd) : undefined;
3749
+ const sendBundleForFwd = sendBundleFwd ?? readBundleFwd;
3322
3750
  const sanitizeHtmlFwd = normalizeBoolean(args.sanitizeHtml, true);
3323
3751
  // args.attachments are new attachments the caller wants to add to the
3324
3752
  // forward — they are NOT the original message's attachments. Despite
@@ -3361,14 +3789,14 @@ export function createServer(config, options = {}) {
3361
3789
  const originalAttachments = includeAttachments
3362
3790
  ? await Promise.all(detail.attachments
3363
3791
  .filter((a) => a.id && (!attachmentParts || (a.part !== undefined && attachmentParts.includes(a.part))))
3364
- .map((a) => imapService.getAttachmentForForward(detail.id, a.id)))
3792
+ .map((a) => readBundleFwd.imapService.getAttachmentForForward(detail.id, a.id)))
3365
3793
  : [];
3366
3794
  const fwdAttachments = [...originalAttachments, ...attachments];
3367
3795
  // Signature goes right after the user's own note, before the
3368
3796
  // "---------- Forwarded message ---------" block — not at the very
3369
3797
  // end, after the forwarded content.
3370
3798
  const signedFwd = applySignature(body ?? "", htmlBody, normalizeBoolean(args.appendSignature, true));
3371
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3799
+ const result = await withAudit(auditService, name, args, async () => sendBundleForFwd.smtpService.sendEmail({
3372
3800
  to,
3373
3801
  cc,
3374
3802
  bcc,
@@ -3377,6 +3805,7 @@ export function createServer(config, options = {}) {
3377
3805
  isHtml,
3378
3806
  htmlBody: signedFwd.htmlBody,
3379
3807
  fromName: fromNameFwd,
3808
+ from: fromFwd,
3380
3809
  sanitizeHtml: sanitizeHtmlFwd,
3381
3810
  attachments: fwdAttachments,
3382
3811
  // Already applied above, before quote-wrapping.
@@ -3386,14 +3815,14 @@ export function createServer(config, options = {}) {
3386
3815
  try {
3387
3816
  const verifyMsgId = result.messageId;
3388
3817
  if (verifyMsgId) {
3389
- const scv = await verifySentCopy(imapService, verifyMsgId);
3818
+ const scv = await verifySentCopy(sendBundleForFwd.imapService, verifyMsgId);
3390
3819
  if (scv.found)
3391
3820
  sentCopyTokenFwd = "[sent-copy:verified]";
3392
3821
  }
3393
3822
  }
3394
3823
  catch (_) { }
3395
3824
  return createTextResult({
3396
- forwardedMessage: detail.id,
3825
+ forwardedMessage: prefixedIdFor(readBundleFwd, detail.id),
3397
3826
  to,
3398
3827
  cc,
3399
3828
  messageId: result.messageId,
@@ -3401,7 +3830,7 @@ export function createServer(config, options = {}) {
3401
3830
  rejected: result.rejected,
3402
3831
  response: result.response,
3403
3832
  sentCopy: sentCopyTokenFwd,
3404
- }, false, [emailSource(detail)]);
3833
+ }, false, [emailSource({ ...detail, id: prefixedIdFor(readBundleFwd, detail.id) })]);
3405
3834
  }
3406
3835
  case "create_draft": {
3407
3836
  const to = parseEmails(optionalString(args, "to"));
@@ -3410,6 +3839,7 @@ export function createServer(config, options = {}) {
3410
3839
  const subject = requireString(args, "subject");
3411
3840
  const body = requireString(args, "body");
3412
3841
  const replyTo = optionalString(args, "replyTo");
3842
+ const from = optionalString(args, "from");
3413
3843
  const attachments = optionalAttachmentList(args.attachments);
3414
3844
  const priority = optionalString(args, "priority");
3415
3845
  ensureValidEmails(to, "to");
@@ -3418,8 +3848,16 @@ export function createServer(config, options = {}) {
3418
3848
  if (replyTo && !isValidEmail(replyTo)) {
3419
3849
  throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
3420
3850
  }
3851
+ if (from && !isValidEmail(from)) {
3852
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3853
+ }
3854
+ // A `from` matching a configured additional account stores the draft in
3855
+ // THAT account's own draftStore (own dataDir/drafts.json) instead of the
3856
+ // primary's — the draft's id is prefixed with that account's slug in the
3857
+ // response so get_draft/update_draft/send_draft/... can find it again.
3858
+ const createDraftBundle = from ? accountManager.byAddress(from) ?? primaryBundle : primaryBundle;
3421
3859
  const result = await withAudit(auditService, name, args, async () => {
3422
- const draft = await draftStore.createDraft({
3860
+ const draft = await createDraftBundle.draftStore.createDraft({
3423
3861
  mode: "compose",
3424
3862
  to,
3425
3863
  cc,
@@ -3431,12 +3869,13 @@ export function createServer(config, options = {}) {
3431
3869
  ? priority
3432
3870
  : undefined,
3433
3871
  replyTo,
3872
+ from,
3434
3873
  notes: optionalString(args, "notes"),
3435
3874
  attachments,
3436
3875
  });
3437
3876
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3438
3877
  const synced = remoteSyncDecision.enabled
3439
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
3878
+ ? await syncDraftToRemote(createDraftBundle.draftStore, createDraftBundle.smtpService, createDraftBundle.imapService, draft)
3440
3879
  : { draft, remoteSync: undefined };
3441
3880
  const remoteSync = synced.remoteSync ??
3442
3881
  (remoteSyncDecision.reason
@@ -3446,7 +3885,7 @@ export function createServer(config, options = {}) {
3446
3885
  message: remoteSyncDecision.reason,
3447
3886
  }
3448
3887
  : undefined);
3449
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
3888
+ return presentDraft(createDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3450
3889
  });
3451
3890
  return createTextResult(result, false, [
3452
3891
  draftSource(result),
@@ -3478,8 +3917,12 @@ export function createServer(config, options = {}) {
3478
3917
  if (to.length === 0) {
3479
3918
  throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
3480
3919
  }
3920
+ // Same from-based draftStore routing as create_draft — see its comment.
3921
+ const createReplyDraftBundle = optionalString(args, "from") !== undefined
3922
+ ? accountManager.byAddress(optionalString(args, "from")) ?? primaryBundle
3923
+ : primaryBundle;
3481
3924
  const result = await withAudit(auditService, name, args, async () => {
3482
- const draft = await draftStore.createDraft({
3925
+ const draft = await createReplyDraftBundle.draftStore.createDraft({
3483
3926
  mode: "reply",
3484
3927
  to,
3485
3928
  cc,
@@ -3496,7 +3939,7 @@ export function createServer(config, options = {}) {
3496
3939
  });
3497
3940
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3498
3941
  const synced = remoteSyncDecision.enabled
3499
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
3942
+ ? await syncDraftToRemote(createReplyDraftBundle.draftStore, createReplyDraftBundle.smtpService, createReplyDraftBundle.imapService, draft)
3500
3943
  : { draft, remoteSync: undefined };
3501
3944
  const remoteSync = synced.remoteSync ??
3502
3945
  (remoteSyncDecision.reason
@@ -3506,7 +3949,7 @@ export function createServer(config, options = {}) {
3506
3949
  message: remoteSyncDecision.reason,
3507
3950
  }
3508
3951
  : undefined);
3509
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
3952
+ return presentDraft(createReplyDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3510
3953
  });
3511
3954
  return createTextResult(result, false, [
3512
3955
  draftSource(result),
@@ -3544,8 +3987,12 @@ export function createServer(config, options = {}) {
3544
3987
  .map((a) => imapService.getAttachmentForForward(detail.id, a.id)))
3545
3988
  : [];
3546
3989
  const attachments = [...originalAttachments, ...callerAttachments];
3990
+ // Same from-based draftStore routing as create_draft — see its comment.
3991
+ const createForwardDraftBundle = optionalString(args, "from") !== undefined
3992
+ ? accountManager.byAddress(optionalString(args, "from")) ?? primaryBundle
3993
+ : primaryBundle;
3547
3994
  const result = await withAudit(auditService, name, args, async () => {
3548
- const draft = await draftStore.createDraft({
3995
+ const draft = await createForwardDraftBundle.draftStore.createDraft({
3549
3996
  mode: "forward",
3550
3997
  to,
3551
3998
  cc,
@@ -3560,7 +4007,7 @@ export function createServer(config, options = {}) {
3560
4007
  });
3561
4008
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3562
4009
  const synced = remoteSyncDecision.enabled
3563
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
4010
+ ? await syncDraftToRemote(createForwardDraftBundle.draftStore, createForwardDraftBundle.smtpService, createForwardDraftBundle.imapService, draft)
3564
4011
  : { draft, remoteSync: undefined };
3565
4012
  const remoteSync = synced.remoteSync ??
3566
4013
  (remoteSyncDecision.reason
@@ -3570,7 +4017,7 @@ export function createServer(config, options = {}) {
3570
4017
  message: remoteSyncDecision.reason,
3571
4018
  }
3572
4019
  : undefined);
3573
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
4020
+ return presentDraft(createForwardDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3574
4021
  });
3575
4022
  return createTextResult(result, false, [
3576
4023
  draftSource(result),
@@ -3587,6 +4034,9 @@ export function createServer(config, options = {}) {
3587
4034
  ]);
3588
4035
  }
3589
4036
  case "list_drafts": {
4037
+ // Scope limitation (follow-up round): stays scoped to the primary account's
4038
+ // drafts only — a non-primary account's drafts aren't listed here yet. Use
4039
+ // get_draft with that account's prefixed draftId if you already have one.
3590
4040
  const drafts = await draftStore.listDrafts(normalizeBoolean(args.includeSent, false));
3591
4041
  return createTextResult({
3592
4042
  total: drafts.length,
@@ -3594,11 +4044,14 @@ export function createServer(config, options = {}) {
3594
4044
  }, false, drafts.map(draftSource));
3595
4045
  }
3596
4046
  case "list_remote_drafts": {
4047
+ // Scope limitation (follow-up round): stays scoped to the primary account's
4048
+ // remote Drafts mailbox only — see list_drafts' identical note.
3597
4049
  const result = await imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
3598
4050
  return createTextResult(result, false, result.emails.map(emailSource));
3599
4051
  }
3600
4052
  case "get_draft": {
3601
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
4053
+ const { bundle: getDraftBundle, rest: getDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4054
+ const draft = presentDraft(getDraftBundle, await getDraftBundle.draftStore.getDraft(getDraftIdRest));
3602
4055
  return createTextResult(draft, false, [
3603
4056
  draftSource(draft),
3604
4057
  ...(draft.remoteDraft?.emailId
@@ -3613,11 +4066,12 @@ export function createServer(config, options = {}) {
3613
4066
  ]);
3614
4067
  }
3615
4068
  case "update_draft": {
3616
- const draftId = requireString(args, "draftId");
4069
+ const { bundle: updateDraftBundle, rest: updateDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
3617
4070
  const to = args.to === undefined ? undefined : parseEmails(optionalString(args, "to"));
3618
4071
  const cc = args.cc === undefined ? undefined : parseEmails(optionalString(args, "cc"));
3619
4072
  const bcc = args.bcc === undefined ? undefined : parseEmails(optionalString(args, "bcc"));
3620
4073
  const replyTo = optionalString(args, "replyTo");
4074
+ const from = optionalString(args, "from");
3621
4075
  const priority = optionalString(args, "priority");
3622
4076
  const attachments = args.attachments === undefined ? undefined : optionalAttachmentList(args.attachments);
3623
4077
  if (to) {
@@ -3632,8 +4086,11 @@ export function createServer(config, options = {}) {
3632
4086
  if (replyTo && !isValidEmail(replyTo)) {
3633
4087
  throw new McpError(ErrorCode.InvalidParams, "replyTo must be a valid email address.");
3634
4088
  }
4089
+ if (from && !isValidEmail(from)) {
4090
+ throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
4091
+ }
3635
4092
  const result = await withAudit(auditService, name, args, async () => {
3636
- const draft = await draftStore.updateDraft(draftId, {
4093
+ const draft = await updateDraftBundle.draftStore.updateDraft(updateDraftIdRest, {
3637
4094
  to,
3638
4095
  cc,
3639
4096
  bcc,
@@ -3644,12 +4101,13 @@ export function createServer(config, options = {}) {
3644
4101
  ? priority
3645
4102
  : undefined,
3646
4103
  replyTo,
4104
+ from,
3647
4105
  attachments,
3648
4106
  notes: optionalString(args, "notes"),
3649
4107
  });
3650
4108
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3651
4109
  const synced = remoteSyncDecision.enabled
3652
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
4110
+ ? await syncDraftToRemote(updateDraftBundle.draftStore, updateDraftBundle.smtpService, updateDraftBundle.imapService, draft)
3653
4111
  : { draft, remoteSync: undefined };
3654
4112
  const remoteSync = synced.remoteSync ??
3655
4113
  (remoteSyncDecision.reason
@@ -3659,7 +4117,7 @@ export function createServer(config, options = {}) {
3659
4117
  message: remoteSyncDecision.reason,
3660
4118
  }
3661
4119
  : undefined);
3662
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
4120
+ return presentDraft(updateDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3663
4121
  });
3664
4122
  return createTextResult(result, false, [
3665
4123
  draftSource(result),
@@ -3676,16 +4134,18 @@ export function createServer(config, options = {}) {
3676
4134
  }
3677
4135
  case "sync_draft_to_remote": {
3678
4136
  ensureRemoteDraftSyncAllowed(config.runtime);
3679
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
3680
- const synced = await withAudit(auditService, name, args, async () => syncDraftToRemote(draftStore, smtpService, imapService, draft));
3681
- return createTextResult({ ...synced.draft, remoteSync: synced.remoteSync }, false, [
3682
- draftSource(synced.draft),
3683
- ...(synced.draft.remoteDraft?.emailId
4137
+ const { bundle: syncDraftBundle, rest: syncDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4138
+ const draft = await syncDraftBundle.draftStore.getDraft(syncDraftIdRest);
4139
+ const synced = await withAudit(auditService, name, args, async () => syncDraftToRemote(syncDraftBundle.draftStore, syncDraftBundle.smtpService, syncDraftBundle.imapService, draft));
4140
+ const presentedSynced = presentDraft(syncDraftBundle, synced.draft);
4141
+ return createTextResult({ ...presentedSynced, remoteSync: synced.remoteSync }, false, [
4142
+ draftSource(presentedSynced),
4143
+ ...(presentedSynced.remoteDraft?.emailId
3684
4144
  ? [
3685
4145
  emailSource({
3686
- id: synced.draft.remoteDraft.emailId,
3687
- subject: synced.draft.subject,
3688
- folder: synced.draft.remoteDraft.folder,
4146
+ id: presentedSynced.remoteDraft.emailId,
4147
+ subject: presentedSynced.subject,
4148
+ folder: presentedSynced.remoteDraft.folder,
3689
4149
  })
3690
4150
  ]
3691
4151
  : []),
@@ -3694,7 +4154,8 @@ export function createServer(config, options = {}) {
3694
4154
  case "send_draft": {
3695
4155
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send draft ${String(args.draftId ?? "?")}`);
3696
4156
  ensureSendAllowed(config.runtime);
3697
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
4157
+ const { bundle: sendDraftBundle, rest: sendDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4158
+ const draft = await sendDraftBundle.draftStore.getDraft(sendDraftIdRest);
3698
4159
  // Found live: calling send_draft twice on the same draft sent it
3699
4160
  // twice — nothing here checked whether it was already marked
3700
4161
  // "sent" before sending again. Refuse; create_draft for a new
@@ -3707,7 +4168,7 @@ export function createServer(config, options = {}) {
3707
4168
  // transactions, since nothing here knew about the still-pending
3708
4169
  // scheduled send. Refuse instead; cancel_send first if the
3709
4170
  // immediate send is actually what's wanted.
3710
- const pendingScheduled = (await deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4171
+ const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
3711
4172
  if (pendingScheduled) {
3712
4173
  throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${pendingScheduled.id}, sendAt ${pendingScheduled.sendAt}). Sending now would deliver it twice. Cancel that scheduled send with cancel_send first, or wait for it to fire.`);
3713
4174
  }
@@ -3748,7 +4209,7 @@ export function createServer(config, options = {}) {
3748
4209
  // them. claimForSending() re-reads status under the draft store's
3749
4210
  // lock and flips "draft" -> "sending" in that same critical
3750
4211
  // section — only the call that wins the claim proceeds to SMTP.
3751
- await draftStore.claimForSending(draft.id);
4212
+ await sendDraftBundle.draftStore.claimForSending(draft.id);
3752
4213
  // Found live: withAudit wrapped the SMTP call, so an audit-log
3753
4214
  // write failure AFTER a successful send (e.g. disk full) was
3754
4215
  // indistinguishable from a send failure — it threw out of
@@ -3762,7 +4223,7 @@ export function createServer(config, options = {}) {
3762
4223
  const sendStartedAt = Date.now();
3763
4224
  let result;
3764
4225
  try {
3765
- result = await smtpService.sendEmail({
4226
+ result = await sendDraftBundle.smtpService.sendEmail({
3766
4227
  to: draft.to,
3767
4228
  cc: draft.cc,
3768
4229
  bcc: draft.bcc,
@@ -3771,6 +4232,7 @@ export function createServer(config, options = {}) {
3771
4232
  isHtml: draft.isHtml,
3772
4233
  priority: draft.priority,
3773
4234
  replyTo: draft.replyTo,
4235
+ from: draft.from,
3774
4236
  inReplyTo: draft.inReplyTo,
3775
4237
  references: draft.references,
3776
4238
  attachments: draft.attachments,
@@ -3785,7 +4247,7 @@ export function createServer(config, options = {}) {
3785
4247
  // Mirrors SnoozeService.wake()'s catch handler: revert the claim
3786
4248
  // so the draft is retryable instead of stuck in "sending"
3787
4249
  // forever because SMTP failed.
3788
- await draftStore.revertSending(draft.id);
4250
+ await sendDraftBundle.draftStore.revertSending(draft.id);
3789
4251
  await auditService
3790
4252
  .record({
3791
4253
  timestamp: new Date().toISOString(),
@@ -3814,14 +4276,14 @@ export function createServer(config, options = {}) {
3814
4276
  catch (auditError) {
3815
4277
  logger.error("Failed to record audit log for a successful send_draft — the email was already sent, so the draft is not being reverted", "MCPServer", { draftId: draft.id, error: auditError });
3816
4278
  }
3817
- let sentDraft = await draftStore.markSent(draft.id, {
4279
+ let sentDraft = await sendDraftBundle.draftStore.markSent(draft.id, {
3818
4280
  messageId: result.messageId,
3819
4281
  accepted: result.accepted,
3820
4282
  rejected: result.rejected,
3821
4283
  response: result.response,
3822
4284
  });
3823
4285
  const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
3824
- ? await clearRemoteDraft(draftStore, imapService, sentDraft)
4286
+ ? await clearRemoteDraft(sendDraftBundle.draftStore, sendDraftBundle.imapService, sentDraft)
3825
4287
  : {
3826
4288
  draft: sentDraft,
3827
4289
  remoteDelete: sentDraft.remoteDraft?.emailId
@@ -3832,16 +4294,16 @@ export function createServer(config, options = {}) {
3832
4294
  }
3833
4295
  : undefined,
3834
4296
  };
3835
- sentDraft = remoteCleanup.draft;
4297
+ sentDraft = presentDraft(sendDraftBundle, remoteCleanup.draft);
3836
4298
  const sources = [draftSource(sentDraft)];
3837
4299
  if (sentDraft.sourceEmailId) {
3838
- sources.push(emailSource(await imapService.getEmailById(sentDraft.sourceEmailId)));
4300
+ sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
3839
4301
  }
3840
4302
  let sentCopyTokenDraft = "[sent-copy:unverified]";
3841
4303
  try {
3842
4304
  const verifyMsgId = result.messageId;
3843
4305
  if (verifyMsgId) {
3844
- const scv = await verifySentCopy(imapService, verifyMsgId);
4306
+ const scv = await verifySentCopy(sendDraftBundle.imapService, verifyMsgId);
3845
4307
  if (scv.found)
3846
4308
  sentCopyTokenDraft = "[sent-copy:verified]";
3847
4309
  }
@@ -3861,7 +4323,8 @@ export function createServer(config, options = {}) {
3861
4323
  case "schedule_draft": {
3862
4324
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Schedule draft ${String(args.draftId ?? "?")} to send later`);
3863
4325
  ensureSendAllowed(config.runtime);
3864
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
4326
+ const { bundle: scheduleDraftBundle, rest: scheduleDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4327
+ const draft = await scheduleDraftBundle.draftStore.getDraft(scheduleDraftIdRest);
3865
4328
  // Mirrors send_draft's own pendingScheduled guard, for the reverse
3866
4329
  // ordering: send_draft already delivered this draft once, and
3867
4330
  // scheduling it again here would deliver it a second time at
@@ -3886,7 +4349,7 @@ export function createServer(config, options = {}) {
3886
4349
  // duplicate. This external check is kept only as an optional
3887
4350
  // early-exit optimization to avoid a wasted round-trip to the lock
3888
4351
  // in the common non-racing case.
3889
- const alreadyPendingScheduled = (await deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4352
+ const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
3890
4353
  if (alreadyPendingScheduled) {
3891
4354
  throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${alreadyPendingScheduled.id}, sendAt ${alreadyPendingScheduled.sendAt}). Scheduling it again would deliver it twice. Cancel the existing one with cancel_send first if you want a different sendAt.`);
3892
4355
  }
@@ -3912,7 +4375,7 @@ export function createServer(config, options = {}) {
3912
4375
  if (external.length > 0)
3913
4376
  throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF is enabled. All recipients must be the authenticated user.");
3914
4377
  }
3915
- const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue({
4378
+ const queued = await withAudit(auditService, name, args, async () => scheduleDraftBundle.deliveryQueueService.enqueue({
3916
4379
  to: draft.to,
3917
4380
  cc: draft.cc,
3918
4381
  bcc: draft.bcc,
@@ -3921,6 +4384,7 @@ export function createServer(config, options = {}) {
3921
4384
  isHtml: draft.isHtml,
3922
4385
  priority: draft.priority,
3923
4386
  replyTo: draft.replyTo,
4387
+ from: draft.from,
3924
4388
  inReplyTo: draft.inReplyTo,
3925
4389
  references: draft.references,
3926
4390
  attachments: draft.attachments,
@@ -3931,17 +4395,18 @@ export function createServer(config, options = {}) {
3931
4395
  return createTextResult({
3932
4396
  queued: true,
3933
4397
  id: queued.id,
3934
- draftId: draft.id,
4398
+ draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
3935
4399
  sendAt: queued.sendAt,
3936
4400
  note: "The draft's content was snapshotted now and queued. This server must stay running for the send to fire at sendAt — if restarted first, it fires on next startup instead. The draft record itself is not automatically marked sent; use cancel_send with this id to abort before it fires.",
3937
4401
  });
3938
4402
  }
3939
4403
  case "delete_draft": {
3940
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
4404
+ const { bundle: deleteDraftBundle, rest: deleteDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4405
+ const draft = await deleteDraftBundle.draftStore.getDraft(deleteDraftIdRest);
3941
4406
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args?.confirmed, false), "Permanently delete draft: " + draft.id);
3942
4407
  const deleted = await withAudit(auditService, name, args, async () => {
3943
4408
  const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
3944
- ? await clearRemoteDraft(draftStore, imapService, draft)
4409
+ ? await clearRemoteDraft(deleteDraftBundle.draftStore, deleteDraftBundle.imapService, draft)
3945
4410
  : {
3946
4411
  draft,
3947
4412
  remoteDelete: draft.remoteDraft?.emailId
@@ -3952,8 +4417,10 @@ export function createServer(config, options = {}) {
3952
4417
  }
3953
4418
  : undefined,
3954
4419
  };
4420
+ const deletedResult = await deleteDraftBundle.draftStore.deleteDraft(draft.id);
3955
4421
  return {
3956
- ...(await draftStore.deleteDraft(draft.id)),
4422
+ ...deletedResult,
4423
+ id: withAccountPrefix(responseSlug(deleteDraftBundle), deletedResult.id),
3957
4424
  remoteDelete: remoteCleanup.remoteDelete,
3958
4425
  };
3959
4426
  });
@@ -3961,23 +4428,50 @@ export function createServer(config, options = {}) {
3961
4428
  }
3962
4429
  case "get_emails": {
3963
4430
  const effectiveLimit = normalizeLimit(args.limit, 50);
3964
- const result = await imapService.getEmails({
4431
+ const getEmailsInput = {
3965
4432
  folder: optionalString(args, "folder"),
3966
4433
  limit: effectiveLimit,
3967
4434
  offset: typeof args.offset === "number" ? args.offset : undefined,
3968
4435
  beforeUid: typeof args?.beforeUid === "number" ? args.beforeUid : undefined,
3969
- sortByUid: args?.sortByUid === "asc" || args?.sortByUid === "desc" ? args.sortByUid : undefined,
4436
+ sortByUid: (args?.sortByUid === "asc" || args?.sortByUid === "desc" ? args.sortByUid : undefined),
3970
4437
  includeSnippet: normalizeBoolean(args.includeSnippet, false),
3971
- });
4438
+ };
4439
+ if (accountManager.all().length === 1) {
4440
+ const result = await imapService.getEmails(getEmailsInput);
4441
+ return createTextResult({
4442
+ ...result,
4443
+ emails: projectFields(result.emails, parseFieldsArg(args.fields)),
4444
+ returned: result.emails.length,
4445
+ hasMore: result.emails.length === effectiveLimit,
4446
+ }, false, result.emails.map(emailSource));
4447
+ }
4448
+ // Merge strategy: live-fetch each account's own folder page (LIVE IMAP,
4449
+ // one call per account), tag ids with the account slug, concatenate,
4450
+ // re-sort newest-first, then re-apply the original limit across the
4451
+ // merged set (not per account) and sum totals across accounts.
4452
+ const primarySlug = accountManager.primary().account.slug;
4453
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
4454
+ const result = await bundle.imapService.getEmails(getEmailsInput);
4455
+ return { bundle, result };
4456
+ }));
4457
+ const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
4458
+ const merged = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date).slice(0, effectiveLimit);
4459
+ const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
3972
4460
  return createTextResult({
3973
- ...result,
3974
- emails: projectFields(result.emails, parseFieldsArg(args.fields)),
3975
- returned: result.emails.length,
3976
- hasMore: result.emails.length === effectiveLimit,
3977
- }, false, result.emails.map(emailSource));
4461
+ folder: getEmailsInput.folder?.trim() || "INBOX",
4462
+ total,
4463
+ limit: effectiveLimit,
4464
+ offset: getEmailsInput.offset ?? 0,
4465
+ emails: projectFields(merged, parseFieldsArg(args.fields)),
4466
+ returned: merged.length,
4467
+ hasMore: merged.length === effectiveLimit,
4468
+ byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
4469
+ }, false, merged.map(emailSource));
3978
4470
  }
3979
4471
  case "get_email_by_id": {
3980
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
4472
+ const rawEmailId = requireString(args, "emailId");
4473
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4474
+ const detail = await bundle.imapService.getEmailById(emailId);
3981
4475
  const preferHtml = normalizeBoolean(args.preferHtml, false);
3982
4476
  const maxBodyLength = normalizeLimit(args.maxBodyLength, undefined, 1, 500_000);
3983
4477
  const showHeaders = normalizeBoolean(args.showHeaders, false);
@@ -3988,7 +4482,9 @@ export function createServer(config, options = {}) {
3988
4482
  // on top without ever removing the always-present full map.
3989
4483
  if (showHeaders)
3990
4484
  output.headers = detail.headers;
3991
- return createTextResult(output, false, [emailSource(detail), ...detail.attachments.map((attachment) => attachmentSource(detail.id, attachment))]);
4485
+ const prefixedEmailId = prefixedIdFor(bundle, detail.id);
4486
+ output.id = prefixedEmailId;
4487
+ return createTextResult(output, false, [emailSource({ ...detail, id: prefixedEmailId }), ...detail.attachments.map((attachment) => attachmentSource(prefixedEmailId, attachment))]);
3992
4488
  }
3993
4489
  case "get_emails_by_ids": {
3994
4490
  const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];
@@ -4057,7 +4553,8 @@ export function createServer(config, options = {}) {
4057
4553
  }
4058
4554
  case "update_message_labels": {
4059
4555
  ensureMailboxWriteAllowed(config.runtime);
4060
- const emailId = requireString(args, "emailId");
4556
+ const rawEmailId = requireString(args, "emailId");
4557
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4061
4558
  const labelsToAdd = Array.isArray(args.labelsToAdd)
4062
4559
  ? args.labelsToAdd.map(String)
4063
4560
  : [];
@@ -4072,12 +4569,13 @@ export function createServer(config, options = {}) {
4072
4569
  // treats that as "unverifiable, don't block on it", same as every
4073
4570
  // other mutation handler below.
4074
4571
  const uidValidity = parseEmailId(emailId).uidValidity;
4075
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity));
4076
- return createTextResult(result, false, [emailSource({ id: emailId })]);
4572
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity));
4573
+ return createTextResult(result, false, [emailSource({ id: prefixedIdFor(bundle, emailId) })]);
4077
4574
  }
4078
4575
  case "update_message_flags": {
4079
4576
  ensureMailboxWriteAllowed(config.runtime);
4080
- const emailId = requireString(args, "emailId");
4577
+ const rawEmailId = requireString(args, "emailId");
4578
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4081
4579
  const flagsToAdd = Array.isArray(args.flagsToAdd)
4082
4580
  ? args.flagsToAdd.map(String)
4083
4581
  : [];
@@ -4088,11 +4586,11 @@ export function createServer(config, options = {}) {
4088
4586
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one flag in flagsToAdd or flagsToRemove.");
4089
4587
  }
4090
4588
  const uidValidity = parseEmailId(emailId).uidValidity;
4091
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity));
4092
- return createTextResult(result, false, [emailSource({ id: emailId })]);
4589
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity));
4590
+ return createTextResult(result, false, [emailSource({ id: prefixedIdFor(bundle, emailId) })]);
4093
4591
  }
4094
4592
  case "count_messages": {
4095
- const result = await imapService.countMessages({
4593
+ const countInput = {
4096
4594
  folder: optionalString(args, "folder"),
4097
4595
  query: optionalString(args, "query"),
4098
4596
  from: optionalString(args, "from"),
@@ -4108,12 +4606,44 @@ export function createServer(config, options = {}) {
4108
4606
  dateTo: optionalString(args, "dateTo"),
4109
4607
  sizeLarger: typeof args.sizeLarger === "number" ? args.sizeLarger : undefined,
4110
4608
  sizeSmaller: typeof args.sizeSmaller === "number" ? args.sizeSmaller : undefined,
4609
+ };
4610
+ if (accountManager.all().length === 1) {
4611
+ const result = await imapService.countMessages(countInput);
4612
+ return createTextResult(result);
4613
+ }
4614
+ // Merge strategy: count in every account's mailbox and sum — folder is
4615
+ // reported from the request (all accounts are counted against the same
4616
+ // folder name), with a per-account breakdown alongside the total.
4617
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
4618
+ slug: bundle.account.slug,
4619
+ ...(await bundle.imapService.countMessages(countInput)),
4620
+ })));
4621
+ return createTextResult({
4622
+ folder: countInput.folder ?? "INBOX",
4623
+ count: perAccount.reduce((sum, entry) => sum + entry.count, 0),
4624
+ byAccount: perAccount,
4111
4625
  });
4112
- return createTextResult(result);
4113
4626
  }
4114
4627
  case "folder_stats": {
4115
- const result = await imapService.getFolderStats(optionalString(args, "folder"), typeof args.scanLimit === "number" ? args.scanLimit : undefined);
4116
- return createTextResult(result);
4628
+ const folderStatsFolder = optionalString(args, "folder");
4629
+ const scanLimit = typeof args.scanLimit === "number" ? args.scanLimit : undefined;
4630
+ if (accountManager.all().length === 1) {
4631
+ const result = await imapService.getFolderStats(folderStatsFolder, scanLimit);
4632
+ return createTextResult(result);
4633
+ }
4634
+ // Merge strategy: fetch stats for the same folder in every account and
4635
+ // sum the numeric fields (total/unseen); uidNext/uidValidity are
4636
+ // per-account concepts and only make sense in the breakdown.
4637
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
4638
+ slug: bundle.account.slug,
4639
+ ...(await bundle.imapService.getFolderStats(folderStatsFolder, scanLimit)),
4640
+ })));
4641
+ return createTextResult({
4642
+ folder: folderStatsFolder ?? "INBOX",
4643
+ total: perAccount.reduce((sum, entry) => sum + entry.total, 0),
4644
+ unseen: perAccount.reduce((sum, entry) => sum + entry.unseen, 0),
4645
+ byAccount: perAccount,
4646
+ });
4117
4647
  }
4118
4648
  case "empty_folder": {
4119
4649
  if (!config.runtime.allowEmptyFolder) {
@@ -4151,41 +4681,57 @@ export function createServer(config, options = {}) {
4151
4681
  const folder = optionalString(args, "folder") ?? "INBOX";
4152
4682
  const targetFolder = requireString(args, "targetFolder");
4153
4683
  const max = getBulkMaxBatchSize(args);
4154
- // Only fetched (an extra STATUS round trip) when there are ids to
4155
- // check against it — a match-only bulk_move has no ids that could
4156
- // be stale, since match resolves directly against the live
4157
- // mailbox each time.
4158
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4159
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4160
- // Resolve the match/emailIds set exactly once and reuse it for both
4161
- // the preview and the real run — see resolveUidsForBulkOp's
4162
- // comment for why re-resolving `match` a second time (the old
4163
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4164
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4165
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4166
- ensureBulkBatchSize(uids.length, max);
4167
- if (normalizeBoolean(args.dryRun, false)) {
4168
- const preview = await imapService.bulkMove({
4169
- emailIds,
4170
- match,
4171
- folder,
4172
- targetFolder,
4173
- resolvedUids: uids,
4174
- uidValidity: currentUidValidity,
4175
- dryRun: true,
4684
+ const dryRun = normalizeBoolean(args.dryRun, false);
4685
+ // emailIds can span multiple configured accounts (a "<slug>::"
4686
+ // prefix names one; a plain id is the primary's) — group them so
4687
+ // each account's ids resolve/run against that account's own
4688
+ // imapService, then merge the per-group results back below. A
4689
+ // match-only call (no emailIds) has no account identity to group
4690
+ // by, so it stays scoped to the primary account, unchanged.
4691
+ const groups = emailIds
4692
+ ? groupEmailIdsByAccount(accountManager, emailIds)
4693
+ : new Map([
4694
+ [undefined, { bundle: primaryBundle, restIds: undefined }],
4695
+ ]);
4696
+ let totalUids = 0;
4697
+ const prepared = [];
4698
+ for (const [slug, group] of groups) {
4699
+ // Only fetched (an extra STATUS round trip) when there are ids to
4700
+ // check against it — a match-only bulk_move has no ids that could
4701
+ // be stale, since match resolves directly against the live
4702
+ // mailbox each time.
4703
+ const currentUidValidity = group.restIds ? await group.bundle.imapService.getMailboxUidValidity(folder) : undefined;
4704
+ const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
4705
+ // Resolve the match/emailIds set exactly once and reuse it for both
4706
+ // the preview and the real run — see resolveUidsForBulkOp's
4707
+ // comment for why re-resolving `match` a second time (the old
4708
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4709
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4710
+ const uids = await group.bundle.imapService.resolveUidsForBulkOp(folder, group.restIds, match, currentUidValidity);
4711
+ totalUids += uids.length;
4712
+ prepared.push({
4713
+ slug,
4714
+ bundle: group.bundle,
4715
+ notFound: notFoundEmailIds,
4716
+ run: () => group.bundle.imapService.bulkMove({
4717
+ emailIds: group.restIds,
4718
+ match,
4719
+ folder,
4720
+ targetFolder,
4721
+ resolvedUids: uids,
4722
+ uidValidity: currentUidValidity,
4723
+ dryRun,
4724
+ }),
4176
4725
  });
4177
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4178
4726
  }
4179
- const result = await withAudit(auditService, name, args, () => imapService.bulkMove({
4180
- emailIds,
4181
- match,
4182
- folder,
4183
- targetFolder,
4184
- resolvedUids: uids,
4185
- uidValidity: currentUidValidity,
4186
- dryRun: false,
4187
- }));
4188
- return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4727
+ ensureBulkBatchSize(totalUids, max);
4728
+ const outputs = [];
4729
+ for (const group of prepared) {
4730
+ const outputSlug = group.slug === primaryBundle.account.slug ? undefined : group.slug;
4731
+ const raw = dryRun ? await group.run() : await withAudit(group.bundle.auditService, name, args, group.run);
4732
+ outputs.push(withBulkNotFound(prefixBulkResult(raw, outputSlug), prefixNotFound(group.notFound, outputSlug)));
4733
+ }
4734
+ return createTextResult(mergeBulkResults(outputs));
4189
4735
  }
4190
4736
  case "bulk_delete": {
4191
4737
  ensureMailboxWriteAllowed(config.runtime);
@@ -4204,45 +4750,60 @@ export function createServer(config, options = {}) {
4204
4750
  throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
4205
4751
  const folder = optionalString(args, "folder") ?? "INBOX";
4206
4752
  const max = getBulkMaxBatchSize(args);
4207
- // Fetched once, up front, and reused for both the notFound
4208
- // reporting below and resolveUidsForBulkOp's own filtering — a
4209
- // single shared point-in-time value, same "resolve once" spirit
4210
- // as the uids resolution itself, so the ids this call reports as
4211
- // excluded and the uids it actually acts on can never disagree
4212
- // about which generation was current. Only fetched when there are
4213
- // ids to check against it (a match-only call has none).
4214
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4215
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4216
- // Resolve the match/emailIds set exactly once and reuse it for both
4217
- // the preview and the real run — see resolveUidsForBulkOp's
4218
- // comment for why re-resolving `match` a second time (the old
4219
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4220
- // Also excludes any id whose embedded UIDVALIDITY is stale — see
4221
- // resolveUidsForBulkOp's comment.
4222
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4223
- ensureBulkBatchSize(uids.length, max);
4224
- if (normalizeBoolean(args.dryRun, false)) {
4225
- const preview = await imapService.bulkDelete({
4226
- emailIds,
4227
- match,
4228
- folder,
4229
- permanent,
4230
- resolvedUids: uids,
4231
- uidValidity: currentUidValidity,
4232
- dryRun: true,
4753
+ const dryRun = normalizeBoolean(args.dryRun, false);
4754
+ // emailIds can span multiple configured accounts — group them so
4755
+ // each account's ids resolve/run against that account's own
4756
+ // imapService, then merge the per-group results back below. See
4757
+ // bulk_move's identical grouping comment. A match-only call stays
4758
+ // scoped to the primary account, unchanged.
4759
+ const groups = emailIds
4760
+ ? groupEmailIdsByAccount(accountManager, emailIds)
4761
+ : new Map([
4762
+ [undefined, { bundle: primaryBundle, restIds: undefined }],
4763
+ ]);
4764
+ let totalUids = 0;
4765
+ const prepared = [];
4766
+ for (const [slug, group] of groups) {
4767
+ // Fetched once, up front, and reused for both the notFound
4768
+ // reporting below and resolveUidsForBulkOp's own filtering — a
4769
+ // single shared point-in-time value, same "resolve once" spirit
4770
+ // as the uids resolution itself, so the ids this call reports as
4771
+ // excluded and the uids it actually acts on can never disagree
4772
+ // about which generation was current. Only fetched when there are
4773
+ // ids to check against it (a match-only call has none).
4774
+ const currentUidValidity = group.restIds ? await group.bundle.imapService.getMailboxUidValidity(folder) : undefined;
4775
+ const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
4776
+ // Resolve the match/emailIds set exactly once and reuse it for both
4777
+ // the preview and the real run — see resolveUidsForBulkOp's
4778
+ // comment for why re-resolving `match` a second time (the old
4779
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4780
+ // Also excludes any id whose embedded UIDVALIDITY is stale — see
4781
+ // resolveUidsForBulkOp's comment.
4782
+ const uids = await group.bundle.imapService.resolveUidsForBulkOp(folder, group.restIds, match, currentUidValidity);
4783
+ totalUids += uids.length;
4784
+ prepared.push({
4785
+ slug,
4786
+ bundle: group.bundle,
4787
+ notFound: notFoundEmailIds,
4788
+ run: () => group.bundle.imapService.bulkDelete({
4789
+ emailIds: group.restIds,
4790
+ match,
4791
+ folder,
4792
+ permanent,
4793
+ resolvedUids: uids,
4794
+ uidValidity: currentUidValidity,
4795
+ dryRun,
4796
+ }),
4233
4797
  });
4234
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4235
4798
  }
4236
- const result = await withAudit(auditService, name, args, () => imapService.bulkDelete({
4237
- emailIds,
4238
- match,
4239
- folder,
4240
- permanent,
4241
- resolvedUids: uids,
4242
- uidValidity: currentUidValidity,
4243
- dryRun: false,
4244
- }));
4245
- return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4799
+ ensureBulkBatchSize(totalUids, max);
4800
+ const outputs = [];
4801
+ for (const group of prepared) {
4802
+ const outputSlug = group.slug === primaryBundle.account.slug ? undefined : group.slug;
4803
+ const raw = dryRun ? await group.run() : await withAudit(group.bundle.auditService, name, args, group.run);
4804
+ outputs.push(withBulkNotFound(prefixBulkResult(raw, outputSlug), prefixNotFound(group.notFound, outputSlug)));
4805
+ }
4806
+ return createTextResult(mergeBulkResults(outputs));
4246
4807
  }
4247
4808
  case "bulk_update_flags": {
4248
4809
  ensureMailboxWriteAllowed(config.runtime);
@@ -4262,23 +4823,55 @@ export function createServer(config, options = {}) {
4262
4823
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4263
4824
  const folder = optionalString(args, "folder") ?? "INBOX";
4264
4825
  const max = getBulkMaxBatchSize(args);
4265
- // See the bulk_delete case above for why this is fetched once and
4266
- // shared between notFound reporting and uid resolution.
4267
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4268
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4269
- // Resolve the match/emailIds set exactly once and reuse it for both
4270
- // the preview and the real run — see resolveUidsForBulkOp's
4271
- // comment for why re-resolving `match` a second time (the old
4272
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4273
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4274
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4275
- ensureBulkBatchSize(uids.length, max);
4276
- if (normalizeBoolean(args.dryRun, false)) {
4277
- const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4278
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4826
+ const dryRun = normalizeBoolean(args.dryRun, false);
4827
+ // emailIds can span multiple configured accounts — group them so
4828
+ // each account's ids resolve/run against that account's own
4829
+ // imapService, then merge the per-group results back below. See
4830
+ // bulk_move's identical grouping comment. A match-only call stays
4831
+ // scoped to the primary account, unchanged.
4832
+ const groups = emailIds
4833
+ ? groupEmailIdsByAccount(accountManager, emailIds)
4834
+ : new Map([
4835
+ [undefined, { bundle: primaryBundle, restIds: undefined }],
4836
+ ]);
4837
+ let totalUids = 0;
4838
+ const prepared = [];
4839
+ for (const [slug, group] of groups) {
4840
+ // See the bulk_delete case above for why this is fetched once and
4841
+ // shared between notFound reporting and uid resolution.
4842
+ const currentUidValidity = group.restIds ? await group.bundle.imapService.getMailboxUidValidity(folder) : undefined;
4843
+ const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
4844
+ // Resolve the match/emailIds set exactly once and reuse it for both
4845
+ // the preview and the real run — see resolveUidsForBulkOp's
4846
+ // comment for why re-resolving `match` a second time (the old
4847
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4848
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4849
+ const uids = await group.bundle.imapService.resolveUidsForBulkOp(folder, group.restIds, match, currentUidValidity);
4850
+ totalUids += uids.length;
4851
+ prepared.push({
4852
+ slug,
4853
+ bundle: group.bundle,
4854
+ notFound: notFoundEmailIds,
4855
+ run: () => group.bundle.imapService.bulkUpdateFlags({
4856
+ emailIds: group.restIds,
4857
+ match,
4858
+ folder,
4859
+ flagsToAdd,
4860
+ flagsToRemove,
4861
+ resolvedUids: uids,
4862
+ uidValidity: currentUidValidity,
4863
+ dryRun,
4864
+ }),
4865
+ });
4279
4866
  }
4280
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4281
- return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4867
+ ensureBulkBatchSize(totalUids, max);
4868
+ const outputs = [];
4869
+ for (const group of prepared) {
4870
+ const outputSlug = group.slug === primaryBundle.account.slug ? undefined : group.slug;
4871
+ const raw = dryRun ? await group.run() : await withAudit(group.bundle.auditService, name, args, group.run);
4872
+ outputs.push(withBulkNotFound(prefixBulkResult(raw, outputSlug), prefixNotFound(group.notFound, outputSlug)));
4873
+ }
4874
+ return createTextResult(mergeBulkResults(outputs));
4282
4875
  }
4283
4876
  case "bulk_update_labels": {
4284
4877
  ensureMailboxWriteAllowed(config.runtime);
@@ -4296,39 +4889,111 @@ export function createServer(config, options = {}) {
4296
4889
  const labelsToRemove = Array.isArray(args.labelsToRemove) ? args.labelsToRemove.map(String) : [];
4297
4890
  const folder = optionalString(args, "folder") ?? "INBOX";
4298
4891
  const max = getBulkMaxBatchSize(args);
4299
- // See the bulk_delete case above for why this is fetched once and
4300
- // shared between notFound reporting and uid resolution.
4301
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4302
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4303
- // Resolve the match/emailIds set exactly once and reuse it for both
4304
- // the preview and the real run — see resolveUidsForBulkOp's
4305
- // comment for why re-resolving `match` a second time (the old
4306
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4307
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4308
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4309
- ensureBulkBatchSize(uids.length, max);
4310
- if (normalizeBoolean(args.dryRun, false)) {
4311
- const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4312
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4892
+ const dryRun = normalizeBoolean(args.dryRun, false);
4893
+ // emailIds can span multiple configured accounts — group them so
4894
+ // each account's ids resolve/run against that account's own
4895
+ // imapService, then merge the per-group results back below. See
4896
+ // bulk_move's identical grouping comment. A match-only call stays
4897
+ // scoped to the primary account, unchanged.
4898
+ const groups = emailIds
4899
+ ? groupEmailIdsByAccount(accountManager, emailIds)
4900
+ : new Map([
4901
+ [undefined, { bundle: primaryBundle, restIds: undefined }],
4902
+ ]);
4903
+ let totalUids = 0;
4904
+ const prepared = [];
4905
+ for (const [slug, group] of groups) {
4906
+ // See the bulk_delete case above for why this is fetched once and
4907
+ // shared between notFound reporting and uid resolution.
4908
+ const currentUidValidity = group.restIds ? await group.bundle.imapService.getMailboxUidValidity(folder) : undefined;
4909
+ const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
4910
+ // Resolve the match/emailIds set exactly once and reuse it for both
4911
+ // the preview and the real run — see resolveUidsForBulkOp's
4912
+ // comment for why re-resolving `match` a second time (the old
4913
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4914
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4915
+ const uids = await group.bundle.imapService.resolveUidsForBulkOp(folder, group.restIds, match, currentUidValidity);
4916
+ totalUids += uids.length;
4917
+ prepared.push({
4918
+ slug,
4919
+ bundle: group.bundle,
4920
+ notFound: notFoundEmailIds,
4921
+ run: () => group.bundle.imapService.bulkUpdateLabels({
4922
+ emailIds: group.restIds,
4923
+ match,
4924
+ folder,
4925
+ labelsToAdd,
4926
+ labelsToRemove,
4927
+ resolvedUids: uids,
4928
+ uidValidity: currentUidValidity,
4929
+ dryRun,
4930
+ }),
4931
+ });
4932
+ }
4933
+ ensureBulkBatchSize(totalUids, max);
4934
+ const outputs = [];
4935
+ for (const group of prepared) {
4936
+ const outputSlug = group.slug === primaryBundle.account.slug ? undefined : group.slug;
4937
+ const raw = dryRun ? await group.run() : await withAudit(group.bundle.auditService, name, args, group.run);
4938
+ outputs.push(withBulkNotFound(prefixBulkResult(raw, outputSlug), prefixNotFound(group.notFound, outputSlug)));
4313
4939
  }
4314
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4315
- return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4940
+ return createTextResult(mergeBulkResults(outputs));
4316
4941
  }
4317
4942
  case "top_senders": {
4318
- const result = await imapService.topSenders({
4943
+ const topSendersLimit = typeof args.limit === "number" ? args.limit : undefined;
4944
+ const topSendersInput = {
4319
4945
  folder: optionalString(args, "folder"),
4320
4946
  since: optionalString(args, "since"),
4321
4947
  before: optionalString(args, "before"),
4322
- limit: typeof args.limit === "number" ? args.limit : undefined,
4948
+ limit: topSendersLimit,
4323
4949
  scanLimit: typeof args.scanLimit === "number" ? args.scanLimit : undefined,
4324
4950
  excludeSelf: normalizeBoolean(args.excludeSelf, true),
4951
+ };
4952
+ if (accountManager.all().length === 1) {
4953
+ const result = await imapService.topSenders(topSendersInput);
4954
+ return createTextResult(result);
4955
+ }
4956
+ // Merge strategy: scan each account's folder independently, merge
4957
+ // sender frequency by address (summing counts across accounts), then
4958
+ // re-sort and re-apply the requested top-N limit over the merged set.
4959
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
4960
+ bundle,
4961
+ result: await bundle.imapService.topSenders(topSendersInput),
4962
+ })));
4963
+ const senderMap = new Map();
4964
+ for (const { result } of perAccount) {
4965
+ for (const sender of result.senders) {
4966
+ const existing = senderMap.get(sender.address);
4967
+ if (existing) {
4968
+ existing.count += sender.count;
4969
+ }
4970
+ else {
4971
+ senderMap.set(sender.address, { ...sender });
4972
+ }
4973
+ }
4974
+ }
4975
+ const mergedSenders = [...senderMap.values()].sort((left, right) => right.count - left.count).slice(0, topSendersLimit ?? 20);
4976
+ return createTextResult({
4977
+ folder: topSendersInput.folder?.trim() || "INBOX",
4978
+ scanned: perAccount.reduce((sum, { result }) => sum + result.scanned, 0),
4979
+ senders: mergedSenders,
4980
+ byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, scanned: result.scanned })),
4325
4981
  });
4326
- return createTextResult(result);
4327
4982
  }
4328
4983
  case "move_thread": {
4329
4984
  ensureMailboxWriteAllowed(config.runtime);
4330
- const result = await withAudit(auditService, name, args, () => imapService.moveThread({
4331
- messageId: requireString(args, "messageId"),
4985
+ // A threadId only ever names ONE account — a thread can't span
4986
+ // multiple mailboxes/accounts — so this reuses
4987
+ // resolveAccountForEmailId's generic "<slug>::" prefix stripping
4988
+ // (it doesn't care that the string is an emailId vs a threadId) to
4989
+ // route the whole call to that account's own imapService/
4990
+ // auditService. A plain, unprefixed threadId keeps going to the
4991
+ // primary account, unchanged. Full cross-account thread resolution
4992
+ // (e.g. a match/threading key that could resolve differently per
4993
+ // account) is out of scope for this pass.
4994
+ const { bundle, rest: messageId } = resolveAccountForEmailId(requireString(args, "messageId"));
4995
+ const result = await withAudit(bundle.auditService, name, args, () => bundle.imapService.moveThread({
4996
+ messageId,
4332
4997
  destination: requireString(args, "destination"),
4333
4998
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
4334
4999
  dryRun: normalizeBoolean(args.dryRun, false),
@@ -4340,8 +5005,12 @@ export function createServer(config, options = {}) {
4340
5005
  const permanentThread = normalizeBoolean(args.permanent, false);
4341
5006
  if (permanentThread)
4342
5007
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), "Permanently delete thread");
4343
- const result = await withAudit(auditService, name, args, () => imapService.deleteThread({
4344
- messageId: requireString(args, "messageId"),
5008
+ // See move_thread's comment on scoping by the threadId's own
5009
+ // "<slug>::" prefix — same convention, same single-account
5010
+ // limitation.
5011
+ const { bundle, rest: messageId } = resolveAccountForEmailId(requireString(args, "messageId"));
5012
+ const result = await withAudit(bundle.auditService, name, args, () => bundle.imapService.deleteThread({
5013
+ messageId,
4345
5014
  permanent: permanentThread,
4346
5015
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
4347
5016
  dryRun: normalizeBoolean(args.dryRun, false),
@@ -4354,8 +5023,12 @@ export function createServer(config, options = {}) {
4354
5023
  const flagsToRemove = Array.isArray(args.flagsToRemove) ? args.flagsToRemove.map(String) : [];
4355
5024
  if (flagsToAdd.length === 0 && flagsToRemove.length === 0)
4356
5025
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4357
- const result = await withAudit(auditService, name, args, () => imapService.flagThread({
4358
- messageId: requireString(args, "messageId"),
5026
+ // See move_thread's comment on scoping by the threadId's own
5027
+ // "<slug>::" prefix — same convention, same single-account
5028
+ // limitation.
5029
+ const { bundle, rest: messageId } = resolveAccountForEmailId(requireString(args, "messageId"));
5030
+ const result = await withAudit(bundle.auditService, name, args, () => bundle.imapService.flagThread({
5031
+ messageId,
4359
5032
  flagsToAdd,
4360
5033
  flagsToRemove,
4361
5034
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
@@ -4395,8 +5068,22 @@ export function createServer(config, options = {}) {
4395
5068
  const result = await withAudit(auditService, name, args, () => imapService.deleteFolder(labelPath));
4396
5069
  return createTextResult(result);
4397
5070
  }
4398
- case "get_folders":
4399
- return createTextResult(await imapService.getFolders());
5071
+ case "get_folders": {
5072
+ if (accountManager.all().length === 1) {
5073
+ return createTextResult(await imapService.getFolders());
5074
+ }
5075
+ // Merge strategy: fetch each account's own folder list (LIVE IMAP) and
5076
+ // tag each folder's path with its account slug (except the primary's,
5077
+ // which is left bare) so the merged list stays unambiguous while a
5078
+ // single-account setup's own folder paths are completely untouched.
5079
+ const primarySlug = accountManager.primary().account.slug;
5080
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({ bundle, folders: await bundle.imapService.getFolders() })));
5081
+ const merged = perAccount.flatMap(({ bundle, folders }) => folders.map((folder) => {
5082
+ const slug = accountSlugForTag(bundle, primarySlug);
5083
+ return slug ? { ...folder, path: `${slug}::${folder.path}`, account: slug } : folder;
5084
+ }));
5085
+ return createTextResult(merged);
5086
+ }
4400
5087
  case "sync_folders":
4401
5088
  return createTextResult(await imapService.syncFolders());
4402
5089
  case "create_folder":
@@ -4411,25 +5098,28 @@ export function createServer(config, options = {}) {
4411
5098
  return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
4412
5099
  case "mark_email_read": {
4413
5100
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
4414
- const emailId = requireString(args, "emailId");
5101
+ const rawEmailId = requireString(args, "emailId");
5102
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4415
5103
  const uidValidity = parseEmailId(emailId).uidValidity;
4416
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(emailId, normalizeBoolean(args.isRead, true), uidValidity)));
5104
+ return createTextResult(await withAudit(auditService, name, args, async () => bundle.imapService.markEmailRead(emailId, normalizeBoolean(args.isRead, true), uidValidity)));
4417
5105
  }
4418
5106
  case "star_email": {
4419
5107
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
4420
- const emailId = requireString(args, "emailId");
5108
+ const rawEmailId = requireString(args, "emailId");
5109
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4421
5110
  const uidValidity = parseEmailId(emailId).uidValidity;
4422
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(emailId, normalizeBoolean(args.isStarred, true), uidValidity)));
5111
+ return createTextResult(await withAudit(auditService, name, args, async () => bundle.imapService.starEmail(emailId, normalizeBoolean(args.isStarred, true), uidValidity)));
4423
5112
  }
4424
5113
  case "move_email":
4425
5114
  {
4426
5115
  ensureEmailActionAllowed(config.runtime, "move");
4427
- const emailId = requireString(args, "emailId");
5116
+ const rawEmailId = requireString(args, "emailId");
5117
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4428
5118
  const targetFolder = requireString(args, "targetFolder");
4429
5119
  const uidValidity = parseEmailId(emailId).uidValidity;
4430
5120
  const result = await withAudit(auditService, name, args, async () => {
4431
5121
  try {
4432
- return await imapService.moveEmail(emailId, targetFolder, uidValidity);
5122
+ return await bundle.imapService.moveEmail(emailId, targetFolder, uidValidity);
4433
5123
  }
4434
5124
  catch (error) {
4435
5125
  if (isMissingTargetFolderError(error)) {
@@ -4438,75 +5128,82 @@ export function createServer(config, options = {}) {
4438
5128
  throw error;
4439
5129
  }
4440
5130
  });
4441
- const sources = result.targetEmailId
5131
+ const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
5132
+ const sources = targetEmailId
4442
5133
  ? [
4443
5134
  {
4444
- uri: buildEmailResourceUri(result.targetEmailId),
4445
- name: result.targetEmailId,
4446
- title: `Moved email ${result.targetEmailId}`,
5135
+ uri: buildEmailResourceUri(targetEmailId),
5136
+ name: targetEmailId,
5137
+ title: `Moved email ${targetEmailId}`,
4447
5138
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4448
5139
  mimeType: "message/rfc822",
4449
5140
  },
4450
5141
  ]
4451
5142
  : [];
4452
- return createTextResult(result, false, sources);
5143
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4453
5144
  }
4454
5145
  case "archive_email":
4455
5146
  {
4456
5147
  ensureEmailActionAllowed(config.runtime, "archive");
4457
- const emailId = requireString(args, "emailId");
5148
+ const rawEmailId = requireString(args, "emailId");
5149
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4458
5150
  const uidValidity = parseEmailId(emailId).uidValidity;
4459
- const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(emailId, uidValidity));
4460
- const sources = result.targetEmailId
5151
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.archiveEmail(emailId, uidValidity));
5152
+ const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
5153
+ const sources = targetEmailId
4461
5154
  ? [
4462
5155
  {
4463
- uri: buildEmailResourceUri(result.targetEmailId),
4464
- name: result.targetEmailId,
4465
- title: `Archived email ${result.targetEmailId}`,
5156
+ uri: buildEmailResourceUri(targetEmailId),
5157
+ name: targetEmailId,
5158
+ title: `Archived email ${targetEmailId}`,
4466
5159
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4467
5160
  mimeType: "message/rfc822",
4468
5161
  },
4469
5162
  ]
4470
5163
  : [];
4471
- return createTextResult(result, false, sources);
5164
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4472
5165
  }
4473
5166
  case "trash_email":
4474
5167
  {
4475
5168
  ensureEmailActionAllowed(config.runtime, "trash");
4476
- const emailId = requireString(args, "emailId");
5169
+ const rawEmailId = requireString(args, "emailId");
5170
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4477
5171
  const uidValidity = parseEmailId(emailId).uidValidity;
4478
- const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(emailId, uidValidity));
4479
- const sources = result.targetEmailId
5172
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.trashEmail(emailId, uidValidity));
5173
+ const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
5174
+ const sources = targetEmailId
4480
5175
  ? [
4481
5176
  {
4482
- uri: buildEmailResourceUri(result.targetEmailId),
4483
- name: result.targetEmailId,
4484
- title: `Trashed email ${result.targetEmailId}`,
5177
+ uri: buildEmailResourceUri(targetEmailId),
5178
+ name: targetEmailId,
5179
+ title: `Trashed email ${targetEmailId}`,
4485
5180
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4486
5181
  mimeType: "message/rfc822",
4487
5182
  },
4488
5183
  ]
4489
5184
  : [];
4490
- return createTextResult(result, false, sources);
5185
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4491
5186
  }
4492
5187
  case "restore_email":
4493
5188
  {
4494
5189
  ensureEmailActionAllowed(config.runtime, "restore");
4495
- const emailId = requireString(args, "emailId");
5190
+ const rawEmailId = requireString(args, "emailId");
5191
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4496
5192
  const uidValidity = parseEmailId(emailId).uidValidity;
4497
- const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
4498
- const sources = result.targetEmailId
5193
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
5194
+ const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
5195
+ const sources = targetEmailId
4499
5196
  ? [
4500
5197
  {
4501
- uri: buildEmailResourceUri(result.targetEmailId),
4502
- name: result.targetEmailId,
4503
- title: `Restored email ${result.targetEmailId}`,
5198
+ uri: buildEmailResourceUri(targetEmailId),
5199
+ name: targetEmailId,
5200
+ title: `Restored email ${targetEmailId}`,
4504
5201
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4505
5202
  mimeType: "message/rfc822",
4506
5203
  },
4507
5204
  ]
4508
5205
  : [];
4509
- return createTextResult(result, false, sources);
5206
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4510
5207
  }
4511
5208
  case "snooze_email": {
4512
5209
  ensureEmailActionAllowed(config.runtime, "archive");
@@ -4570,9 +5267,10 @@ export function createServer(config, options = {}) {
4570
5267
  case "delete_email": {
4571
5268
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
4572
5269
  ensureMailboxWriteAllowed(config.runtime);
4573
- const emailId = requireString(args, "emailId");
5270
+ const rawEmailId = requireString(args, "emailId");
5271
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4574
5272
  const uidValidity = parseEmailId(emailId).uidValidity;
4575
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(emailId, uidValidity)));
5273
+ return createTextResult(await withAudit(auditService, name, args, async () => bundle.imapService.deleteEmail(emailId, uidValidity)));
4576
5274
  }
4577
5275
  case "batch_email_action":
4578
5276
  {
@@ -4589,24 +5287,135 @@ export function createServer(config, options = {}) {
4589
5287
  if (action === "delete") {
4590
5288
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) (cannot be recovered)`);
4591
5289
  }
4592
- const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
4593
- emailIds,
5290
+ // emailIds can span multiple configured accounts — group them so
5291
+ // each account's ids run against that account's own imapService
5292
+ // (and get audited to that account's own auditService), then merge
5293
+ // the per-group BatchActionResults back into one. continueOnError
5294
+ // is honored within each group (as before); across groups, a
5295
+ // failure in one account's group still lets independent, unrelated
5296
+ // groups for OTHER accounts run — an id in account B failing
5297
+ // doesn't block account A's ids, which are on an entirely separate
5298
+ // connection anyway.
5299
+ const continueOnError = normalizeBoolean(args.continueOnError, true);
5300
+ const dryRun = normalizeBoolean(args.dryRun, false);
5301
+ const targetFolder = optionalString(args, "targetFolder");
5302
+ const groups = groupEmailIdsByAccount(accountManager, emailIds);
5303
+ const groupResults = [];
5304
+ for (const [slug, group] of groups) {
5305
+ const entries = [];
5306
+ const groupResult = await withAudit(group.bundle.auditService, name, args, () => applyBatchEmailAction(group.bundle.imapService, entries, {
5307
+ emailIds: group.restIds,
5308
+ action,
5309
+ targetFolder,
5310
+ continueOnError,
5311
+ dryRun,
5312
+ }));
5313
+ groupResults.push({ slug, result: groupResult });
5314
+ }
5315
+ const outputSlugFor = (slug) => (slug === primaryBundle.account.slug ? undefined : slug);
5316
+ const mergedResults = groupResults.flatMap(({ slug, result: groupResult }) => groupResult.results.map((entry) => ({ ...entry, emailId: withAccountPrefix(outputSlugFor(slug), entry.emailId) })));
5317
+ // Reordered to match the caller's original emailIds order (each
5318
+ // group only preserves order within itself) so a mixed-account
5319
+ // request's results still line up with the ids the caller passed.
5320
+ const byEmailId = new Map(mergedResults.map((entry) => [entry.emailId, entry]));
5321
+ const orderedResults = emailIds.flatMap((emailId) => {
5322
+ const entry = byEmailId.get(emailId);
5323
+ return entry ? [entry] : [];
5324
+ });
5325
+ const succeeded = orderedResults.filter((entry) => entry.ok).length;
5326
+ const result = {
4594
5327
  action,
4595
- targetFolder: optionalString(args, "targetFolder"),
4596
- continueOnError: normalizeBoolean(args.continueOnError, true),
4597
- dryRun: normalizeBoolean(args.dryRun, false),
4598
- }));
5328
+ total: orderedResults.length,
5329
+ succeeded,
5330
+ failed: orderedResults.length - succeeded,
5331
+ results: orderedResults,
5332
+ };
4599
5333
  const sources = result.results.flatMap((entry) => entry.ok ? emailSourceFromActionResult(entry.result) : []);
4600
5334
  return createTextResult(result, false, sources);
4601
5335
  }
4602
5336
  case "get_email_stats": {
4603
- const folders = await imapService.getFolders();
4604
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, typeof args.days === "number" ? args.days : 30, typeof args.limit === "number" ? args.limit : 2000);
4605
- return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
5337
+ const statsDays = typeof args.days === "number" ? args.days : 30;
5338
+ const statsLimit = typeof args.limit === "number" ? args.limit : 2000;
5339
+ if (accountManager.all().length === 1) {
5340
+ const folders = await imapService.getFolders();
5341
+ const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, statsDays, statsLimit);
5342
+ return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
5343
+ }
5344
+ // Merge strategy: compute each account's own stats independently
5345
+ // (self-detection needs each account's own address), then sum the
5346
+ // numeric mailbox/sample fields and merge folder lists (non-primary
5347
+ // folder paths tagged with their account slug to avoid collisions).
5348
+ const primarySlugForStats = accountManager.primary().account.slug;
5349
+ const statsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5350
+ const folders = await bundle.imapService.getFolders();
5351
+ const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, statsDays, statsLimit);
5352
+ return {
5353
+ bundle,
5354
+ stats: analyticsService.getEmailStats(sample, folders, bundle.config.smtp.username),
5355
+ };
5356
+ }));
5357
+ return createTextResult({
5358
+ generatedAt: new Date().toISOString(),
5359
+ mailbox: {
5360
+ folderCount: statsPerAccount.reduce((sum, { stats }) => sum + stats.mailbox.folderCount, 0),
5361
+ totalMessages: statsPerAccount.reduce((sum, { stats }) => sum + stats.mailbox.totalMessages, 0),
5362
+ unreadMessages: statsPerAccount.reduce((sum, { stats }) => sum + stats.mailbox.unreadMessages, 0),
5363
+ },
5364
+ sample: {
5365
+ size: statsPerAccount.reduce((sum, { stats }) => sum + stats.sample.size, 0),
5366
+ starredMessages: statsPerAccount.reduce((sum, { stats }) => sum + stats.sample.starredMessages, 0),
5367
+ messagesWithAttachments: statsPerAccount.reduce((sum, { stats }) => sum + stats.sample.messagesWithAttachments, 0),
5368
+ uniqueContacts: statsPerAccount.reduce((sum, { stats }) => sum + stats.sample.uniqueContacts, 0),
5369
+ },
5370
+ folders: statsPerAccount.flatMap(({ bundle, stats }) => {
5371
+ const slug = accountSlugForTag(bundle, primarySlugForStats);
5372
+ return stats.folders.map((folder) => (slug ? { ...folder, path: `${slug}::${folder.path}` } : folder));
5373
+ }),
5374
+ byAccount: statsPerAccount.map(({ bundle, stats }) => ({ slug: bundle.account.slug, mailbox: stats.mailbox, sample: stats.sample })),
5375
+ });
4606
5376
  }
4607
5377
  case "get_email_analytics": {
4608
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, typeof args.days === "number" ? args.days : 30, typeof args.limit === "number" ? args.limit : 2000);
4609
- return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username));
5378
+ const analyticsDays = typeof args.days === "number" ? args.days : 30;
5379
+ const analyticsLimit = typeof args.limit === "number" ? args.limit : 2000;
5380
+ if (accountManager.all().length === 1) {
5381
+ const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, analyticsDays, analyticsLimit);
5382
+ return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username));
5383
+ }
5384
+ // Merge strategy: compute each account's own analytics independently
5385
+ // (self-detection needs each account's own address), then merge the
5386
+ // top-N breakdowns (hours/senders/domains) by summing counts per key
5387
+ // and re-sorting, and recompute busiestDay/insights from the merged
5388
+ // results.
5389
+ const analyticsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5390
+ const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, analyticsDays, analyticsLimit);
5391
+ return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username);
5392
+ }));
5393
+ const busiestHours = mergeCountedEntries(analyticsPerAccount.map((entry) => entry.busiestHours), (entry) => entry.hour, 5);
5394
+ const topSenders = mergeCountedEntries(analyticsPerAccount.map((entry) => entry.topSenders), (entry) => entry.address, 10);
5395
+ const topDomains = mergeCountedEntries(analyticsPerAccount.map((entry) => entry.topDomains), (entry) => entry.domain, 10);
5396
+ const busiestDayCandidates = analyticsPerAccount.flatMap((entry) => (entry.busiestDay ? [entry.busiestDay] : []));
5397
+ const busiestDay = busiestDayCandidates.length > 0
5398
+ ? busiestDayCandidates.reduce((best, candidate) => (candidate.count > best.count ? candidate : best))
5399
+ : undefined;
5400
+ return createTextResult({
5401
+ generatedAt: new Date().toISOString(),
5402
+ sampleSize: analyticsPerAccount.reduce((sum, entry) => sum + entry.sampleSize, 0),
5403
+ busiestHours,
5404
+ topSenders,
5405
+ topDomains,
5406
+ busiestDay,
5407
+ insights: [
5408
+ busiestHours[0]
5409
+ ? `Peak sampled activity occurs around ${busiestHours[0].hour}.`
5410
+ : "No recent activity was available for hourly analysis.",
5411
+ topSenders[0]
5412
+ ? `Top sender in the sampled window: ${topSenders[0].address}.`
5413
+ : "No sender data was available in the sampled window.",
5414
+ busiestDay
5415
+ ? `Busiest sampled day was ${busiestDay.date} with ${busiestDay.count} messages.`
5416
+ : "No day-level trend data was available.",
5417
+ ],
5418
+ });
4610
5419
  }
4611
5420
  case "get_contacts": {
4612
5421
  // limit is "how many contacts to return", not "how many messages to
@@ -4615,13 +5424,35 @@ export function createServer(config, options = {}) {
4615
5424
  // get_contacts returns a ranked list. Reusing limit as the sample size
4616
5425
  // meant limit: 5 silently ranked contacts from only 5 messages total.
4617
5426
  const limit = normalizeLimit(args.limit, 100);
4618
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
4619
- return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
5427
+ if (accountManager.all().length === 1) {
5428
+ const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
5429
+ return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
5430
+ }
5431
+ // Merge strategy: compute each account's own contact list independently
5432
+ // (self-detection needs each account's own address), then merge same
5433
+ // address across accounts by summing incoming/outgoing/totalMessages
5434
+ // and keeping the most recent lastContactAt, and re-apply the
5435
+ // requested limit over the merged, re-sorted set.
5436
+ const contactsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5437
+ const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, 30, 3000);
5438
+ return analyticsService.getContacts(sample, Number.MAX_SAFE_INTEGER, bundle.config.smtp.username);
5439
+ }));
5440
+ return createTextResult(mergeContactStats(contactsPerAccount, limit));
4620
5441
  }
4621
5442
  case "get_volume_trends": {
4622
5443
  const days = normalizeLimit(args.days, 30, 1, 365);
4623
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, days, 3000);
4624
- return createTextResult(analyticsService.getVolumeTrends(sample, days));
5444
+ if (accountManager.all().length === 1) {
5445
+ const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, days, 3000);
5446
+ return createTextResult(analyticsService.getVolumeTrends(sample, days));
5447
+ }
5448
+ // Merge strategy: compute each account's own daily trend points
5449
+ // independently, then sum count/unreadCount/starredCount/attachmentCount
5450
+ // for matching dates across accounts.
5451
+ const trendsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5452
+ const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, days, 3000);
5453
+ return analyticsService.getVolumeTrends(sample, days);
5454
+ }));
5455
+ return createTextResult(mergeVolumeTrends(trendsPerAccount));
4625
5456
  }
4626
5457
  case "get_connection_status": {
4627
5458
  const includeRawConnectionErrors = process.env.PROTONMAIL_DEBUG === "true";
@@ -4723,6 +5554,36 @@ export function createServer(config, options = {}) {
4723
5554
  }
4724
5555
  return undefined;
4725
5556
  };
5557
+ // Per-account breakdown, purely additive alongside every existing top-level
5558
+ // field above (which keep describing the PRIMARY account only, unchanged, for
5559
+ // backward compatibility) — same shape list_accounts already returns, plus a
5560
+ // live connection check reusing that same checkConnections pattern, gated on
5561
+ // this tool's own includeSmtp/includeImap flags.
5562
+ const accountsInfo = await Promise.all(accountManager.all().map(async (bundle) => {
5563
+ const [accountIndexStatus, connection] = await Promise.all([
5564
+ bundle.localIndexService.getStatus(),
5565
+ includeSmtp || includeImap
5566
+ ? Promise.allSettled([
5567
+ includeSmtp ? bundle.smtpService.verifyConnection() : Promise.resolve(undefined),
5568
+ includeImap ? bundle.imapService.ping() : Promise.resolve(undefined),
5569
+ ]).then(([smtpResult, imapResult]) => ({
5570
+ smtp: !includeSmtp
5571
+ ? undefined
5572
+ : smtpResult.status === "fulfilled" ? { ok: true } : { ok: false, error: describeImapError(smtpResult.reason) },
5573
+ imap: !includeImap
5574
+ ? undefined
5575
+ : imapResult.status === "fulfilled" ? { ok: true } : { ok: false, error: describeImapError(imapResult.reason) },
5576
+ }))
5577
+ : Promise.resolve(undefined),
5578
+ ]);
5579
+ return {
5580
+ slug: bundle.account.slug,
5581
+ address: bundle.account.address,
5582
+ isPrimary: bundle.account.slug === primaryBundle.account.slug,
5583
+ index: { storedMessageCount: accountIndexStatus.storedMessageCount, updatedAt: accountIndexStatus.updatedAt, isStale: accountIndexStatus.isStale },
5584
+ connection,
5585
+ };
5586
+ }));
4726
5587
  return createTextResult({
4727
5588
  checkedAt: new Date().toISOString(),
4728
5589
  // See get_connection_status for why this matters: from inside
@@ -4798,6 +5659,7 @@ export function createServer(config, options = {}) {
4798
5659
  "vacation responder (Proton account API only, not exposed via Bridge)",
4799
5660
  ],
4800
5661
  },
5662
+ accounts: accountsInfo,
4801
5663
  });
4802
5664
  }
4803
5665
  case "get_runtime_status": {
@@ -4805,6 +5667,20 @@ export function createServer(config, options = {}) {
4805
5667
  localIndexService.getStatus(),
4806
5668
  draftStore.listDrafts(true),
4807
5669
  ]);
5670
+ // Per-account breakdown, purely additive alongside every existing top-level
5671
+ // field above (which keep describing the PRIMARY account only, unchanged, for
5672
+ // backward compatibility) — same shape list_accounts already returns, index
5673
+ // status only (no live connection check here — this tool is meant to be fast,
5674
+ // unlike run_doctor).
5675
+ const accountsInfo = await Promise.all(accountManager.all().map(async (bundle) => {
5676
+ const accountIndexStatus = await bundle.localIndexService.getStatus();
5677
+ return {
5678
+ slug: bundle.account.slug,
5679
+ address: bundle.account.address,
5680
+ isPrimary: bundle.account.slug === primaryBundle.account.slug,
5681
+ index: { storedMessageCount: accountIndexStatus.storedMessageCount, updatedAt: accountIndexStatus.updatedAt, isStale: accountIndexStatus.isStale },
5682
+ };
5683
+ }));
4808
5684
  return createTextResult({
4809
5685
  checkedAt: new Date().toISOString(),
4810
5686
  runtime: sanitizeRuntimeConfig(config.runtime),
@@ -4820,6 +5696,37 @@ export function createServer(config, options = {}) {
4820
5696
  remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
4821
5697
  syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
4822
5698
  },
5699
+ accounts: accountsInfo,
5700
+ });
5701
+ }
5702
+ case "list_accounts": {
5703
+ const checkConnections = normalizeBoolean(args.checkConnections, false);
5704
+ const accountsInfo = await Promise.all(accountManager.all().map(async (bundle) => {
5705
+ const [indexStatus, connection] = await Promise.all([
5706
+ bundle.localIndexService.getStatus(),
5707
+ checkConnections
5708
+ ? Promise.allSettled([bundle.smtpService.verifyConnection(), bundle.imapService.ping()]).then(([smtpResult, imapResult]) => ({
5709
+ smtp: smtpResult.status === "fulfilled" ? { ok: true } : { ok: false, error: describeImapError(smtpResult.reason) },
5710
+ imap: imapResult.status === "fulfilled" ? { ok: true } : { ok: false, error: describeImapError(imapResult.reason) },
5711
+ }))
5712
+ : Promise.resolve(undefined),
5713
+ ]);
5714
+ return {
5715
+ slug: bundle.account.slug,
5716
+ address: bundle.account.address,
5717
+ isPrimary: bundle.account.slug === accountManager.primary().account.slug,
5718
+ imapUsername: bundle.account.imap.username,
5719
+ smtpUsername: bundle.account.smtp.username,
5720
+ dataDir: bundle.account.dataDir,
5721
+ index: { storedMessageCount: indexStatus.storedMessageCount, updatedAt: indexStatus.updatedAt, isStale: indexStatus.isStale },
5722
+ connection,
5723
+ };
5724
+ }));
5725
+ return createTextResult({
5726
+ checkedAt: new Date().toISOString(),
5727
+ total: accountsInfo.length,
5728
+ accounts: accountsInfo,
5729
+ note: "Prefix an emailId/draftId with '<slug>::' to target a non-primary account in any other tool; a plain id always means the primary account.",
4823
5730
  });
4824
5731
  }
4825
5732
  case "run_background_sync":
@@ -4893,7 +5800,7 @@ export function createServer(config, options = {}) {
4893
5800
  return createTextResult(await localIndexService.getStatus());
4894
5801
  case "search_indexed_emails":
4895
5802
  {
4896
- const result = await localIndexService.search({
5803
+ const searchIndexedInput = {
4897
5804
  query: optionalString(args, "query"),
4898
5805
  folder: optionalString(args, "folder"),
4899
5806
  label: optionalString(args, "label"),
@@ -4910,133 +5817,346 @@ export function createServer(config, options = {}) {
4910
5817
  dateFrom: optionalString(args, "dateFrom"),
4911
5818
  dateTo: optionalString(args, "dateTo"),
4912
5819
  limit: typeof args.limit === "number" ? args.limit : undefined,
4913
- });
4914
- return createTextResult({ ...result, emails: projectFields(result.emails, parseFieldsArg(args.fields)) }, false, result.emails.map(emailSource));
5820
+ };
5821
+ if (accountManager.all().length === 1) {
5822
+ const result = await localIndexService.search(searchIndexedInput);
5823
+ return createTextResult({ ...result, emails: projectFields(result.emails, parseFieldsArg(args.fields)) }, false, result.emails.map(emailSource));
5824
+ }
5825
+ // Merge strategy: search every account's local index independently,
5826
+ // tag ids with the account slug, concatenate, re-sort newest-first
5827
+ // (each account's own list already arrives relevance-sorted, so this
5828
+ // is an approximation of a single global ranking), then re-apply the
5829
+ // requested limit across the merged set and sum totals/hasMore.
5830
+ const primarySlugForSearch = accountManager.primary().account.slug;
5831
+ const searchPerAccount = await Promise.all(accountManager.all().map(async (bundle) => ({ bundle, result: await bundle.localIndexService.search(searchIndexedInput) })));
5832
+ const searchLimit = searchIndexedInput.limit ?? 100;
5833
+ const taggedSearchEmails = searchPerAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlugForSearch), email.id) })));
5834
+ const mergedSearchEmails = sortByDateDesc(taggedSearchEmails, (email) => email.internalDate || email.date).slice(0, searchLimit);
5835
+ const totalSearch = searchPerAccount.reduce((sum, { result }) => sum + result.total, 0);
5836
+ const warnings = searchPerAccount.flatMap(({ result }) => result.warnings ?? []);
5837
+ const primarySearchEntry = searchPerAccount.find(({ bundle }) => bundle.account.slug === primarySlugForSearch);
5838
+ return createTextResult({
5839
+ total: totalSearch,
5840
+ hasMore: totalSearch > mergedSearchEmails.length,
5841
+ emails: projectFields(mergedSearchEmails, parseFieldsArg(args.fields)),
5842
+ lastSyncAt: primarySearchEntry?.result.lastSyncAt,
5843
+ ...(warnings.length > 0 ? { warnings } : {}),
5844
+ }, false, mergedSearchEmails.map(emailSource));
4915
5845
  }
4916
5846
  case "get_labels":
4917
5847
  {
4918
- const labels = await localIndexService.getLabels(normalizeLimit(args.limit, 250));
5848
+ const labelsLimit = normalizeLimit(args.limit, 250);
5849
+ if (accountManager.all().length === 1) {
5850
+ const labels = await localIndexService.getLabels(labelsLimit);
5851
+ return createTextResult({
5852
+ total: labels.length,
5853
+ labels,
5854
+ });
5855
+ }
5856
+ // Merge strategy: fetch each account's own label list and sum
5857
+ // messageCount/unreadCount/threadCount for labels that share the same
5858
+ // name+type across accounts, so e.g. "INBOX" folders from two accounts
5859
+ // combine into one row rather than appearing as unrelated duplicates.
5860
+ const perAccountLabels = await Promise.all(accountManager.all().map((bundle) => bundle.localIndexService.getLabels(labelsLimit)));
5861
+ const mergedLabels = mergeMailboxLabels(perAccountLabels).slice(0, labelsLimit);
4919
5862
  return createTextResult({
4920
- total: labels.length,
4921
- labels,
5863
+ total: mergedLabels.length,
5864
+ labels: mergedLabels,
4922
5865
  });
4923
5866
  }
4924
5867
  case "get_threads":
4925
5868
  {
4926
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4927
- folder: "INBOX",
4928
- limitPerFolder: 100,
4929
- });
4930
- const result = await localIndexService.getThreads({
5869
+ const threadsInput = {
4931
5870
  query: optionalString(args, "query"),
4932
5871
  label: optionalString(args, "label"),
4933
5872
  limit: typeof args.limit === "number" ? args.limit : undefined,
4934
- });
4935
- return createTextResult(result, false, result.threads.map(threadSource));
5873
+ };
5874
+ if (accountManager.all().length === 1) {
5875
+ await maybeRefreshLocalIndex(imapService, localIndexService, {
5876
+ folder: "INBOX",
5877
+ limitPerFolder: 100,
5878
+ });
5879
+ const result = await localIndexService.getThreads(threadsInput);
5880
+ return createTextResult(result, false, result.threads.map(threadSource));
5881
+ }
5882
+ // Merge strategy: refresh and query each account's own thread index
5883
+ // independently, tag thread ids with the account slug, concatenate,
5884
+ // re-sort newest-first by latestDate, then re-apply the requested
5885
+ // limit across the merged set and sum totals/hasMore.
5886
+ const primarySlugForThreads = accountManager.primary().account.slug;
5887
+ const threadsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5888
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
5889
+ folder: "INBOX",
5890
+ limitPerFolder: 100,
5891
+ });
5892
+ return { bundle, result: await bundle.localIndexService.getThreads(threadsInput) };
5893
+ }));
5894
+ const threadsLimit = threadsInput.limit ?? 100;
5895
+ const taggedThreads = threadsPerAccount.flatMap(({ bundle, result }) => result.threads.map((thread) => ({
5896
+ ...thread,
5897
+ id: withAccountPrefix(accountSlugForTag(bundle, primarySlugForThreads), thread.id),
5898
+ messageIds: thread.messageIds.map((id) => withAccountPrefix(accountSlugForTag(bundle, primarySlugForThreads), id)),
5899
+ })));
5900
+ const mergedThreads = sortByDateDesc(taggedThreads, (thread) => thread.latestDate).slice(0, threadsLimit);
5901
+ const totalThreads = threadsPerAccount.reduce((sum, { result }) => sum + result.total, 0);
5902
+ return createTextResult({ total: totalThreads, hasMore: totalThreads > mergedThreads.length, threads: mergedThreads }, false, mergedThreads.map(threadSource));
4936
5903
  }
4937
5904
  case "get_actionable_threads":
4938
5905
  {
4939
- const refresh = await maybeRefreshLocalIndex(imapService, localIndexService, {
4940
- force: normalizeBoolean(args.syncBefore, false),
4941
- folder: "INBOX",
4942
- limitPerFolder: 100,
4943
- });
4944
- const result = await localIndexService.getActionableThreads({
4945
- query: optionalString(args, "query"),
4946
- label: optionalString(args, "label"),
4947
- pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
4948
- ? args.pendingOn
4949
- : undefined,
4950
- unreadOnly: normalizeBoolean(args.unreadOnly, true),
4951
- limit: typeof args.limit === "number" ? args.limit : undefined,
4952
- });
4953
- return createTextResult(refresh ? { ...result, indexUpdatedAt: refresh.indexStatus.updatedAt } : result, false, result.threads.map(threadSource));
5906
+ const limit = typeof args.limit === "number" ? args.limit : 50;
5907
+ // Fan-out merge: ask every account for its own top-`limit` actionable threads
5908
+ // (each account's own getActionableThreads already sorts by score, then
5909
+ // recency — see local-index-service.ts), tag every thread/message id with its
5910
+ // account's slug, then merge all accounts' lists, re-sort by that exact same
5911
+ // (score, then latestDate) order, and take the top `limit` of the MERGED set —
5912
+ // never per-account. Requesting `limit` (not divided) from each account is what
5913
+ // makes this correct: any thread in the true global top-`limit` must already
5914
+ // rank within its own account's top-`limit`, since a lower rank there would mean
5915
+ // at least `limit` better-or-equal threads exist ahead of it account-wide alone.
5916
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5917
+ const refresh = await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
5918
+ force: normalizeBoolean(args.syncBefore, false),
5919
+ folder: "INBOX",
5920
+ limitPerFolder: 100,
5921
+ });
5922
+ const result = await bundle.localIndexService.getActionableThreads({
5923
+ query: optionalString(args, "query"),
5924
+ label: optionalString(args, "label"),
5925
+ pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
5926
+ ? args.pendingOn
5927
+ : undefined,
5928
+ unreadOnly: normalizeBoolean(args.unreadOnly, true),
5929
+ limit,
5930
+ });
5931
+ return { bundle, refresh, result };
5932
+ }));
5933
+ const mergedThreads = perAccount
5934
+ .flatMap(({ bundle, result }) => tagAccountIds(slugForBundle(bundle), result.threads))
5935
+ .sort((left, right) => {
5936
+ if (right.score !== left.score)
5937
+ return right.score - left.score;
5938
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
5939
+ })
5940
+ .slice(0, limit);
5941
+ const totalCount = perAccount.reduce((sum, { result }) => sum + result.total, 0);
5942
+ const messagesCapped = perAccount.some(({ result }) => result.messagesCapped);
5943
+ const latestRefresh = perAccount.map(({ refresh }) => refresh).find((refresh) => refresh);
5944
+ return createTextResult({
5945
+ total: totalCount,
5946
+ hasMore: totalCount > limit,
5947
+ threads: mergedThreads,
5948
+ ...(messagesCapped ? { messagesCapped: true } : {}),
5949
+ ...(latestRefresh ? { indexUpdatedAt: latestRefresh.indexStatus.updatedAt } : {}),
5950
+ }, false, mergedThreads.map(threadSource));
4954
5951
  }
4955
5952
  case "get_inbox_digest":
4956
5953
  {
4957
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4958
- force: normalizeBoolean(args.syncBefore, false),
4959
- folder: "INBOX",
4960
- limitPerFolder: 100,
4961
- });
4962
- const result = await localIndexService.getInboxDigest({
4963
- limit: typeof args.limit === "number" ? args.limit : undefined,
4964
- minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
4965
- });
4966
- const topThreads = Array.isArray(result.topThreads) ? result.topThreads : [];
4967
- const staleThreads = Array.isArray(result.staleAwaitingYou) ? result.staleAwaitingYou : [];
4968
- return createTextResult(result, false, [...topThreads, ...staleThreads]
4969
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
4970
- .map(threadSource));
5954
+ const limit = typeof args.limit === "number" ? args.limit : 10;
5955
+ // Fan-out merge: get_inbox_digest already internally aggregates two lists
5956
+ // (topThreads by score/recency, staleAwaitingYou by score/recency) plus a
5957
+ // `counts` summary — mirror that same internal merge style across accounts.
5958
+ // Ask each account for its own top-`limit` of both lists, tag ids with the
5959
+ // account's slug, concatenate each list across accounts, re-sort by the same
5960
+ // (score, then latestDate) order the single-account version uses, and take the
5961
+ // top `limit` of the MERGED list (never per-account — see get_actionable_threads
5962
+ // for why requesting `limit`, not limit/accounts, per account is what makes this
5963
+ // correct). `counts` is summed across accounts field-by-field.
5964
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
5965
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
5966
+ force: normalizeBoolean(args.syncBefore, false),
5967
+ folder: "INBOX",
5968
+ limitPerFolder: 100,
5969
+ });
5970
+ const result = await bundle.localIndexService.getInboxDigest({
5971
+ limit,
5972
+ minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
5973
+ });
5974
+ const slug = slugForBundle(bundle);
5975
+ return {
5976
+ counts: (result.counts ?? {}),
5977
+ indexUpdatedAt: result.indexUpdatedAt,
5978
+ topThreads: tagAccountIds(slug, (result.topThreads ?? [])),
5979
+ staleAwaitingYou: tagAccountIds(slug, (result.staleAwaitingYou ?? [])),
5980
+ };
5981
+ }));
5982
+ const byScoreThenDate = (left, right) => {
5983
+ if (right.score !== left.score)
5984
+ return right.score - left.score;
5985
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
5986
+ };
5987
+ const topThreads = perAccount.flatMap((entry) => entry.topThreads).sort(byScoreThenDate).slice(0, limit);
5988
+ const staleAwaitingYou = perAccount.flatMap((entry) => entry.staleAwaitingYou).sort(byScoreThenDate).slice(0, limit);
5989
+ const countKeys = new Set(perAccount.flatMap((entry) => Object.keys(entry.counts)));
5990
+ const counts = {};
5991
+ for (const key of countKeys) {
5992
+ counts[key] = perAccount.reduce((sum, entry) => sum + (entry.counts[key] ?? 0), 0);
5993
+ }
5994
+ const result = {
5995
+ generatedAt: new Date().toISOString(),
5996
+ indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
5997
+ counts,
5998
+ topThreads,
5999
+ staleAwaitingYou,
6000
+ };
6001
+ return createTextResult(result, false, [...topThreads, ...staleAwaitingYou].map(threadSource));
4971
6002
  }
4972
6003
  case "get_follow_up_candidates":
4973
6004
  {
4974
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4975
- force: normalizeBoolean(args.syncBefore, false),
4976
- folder: "INBOX",
4977
- limitPerFolder: 100,
4978
- });
4979
- const result = await localIndexService.getFollowUpCandidates({
4980
- limit: typeof args.limit === "number" ? args.limit : undefined,
4981
- minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
4982
- pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
4983
- ? args.pendingOn
4984
- : undefined,
4985
- });
4986
- return createTextResult(result, false, Array.isArray(result.threads)
4987
- ? result.threads
4988
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
4989
- .map(threadSource)
4990
- : []);
4991
- }
4992
- case "find_document_threads":
4993
- {
4994
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4995
- force: normalizeBoolean(args.syncBefore, false),
4996
- folder: "INBOX",
4997
- limitPerFolder: 100,
4998
- });
4999
- const result = await localIndexService.findDocumentThreads({
5000
- category: args.category === "document" ||
5001
- args.category === "invoice" ||
5002
- args.category === "contract" ||
5003
- args.category === "travel" ||
5004
- args.category === "calendar"
5005
- ? args.category
5006
- : undefined,
5007
- query: optionalString(args, "query"),
5008
- limit: typeof args.limit === "number" ? args.limit : undefined,
5009
- });
5010
- const threads = Array.isArray(result.threads) ? result.threads : [];
6005
+ const limit = typeof args.limit === "number" ? args.limit : 25;
6006
+ // Fan-out merge: ask every account for its own top-`limit` follow-up
6007
+ // candidates (each account's own getFollowUpCandidates sorts by ageHours, then
6008
+ // score), tag ids with the account's slug, concatenate, re-sort by that same
6009
+ // (ageHours, then score) order, and take the top `limit` of the MERGED set —
6010
+ // same correctness argument as get_actionable_threads' fan-out.
6011
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6012
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
6013
+ force: normalizeBoolean(args.syncBefore, false),
6014
+ folder: "INBOX",
6015
+ limitPerFolder: 100,
6016
+ });
6017
+ const result = await bundle.localIndexService.getFollowUpCandidates({
6018
+ limit,
6019
+ minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
6020
+ pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
6021
+ ? args.pendingOn
6022
+ : undefined,
6023
+ });
6024
+ return {
6025
+ bundle,
6026
+ indexUpdatedAt: result.indexUpdatedAt,
6027
+ threads: tagAccountIds(slugForBundle(bundle), (result.threads ?? [])),
6028
+ total: result.total ?? 0,
6029
+ };
6030
+ }));
6031
+ const threads = perAccount
6032
+ .flatMap((entry) => entry.threads)
6033
+ .sort((left, right) => {
6034
+ if ((right.ageHours ?? 0) !== (left.ageHours ?? 0))
6035
+ return (right.ageHours ?? 0) - (left.ageHours ?? 0);
6036
+ return right.score - left.score;
6037
+ })
6038
+ .slice(0, limit);
6039
+ const totalCount = perAccount.reduce((sum, entry) => sum + entry.total, 0);
6040
+ const result = {
6041
+ generatedAt: new Date().toISOString(),
6042
+ indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
6043
+ minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : 24,
6044
+ pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any" ? args.pendingOn : "you",
6045
+ total: totalCount,
6046
+ hasMore: totalCount > limit,
6047
+ threads,
6048
+ };
5011
6049
  return createTextResult(result, false, threads
5012
6050
  .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5013
6051
  .map(threadSource));
5014
6052
  }
6053
+ case "find_document_threads":
6054
+ {
6055
+ const limit = typeof args.limit === "number" ? args.limit : 25;
6056
+ // Fan-out merge: ask every account for its own top-`limit` document threads
6057
+ // (each account's own findDocumentThreads sorts by document count, then
6058
+ // recency), tag ids (including each document's own emailId) with the
6059
+ // account's slug, concatenate, re-sort by that same order, and take the top
6060
+ // `limit` of the MERGED set — same correctness argument as
6061
+ // get_actionable_threads' fan-out.
6062
+ const category = args.category === "document" ||
6063
+ args.category === "invoice" ||
6064
+ args.category === "contract" ||
6065
+ args.category === "travel" ||
6066
+ args.category === "calendar"
6067
+ ? args.category
6068
+ : "document";
6069
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6070
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
6071
+ force: normalizeBoolean(args.syncBefore, false),
6072
+ folder: "INBOX",
6073
+ limitPerFolder: 100,
6074
+ });
6075
+ const result = await bundle.localIndexService.findDocumentThreads({
6076
+ category,
6077
+ query: optionalString(args, "query"),
6078
+ limit,
6079
+ });
6080
+ return {
6081
+ indexUpdatedAt: result.indexUpdatedAt,
6082
+ threads: tagAccountIds(slugForBundle(bundle), (result.threads ?? [])),
6083
+ total: result.total ?? 0,
6084
+ };
6085
+ }));
6086
+ const threads = perAccount
6087
+ .flatMap((entry) => entry.threads)
6088
+ .sort((left, right) => {
6089
+ if (right.documents.length !== left.documents.length)
6090
+ return right.documents.length - left.documents.length;
6091
+ return new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime();
6092
+ })
6093
+ .slice(0, limit);
6094
+ const totalCount = perAccount.reduce((sum, entry) => sum + entry.total, 0);
6095
+ const result = {
6096
+ generatedAt: new Date().toISOString(),
6097
+ indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
6098
+ category,
6099
+ total: totalCount,
6100
+ hasMore: totalCount > limit,
6101
+ threads,
6102
+ };
6103
+ return createTextResult(result, false, threads.map(threadSource));
6104
+ }
5015
6105
  case "prepare_meeting_context":
5016
6106
  {
5017
- await maybeRefreshLocalIndex(imapService, localIndexService, {
5018
- force: normalizeBoolean(args.syncBefore, false),
5019
- folder: "INBOX",
5020
- limitPerFolder: 100,
5021
- });
5022
- const result = await localIndexService.getMeetingPrep({
5023
- person: optionalString(args, "person"),
5024
- domain: optionalString(args, "domain"),
5025
- limit: typeof args.limit === "number" ? args.limit : undefined,
5026
- });
5027
- const threads = Array.isArray(result.threads) ? result.threads : [];
5028
- return createTextResult(result, false, threads
5029
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5030
- .map(threadSource));
6107
+ const limit = typeof args.limit === "number" ? args.limit : 10;
6108
+ // Fan-out merge: ask every account for its own top-`limit` meeting-prep
6109
+ // threads (each account's own getMeetingPrep sorts by recency — see
6110
+ // local-index-service.ts), tag ids with the account's slug, concatenate,
6111
+ // re-sort by that same recency order, and take the top `limit` of the
6112
+ // MERGED set — same correctness argument as get_actionable_threads' fan-out.
6113
+ const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
6114
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
6115
+ force: normalizeBoolean(args.syncBefore, false),
6116
+ folder: "INBOX",
6117
+ limitPerFolder: 100,
6118
+ });
6119
+ const result = await bundle.localIndexService.getMeetingPrep({
6120
+ person: optionalString(args, "person"),
6121
+ domain: optionalString(args, "domain"),
6122
+ limit,
6123
+ });
6124
+ return {
6125
+ indexUpdatedAt: result.indexUpdatedAt,
6126
+ threads: tagAccountIds(slugForBundle(bundle), (result.threads ?? [])),
6127
+ total: result.total ?? 0,
6128
+ };
6129
+ }));
6130
+ const threads = perAccount
6131
+ .flatMap((entry) => entry.threads)
6132
+ .sort((left, right) => new Date(right.latestDate || 0).getTime() - new Date(left.latestDate || 0).getTime())
6133
+ .slice(0, limit);
6134
+ const totalCount = perAccount.reduce((sum, entry) => sum + entry.total, 0);
6135
+ const result = {
6136
+ generatedAt: new Date().toISOString(),
6137
+ indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
6138
+ total: totalCount,
6139
+ hasMore: totalCount > limit,
6140
+ threads,
6141
+ };
6142
+ return createTextResult(result, false, threads.map(threadSource));
5031
6143
  }
5032
6144
  case "get_thread_brief":
5033
6145
  {
5034
- await maybeRefreshLocalIndex(imapService, localIndexService, {
6146
+ // Fanned out across accounts the same way emailId-targeted tools resolve a
6147
+ // specific account: the threadId's optional "<slug>::" prefix (from a merged
6148
+ // digest/follow-up/etc. result above) picks the owning account's bundle, same
6149
+ // pattern as resolveAccountForEmailId. A plain, unprefixed threadId still
6150
+ // means the primary account, unchanged from single-account behavior.
6151
+ const { bundle, rest } = resolveAccountForEmailId(requireString(args, "threadId"));
6152
+ const slug = slugForBundle(bundle);
6153
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
5035
6154
  folder: "INBOX",
5036
6155
  limitPerFolder: 100,
5037
6156
  });
5038
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
5039
- const result = buildThreadBrief(thread, config.smtp.username);
6157
+ const rawThread = await bundle.localIndexService.getThreadById(rest);
6158
+ const thread = tagAccountIds(slug, rawThread);
6159
+ const result = buildThreadBrief(thread, bundle.config.smtp.username);
5040
6160
  return createTextResult(result, false, [
5041
6161
  threadSource(thread),
5042
6162
  ...thread.messages.map((message) => emailSource({
@@ -5054,13 +6174,29 @@ export function createServer(config, options = {}) {
5054
6174
  }
5055
6175
  case "get_thread_by_id":
5056
6176
  {
5057
- await maybeRefreshLocalIndex(imapService, localIndexService, {
6177
+ // A threadId is only ever valid within the single account it was
6178
+ // produced by (see get_threads/search_indexed_emails above) — this is
6179
+ // a routed lookup, not a fan-out/merge: resolve the optional
6180
+ // "<slug>::" prefix (added when the id came from a non-primary
6181
+ // account) back to that account's bundle, defaulting to the primary
6182
+ // for a plain id exactly as before multi-account support existed.
6183
+ const rawThreadId = requireString(args, "threadId");
6184
+ const { accountSlug: threadAccountSlug, rest: unprefixedThreadId } = splitAccountPrefix(rawThreadId, accountManager.additionalSlugs());
6185
+ const threadBundle = accountManager.bySlugOrPrimary(threadAccountSlug);
6186
+ await maybeRefreshLocalIndex(threadBundle.imapService, threadBundle.localIndexService, {
5058
6187
  folder: "INBOX",
5059
6188
  limitPerFolder: 100,
5060
6189
  });
5061
6190
  const rawFolders = args?.folders;
5062
6191
  const threadFolders = Array.isArray(rawFolders) ? rawFolders.filter((f) => typeof f === "string") : undefined;
5063
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
6192
+ const rawThread = await threadBundle.localIndexService.getThreadById(unprefixedThreadId);
6193
+ const thread = threadAccountSlug
6194
+ ? {
6195
+ ...rawThread,
6196
+ id: withAccountPrefix(threadAccountSlug, rawThread.id),
6197
+ messageIds: rawThread.messageIds.map((id) => withAccountPrefix(threadAccountSlug, id)),
6198
+ }
6199
+ : rawThread;
5064
6200
  const result = threadFolders && threadFolders.length > 0
5065
6201
  ? {
5066
6202
  ...thread,
@@ -5105,8 +6241,12 @@ export function createServer(config, options = {}) {
5105
6241
  if (to.length === 0) {
5106
6242
  throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
5107
6243
  }
6244
+ // Same from-based draftStore routing as create_draft — see its comment.
6245
+ const createThreadReplyDraftBundle = optionalString(args, "from") !== undefined
6246
+ ? accountManager.byAddress(optionalString(args, "from")) ?? primaryBundle
6247
+ : primaryBundle;
5108
6248
  const result = await withAudit(auditService, name, args, async () => {
5109
- const draft = await draftStore.createDraft({
6249
+ const draft = await createThreadReplyDraftBundle.draftStore.createDraft({
5110
6250
  mode: "reply",
5111
6251
  to,
5112
6252
  cc,
@@ -5123,7 +6263,7 @@ export function createServer(config, options = {}) {
5123
6263
  });
5124
6264
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
5125
6265
  const synced = remoteSyncDecision.enabled
5126
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
6266
+ ? await syncDraftToRemote(createThreadReplyDraftBundle.draftStore, createThreadReplyDraftBundle.smtpService, createThreadReplyDraftBundle.imapService, draft)
5127
6267
  : { draft, remoteSync: undefined };
5128
6268
  const remoteSync = synced.remoteSync ??
5129
6269
  (remoteSyncDecision.reason
@@ -5133,9 +6273,8 @@ export function createServer(config, options = {}) {
5133
6273
  message: remoteSyncDecision.reason,
5134
6274
  }
5135
6275
  : undefined);
5136
- return remoteSync
5137
- ? { ...synced.draft, remoteSync, threadId: thread.id }
5138
- : { ...synced.draft, threadId: thread.id };
6276
+ const presented = presentDraft(createThreadReplyDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
6277
+ return { ...presented, threadId: thread.id };
5139
6278
  });
5140
6279
  return createTextResult(result, false, [
5141
6280
  draftSource(result),
@@ -5154,12 +6293,22 @@ export function createServer(config, options = {}) {
5154
6293
  }
5155
6294
  case "apply_thread_action":
5156
6295
  {
5157
- await maybeRefreshLocalIndex(imapService, localIndexService, {
6296
+ // A threadId only ever names ONE account — see move_thread's
6297
+ // identical comment on reusing resolveAccountForEmailId's generic
6298
+ // "<slug>::" prefix stripping for threadIds. Every message in the
6299
+ // resolved thread therefore belongs to this same account, so
6300
+ // (unlike batch_email_action's emailIds) there is nothing to
6301
+ // group — the whole action runs against this one bundle's own
6302
+ // imapService/localIndexService/auditService. Full cross-account
6303
+ // thread resolution is out of scope for this pass.
6304
+ const { bundle, rest: threadId } = resolveAccountForEmailId(requireString(args, "threadId"));
6305
+ const outputSlug = bundle.account.slug === primaryBundle.account.slug ? undefined : bundle.account.slug;
6306
+ await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
5158
6307
  force: normalizeBoolean(args.syncBefore, false),
5159
6308
  folder: "INBOX",
5160
6309
  limitPerFolder: 100,
5161
6310
  });
5162
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
6311
+ const thread = await bundle.localIndexService.getThreadById(threadId);
5163
6312
  const action = requireEmailAction(args);
5164
6313
  ensureEmailActionAllowed(config.runtime, action);
5165
6314
  const unreadOnly = normalizeBoolean(args.unreadOnly, false);
@@ -5173,27 +6322,33 @@ export function createServer(config, options = {}) {
5173
6322
  if (action === "delete") {
5174
6323
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) in thread (cannot be recovered)`);
5175
6324
  }
5176
- const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
6325
+ const result = await withAudit(bundle.auditService, name, args, async () => applyBatchEmailAction(bundle.imapService, [], {
5177
6326
  emailIds,
5178
6327
  action,
5179
6328
  targetFolder: optionalString(args, "targetFolder"),
5180
6329
  continueOnError: normalizeBoolean(args.continueOnError, true),
5181
6330
  dryRun: normalizeBoolean(args.dryRun, false),
5182
6331
  }));
6332
+ const prefixedResult = {
6333
+ ...result,
6334
+ results: result.results.map((entry) => ({ ...entry, emailId: withAccountPrefix(outputSlug, entry.emailId) })),
6335
+ };
5183
6336
  const sources = [
5184
6337
  threadSource(thread),
5185
6338
  ...result.results.flatMap((entry) => (entry.ok ? emailSourceFromActionResult(entry.result) : [])),
5186
6339
  ];
5187
6340
  return createTextResult({
5188
- threadId: thread.id,
6341
+ threadId: withAccountPrefix(outputSlug, thread.id),
5189
6342
  unreadOnly,
5190
6343
  dryRun: normalizeBoolean(args.dryRun, false),
5191
- ...result,
6344
+ ...prefixedResult,
5192
6345
  }, false, sources);
5193
6346
  }
5194
6347
  case "list_attachments":
5195
6348
  {
5196
- const attachmentList = await imapService.listAttachments(requireString(args, "emailId"));
6349
+ const rawEmailId = requireString(args, "emailId");
6350
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6351
+ const attachmentList = await bundle.imapService.listAttachments(emailId);
5197
6352
  const includeInline = normalizeBoolean(args.includeInline, true);
5198
6353
  const filenameContains = optionalString(args, "filenameContains");
5199
6354
  const contentType = optionalString(args, "contentType");
@@ -5212,14 +6367,17 @@ export function createServer(config, options = {}) {
5212
6367
  return true;
5213
6368
  });
5214
6369
  const result = {
5215
- emailId: attachmentList.emailId,
6370
+ emailId: prefixedIdFor(bundle, attachmentList.emailId),
5216
6371
  attachments: filtered,
5217
6372
  };
5218
6373
  return createTextResult(result, false, result.attachments.map((attachment) => attachmentSource(result.emailId, attachment)));
5219
6374
  }
5220
6375
  case "get_attachment_content":
5221
6376
  {
5222
- const result = await imapService.getAttachmentContent(requireString(args, "emailId"), requireString(args, "attachmentId"), normalizeBoolean(args.includeBase64, false));
6377
+ const rawEmailId = requireString(args, "emailId");
6378
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6379
+ const result = await bundle.imapService.getAttachmentContent(emailId, requireString(args, "attachmentId"), normalizeBoolean(args.includeBase64, false));
6380
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5223
6381
  const saveTo = optionalString(args, "saveTo");
5224
6382
  if (!saveTo && result.base64) {
5225
6383
  const MAX_INLINE_BYTES = (config.runtime.maxInlineBytes ?? 40) * 1024;
@@ -5273,11 +6431,16 @@ export function createServer(config, options = {}) {
5273
6431
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5274
6432
  }
5275
6433
  case "get_attachment_text": {
5276
- const result = await imapService.getAttachmentText(requireString(args, "emailId"), requireString(args, "attachmentId"));
6434
+ const rawEmailId = requireString(args, "emailId");
6435
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6436
+ const result = await bundle.imapService.getAttachmentText(emailId, requireString(args, "attachmentId"));
6437
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5277
6438
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5278
6439
  }
5279
6440
  case "save_attachment":
5280
6441
  {
6442
+ const rawEmailId = requireString(args, "emailId");
6443
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
5281
6444
  const saveTo = optionalString(args, "saveTo");
5282
6445
  const outputPath = optionalString(args, "outputPath");
5283
6446
  let resolvedPath = outputPath;
@@ -5294,11 +6457,15 @@ export function createServer(config, options = {}) {
5294
6457
  }
5295
6458
  resolvedPath = absTarget;
5296
6459
  }
5297
- const result = await imapService.saveAttachment(requireString(args, "emailId"), requireString(args, "attachmentId"), resolvedPath);
6460
+ const result = await bundle.imapService.saveAttachment(emailId, requireString(args, "attachmentId"), resolvedPath);
6461
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5298
6462
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5299
6463
  }
5300
6464
  case "export_email": {
5301
- const result = await withAudit(auditService, name, args, async () => imapService.exportEmail(requireString(args, "emailId"), optionalString(args, "outputPath")));
6465
+ const rawEmailId = requireString(args, "emailId");
6466
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6467
+ const result = await withAudit(auditService, name, args, async () => bundle.imapService.exportEmail(emailId, optionalString(args, "outputPath")));
6468
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5302
6469
  return createTextResult(result);
5303
6470
  }
5304
6471
  case "import_email": {
@@ -5328,13 +6495,16 @@ export function createServer(config, options = {}) {
5328
6495
  }
5329
6496
  case "save_attachments":
5330
6497
  {
5331
- const result = await imapService.saveAttachments({
5332
- emailId: requireString(args, "emailId"),
6498
+ const rawEmailId = requireString(args, "emailId");
6499
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
6500
+ const result = await bundle.imapService.saveAttachments({
6501
+ emailId,
5333
6502
  outputPath: optionalString(args, "outputPath"),
5334
6503
  includeInline: normalizeBoolean(args.includeInline, false),
5335
6504
  filenameContains: optionalString(args, "filenameContains"),
5336
6505
  contentType: optionalString(args, "contentType"),
5337
6506
  });
6507
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5338
6508
  return createTextResult(result, false, result.saved.map((entry) => attachmentSource(result.emailId, entry.attachment)));
5339
6509
  }
5340
6510
  case "clear_cache":