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.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1548 -416
- package/dist/index.js.map +1 -1
- package/dist/services/account-manager.d.ts +37 -0
- package/dist/services/account-manager.d.ts.map +1 -0
- package/dist/services/account-manager.js +98 -0
- package/dist/services/account-manager.js.map +1 -0
- package/dist/services/background-sync-service.d.ts.map +1 -1
- package/dist/services/background-sync-service.js +8 -0
- package/dist/services/background-sync-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts +1 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +2 -1
- package/dist/services/smtp-service.js.map +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/helpers.d.ts +6 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +33 -0
- package/dist/utils/helpers.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,16 +11,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
11
11
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { isMainModule } from "./is-main.js";
|
|
13
13
|
import { AnalyticsService } from "./services/analytics-service.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
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 =
|
|
2842
|
-
const localIndexService =
|
|
2843
|
-
const draftStore =
|
|
2844
|
-
const backgroundSyncService =
|
|
2845
|
-
const deliveryQueueService =
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
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
|
-
|
|
2853
|
-
//
|
|
2854
|
-
//
|
|
2855
|
-
//
|
|
2856
|
-
//
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
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:
|
|
3723
|
-
subject:
|
|
3724
|
-
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4154
|
-
|
|
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
|
-
|
|
4193
|
-
//
|
|
4194
|
-
//
|
|
4195
|
-
//
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
//
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
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
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
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
|
-
|
|
4246
|
-
//
|
|
4247
|
-
//
|
|
4248
|
-
//
|
|
4249
|
-
//
|
|
4250
|
-
//
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
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
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
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
|
-
|
|
4304
|
-
//
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
//
|
|
4308
|
-
//
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
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
|
-
|
|
4319
|
-
|
|
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
|
-
|
|
4338
|
-
//
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
//
|
|
4342
|
-
//
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
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
|
-
|
|
4353
|
-
return createTextResult(withBulkNotFound(result, notFoundEmailIds));
|
|
4940
|
+
return createTextResult(mergeBulkResults(outputs));
|
|
4354
4941
|
}
|
|
4355
4942
|
case "top_senders": {
|
|
4356
|
-
const
|
|
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:
|
|
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
|
-
|
|
4369
|
-
|
|
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
|
-
|
|
4382
|
-
|
|
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
|
-
|
|
4396
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5131
|
+
const targetEmailId = result.targetEmailId ? prefixedIdFor(bundle, result.targetEmailId) : undefined;
|
|
5132
|
+
const sources = targetEmailId
|
|
4480
5133
|
? [
|
|
4481
5134
|
{
|
|
4482
|
-
uri: buildEmailResourceUri(
|
|
4483
|
-
name:
|
|
4484
|
-
title: `Moved email ${
|
|
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
|
|
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
|
|
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(
|
|
4502
|
-
name:
|
|
4503
|
-
title: `Archived email ${
|
|
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
|
|
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
|
|
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(
|
|
4521
|
-
name:
|
|
4522
|
-
title: `Trashed email ${
|
|
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
|
|
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
|
|
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(
|
|
4540
|
-
name:
|
|
4541
|
-
title: `Restored email ${
|
|
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
|
|
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
|
-
|
|
4631
|
-
|
|
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
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
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
|
|
4642
|
-
const
|
|
4643
|
-
|
|
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
|
|
4647
|
-
|
|
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
|
-
|
|
4657
|
-
|
|
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
|
-
|
|
4662
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
4959
|
-
labels,
|
|
5863
|
+
total: mergedLabels.length,
|
|
5864
|
+
labels: mergedLabels,
|
|
4960
5865
|
});
|
|
4961
5866
|
}
|
|
4962
5867
|
case "get_threads":
|
|
4963
5868
|
{
|
|
4964
|
-
|
|
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
|
-
|
|
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
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
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
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
const
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
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
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
:
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
.
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
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
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
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
|
-
|
|
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
|
|
5077
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5175
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
...
|
|
6344
|
+
...prefixedResult,
|
|
5230
6345
|
}, false, sources);
|
|
5231
6346
|
}
|
|
5232
6347
|
case "list_attachments":
|
|
5233
6348
|
{
|
|
5234
|
-
const
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
5370
|
-
|
|
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":
|