proton-mail-bridge-client 2.0.9 → 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.
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);
@@ -143,6 +137,7 @@ const TOOLS = [
143
137
  properties: {
144
138
  to: { type: "string", description: "Recipient email address." },
145
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." },
146
141
  confirmed: { type: "boolean", description: "Set to true to confirm this send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
147
142
  },
148
143
  required: ["to"],
@@ -323,6 +318,7 @@ const TOOLS = [
323
318
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
324
319
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
325
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." },
326
322
  syncToRemote: {
327
323
  type: "boolean",
328
324
  description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
@@ -361,6 +357,7 @@ const TOOLS = [
361
357
  cc: { type: "string", description: "CC recipients, comma-separated." },
362
358
  bcc: { type: "string", description: "BCC recipients, comma-separated." },
363
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." },
364
361
  syncToRemote: {
365
362
  type: "boolean",
366
363
  description: "Whether to sync the draft to the Proton Drafts mailbox when IMAP is available.",
@@ -1241,6 +1238,17 @@ const TOOLS = [
1241
1238
  annotations: { readOnlyHint: true },
1242
1239
  inputSchema: { type: "object", properties: {} },
1243
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
+ },
1244
1252
  {
1245
1253
  name: "run_doctor",
1246
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.",
@@ -1514,6 +1522,7 @@ const TOOLS = [
1514
1522
  cc: { type: "string", description: "Additional CC recipients, comma-separated." },
1515
1523
  bcc: { type: "string", description: "Additional BCC recipients, comma-separated." },
1516
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." },
1517
1526
  syncBefore: {
1518
1527
  type: "boolean",
1519
1528
  description: "Refresh the local mailbox index from IMAP before resolving the thread.",
@@ -2134,6 +2143,67 @@ function threadSource(thread) {
2134
2143
  },
2135
2144
  };
2136
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
+ }
2137
2207
  // list_drafts has no filter and returns every draft unconditionally — unlike get_draft/create_draft,
2138
2208
  // which each touch exactly one draft the caller already knows the content of. DraftRecord.attachments
2139
2209
  // carries full base64 `content`, and createTextResult() serializes the whole payload TWICE (once into
@@ -2402,6 +2472,109 @@ async function getAnalyticsSampleFromIndex(imapService, localIndexService, days,
2402
2472
  const result = await localIndexService.search({ dateFrom, limit });
2403
2473
  return result.emails;
2404
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
+ }
2405
2578
  async function runEmailAction(imapService, emailId, action, targetFolder) {
2406
2579
  // undefined for an old-format id (no embedded UIDVALIDITY) — every method
2407
2580
  // below's own assertMailboxUidValidity guard already treats that as
@@ -2546,7 +2719,7 @@ export function ensureBulkBatchSize(uidsLength, max) {
2546
2719
  // call reused for both this and resolveUidsForBulkOp) should pass it, so a
2547
2720
  // stale-generation id gets its own clear error instead of being lumped in
2548
2721
  // with a genuinely malformed one.
2549
- function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2722
+ export function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2550
2723
  if (!emailIds) {
2551
2724
  return [];
2552
2725
  }
@@ -2591,7 +2764,7 @@ function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2591
2764
  }
2592
2765
  return excluded;
2593
2766
  }
2594
- function withBulkNotFound(result, excludedEmailIds) {
2767
+ export function withBulkNotFound(result, excludedEmailIds) {
2595
2768
  if (excludedEmailIds.length === 0) {
2596
2769
  return result;
2597
2770
  }
@@ -2610,6 +2783,60 @@ function withBulkNotFound(result, excludedEmailIds) {
2610
2783
  ],
2611
2784
  };
2612
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
+ }
2613
2840
  function paginateRecentRecords(records, limit, offset) {
2614
2841
  return records.slice(offset, offset + limit);
2615
2842
  }
@@ -2745,6 +2972,78 @@ function isMissingTargetFolderError(error) {
2745
2972
  const message = error instanceof Error ? error.message : String(error);
2746
2973
  return /TRYCREATE|NONEXISTENT/i.test(message);
2747
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
+ }
2748
3047
  export function buildConfigFromEnv() {
2749
3048
  const username = readEnvValue("PROTONMAIL_USERNAME");
2750
3049
  const password = readEnvValue("PROTONMAIL_PASSWORD");
@@ -2789,27 +3088,26 @@ export function buildConfigFromEnv() {
2789
3088
  if (isLocalBridgeHost(imapHost)) {
2790
3089
  logger.warn("TLS verification is disabled for local Bridge IMAP connections.", "MCPServer", { host: imapHost });
2791
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)];
2792
3101
  return {
2793
- smtp: {
2794
- host: smtpHost,
2795
- port: smtpPort,
2796
- secure: smtpSecure,
2797
- username,
2798
- password,
2799
- },
2800
- imap: {
2801
- host: imapHost,
2802
- port: imapPort,
2803
- secure: imapSecure,
2804
- username: imapUsername,
2805
- password: imapPassword,
2806
- },
3102
+ smtp: primarySmtp,
3103
+ imap: primaryImap,
2807
3104
  dataDir,
2808
3105
  debug,
2809
3106
  cacheEnabled: true,
2810
3107
  analyticsEnabled: true,
2811
3108
  autoSync,
2812
3109
  syncInterval,
3110
+ accounts,
2813
3111
  runtime: {
2814
3112
  readOnly,
2815
3113
  allowSend,
@@ -2835,34 +3133,96 @@ export function buildConfigFromEnv() {
2835
3133
  };
2836
3134
  }
2837
3135
  export function createServer(config, options = {}) {
2838
- const smtpService = new SMTPService(config);
2839
- 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;
2840
3146
  const analyticsService = new AnalyticsService();
2841
- const auditService = new AuditService(config);
2842
- const localIndexService = new LocalIndexService(config, logger);
2843
- const draftStore = new DraftStoreService(config, logger);
2844
- const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
2845
- const deliveryQueueService = new DeliveryQueueService(config, smtpService, logger);
2846
- // Lets a fired scheduled_send close the loop back to its source draft —
2847
- // see DeliveryQueueService.checkDue()'s draftStore usage.
2848
- deliveryQueueService.setDraftStore(draftStore);
2849
- const snoozeService = new SnoozeService(config, imapService, logger);
2850
- 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
+ }
2851
3204
  if (options.startBackgroundSync) {
2852
- backgroundSyncService.start();
2853
- // start()'s first real work is awaiting recoverInterruptedSends(), which
2854
- // can throw a lock-acquisition-timeout error (see file-lock.ts). Bare
2855
- // `void` here would turn that into an unhandled rejection, and the
2856
- // process-wide unhandledRejection handler below calls process.exit(1) —
2857
- // crashing the whole server before it serves a single request over
2858
- // nothing worse than startup lock contention. Log and degrade instead:
2859
- // the delivery queue just won't be running yet.
2860
- deliveryQueueService.start().catch((error) => {
2861
- logger.error("deliveryQueueService.start() failed", "MCPServer", error);
2862
- });
2863
- // snoozeService.start() is synchronous (returns void, not a Promise) —
2864
- // nothing to catch here.
2865
- 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
+ }
2866
3226
  }
2867
3227
  const server = new Server({
2868
3228
  name: "proton-mail-bridge-client",
@@ -3070,6 +3430,12 @@ export function createServer(config, options = {}) {
3070
3430
  undoWindowSeconds = requested;
3071
3431
  }
3072
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.
3073
3439
  const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
3074
3440
  const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
3075
3441
  return createTextResult({
@@ -3079,12 +3445,22 @@ export function createServer(config, options = {}) {
3079
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.`,
3080
3446
  });
3081
3447
  }
3082
- 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));
3083
3459
  let sentCopyTokenSend = "[sent-copy:unverified]";
3084
3460
  try {
3085
3461
  const verifyMsgId = result.messageId;
3086
3462
  if (verifyMsgId) {
3087
- const scv = await verifySentCopy(imapService, verifyMsgId);
3463
+ const scv = await verifySentCopy(sendImap, verifyMsgId);
3088
3464
  if (scv.found)
3089
3465
  sentCopyTokenSend = "[sent-copy:verified]";
3090
3466
  }
@@ -3109,10 +3485,12 @@ export function createServer(config, options = {}) {
3109
3485
  return createTextResult(filtered);
3110
3486
  }
3111
3487
  case "get_unsubscribe_info": {
3112
- 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);
3113
3491
  const info = extractUnsubscribeInfo(detail);
3114
3492
  return createTextResult({
3115
- emailId: detail.id,
3493
+ emailId: prefixedIdFor(bundle, detail.id),
3116
3494
  hasUnsubscribeHeader: Boolean(info.mailto || info.url),
3117
3495
  mailto: info.mailto,
3118
3496
  url: info.url,
@@ -3122,7 +3500,9 @@ export function createServer(config, options = {}) {
3122
3500
  });
3123
3501
  }
3124
3502
  case "unsubscribe_sender": {
3125
- 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);
3126
3506
  const info = extractUnsubscribeInfo(detail);
3127
3507
  if (!info.mailto) {
3128
3508
  if (info.url) {
@@ -3135,14 +3515,14 @@ export function createServer(config, options = {}) {
3135
3515
  // info.mailto comes from an untrusted inbound header — subject to the
3136
3516
  // same outbound restriction as every other send path.
3137
3517
  ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [info.mailto]);
3138
- const result = await withAudit(auditService, name, args, () => smtpService.sendEmail({
3518
+ const result = await withAudit(auditService, name, args, () => bundle.smtpService.sendEmail({
3139
3519
  to: [info.mailto],
3140
3520
  subject: "unsubscribe",
3141
3521
  body: "unsubscribe",
3142
3522
  isHtml: false,
3143
3523
  }));
3144
3524
  return createTextResult({
3145
- emailId: detail.id,
3525
+ emailId: prefixedIdFor(bundle, detail.id),
3146
3526
  unsubscribedVia: info.mailto,
3147
3527
  messageId: result.messageId,
3148
3528
  accepted: result.accepted,
@@ -3154,10 +3534,17 @@ export function createServer(config, options = {}) {
3154
3534
  if (!isValidEmail(to)) {
3155
3535
  throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
3156
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
+ }
3157
3541
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send test email to ${to}`);
3158
3542
  ensureSendAllowed(config.runtime);
3159
3543
  ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [to]);
3160
- 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));
3161
3548
  return createTextResult({
3162
3549
  messageId: result.messageId,
3163
3550
  accepted: result.accepted,
@@ -3168,7 +3555,13 @@ export function createServer(config, options = {}) {
3168
3555
  case "reply_to_email": {
3169
3556
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Reply to email ${String(args.emailId ?? "?")}`);
3170
3557
  ensureSendAllowed(config.runtime);
3171
- 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);
3172
3565
  const markdownBodyReply = optionalString(args, "markdownBody");
3173
3566
  const body = markdownBodyReply ? markdownBodyReply : requireString(args, "body");
3174
3567
  const isHtml = markdownBodyReply ? false : normalizeBoolean(args.isHtml, false);
@@ -3179,11 +3572,13 @@ export function createServer(config, options = {}) {
3179
3572
  if (fromReply && !isValidEmail(fromReply)) {
3180
3573
  throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3181
3574
  }
3575
+ const sendBundleReply = fromReply ? accountManager.byAddress(fromReply) : undefined;
3576
+ const sendBundleForReply = sendBundleReply ?? readBundleReply;
3182
3577
  const sanitizeHtmlReply = normalizeBoolean(args.sanitizeHtml, true);
3183
3578
  const attachments = optionalAttachmentList(args.attachments);
3184
3579
  const extraCc = parseEmails(optionalString(args, "cc"));
3185
3580
  const extraBcc = parseEmails(optionalString(args, "bcc"));
3186
- const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
3581
+ const recipients = getReplyRecipients(detail, readBundleReply.config.smtp.username, replyAll);
3187
3582
  const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
3188
3583
  const to = uniqueAddresses(recipients.to);
3189
3584
  ensureValidEmails(to, "to");
@@ -3207,9 +3602,9 @@ export function createServer(config, options = {}) {
3207
3602
  }
3208
3603
  }
3209
3604
  if (dryRunReply) {
3210
- 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) })]);
3211
3606
  }
3212
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3607
+ const result = await withAudit(auditService, name, args, async () => sendBundleForReply.smtpService.sendEmail({
3213
3608
  to,
3214
3609
  cc,
3215
3610
  bcc: extraBcc,
@@ -3230,14 +3625,14 @@ export function createServer(config, options = {}) {
3230
3625
  try {
3231
3626
  const verifyMsgId = result.messageId;
3232
3627
  if (verifyMsgId) {
3233
- const scv = await verifySentCopy(imapService, verifyMsgId);
3628
+ const scv = await verifySentCopy(sendBundleForReply.imapService, verifyMsgId);
3234
3629
  if (scv.found)
3235
3630
  sentCopyTokenReply = "[sent-copy:verified]";
3236
3631
  }
3237
3632
  }
3238
3633
  catch (_) { }
3239
3634
  return createTextResult({
3240
- repliedTo: detail.id,
3635
+ repliedTo: prefixedIdFor(readBundleReply, detail.id),
3241
3636
  to,
3242
3637
  cc,
3243
3638
  messageId: result.messageId,
@@ -3245,12 +3640,14 @@ export function createServer(config, options = {}) {
3245
3640
  rejected: result.rejected,
3246
3641
  response: result.response,
3247
3642
  sentCopy: sentCopyTokenReply,
3248
- }, false, [emailSource(detail)]);
3643
+ }, false, [emailSource({ ...detail, id: prefixedIdFor(readBundleReply, detail.id) })]);
3249
3644
  }
3250
3645
  case "reply_all_email": {
3251
3646
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Reply-all to email ${String(args.emailId ?? "?")}`);
3252
3647
  ensureSendAllowed(config.runtime);
3253
- 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);
3254
3651
  const markdownBodyRa = optionalString(args, "markdownBody");
3255
3652
  const bodyRa = markdownBodyRa ? markdownBodyRa : requireString(args, "body");
3256
3653
  const isHtmlRa = markdownBodyRa ? false : normalizeBoolean(args.isHtml, false);
@@ -3260,11 +3657,13 @@ export function createServer(config, options = {}) {
3260
3657
  if (fromRa && !isValidEmail(fromRa)) {
3261
3658
  throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3262
3659
  }
3660
+ const sendBundleRa = fromRa ? accountManager.byAddress(fromRa) : undefined;
3661
+ const sendBundleForRa = sendBundleRa ?? readBundleRa;
3263
3662
  const sanitizeHtmlRa = normalizeBoolean(args.sanitizeHtml, true);
3264
3663
  const attachmentsRa = optionalAttachmentList(args.attachments);
3265
3664
  const extraCcRa = parseEmails(optionalString(args, "cc"));
3266
3665
  const extraBccRa = parseEmails(optionalString(args, "bcc"));
3267
- const recipientsRa = getReplyRecipients(detailRa, config.smtp.username, true);
3666
+ const recipientsRa = getReplyRecipients(detailRa, readBundleRa.config.smtp.username, true);
3268
3667
  const ccRa = uniqueAddresses([...recipientsRa.cc, ...extraCcRa]);
3269
3668
  const toRa = uniqueAddresses(recipientsRa.to);
3270
3669
  ensureValidEmails(toRa, "to");
@@ -3290,7 +3689,7 @@ export function createServer(config, options = {}) {
3290
3689
  // quoted original — not at the very end, after the quote.
3291
3690
  const signedReplyRa = applySignature(bodyRa, htmlBodyRa, normalizeBoolean(args.appendSignature, true));
3292
3691
  const replyBodyRa = includeQuoteRa ? buildReplyText(detailRa, signedReplyRa.body) : signedReplyRa.body;
3293
- const resultRa = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3692
+ const resultRa = await withAudit(auditService, name, args, async () => sendBundleForRa.smtpService.sendEmail({
3294
3693
  to: toRa,
3295
3694
  cc: ccRa,
3296
3695
  bcc: extraBccRa,
@@ -3311,14 +3710,14 @@ export function createServer(config, options = {}) {
3311
3710
  try {
3312
3711
  const verifyMsgId = resultRa.messageId;
3313
3712
  if (verifyMsgId) {
3314
- const scv = await verifySentCopy(imapService, verifyMsgId);
3713
+ const scv = await verifySentCopy(sendBundleForRa.imapService, verifyMsgId);
3315
3714
  if (scv.found)
3316
3715
  sentCopyTokenRa = "[sent-copy:verified]";
3317
3716
  }
3318
3717
  }
3319
3718
  catch (_) { }
3320
3719
  return createTextResult({
3321
- repliedTo: detailRa.id,
3720
+ repliedTo: prefixedIdFor(readBundleRa, detailRa.id),
3322
3721
  to: toRa,
3323
3722
  cc: ccRa,
3324
3723
  messageId: resultRa.messageId,
@@ -3326,12 +3725,14 @@ export function createServer(config, options = {}) {
3326
3725
  rejected: resultRa.rejected,
3327
3726
  response: resultRa.response,
3328
3727
  sentCopy: sentCopyTokenRa,
3329
- }, false, [emailSource(detailRa)]);
3728
+ }, false, [emailSource({ ...detailRa, id: prefixedIdFor(readBundleRa, detailRa.id) })]);
3330
3729
  }
3331
3730
  case "forward_email": {
3332
3731
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
3333
3732
  ensureSendAllowed(config.runtime);
3334
- 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);
3335
3736
  const to = parseEmails(requireString(args, "to"));
3336
3737
  const cc = parseEmails(optionalString(args, "cc"));
3337
3738
  const bcc = parseEmails(optionalString(args, "bcc"));
@@ -3344,6 +3745,8 @@ export function createServer(config, options = {}) {
3344
3745
  if (fromFwd && !isValidEmail(fromFwd)) {
3345
3746
  throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3346
3747
  }
3748
+ const sendBundleFwd = fromFwd ? accountManager.byAddress(fromFwd) : undefined;
3749
+ const sendBundleForFwd = sendBundleFwd ?? readBundleFwd;
3347
3750
  const sanitizeHtmlFwd = normalizeBoolean(args.sanitizeHtml, true);
3348
3751
  // args.attachments are new attachments the caller wants to add to the
3349
3752
  // forward — they are NOT the original message's attachments. Despite
@@ -3386,14 +3789,14 @@ export function createServer(config, options = {}) {
3386
3789
  const originalAttachments = includeAttachments
3387
3790
  ? await Promise.all(detail.attachments
3388
3791
  .filter((a) => a.id && (!attachmentParts || (a.part !== undefined && attachmentParts.includes(a.part))))
3389
- .map((a) => imapService.getAttachmentForForward(detail.id, a.id)))
3792
+ .map((a) => readBundleFwd.imapService.getAttachmentForForward(detail.id, a.id)))
3390
3793
  : [];
3391
3794
  const fwdAttachments = [...originalAttachments, ...attachments];
3392
3795
  // Signature goes right after the user's own note, before the
3393
3796
  // "---------- Forwarded message ---------" block — not at the very
3394
3797
  // end, after the forwarded content.
3395
3798
  const signedFwd = applySignature(body ?? "", htmlBody, normalizeBoolean(args.appendSignature, true));
3396
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3799
+ const result = await withAudit(auditService, name, args, async () => sendBundleForFwd.smtpService.sendEmail({
3397
3800
  to,
3398
3801
  cc,
3399
3802
  bcc,
@@ -3412,14 +3815,14 @@ export function createServer(config, options = {}) {
3412
3815
  try {
3413
3816
  const verifyMsgId = result.messageId;
3414
3817
  if (verifyMsgId) {
3415
- const scv = await verifySentCopy(imapService, verifyMsgId);
3818
+ const scv = await verifySentCopy(sendBundleForFwd.imapService, verifyMsgId);
3416
3819
  if (scv.found)
3417
3820
  sentCopyTokenFwd = "[sent-copy:verified]";
3418
3821
  }
3419
3822
  }
3420
3823
  catch (_) { }
3421
3824
  return createTextResult({
3422
- forwardedMessage: detail.id,
3825
+ forwardedMessage: prefixedIdFor(readBundleFwd, detail.id),
3423
3826
  to,
3424
3827
  cc,
3425
3828
  messageId: result.messageId,
@@ -3427,7 +3830,7 @@ export function createServer(config, options = {}) {
3427
3830
  rejected: result.rejected,
3428
3831
  response: result.response,
3429
3832
  sentCopy: sentCopyTokenFwd,
3430
- }, false, [emailSource(detail)]);
3833
+ }, false, [emailSource({ ...detail, id: prefixedIdFor(readBundleFwd, detail.id) })]);
3431
3834
  }
3432
3835
  case "create_draft": {
3433
3836
  const to = parseEmails(optionalString(args, "to"));
@@ -3448,8 +3851,13 @@ export function createServer(config, options = {}) {
3448
3851
  if (from && !isValidEmail(from)) {
3449
3852
  throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3450
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;
3451
3859
  const result = await withAudit(auditService, name, args, async () => {
3452
- const draft = await draftStore.createDraft({
3860
+ const draft = await createDraftBundle.draftStore.createDraft({
3453
3861
  mode: "compose",
3454
3862
  to,
3455
3863
  cc,
@@ -3467,7 +3875,7 @@ export function createServer(config, options = {}) {
3467
3875
  });
3468
3876
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3469
3877
  const synced = remoteSyncDecision.enabled
3470
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
3878
+ ? await syncDraftToRemote(createDraftBundle.draftStore, createDraftBundle.smtpService, createDraftBundle.imapService, draft)
3471
3879
  : { draft, remoteSync: undefined };
3472
3880
  const remoteSync = synced.remoteSync ??
3473
3881
  (remoteSyncDecision.reason
@@ -3477,7 +3885,7 @@ export function createServer(config, options = {}) {
3477
3885
  message: remoteSyncDecision.reason,
3478
3886
  }
3479
3887
  : undefined);
3480
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
3888
+ return presentDraft(createDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3481
3889
  });
3482
3890
  return createTextResult(result, false, [
3483
3891
  draftSource(result),
@@ -3509,8 +3917,12 @@ export function createServer(config, options = {}) {
3509
3917
  if (to.length === 0) {
3510
3918
  throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
3511
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;
3512
3924
  const result = await withAudit(auditService, name, args, async () => {
3513
- const draft = await draftStore.createDraft({
3925
+ const draft = await createReplyDraftBundle.draftStore.createDraft({
3514
3926
  mode: "reply",
3515
3927
  to,
3516
3928
  cc,
@@ -3527,7 +3939,7 @@ export function createServer(config, options = {}) {
3527
3939
  });
3528
3940
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3529
3941
  const synced = remoteSyncDecision.enabled
3530
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
3942
+ ? await syncDraftToRemote(createReplyDraftBundle.draftStore, createReplyDraftBundle.smtpService, createReplyDraftBundle.imapService, draft)
3531
3943
  : { draft, remoteSync: undefined };
3532
3944
  const remoteSync = synced.remoteSync ??
3533
3945
  (remoteSyncDecision.reason
@@ -3537,7 +3949,7 @@ export function createServer(config, options = {}) {
3537
3949
  message: remoteSyncDecision.reason,
3538
3950
  }
3539
3951
  : undefined);
3540
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
3952
+ return presentDraft(createReplyDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3541
3953
  });
3542
3954
  return createTextResult(result, false, [
3543
3955
  draftSource(result),
@@ -3575,8 +3987,12 @@ export function createServer(config, options = {}) {
3575
3987
  .map((a) => imapService.getAttachmentForForward(detail.id, a.id)))
3576
3988
  : [];
3577
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;
3578
3994
  const result = await withAudit(auditService, name, args, async () => {
3579
- const draft = await draftStore.createDraft({
3995
+ const draft = await createForwardDraftBundle.draftStore.createDraft({
3580
3996
  mode: "forward",
3581
3997
  to,
3582
3998
  cc,
@@ -3591,7 +4007,7 @@ export function createServer(config, options = {}) {
3591
4007
  });
3592
4008
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3593
4009
  const synced = remoteSyncDecision.enabled
3594
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
4010
+ ? await syncDraftToRemote(createForwardDraftBundle.draftStore, createForwardDraftBundle.smtpService, createForwardDraftBundle.imapService, draft)
3595
4011
  : { draft, remoteSync: undefined };
3596
4012
  const remoteSync = synced.remoteSync ??
3597
4013
  (remoteSyncDecision.reason
@@ -3601,7 +4017,7 @@ export function createServer(config, options = {}) {
3601
4017
  message: remoteSyncDecision.reason,
3602
4018
  }
3603
4019
  : undefined);
3604
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
4020
+ return presentDraft(createForwardDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3605
4021
  });
3606
4022
  return createTextResult(result, false, [
3607
4023
  draftSource(result),
@@ -3618,6 +4034,9 @@ export function createServer(config, options = {}) {
3618
4034
  ]);
3619
4035
  }
3620
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.
3621
4040
  const drafts = await draftStore.listDrafts(normalizeBoolean(args.includeSent, false));
3622
4041
  return createTextResult({
3623
4042
  total: drafts.length,
@@ -3625,11 +4044,14 @@ export function createServer(config, options = {}) {
3625
4044
  }, false, drafts.map(draftSource));
3626
4045
  }
3627
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.
3628
4049
  const result = await imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
3629
4050
  return createTextResult(result, false, result.emails.map(emailSource));
3630
4051
  }
3631
4052
  case "get_draft": {
3632
- 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));
3633
4055
  return createTextResult(draft, false, [
3634
4056
  draftSource(draft),
3635
4057
  ...(draft.remoteDraft?.emailId
@@ -3644,7 +4066,7 @@ export function createServer(config, options = {}) {
3644
4066
  ]);
3645
4067
  }
3646
4068
  case "update_draft": {
3647
- const draftId = requireString(args, "draftId");
4069
+ const { bundle: updateDraftBundle, rest: updateDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
3648
4070
  const to = args.to === undefined ? undefined : parseEmails(optionalString(args, "to"));
3649
4071
  const cc = args.cc === undefined ? undefined : parseEmails(optionalString(args, "cc"));
3650
4072
  const bcc = args.bcc === undefined ? undefined : parseEmails(optionalString(args, "bcc"));
@@ -3668,7 +4090,7 @@ export function createServer(config, options = {}) {
3668
4090
  throw new McpError(ErrorCode.InvalidParams, "from must be a valid email address.");
3669
4091
  }
3670
4092
  const result = await withAudit(auditService, name, args, async () => {
3671
- const draft = await draftStore.updateDraft(draftId, {
4093
+ const draft = await updateDraftBundle.draftStore.updateDraft(updateDraftIdRest, {
3672
4094
  to,
3673
4095
  cc,
3674
4096
  bcc,
@@ -3685,7 +4107,7 @@ export function createServer(config, options = {}) {
3685
4107
  });
3686
4108
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
3687
4109
  const synced = remoteSyncDecision.enabled
3688
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
4110
+ ? await syncDraftToRemote(updateDraftBundle.draftStore, updateDraftBundle.smtpService, updateDraftBundle.imapService, draft)
3689
4111
  : { draft, remoteSync: undefined };
3690
4112
  const remoteSync = synced.remoteSync ??
3691
4113
  (remoteSyncDecision.reason
@@ -3695,7 +4117,7 @@ export function createServer(config, options = {}) {
3695
4117
  message: remoteSyncDecision.reason,
3696
4118
  }
3697
4119
  : undefined);
3698
- return remoteSync ? { ...synced.draft, remoteSync } : synced.draft;
4120
+ return presentDraft(updateDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
3699
4121
  });
3700
4122
  return createTextResult(result, false, [
3701
4123
  draftSource(result),
@@ -3712,16 +4134,18 @@ export function createServer(config, options = {}) {
3712
4134
  }
3713
4135
  case "sync_draft_to_remote": {
3714
4136
  ensureRemoteDraftSyncAllowed(config.runtime);
3715
- const draft = await draftStore.getDraft(requireString(args, "draftId"));
3716
- const synced = await withAudit(auditService, name, args, async () => syncDraftToRemote(draftStore, smtpService, imapService, draft));
3717
- return createTextResult({ ...synced.draft, remoteSync: synced.remoteSync }, false, [
3718
- draftSource(synced.draft),
3719
- ...(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
3720
4144
  ? [
3721
4145
  emailSource({
3722
- id: synced.draft.remoteDraft.emailId,
3723
- subject: synced.draft.subject,
3724
- folder: synced.draft.remoteDraft.folder,
4146
+ id: presentedSynced.remoteDraft.emailId,
4147
+ subject: presentedSynced.subject,
4148
+ folder: presentedSynced.remoteDraft.folder,
3725
4149
  })
3726
4150
  ]
3727
4151
  : []),
@@ -3730,7 +4154,8 @@ export function createServer(config, options = {}) {
3730
4154
  case "send_draft": {
3731
4155
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send draft ${String(args.draftId ?? "?")}`);
3732
4156
  ensureSendAllowed(config.runtime);
3733
- 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);
3734
4159
  // Found live: calling send_draft twice on the same draft sent it
3735
4160
  // twice — nothing here checked whether it was already marked
3736
4161
  // "sent" before sending again. Refuse; create_draft for a new
@@ -3743,7 +4168,7 @@ export function createServer(config, options = {}) {
3743
4168
  // transactions, since nothing here knew about the still-pending
3744
4169
  // scheduled send. Refuse instead; cancel_send first if the
3745
4170
  // immediate send is actually what's wanted.
3746
- 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");
3747
4172
  if (pendingScheduled) {
3748
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.`);
3749
4174
  }
@@ -3784,7 +4209,7 @@ export function createServer(config, options = {}) {
3784
4209
  // them. claimForSending() re-reads status under the draft store's
3785
4210
  // lock and flips "draft" -> "sending" in that same critical
3786
4211
  // section — only the call that wins the claim proceeds to SMTP.
3787
- await draftStore.claimForSending(draft.id);
4212
+ await sendDraftBundle.draftStore.claimForSending(draft.id);
3788
4213
  // Found live: withAudit wrapped the SMTP call, so an audit-log
3789
4214
  // write failure AFTER a successful send (e.g. disk full) was
3790
4215
  // indistinguishable from a send failure — it threw out of
@@ -3798,7 +4223,7 @@ export function createServer(config, options = {}) {
3798
4223
  const sendStartedAt = Date.now();
3799
4224
  let result;
3800
4225
  try {
3801
- result = await smtpService.sendEmail({
4226
+ result = await sendDraftBundle.smtpService.sendEmail({
3802
4227
  to: draft.to,
3803
4228
  cc: draft.cc,
3804
4229
  bcc: draft.bcc,
@@ -3822,7 +4247,7 @@ export function createServer(config, options = {}) {
3822
4247
  // Mirrors SnoozeService.wake()'s catch handler: revert the claim
3823
4248
  // so the draft is retryable instead of stuck in "sending"
3824
4249
  // forever because SMTP failed.
3825
- await draftStore.revertSending(draft.id);
4250
+ await sendDraftBundle.draftStore.revertSending(draft.id);
3826
4251
  await auditService
3827
4252
  .record({
3828
4253
  timestamp: new Date().toISOString(),
@@ -3851,14 +4276,14 @@ export function createServer(config, options = {}) {
3851
4276
  catch (auditError) {
3852
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 });
3853
4278
  }
3854
- let sentDraft = await draftStore.markSent(draft.id, {
4279
+ let sentDraft = await sendDraftBundle.draftStore.markSent(draft.id, {
3855
4280
  messageId: result.messageId,
3856
4281
  accepted: result.accepted,
3857
4282
  rejected: result.rejected,
3858
4283
  response: result.response,
3859
4284
  });
3860
4285
  const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
3861
- ? await clearRemoteDraft(draftStore, imapService, sentDraft)
4286
+ ? await clearRemoteDraft(sendDraftBundle.draftStore, sendDraftBundle.imapService, sentDraft)
3862
4287
  : {
3863
4288
  draft: sentDraft,
3864
4289
  remoteDelete: sentDraft.remoteDraft?.emailId
@@ -3869,16 +4294,16 @@ export function createServer(config, options = {}) {
3869
4294
  }
3870
4295
  : undefined,
3871
4296
  };
3872
- sentDraft = remoteCleanup.draft;
4297
+ sentDraft = presentDraft(sendDraftBundle, remoteCleanup.draft);
3873
4298
  const sources = [draftSource(sentDraft)];
3874
4299
  if (sentDraft.sourceEmailId) {
3875
- sources.push(emailSource(await imapService.getEmailById(sentDraft.sourceEmailId)));
4300
+ sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
3876
4301
  }
3877
4302
  let sentCopyTokenDraft = "[sent-copy:unverified]";
3878
4303
  try {
3879
4304
  const verifyMsgId = result.messageId;
3880
4305
  if (verifyMsgId) {
3881
- const scv = await verifySentCopy(imapService, verifyMsgId);
4306
+ const scv = await verifySentCopy(sendDraftBundle.imapService, verifyMsgId);
3882
4307
  if (scv.found)
3883
4308
  sentCopyTokenDraft = "[sent-copy:verified]";
3884
4309
  }
@@ -3898,7 +4323,8 @@ export function createServer(config, options = {}) {
3898
4323
  case "schedule_draft": {
3899
4324
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Schedule draft ${String(args.draftId ?? "?")} to send later`);
3900
4325
  ensureSendAllowed(config.runtime);
3901
- 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);
3902
4328
  // Mirrors send_draft's own pendingScheduled guard, for the reverse
3903
4329
  // ordering: send_draft already delivered this draft once, and
3904
4330
  // scheduling it again here would deliver it a second time at
@@ -3923,7 +4349,7 @@ export function createServer(config, options = {}) {
3923
4349
  // duplicate. This external check is kept only as an optional
3924
4350
  // early-exit optimization to avoid a wasted round-trip to the lock
3925
4351
  // in the common non-racing case.
3926
- 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");
3927
4353
  if (alreadyPendingScheduled) {
3928
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.`);
3929
4355
  }
@@ -3949,7 +4375,7 @@ export function createServer(config, options = {}) {
3949
4375
  if (external.length > 0)
3950
4376
  throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF is enabled. All recipients must be the authenticated user.");
3951
4377
  }
3952
- const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue({
4378
+ const queued = await withAudit(auditService, name, args, async () => scheduleDraftBundle.deliveryQueueService.enqueue({
3953
4379
  to: draft.to,
3954
4380
  cc: draft.cc,
3955
4381
  bcc: draft.bcc,
@@ -3969,17 +4395,18 @@ export function createServer(config, options = {}) {
3969
4395
  return createTextResult({
3970
4396
  queued: true,
3971
4397
  id: queued.id,
3972
- draftId: draft.id,
4398
+ draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
3973
4399
  sendAt: queued.sendAt,
3974
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.",
3975
4401
  });
3976
4402
  }
3977
4403
  case "delete_draft": {
3978
- 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);
3979
4406
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args?.confirmed, false), "Permanently delete draft: " + draft.id);
3980
4407
  const deleted = await withAudit(auditService, name, args, async () => {
3981
4408
  const remoteCleanup = resolveRemoteDraftSync(config.runtime, true).enabled
3982
- ? await clearRemoteDraft(draftStore, imapService, draft)
4409
+ ? await clearRemoteDraft(deleteDraftBundle.draftStore, deleteDraftBundle.imapService, draft)
3983
4410
  : {
3984
4411
  draft,
3985
4412
  remoteDelete: draft.remoteDraft?.emailId
@@ -3990,8 +4417,10 @@ export function createServer(config, options = {}) {
3990
4417
  }
3991
4418
  : undefined,
3992
4419
  };
4420
+ const deletedResult = await deleteDraftBundle.draftStore.deleteDraft(draft.id);
3993
4421
  return {
3994
- ...(await draftStore.deleteDraft(draft.id)),
4422
+ ...deletedResult,
4423
+ id: withAccountPrefix(responseSlug(deleteDraftBundle), deletedResult.id),
3995
4424
  remoteDelete: remoteCleanup.remoteDelete,
3996
4425
  };
3997
4426
  });
@@ -3999,23 +4428,50 @@ export function createServer(config, options = {}) {
3999
4428
  }
4000
4429
  case "get_emails": {
4001
4430
  const effectiveLimit = normalizeLimit(args.limit, 50);
4002
- const result = await imapService.getEmails({
4431
+ const getEmailsInput = {
4003
4432
  folder: optionalString(args, "folder"),
4004
4433
  limit: effectiveLimit,
4005
4434
  offset: typeof args.offset === "number" ? args.offset : undefined,
4006
4435
  beforeUid: typeof args?.beforeUid === "number" ? args.beforeUid : undefined,
4007
- sortByUid: args?.sortByUid === "asc" || args?.sortByUid === "desc" ? args.sortByUid : undefined,
4436
+ sortByUid: (args?.sortByUid === "asc" || args?.sortByUid === "desc" ? args.sortByUid : undefined),
4008
4437
  includeSnippet: normalizeBoolean(args.includeSnippet, false),
4009
- });
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);
4010
4460
  return createTextResult({
4011
- ...result,
4012
- emails: projectFields(result.emails, parseFieldsArg(args.fields)),
4013
- returned: result.emails.length,
4014
- hasMore: result.emails.length === effectiveLimit,
4015
- }, 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));
4016
4470
  }
4017
4471
  case "get_email_by_id": {
4018
- 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);
4019
4475
  const preferHtml = normalizeBoolean(args.preferHtml, false);
4020
4476
  const maxBodyLength = normalizeLimit(args.maxBodyLength, undefined, 1, 500_000);
4021
4477
  const showHeaders = normalizeBoolean(args.showHeaders, false);
@@ -4026,7 +4482,9 @@ export function createServer(config, options = {}) {
4026
4482
  // on top without ever removing the always-present full map.
4027
4483
  if (showHeaders)
4028
4484
  output.headers = detail.headers;
4029
- 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))]);
4030
4488
  }
4031
4489
  case "get_emails_by_ids": {
4032
4490
  const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];
@@ -4095,7 +4553,8 @@ export function createServer(config, options = {}) {
4095
4553
  }
4096
4554
  case "update_message_labels": {
4097
4555
  ensureMailboxWriteAllowed(config.runtime);
4098
- const emailId = requireString(args, "emailId");
4556
+ const rawEmailId = requireString(args, "emailId");
4557
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4099
4558
  const labelsToAdd = Array.isArray(args.labelsToAdd)
4100
4559
  ? args.labelsToAdd.map(String)
4101
4560
  : [];
@@ -4110,12 +4569,13 @@ export function createServer(config, options = {}) {
4110
4569
  // treats that as "unverifiable, don't block on it", same as every
4111
4570
  // other mutation handler below.
4112
4571
  const uidValidity = parseEmailId(emailId).uidValidity;
4113
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity));
4114
- 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) })]);
4115
4574
  }
4116
4575
  case "update_message_flags": {
4117
4576
  ensureMailboxWriteAllowed(config.runtime);
4118
- const emailId = requireString(args, "emailId");
4577
+ const rawEmailId = requireString(args, "emailId");
4578
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4119
4579
  const flagsToAdd = Array.isArray(args.flagsToAdd)
4120
4580
  ? args.flagsToAdd.map(String)
4121
4581
  : [];
@@ -4126,11 +4586,11 @@ export function createServer(config, options = {}) {
4126
4586
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one flag in flagsToAdd or flagsToRemove.");
4127
4587
  }
4128
4588
  const uidValidity = parseEmailId(emailId).uidValidity;
4129
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity));
4130
- 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) })]);
4131
4591
  }
4132
4592
  case "count_messages": {
4133
- const result = await imapService.countMessages({
4593
+ const countInput = {
4134
4594
  folder: optionalString(args, "folder"),
4135
4595
  query: optionalString(args, "query"),
4136
4596
  from: optionalString(args, "from"),
@@ -4146,12 +4606,44 @@ export function createServer(config, options = {}) {
4146
4606
  dateTo: optionalString(args, "dateTo"),
4147
4607
  sizeLarger: typeof args.sizeLarger === "number" ? args.sizeLarger : undefined,
4148
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,
4149
4625
  });
4150
- return createTextResult(result);
4151
4626
  }
4152
4627
  case "folder_stats": {
4153
- const result = await imapService.getFolderStats(optionalString(args, "folder"), typeof args.scanLimit === "number" ? args.scanLimit : undefined);
4154
- 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
+ });
4155
4647
  }
4156
4648
  case "empty_folder": {
4157
4649
  if (!config.runtime.allowEmptyFolder) {
@@ -4189,41 +4681,57 @@ export function createServer(config, options = {}) {
4189
4681
  const folder = optionalString(args, "folder") ?? "INBOX";
4190
4682
  const targetFolder = requireString(args, "targetFolder");
4191
4683
  const max = getBulkMaxBatchSize(args);
4192
- // Only fetched (an extra STATUS round trip) when there are ids to
4193
- // check against it — a match-only bulk_move has no ids that could
4194
- // be stale, since match resolves directly against the live
4195
- // mailbox each time.
4196
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4197
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4198
- // Resolve the match/emailIds set exactly once and reuse it for both
4199
- // the preview and the real run — see resolveUidsForBulkOp's
4200
- // comment for why re-resolving `match` a second time (the old
4201
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4202
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4203
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4204
- ensureBulkBatchSize(uids.length, max);
4205
- if (normalizeBoolean(args.dryRun, false)) {
4206
- const preview = await imapService.bulkMove({
4207
- emailIds,
4208
- match,
4209
- folder,
4210
- targetFolder,
4211
- resolvedUids: uids,
4212
- uidValidity: currentUidValidity,
4213
- 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
+ }),
4214
4725
  });
4215
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4216
4726
  }
4217
- const result = await withAudit(auditService, name, args, () => imapService.bulkMove({
4218
- emailIds,
4219
- match,
4220
- folder,
4221
- targetFolder,
4222
- resolvedUids: uids,
4223
- uidValidity: currentUidValidity,
4224
- dryRun: false,
4225
- }));
4226
- 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));
4227
4735
  }
4228
4736
  case "bulk_delete": {
4229
4737
  ensureMailboxWriteAllowed(config.runtime);
@@ -4242,45 +4750,60 @@ export function createServer(config, options = {}) {
4242
4750
  throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
4243
4751
  const folder = optionalString(args, "folder") ?? "INBOX";
4244
4752
  const max = getBulkMaxBatchSize(args);
4245
- // Fetched once, up front, and reused for both the notFound
4246
- // reporting below and resolveUidsForBulkOp's own filtering — a
4247
- // single shared point-in-time value, same "resolve once" spirit
4248
- // as the uids resolution itself, so the ids this call reports as
4249
- // excluded and the uids it actually acts on can never disagree
4250
- // about which generation was current. Only fetched when there are
4251
- // ids to check against it (a match-only call has none).
4252
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4253
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4254
- // Resolve the match/emailIds set exactly once and reuse it for both
4255
- // the preview and the real run — see resolveUidsForBulkOp's
4256
- // comment for why re-resolving `match` a second time (the old
4257
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4258
- // Also excludes any id whose embedded UIDVALIDITY is stale — see
4259
- // resolveUidsForBulkOp's comment.
4260
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4261
- ensureBulkBatchSize(uids.length, max);
4262
- if (normalizeBoolean(args.dryRun, false)) {
4263
- const preview = await imapService.bulkDelete({
4264
- emailIds,
4265
- match,
4266
- folder,
4267
- permanent,
4268
- resolvedUids: uids,
4269
- uidValidity: currentUidValidity,
4270
- 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
+ }),
4271
4797
  });
4272
- return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4273
4798
  }
4274
- const result = await withAudit(auditService, name, args, () => imapService.bulkDelete({
4275
- emailIds,
4276
- match,
4277
- folder,
4278
- permanent,
4279
- resolvedUids: uids,
4280
- uidValidity: currentUidValidity,
4281
- dryRun: false,
4282
- }));
4283
- 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));
4284
4807
  }
4285
4808
  case "bulk_update_flags": {
4286
4809
  ensureMailboxWriteAllowed(config.runtime);
@@ -4300,23 +4823,55 @@ export function createServer(config, options = {}) {
4300
4823
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4301
4824
  const folder = optionalString(args, "folder") ?? "INBOX";
4302
4825
  const max = getBulkMaxBatchSize(args);
4303
- // See the bulk_delete case above for why this is fetched once and
4304
- // shared between notFound reporting and uid resolution.
4305
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4306
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4307
- // Resolve the match/emailIds set exactly once and reuse it for both
4308
- // the preview and the real run — see resolveUidsForBulkOp's
4309
- // comment for why re-resolving `match` a second time (the old
4310
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4311
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4312
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4313
- ensureBulkBatchSize(uids.length, max);
4314
- if (normalizeBoolean(args.dryRun, false)) {
4315
- const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4316
- 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
+ });
4317
4866
  }
4318
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4319
- 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));
4320
4875
  }
4321
4876
  case "bulk_update_labels": {
4322
4877
  ensureMailboxWriteAllowed(config.runtime);
@@ -4334,39 +4889,111 @@ export function createServer(config, options = {}) {
4334
4889
  const labelsToRemove = Array.isArray(args.labelsToRemove) ? args.labelsToRemove.map(String) : [];
4335
4890
  const folder = optionalString(args, "folder") ?? "INBOX";
4336
4891
  const max = getBulkMaxBatchSize(args);
4337
- // See the bulk_delete case above for why this is fetched once and
4338
- // shared between notFound reporting and uid resolution.
4339
- const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4340
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4341
- // Resolve the match/emailIds set exactly once and reuse it for both
4342
- // the preview and the real run — see resolveUidsForBulkOp's
4343
- // comment for why re-resolving `match` a second time (the old
4344
- // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4345
- // Also excludes any id whose embedded UIDVALIDITY is stale.
4346
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4347
- ensureBulkBatchSize(uids.length, max);
4348
- if (normalizeBoolean(args.dryRun, false)) {
4349
- const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4350
- 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)));
4351
4939
  }
4352
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4353
- return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4940
+ return createTextResult(mergeBulkResults(outputs));
4354
4941
  }
4355
4942
  case "top_senders": {
4356
- const result = await imapService.topSenders({
4943
+ const topSendersLimit = typeof args.limit === "number" ? args.limit : undefined;
4944
+ const topSendersInput = {
4357
4945
  folder: optionalString(args, "folder"),
4358
4946
  since: optionalString(args, "since"),
4359
4947
  before: optionalString(args, "before"),
4360
- limit: typeof args.limit === "number" ? args.limit : undefined,
4948
+ limit: topSendersLimit,
4361
4949
  scanLimit: typeof args.scanLimit === "number" ? args.scanLimit : undefined,
4362
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 })),
4363
4981
  });
4364
- return createTextResult(result);
4365
4982
  }
4366
4983
  case "move_thread": {
4367
4984
  ensureMailboxWriteAllowed(config.runtime);
4368
- const result = await withAudit(auditService, name, args, () => imapService.moveThread({
4369
- 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,
4370
4997
  destination: requireString(args, "destination"),
4371
4998
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
4372
4999
  dryRun: normalizeBoolean(args.dryRun, false),
@@ -4378,8 +5005,12 @@ export function createServer(config, options = {}) {
4378
5005
  const permanentThread = normalizeBoolean(args.permanent, false);
4379
5006
  if (permanentThread)
4380
5007
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), "Permanently delete thread");
4381
- const result = await withAudit(auditService, name, args, () => imapService.deleteThread({
4382
- 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,
4383
5014
  permanent: permanentThread,
4384
5015
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
4385
5016
  dryRun: normalizeBoolean(args.dryRun, false),
@@ -4392,8 +5023,12 @@ export function createServer(config, options = {}) {
4392
5023
  const flagsToRemove = Array.isArray(args.flagsToRemove) ? args.flagsToRemove.map(String) : [];
4393
5024
  if (flagsToAdd.length === 0 && flagsToRemove.length === 0)
4394
5025
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4395
- const result = await withAudit(auditService, name, args, () => imapService.flagThread({
4396
- 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,
4397
5032
  flagsToAdd,
4398
5033
  flagsToRemove,
4399
5034
  acrossFolders: normalizeBoolean(args.acrossFolders, false),
@@ -4433,8 +5068,22 @@ export function createServer(config, options = {}) {
4433
5068
  const result = await withAudit(auditService, name, args, () => imapService.deleteFolder(labelPath));
4434
5069
  return createTextResult(result);
4435
5070
  }
4436
- case "get_folders":
4437
- 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
+ }
4438
5087
  case "sync_folders":
4439
5088
  return createTextResult(await imapService.syncFolders());
4440
5089
  case "create_folder":
@@ -4449,25 +5098,28 @@ export function createServer(config, options = {}) {
4449
5098
  return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
4450
5099
  case "mark_email_read": {
4451
5100
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
4452
- const emailId = requireString(args, "emailId");
5101
+ const rawEmailId = requireString(args, "emailId");
5102
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4453
5103
  const uidValidity = parseEmailId(emailId).uidValidity;
4454
- 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)));
4455
5105
  }
4456
5106
  case "star_email": {
4457
5107
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
4458
- const emailId = requireString(args, "emailId");
5108
+ const rawEmailId = requireString(args, "emailId");
5109
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4459
5110
  const uidValidity = parseEmailId(emailId).uidValidity;
4460
- 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)));
4461
5112
  }
4462
5113
  case "move_email":
4463
5114
  {
4464
5115
  ensureEmailActionAllowed(config.runtime, "move");
4465
- const emailId = requireString(args, "emailId");
5116
+ const rawEmailId = requireString(args, "emailId");
5117
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4466
5118
  const targetFolder = requireString(args, "targetFolder");
4467
5119
  const uidValidity = parseEmailId(emailId).uidValidity;
4468
5120
  const result = await withAudit(auditService, name, args, async () => {
4469
5121
  try {
4470
- return await imapService.moveEmail(emailId, targetFolder, uidValidity);
5122
+ return await bundle.imapService.moveEmail(emailId, targetFolder, uidValidity);
4471
5123
  }
4472
5124
  catch (error) {
4473
5125
  if (isMissingTargetFolderError(error)) {
@@ -4476,75 +5128,82 @@ export function createServer(config, options = {}) {
4476
5128
  throw error;
4477
5129
  }
4478
5130
  });
4479
- const sources = result.targetEmailId
5131
+ const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
5132
+ const sources = targetEmailId
4480
5133
  ? [
4481
5134
  {
4482
- uri: buildEmailResourceUri(result.targetEmailId),
4483
- name: result.targetEmailId,
4484
- title: `Moved email ${result.targetEmailId}`,
5135
+ uri: buildEmailResourceUri(targetEmailId),
5136
+ name: targetEmailId,
5137
+ title: `Moved email ${targetEmailId}`,
4485
5138
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4486
5139
  mimeType: "message/rfc822",
4487
5140
  },
4488
5141
  ]
4489
5142
  : [];
4490
- return createTextResult(result, false, sources);
5143
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4491
5144
  }
4492
5145
  case "archive_email":
4493
5146
  {
4494
5147
  ensureEmailActionAllowed(config.runtime, "archive");
4495
- const emailId = requireString(args, "emailId");
5148
+ const rawEmailId = requireString(args, "emailId");
5149
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4496
5150
  const uidValidity = parseEmailId(emailId).uidValidity;
4497
- const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(emailId, uidValidity));
4498
- 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
4499
5154
  ? [
4500
5155
  {
4501
- uri: buildEmailResourceUri(result.targetEmailId),
4502
- name: result.targetEmailId,
4503
- title: `Archived email ${result.targetEmailId}`,
5156
+ uri: buildEmailResourceUri(targetEmailId),
5157
+ name: targetEmailId,
5158
+ title: `Archived email ${targetEmailId}`,
4504
5159
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4505
5160
  mimeType: "message/rfc822",
4506
5161
  },
4507
5162
  ]
4508
5163
  : [];
4509
- return createTextResult(result, false, sources);
5164
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4510
5165
  }
4511
5166
  case "trash_email":
4512
5167
  {
4513
5168
  ensureEmailActionAllowed(config.runtime, "trash");
4514
- const emailId = requireString(args, "emailId");
5169
+ const rawEmailId = requireString(args, "emailId");
5170
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4515
5171
  const uidValidity = parseEmailId(emailId).uidValidity;
4516
- const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(emailId, uidValidity));
4517
- 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
4518
5175
  ? [
4519
5176
  {
4520
- uri: buildEmailResourceUri(result.targetEmailId),
4521
- name: result.targetEmailId,
4522
- title: `Trashed email ${result.targetEmailId}`,
5177
+ uri: buildEmailResourceUri(targetEmailId),
5178
+ name: targetEmailId,
5179
+ title: `Trashed email ${targetEmailId}`,
4523
5180
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4524
5181
  mimeType: "message/rfc822",
4525
5182
  },
4526
5183
  ]
4527
5184
  : [];
4528
- return createTextResult(result, false, sources);
5185
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4529
5186
  }
4530
5187
  case "restore_email":
4531
5188
  {
4532
5189
  ensureEmailActionAllowed(config.runtime, "restore");
4533
- const emailId = requireString(args, "emailId");
5190
+ const rawEmailId = requireString(args, "emailId");
5191
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4534
5192
  const uidValidity = parseEmailId(emailId).uidValidity;
4535
- const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
4536
- 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
4537
5196
  ? [
4538
5197
  {
4539
- uri: buildEmailResourceUri(result.targetEmailId),
4540
- name: result.targetEmailId,
4541
- title: `Restored email ${result.targetEmailId}`,
5198
+ uri: buildEmailResourceUri(targetEmailId),
5199
+ name: targetEmailId,
5200
+ title: `Restored email ${targetEmailId}`,
4542
5201
  description: `${result.targetFolder} · uid ${result.targetUid || result.uid}`,
4543
5202
  mimeType: "message/rfc822",
4544
5203
  },
4545
5204
  ]
4546
5205
  : [];
4547
- return createTextResult(result, false, sources);
5206
+ return createTextResult({ ...result, targetEmailId }, false, sources);
4548
5207
  }
4549
5208
  case "snooze_email": {
4550
5209
  ensureEmailActionAllowed(config.runtime, "archive");
@@ -4608,9 +5267,10 @@ export function createServer(config, options = {}) {
4608
5267
  case "delete_email": {
4609
5268
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
4610
5269
  ensureMailboxWriteAllowed(config.runtime);
4611
- const emailId = requireString(args, "emailId");
5270
+ const rawEmailId = requireString(args, "emailId");
5271
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
4612
5272
  const uidValidity = parseEmailId(emailId).uidValidity;
4613
- 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)));
4614
5274
  }
4615
5275
  case "batch_email_action":
4616
5276
  {
@@ -4627,24 +5287,135 @@ export function createServer(config, options = {}) {
4627
5287
  if (action === "delete") {
4628
5288
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) (cannot be recovered)`);
4629
5289
  }
4630
- const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
4631
- 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 = {
4632
5327
  action,
4633
- targetFolder: optionalString(args, "targetFolder"),
4634
- continueOnError: normalizeBoolean(args.continueOnError, true),
4635
- dryRun: normalizeBoolean(args.dryRun, false),
4636
- }));
5328
+ total: orderedResults.length,
5329
+ succeeded,
5330
+ failed: orderedResults.length - succeeded,
5331
+ results: orderedResults,
5332
+ };
4637
5333
  const sources = result.results.flatMap((entry) => entry.ok ? emailSourceFromActionResult(entry.result) : []);
4638
5334
  return createTextResult(result, false, sources);
4639
5335
  }
4640
5336
  case "get_email_stats": {
4641
- const folders = await imapService.getFolders();
4642
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, typeof args.days === "number" ? args.days : 30, typeof args.limit === "number" ? args.limit : 2000);
4643
- 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
+ });
4644
5376
  }
4645
5377
  case "get_email_analytics": {
4646
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, typeof args.days === "number" ? args.days : 30, typeof args.limit === "number" ? args.limit : 2000);
4647
- 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
+ });
4648
5419
  }
4649
5420
  case "get_contacts": {
4650
5421
  // limit is "how many contacts to return", not "how many messages to
@@ -4653,13 +5424,35 @@ export function createServer(config, options = {}) {
4653
5424
  // get_contacts returns a ranked list. Reusing limit as the sample size
4654
5425
  // meant limit: 5 silently ranked contacts from only 5 messages total.
4655
5426
  const limit = normalizeLimit(args.limit, 100);
4656
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
4657
- 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));
4658
5441
  }
4659
5442
  case "get_volume_trends": {
4660
5443
  const days = normalizeLimit(args.days, 30, 1, 365);
4661
- const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, days, 3000);
4662
- 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));
4663
5456
  }
4664
5457
  case "get_connection_status": {
4665
5458
  const includeRawConnectionErrors = process.env.PROTONMAIL_DEBUG === "true";
@@ -4761,6 +5554,36 @@ export function createServer(config, options = {}) {
4761
5554
  }
4762
5555
  return undefined;
4763
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
+ }));
4764
5587
  return createTextResult({
4765
5588
  checkedAt: new Date().toISOString(),
4766
5589
  // See get_connection_status for why this matters: from inside
@@ -4836,6 +5659,7 @@ export function createServer(config, options = {}) {
4836
5659
  "vacation responder (Proton account API only, not exposed via Bridge)",
4837
5660
  ],
4838
5661
  },
5662
+ accounts: accountsInfo,
4839
5663
  });
4840
5664
  }
4841
5665
  case "get_runtime_status": {
@@ -4843,6 +5667,20 @@ export function createServer(config, options = {}) {
4843
5667
  localIndexService.getStatus(),
4844
5668
  draftStore.listDrafts(true),
4845
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
+ }));
4846
5684
  return createTextResult({
4847
5685
  checkedAt: new Date().toISOString(),
4848
5686
  runtime: sanitizeRuntimeConfig(config.runtime),
@@ -4858,6 +5696,37 @@ export function createServer(config, options = {}) {
4858
5696
  remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
4859
5697
  syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
4860
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.",
4861
5730
  });
4862
5731
  }
4863
5732
  case "run_background_sync":
@@ -4931,7 +5800,7 @@ export function createServer(config, options = {}) {
4931
5800
  return createTextResult(await localIndexService.getStatus());
4932
5801
  case "search_indexed_emails":
4933
5802
  {
4934
- const result = await localIndexService.search({
5803
+ const searchIndexedInput = {
4935
5804
  query: optionalString(args, "query"),
4936
5805
  folder: optionalString(args, "folder"),
4937
5806
  label: optionalString(args, "label"),
@@ -4948,133 +5817,346 @@ export function createServer(config, options = {}) {
4948
5817
  dateFrom: optionalString(args, "dateFrom"),
4949
5818
  dateTo: optionalString(args, "dateTo"),
4950
5819
  limit: typeof args.limit === "number" ? args.limit : undefined,
4951
- });
4952
- 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));
4953
5845
  }
4954
5846
  case "get_labels":
4955
5847
  {
4956
- 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);
4957
5862
  return createTextResult({
4958
- total: labels.length,
4959
- labels,
5863
+ total: mergedLabels.length,
5864
+ labels: mergedLabels,
4960
5865
  });
4961
5866
  }
4962
5867
  case "get_threads":
4963
5868
  {
4964
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4965
- folder: "INBOX",
4966
- limitPerFolder: 100,
4967
- });
4968
- const result = await localIndexService.getThreads({
5869
+ const threadsInput = {
4969
5870
  query: optionalString(args, "query"),
4970
5871
  label: optionalString(args, "label"),
4971
5872
  limit: typeof args.limit === "number" ? args.limit : undefined,
4972
- });
4973
- 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));
4974
5903
  }
4975
5904
  case "get_actionable_threads":
4976
5905
  {
4977
- const refresh = await maybeRefreshLocalIndex(imapService, localIndexService, {
4978
- force: normalizeBoolean(args.syncBefore, false),
4979
- folder: "INBOX",
4980
- limitPerFolder: 100,
4981
- });
4982
- const result = await localIndexService.getActionableThreads({
4983
- query: optionalString(args, "query"),
4984
- label: optionalString(args, "label"),
4985
- pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
4986
- ? args.pendingOn
4987
- : undefined,
4988
- unreadOnly: normalizeBoolean(args.unreadOnly, true),
4989
- limit: typeof args.limit === "number" ? args.limit : undefined,
4990
- });
4991
- 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));
4992
5951
  }
4993
5952
  case "get_inbox_digest":
4994
5953
  {
4995
- await maybeRefreshLocalIndex(imapService, localIndexService, {
4996
- force: normalizeBoolean(args.syncBefore, false),
4997
- folder: "INBOX",
4998
- limitPerFolder: 100,
4999
- });
5000
- const result = await localIndexService.getInboxDigest({
5001
- limit: typeof args.limit === "number" ? args.limit : undefined,
5002
- minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
5003
- });
5004
- const topThreads = Array.isArray(result.topThreads) ? result.topThreads : [];
5005
- const staleThreads = Array.isArray(result.staleAwaitingYou) ? result.staleAwaitingYou : [];
5006
- return createTextResult(result, false, [...topThreads, ...staleThreads]
5007
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5008
- .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));
5009
6002
  }
5010
6003
  case "get_follow_up_candidates":
5011
6004
  {
5012
- await maybeRefreshLocalIndex(imapService, localIndexService, {
5013
- force: normalizeBoolean(args.syncBefore, false),
5014
- folder: "INBOX",
5015
- limitPerFolder: 100,
5016
- });
5017
- const result = await localIndexService.getFollowUpCandidates({
5018
- limit: typeof args.limit === "number" ? args.limit : undefined,
5019
- minAgeHours: typeof args.minAgeHours === "number" ? args.minAgeHours : undefined,
5020
- pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
5021
- ? args.pendingOn
5022
- : undefined,
5023
- });
5024
- return createTextResult(result, false, Array.isArray(result.threads)
5025
- ? result.threads
5026
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5027
- .map(threadSource)
5028
- : []);
5029
- }
5030
- case "find_document_threads":
5031
- {
5032
- await maybeRefreshLocalIndex(imapService, localIndexService, {
5033
- force: normalizeBoolean(args.syncBefore, false),
5034
- folder: "INBOX",
5035
- limitPerFolder: 100,
5036
- });
5037
- const result = await localIndexService.findDocumentThreads({
5038
- category: args.category === "document" ||
5039
- args.category === "invoice" ||
5040
- args.category === "contract" ||
5041
- args.category === "travel" ||
5042
- args.category === "calendar"
5043
- ? args.category
5044
- : undefined,
5045
- query: optionalString(args, "query"),
5046
- limit: typeof args.limit === "number" ? args.limit : undefined,
5047
- });
5048
- 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
+ };
5049
6049
  return createTextResult(result, false, threads
5050
6050
  .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5051
6051
  .map(threadSource));
5052
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
+ }
5053
6105
  case "prepare_meeting_context":
5054
6106
  {
5055
- await maybeRefreshLocalIndex(imapService, localIndexService, {
5056
- force: normalizeBoolean(args.syncBefore, false),
5057
- folder: "INBOX",
5058
- limitPerFolder: 100,
5059
- });
5060
- const result = await localIndexService.getMeetingPrep({
5061
- person: optionalString(args, "person"),
5062
- domain: optionalString(args, "domain"),
5063
- limit: typeof args.limit === "number" ? args.limit : undefined,
5064
- });
5065
- const threads = Array.isArray(result.threads) ? result.threads : [];
5066
- return createTextResult(result, false, threads
5067
- .filter((thread) => Boolean(thread && typeof thread === "object" && "id" in thread))
5068
- .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));
5069
6143
  }
5070
6144
  case "get_thread_brief":
5071
6145
  {
5072
- 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, {
5073
6154
  folder: "INBOX",
5074
6155
  limitPerFolder: 100,
5075
6156
  });
5076
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
5077
- 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);
5078
6160
  return createTextResult(result, false, [
5079
6161
  threadSource(thread),
5080
6162
  ...thread.messages.map((message) => emailSource({
@@ -5092,13 +6174,29 @@ export function createServer(config, options = {}) {
5092
6174
  }
5093
6175
  case "get_thread_by_id":
5094
6176
  {
5095
- 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, {
5096
6187
  folder: "INBOX",
5097
6188
  limitPerFolder: 100,
5098
6189
  });
5099
6190
  const rawFolders = args?.folders;
5100
6191
  const threadFolders = Array.isArray(rawFolders) ? rawFolders.filter((f) => typeof f === "string") : undefined;
5101
- 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;
5102
6200
  const result = threadFolders && threadFolders.length > 0
5103
6201
  ? {
5104
6202
  ...thread,
@@ -5143,8 +6241,12 @@ export function createServer(config, options = {}) {
5143
6241
  if (to.length === 0) {
5144
6242
  throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
5145
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;
5146
6248
  const result = await withAudit(auditService, name, args, async () => {
5147
- const draft = await draftStore.createDraft({
6249
+ const draft = await createThreadReplyDraftBundle.draftStore.createDraft({
5148
6250
  mode: "reply",
5149
6251
  to,
5150
6252
  cc,
@@ -5161,7 +6263,7 @@ export function createServer(config, options = {}) {
5161
6263
  });
5162
6264
  const remoteSyncDecision = resolveRemoteDraftSync(config.runtime, normalizeBoolean(args.syncToRemote, true));
5163
6265
  const synced = remoteSyncDecision.enabled
5164
- ? await syncDraftToRemote(draftStore, smtpService, imapService, draft)
6266
+ ? await syncDraftToRemote(createThreadReplyDraftBundle.draftStore, createThreadReplyDraftBundle.smtpService, createThreadReplyDraftBundle.imapService, draft)
5165
6267
  : { draft, remoteSync: undefined };
5166
6268
  const remoteSync = synced.remoteSync ??
5167
6269
  (remoteSyncDecision.reason
@@ -5171,9 +6273,8 @@ export function createServer(config, options = {}) {
5171
6273
  message: remoteSyncDecision.reason,
5172
6274
  }
5173
6275
  : undefined);
5174
- return remoteSync
5175
- ? { ...synced.draft, remoteSync, threadId: thread.id }
5176
- : { ...synced.draft, threadId: thread.id };
6276
+ const presented = presentDraft(createThreadReplyDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
6277
+ return { ...presented, threadId: thread.id };
5177
6278
  });
5178
6279
  return createTextResult(result, false, [
5179
6280
  draftSource(result),
@@ -5192,12 +6293,22 @@ export function createServer(config, options = {}) {
5192
6293
  }
5193
6294
  case "apply_thread_action":
5194
6295
  {
5195
- 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, {
5196
6307
  force: normalizeBoolean(args.syncBefore, false),
5197
6308
  folder: "INBOX",
5198
6309
  limitPerFolder: 100,
5199
6310
  });
5200
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
6311
+ const thread = await bundle.localIndexService.getThreadById(threadId);
5201
6312
  const action = requireEmailAction(args);
5202
6313
  ensureEmailActionAllowed(config.runtime, action);
5203
6314
  const unreadOnly = normalizeBoolean(args.unreadOnly, false);
@@ -5211,27 +6322,33 @@ export function createServer(config, options = {}) {
5211
6322
  if (action === "delete") {
5212
6323
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) in thread (cannot be recovered)`);
5213
6324
  }
5214
- const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
6325
+ const result = await withAudit(bundle.auditService, name, args, async () => applyBatchEmailAction(bundle.imapService, [], {
5215
6326
  emailIds,
5216
6327
  action,
5217
6328
  targetFolder: optionalString(args, "targetFolder"),
5218
6329
  continueOnError: normalizeBoolean(args.continueOnError, true),
5219
6330
  dryRun: normalizeBoolean(args.dryRun, false),
5220
6331
  }));
6332
+ const prefixedResult = {
6333
+ ...result,
6334
+ results: result.results.map((entry) => ({ ...entry, emailId: withAccountPrefix(outputSlug, entry.emailId) })),
6335
+ };
5221
6336
  const sources = [
5222
6337
  threadSource(thread),
5223
6338
  ...result.results.flatMap((entry) => (entry.ok ? emailSourceFromActionResult(entry.result) : [])),
5224
6339
  ];
5225
6340
  return createTextResult({
5226
- threadId: thread.id,
6341
+ threadId: withAccountPrefix(outputSlug, thread.id),
5227
6342
  unreadOnly,
5228
6343
  dryRun: normalizeBoolean(args.dryRun, false),
5229
- ...result,
6344
+ ...prefixedResult,
5230
6345
  }, false, sources);
5231
6346
  }
5232
6347
  case "list_attachments":
5233
6348
  {
5234
- 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);
5235
6352
  const includeInline = normalizeBoolean(args.includeInline, true);
5236
6353
  const filenameContains = optionalString(args, "filenameContains");
5237
6354
  const contentType = optionalString(args, "contentType");
@@ -5250,14 +6367,17 @@ export function createServer(config, options = {}) {
5250
6367
  return true;
5251
6368
  });
5252
6369
  const result = {
5253
- emailId: attachmentList.emailId,
6370
+ emailId: prefixedIdFor(bundle, attachmentList.emailId),
5254
6371
  attachments: filtered,
5255
6372
  };
5256
6373
  return createTextResult(result, false, result.attachments.map((attachment) => attachmentSource(result.emailId, attachment)));
5257
6374
  }
5258
6375
  case "get_attachment_content":
5259
6376
  {
5260
- 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);
5261
6381
  const saveTo = optionalString(args, "saveTo");
5262
6382
  if (!saveTo && result.base64) {
5263
6383
  const MAX_INLINE_BYTES = (config.runtime.maxInlineBytes ?? 40) * 1024;
@@ -5311,11 +6431,16 @@ export function createServer(config, options = {}) {
5311
6431
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5312
6432
  }
5313
6433
  case "get_attachment_text": {
5314
- 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);
5315
6438
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5316
6439
  }
5317
6440
  case "save_attachment":
5318
6441
  {
6442
+ const rawEmailId = requireString(args, "emailId");
6443
+ const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
5319
6444
  const saveTo = optionalString(args, "saveTo");
5320
6445
  const outputPath = optionalString(args, "outputPath");
5321
6446
  let resolvedPath = outputPath;
@@ -5332,11 +6457,15 @@ export function createServer(config, options = {}) {
5332
6457
  }
5333
6458
  resolvedPath = absTarget;
5334
6459
  }
5335
- 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);
5336
6462
  return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
5337
6463
  }
5338
6464
  case "export_email": {
5339
- 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);
5340
6469
  return createTextResult(result);
5341
6470
  }
5342
6471
  case "import_email": {
@@ -5366,13 +6495,16 @@ export function createServer(config, options = {}) {
5366
6495
  }
5367
6496
  case "save_attachments":
5368
6497
  {
5369
- const result = await imapService.saveAttachments({
5370
- 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,
5371
6502
  outputPath: optionalString(args, "outputPath"),
5372
6503
  includeInline: normalizeBoolean(args.includeInline, false),
5373
6504
  filenameContains: optionalString(args, "filenameContains"),
5374
6505
  contentType: optionalString(args, "contentType"),
5375
6506
  });
6507
+ result.emailId = prefixedIdFor(bundle, result.emailId);
5376
6508
  return createTextResult(result, false, result.saved.map((entry) => attachmentSource(result.emailId, entry.attachment)));
5377
6509
  }
5378
6510
  case "clear_cache":