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