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