proton-mail-bridge-client 2.7.3 → 2.8.1
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.map +1 -1
- package/dist/index.js +302 -26
- package/dist/index.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/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 +22 -2
- 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/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/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.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 +244 -0
- package/dist/utils/ical-reply.js.map +1 -0
- package/package.json +1 -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 },
|
|
@@ -3782,6 +3862,37 @@ export function createServer(config, options = {}) {
|
|
|
3782
3862
|
}
|
|
3783
3863
|
return found;
|
|
3784
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, record.to);
|
|
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
|
+
}
|
|
3785
3896
|
// Every configured account address — reply helpers treat all of them as "self".
|
|
3786
3897
|
function allAccountAddresses() {
|
|
3787
3898
|
return accountManager.all().map((bundle) => bundle.account.address);
|
|
@@ -4168,7 +4279,7 @@ export function createServer(config, options = {}) {
|
|
|
4168
4279
|
// a draft — see redactQueueRecordAttachments' comment. Redacted for every
|
|
4169
4280
|
// record regardless of status (including canceled ones, which otherwise
|
|
4170
4281
|
// stayed just as bloated once queued).
|
|
4171
|
-
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) => ({
|
|
4172
4283
|
bundle,
|
|
4173
4284
|
items: await bundle.deliveryQueueService.list(),
|
|
4174
4285
|
}))));
|
|
@@ -4451,6 +4562,150 @@ export function createServer(config, options = {}) {
|
|
|
4451
4562
|
sentCopy: sentCopyTokenRa,
|
|
4452
4563
|
}, false, [emailSource({ ...detailRa, id: prefixedIdFor(readBundleRa, detailRa.id) })]);
|
|
4453
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 myAttendee = findMyAttendee(invite, (address) => address.toLowerCase() === inviteBundle.config.smtp.username.toLowerCase());
|
|
4655
|
+
// Never another account: a hostile invitation could otherwise make a different mailbox answer it.
|
|
4656
|
+
const inviteSendBundle = inviteBundle;
|
|
4657
|
+
const answeringAs = inviteSendBundle.config.smtp.username;
|
|
4658
|
+
const inviteReply = buildInviteReply(invite, {
|
|
4659
|
+
attendeeAddress: answeringAs,
|
|
4660
|
+
attendeeName: myAttendee?.name,
|
|
4661
|
+
response: responseArg,
|
|
4662
|
+
comment: inviteComment,
|
|
4663
|
+
});
|
|
4664
|
+
const inviteTo = [inviteReply.to];
|
|
4665
|
+
ensureValidEmails(inviteTo, "to");
|
|
4666
|
+
ensureOutboundRecipientsAllowed(config.runtime, inviteSendBundle.config.smtp.username, inviteTo);
|
|
4667
|
+
const senderDomains = (inviteDetail.from ?? []).map((entry) => (entry.address ?? "").split("@").pop()?.toLowerCase());
|
|
4668
|
+
const organizerDomain = invite.organizer.address.split("@").pop()?.toLowerCase();
|
|
4669
|
+
const organizerMismatch = !senderDomains.includes(organizerDomain);
|
|
4670
|
+
const inviteSummary = {
|
|
4671
|
+
event: invite.summary ?? "(no title)",
|
|
4672
|
+
...(organizerMismatch
|
|
4673
|
+
? { 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.` }
|
|
4674
|
+
: {}),
|
|
4675
|
+
organizer: invite.organizer.address,
|
|
4676
|
+
answeringAs,
|
|
4677
|
+
response: responseArg,
|
|
4678
|
+
previousStatus: myAttendee?.partstat,
|
|
4679
|
+
...(invite.hasRecurrence ? { note: "This invitation repeats; the answer applies to the whole series." } : {}),
|
|
4680
|
+
};
|
|
4681
|
+
if (normalizeBoolean(args.dryRun, false)) {
|
|
4682
|
+
return createTextResult({ dryRun: true, wouldSendTo: inviteTo, subject: inviteReply.subject, ...inviteSummary, replyCalendar: inviteReply.ics.split("\r\n").filter(Boolean), note: "No email was sent." });
|
|
4683
|
+
}
|
|
4684
|
+
const invitePayload = {
|
|
4685
|
+
to: inviteTo,
|
|
4686
|
+
subject: inviteReply.subject,
|
|
4687
|
+
body: inviteReply.body,
|
|
4688
|
+
from: inviteSendBundle.config.smtp.username === answeringAs ? undefined : answeringAs,
|
|
4689
|
+
inReplyTo: inviteDetail.messageId,
|
|
4690
|
+
references: replyReferences(inviteDetail),
|
|
4691
|
+
icalEvent: { method: "REPLY", content: inviteReply.ics },
|
|
4692
|
+
appendSignature: false,
|
|
4693
|
+
};
|
|
4694
|
+
const inviteUndo = resolveUndoWindowSeconds(args);
|
|
4695
|
+
if (inviteUndo > 0) {
|
|
4696
|
+
return queueUndoSend(inviteSendBundle, invitePayload, inviteUndo, name, args, { to: inviteTo, ...inviteSummary });
|
|
4697
|
+
}
|
|
4698
|
+
const inviteResult = await withAudit(auditService, name, args, async () => inviteSendBundle.smtpService.sendEmail(invitePayload));
|
|
4699
|
+
return createTextResult({
|
|
4700
|
+
...inviteSummary,
|
|
4701
|
+
to: inviteTo,
|
|
4702
|
+
subject: inviteReply.subject,
|
|
4703
|
+
messageId: inviteResult.messageId,
|
|
4704
|
+
accepted: inviteResult.accepted,
|
|
4705
|
+
rejected: inviteResult.rejected,
|
|
4706
|
+
response_: inviteResult.response,
|
|
4707
|
+
}, false, [emailSource({ ...inviteDetail, id: prefixedIdFor(inviteBundle, inviteDetail.id) })]);
|
|
4708
|
+
}
|
|
4454
4709
|
case "forward_email": {
|
|
4455
4710
|
ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
|
|
4456
4711
|
ensureSendAllowed(config.runtime);
|
|
@@ -4814,9 +5069,10 @@ export function createServer(config, options = {}) {
|
|
|
4814
5069
|
// account's own list already arrives that way). Single-account guard
|
|
4815
5070
|
// keeps byte-identical output for the common case.
|
|
4816
5071
|
const includeSent = normalizeBoolean(args.includeSent, false);
|
|
5072
|
+
resolveAccountArg(args);
|
|
4817
5073
|
const allDrafts = accountManager.all().length === 1
|
|
4818
5074
|
? (await draftStore.listDrafts(includeSent)).map((draft) => ({ bundle: primaryBundle, draft }))
|
|
4819
|
-
: (await Promise.all(
|
|
5075
|
+
: (await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
4820
5076
|
const drafts = await bundle.draftStore.listDrafts(includeSent);
|
|
4821
5077
|
return drafts.map((draft) => ({ bundle, draft: presentDraft(bundle, draft) }));
|
|
4822
5078
|
})))
|
|
@@ -4843,7 +5099,11 @@ export function createServer(config, options = {}) {
|
|
|
4843
5099
|
case "list_remote_drafts": {
|
|
4844
5100
|
// Scope limitation (follow-up round): stays scoped to the primary account's
|
|
4845
5101
|
// remote Drafts mailbox only — see list_drafts' identical note.
|
|
4846
|
-
|
|
5102
|
+
// `account` picks another account's Drafts mailbox; its ids carry that account's prefix like everywhere else.
|
|
5103
|
+
const draftsBundle = resolveAccountArg(args) ?? primaryBundle;
|
|
5104
|
+
const draftsSlug = responseSlug(draftsBundle);
|
|
5105
|
+
const rawResult = await draftsBundle.imapService.listRemoteDrafts(normalizeLimit(args.limit, 50), normalizeLimit(args.offset, 0, 0, 10_000));
|
|
5106
|
+
const result = { ...rawResult, emails: rawResult.emails.map((email) => ({ ...email, id: withAccountPrefix(draftsSlug, email.id) })) };
|
|
4847
5107
|
// projectFields (called with no fields arg) still trims each email's
|
|
4848
5108
|
// attachments to the essentials — same fix as every other list-style
|
|
4849
5109
|
// tool, this one just didn't route through projectFields at all before.
|
|
@@ -5321,6 +5581,7 @@ export function createServer(config, options = {}) {
|
|
|
5321
5581
|
includeSnippet: normalizeBoolean(args.includeSnippet, false),
|
|
5322
5582
|
};
|
|
5323
5583
|
if (accountManager.all().length === 1) {
|
|
5584
|
+
resolveAccountArg(args);
|
|
5324
5585
|
const result = await imapService.getEmails(getEmailsInput);
|
|
5325
5586
|
return createTextResult({
|
|
5326
5587
|
...result,
|
|
@@ -5362,7 +5623,7 @@ export function createServer(config, options = {}) {
|
|
|
5362
5623
|
// than inferring it from a count that can be either capped-by-fetch or
|
|
5363
5624
|
// filter-blind depending on which one you pick.
|
|
5364
5625
|
const perAccountFetchLimit = requestedOffset + effectiveLimit + 1;
|
|
5365
|
-
const perAccount = await Promise.all(
|
|
5626
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
5366
5627
|
const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
|
|
5367
5628
|
return { bundle, result };
|
|
5368
5629
|
}));
|
|
@@ -5582,6 +5843,7 @@ export function createServer(config, options = {}) {
|
|
|
5582
5843
|
sizeSmaller: optionalNumber(args.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
|
|
5583
5844
|
};
|
|
5584
5845
|
if (accountManager.all().length === 1) {
|
|
5846
|
+
resolveAccountArg(args);
|
|
5585
5847
|
const result = await imapService.countMessages(countInput);
|
|
5586
5848
|
return createTextResult(result);
|
|
5587
5849
|
}
|
|
@@ -5590,7 +5852,7 @@ export function createServer(config, options = {}) {
|
|
|
5590
5852
|
// folder name), with a per-account breakdown alongside the total.
|
|
5591
5853
|
// One account lacking the folder or label (or unreachable) must not fail the whole count, as in
|
|
5592
5854
|
// search_emails: report it and return the rest. Every account failing still throws.
|
|
5593
|
-
const settled = await Promise.allSettled(
|
|
5855
|
+
const settled = await Promise.allSettled(selectedBundles(args).map(async (bundle) => ({
|
|
5594
5856
|
slug: bundle.account.slug,
|
|
5595
5857
|
...(await bundle.imapService.countMessages(countInput)),
|
|
5596
5858
|
})));
|
|
@@ -5599,7 +5861,7 @@ export function createServer(config, options = {}) {
|
|
|
5599
5861
|
throw settled[0].reason;
|
|
5600
5862
|
}
|
|
5601
5863
|
const failedAccounts = settled.flatMap((entry, index) => entry.status === "rejected"
|
|
5602
|
-
? [{ account:
|
|
5864
|
+
? [{ account: selectedBundles(args)[index].account.slug, error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason) }]
|
|
5603
5865
|
: []);
|
|
5604
5866
|
return createTextResult({
|
|
5605
5867
|
folder: countInput.folder ?? "INBOX",
|
|
@@ -5612,13 +5874,14 @@ export function createServer(config, options = {}) {
|
|
|
5612
5874
|
case "folder_stats": {
|
|
5613
5875
|
const folderStatsFolder = optionalString(args, "folder");
|
|
5614
5876
|
if (accountManager.all().length === 1) {
|
|
5877
|
+
resolveAccountArg(args);
|
|
5615
5878
|
const result = await imapService.getFolderStats(folderStatsFolder);
|
|
5616
5879
|
return createTextResult(result);
|
|
5617
5880
|
}
|
|
5618
5881
|
// Merge strategy: fetch stats for the same folder in every account and
|
|
5619
5882
|
// sum the numeric fields (total/unseen); uidNext/uidValidity are
|
|
5620
5883
|
// per-account concepts and only make sense in the breakdown.
|
|
5621
|
-
const perAccount = await Promise.all(
|
|
5884
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
|
|
5622
5885
|
slug: bundle.account.slug,
|
|
5623
5886
|
...(await bundle.imapService.getFolderStats(folderStatsFolder)),
|
|
5624
5887
|
})));
|
|
@@ -5938,6 +6201,7 @@ export function createServer(config, options = {}) {
|
|
|
5938
6201
|
excludeSelf: normalizeBoolean(args.excludeSelf, true),
|
|
5939
6202
|
};
|
|
5940
6203
|
if (accountManager.all().length === 1) {
|
|
6204
|
+
resolveAccountArg(args);
|
|
5941
6205
|
const result = await imapService.topSenders(topSendersInput);
|
|
5942
6206
|
return createTextResult(result);
|
|
5943
6207
|
}
|
|
@@ -5946,7 +6210,7 @@ export function createServer(config, options = {}) {
|
|
|
5946
6210
|
// re-sort and re-apply the requested top-N limit over the merged set.
|
|
5947
6211
|
// Each account is asked for its WHOLE table, not just its own top N: a sender in the middle of every
|
|
5948
6212
|
// account's table can still be first in total, and cutting each table first made it disappear.
|
|
5949
|
-
const perAccount = await Promise.all(
|
|
6213
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
|
|
5950
6214
|
bundle,
|
|
5951
6215
|
result: await bundle.imapService.topSenders({ ...topSendersInput, limit: 10_000 }),
|
|
5952
6216
|
})));
|
|
@@ -6065,6 +6329,7 @@ export function createServer(config, options = {}) {
|
|
|
6065
6329
|
}
|
|
6066
6330
|
case "get_folders": {
|
|
6067
6331
|
if (accountManager.all().length === 1) {
|
|
6332
|
+
resolveAccountArg(args);
|
|
6068
6333
|
return createTextResult(await imapService.getFolders());
|
|
6069
6334
|
}
|
|
6070
6335
|
// Merge strategy: fetch each account's own folder list (LIVE IMAP) and
|
|
@@ -6072,7 +6337,7 @@ export function createServer(config, options = {}) {
|
|
|
6072
6337
|
// which is left bare) so the merged list stays unambiguous while a
|
|
6073
6338
|
// single-account setup's own folder paths are completely untouched.
|
|
6074
6339
|
const primarySlug = accountManager.primary().account.slug;
|
|
6075
|
-
const perAccount = await Promise.all(
|
|
6340
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({ bundle, folders: await bundle.imapService.getFolders() })));
|
|
6076
6341
|
const merged = perAccount.flatMap(({ bundle, folders }) => folders.map((folder) => {
|
|
6077
6342
|
const slug = accountSlugForTag(bundle, primarySlug);
|
|
6078
6343
|
return slug ? { ...folder, path: `${slug}::${folder.path}`, account: slug } : folder;
|
|
@@ -6248,11 +6513,12 @@ export function createServer(config, options = {}) {
|
|
|
6248
6513
|
// independent snoozeService/JSON file. Single-account guard keeps
|
|
6249
6514
|
// byte-identical output for the common case.
|
|
6250
6515
|
if (accountManager.all().length === 1) {
|
|
6516
|
+
resolveAccountArg(args);
|
|
6251
6517
|
const all = await withAudit(auditService, name, args, async () => snoozeService.list());
|
|
6252
6518
|
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
6253
6519
|
return createTextResult(filtered);
|
|
6254
6520
|
}
|
|
6255
|
-
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(
|
|
6521
|
+
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(selectedBundles(args).map(async (bundle) => ({
|
|
6256
6522
|
bundle,
|
|
6257
6523
|
items: await bundle.snoozeService.list(),
|
|
6258
6524
|
}))));
|
|
@@ -6377,6 +6643,7 @@ export function createServer(config, options = {}) {
|
|
|
6377
6643
|
const statsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6378
6644
|
const statsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6379
6645
|
if (accountManager.all().length === 1) {
|
|
6646
|
+
resolveAccountArg(args);
|
|
6380
6647
|
const folders = await imapService.getFolders();
|
|
6381
6648
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, statsDays, statsLimit);
|
|
6382
6649
|
return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
|
|
@@ -6386,7 +6653,7 @@ export function createServer(config, options = {}) {
|
|
|
6386
6653
|
// numeric mailbox/sample fields and merge folder lists (non-primary
|
|
6387
6654
|
// folder paths tagged with their account slug to avoid collisions).
|
|
6388
6655
|
const primarySlugForStats = accountManager.primary().account.slug;
|
|
6389
|
-
const statsPerAccount = await Promise.all(
|
|
6656
|
+
const statsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6390
6657
|
const folders = await bundle.imapService.getFolders();
|
|
6391
6658
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, statsDays, statsLimit);
|
|
6392
6659
|
return {
|
|
@@ -6418,6 +6685,7 @@ export function createServer(config, options = {}) {
|
|
|
6418
6685
|
const analyticsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6419
6686
|
const analyticsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6420
6687
|
if (accountManager.all().length === 1) {
|
|
6688
|
+
resolveAccountArg(args);
|
|
6421
6689
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, analyticsDays, analyticsLimit);
|
|
6422
6690
|
return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username, analyticsDays));
|
|
6423
6691
|
}
|
|
@@ -6426,7 +6694,7 @@ export function createServer(config, options = {}) {
|
|
|
6426
6694
|
// top-N breakdowns (hours/senders/domains) by summing counts per key
|
|
6427
6695
|
// and re-sorting, and recompute busiestDay/insights from the merged
|
|
6428
6696
|
// results.
|
|
6429
|
-
const analyticsPerAccount = await Promise.all(
|
|
6697
|
+
const analyticsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6430
6698
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, analyticsDays, analyticsLimit);
|
|
6431
6699
|
return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username, analyticsDays);
|
|
6432
6700
|
}));
|
|
@@ -6465,6 +6733,7 @@ export function createServer(config, options = {}) {
|
|
|
6465
6733
|
// meant limit: 5 silently ranked contacts from only 5 messages total.
|
|
6466
6734
|
const limit = normalizeLimit(args.limit, 100);
|
|
6467
6735
|
if (accountManager.all().length === 1) {
|
|
6736
|
+
resolveAccountArg(args);
|
|
6468
6737
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
|
|
6469
6738
|
return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
|
|
6470
6739
|
}
|
|
@@ -6473,7 +6742,7 @@ export function createServer(config, options = {}) {
|
|
|
6473
6742
|
// address across accounts by summing incoming/outgoing/totalMessages
|
|
6474
6743
|
// and keeping the most recent lastContactAt, and re-apply the
|
|
6475
6744
|
// requested limit over the merged, re-sorted set.
|
|
6476
|
-
const contactsPerAccount = await Promise.all(
|
|
6745
|
+
const contactsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6477
6746
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, 30, 3000);
|
|
6478
6747
|
return analyticsService.getContacts(sample, Number.MAX_SAFE_INTEGER, bundle.config.smtp.username);
|
|
6479
6748
|
}));
|
|
@@ -6483,13 +6752,14 @@ export function createServer(config, options = {}) {
|
|
|
6483
6752
|
const days = normalizeLimit(args.days, 30, 1, 365);
|
|
6484
6753
|
// Counted in SQL over the whole index (a sample of the newest 3000 messages cut the older days short).
|
|
6485
6754
|
if (accountManager.all().length === 1) {
|
|
6755
|
+
resolveAccountArg(args);
|
|
6486
6756
|
await maybeRefreshLocalIndex(imapService, localIndexService, {});
|
|
6487
6757
|
return createTextResult(await localIndexService.getDailyVolume(days));
|
|
6488
6758
|
}
|
|
6489
6759
|
// Merge strategy: compute each account's own daily trend points
|
|
6490
6760
|
// independently, then sum count/unreadCount/starredCount/attachmentCount
|
|
6491
6761
|
// for matching dates across accounts.
|
|
6492
|
-
const trendsPerAccount = await Promise.all(
|
|
6762
|
+
const trendsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6493
6763
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {});
|
|
6494
6764
|
return bundle.localIndexService.getDailyVolume(days);
|
|
6495
6765
|
}));
|
|
@@ -6890,8 +7160,9 @@ export function createServer(config, options = {}) {
|
|
|
6890
7160
|
}
|
|
6891
7161
|
case "get_index_status":
|
|
6892
7162
|
{
|
|
6893
|
-
const
|
|
6894
|
-
const
|
|
7163
|
+
const statusBundle = resolveAccountArg(args) ?? primaryBundle;
|
|
7164
|
+
const indexStatus = await statusBundle.localIndexService.getStatus();
|
|
7165
|
+
const syncCfg = statusBundle.backgroundSyncService.getStatus();
|
|
6895
7166
|
return createTextResult({
|
|
6896
7167
|
...indexStatus,
|
|
6897
7168
|
// What keeps this index current, so a stale/missing message can be explained:
|
|
@@ -6962,6 +7233,7 @@ export function createServer(config, options = {}) {
|
|
|
6962
7233
|
{
|
|
6963
7234
|
const labelsLimit = normalizeLimit(args.limit, 250);
|
|
6964
7235
|
if (accountManager.all().length === 1) {
|
|
7236
|
+
resolveAccountArg(args);
|
|
6965
7237
|
const labels = await localIndexService.getLabels(labelsLimit);
|
|
6966
7238
|
return createTextResult({
|
|
6967
7239
|
total: labels.length,
|
|
@@ -6972,7 +7244,7 @@ export function createServer(config, options = {}) {
|
|
|
6972
7244
|
// messageCount/unreadCount/threadCount for labels that share the same
|
|
6973
7245
|
// name+type across accounts, so e.g. "INBOX" folders from two accounts
|
|
6974
7246
|
// combine into one row rather than appearing as unrelated duplicates.
|
|
6975
|
-
const perAccountLabels = await Promise.all(
|
|
7247
|
+
const perAccountLabels = await Promise.all(selectedBundles(args).map((bundle) => bundle.localIndexService.getLabels(labelsLimit)));
|
|
6976
7248
|
const mergedLabels = mergeMailboxLabels(perAccountLabels).slice(0, labelsLimit);
|
|
6977
7249
|
return createTextResult({
|
|
6978
7250
|
total: mergedLabels.length,
|
|
@@ -6989,6 +7261,7 @@ export function createServer(config, options = {}) {
|
|
|
6989
7261
|
offset: optionalInteger(args.offset, 0, 1_000_000),
|
|
6990
7262
|
};
|
|
6991
7263
|
if (accountManager.all().length === 1) {
|
|
7264
|
+
resolveAccountArg(args);
|
|
6992
7265
|
await maybeRefreshLocalIndex(imapService, localIndexService, {
|
|
6993
7266
|
folder: "INBOX",
|
|
6994
7267
|
limitPerFolder: 100,
|
|
@@ -7002,7 +7275,7 @@ export function createServer(config, options = {}) {
|
|
|
7002
7275
|
// re-sort newest-first by latestDate, then re-apply the requested
|
|
7003
7276
|
// limit across the merged set and sum totals/hasMore.
|
|
7004
7277
|
const primarySlugForThreads = accountManager.primary().account.slug;
|
|
7005
|
-
const threadsPerAccount = await Promise.all(
|
|
7278
|
+
const threadsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7006
7279
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7007
7280
|
folder: "INBOX",
|
|
7008
7281
|
limitPerFolder: 100,
|
|
@@ -7046,7 +7319,7 @@ export function createServer(config, options = {}) {
|
|
|
7046
7319
|
// makes this correct: any thread in the true global top-`limit` must already
|
|
7047
7320
|
// rank within its own account's top-`limit`, since a lower rank there would mean
|
|
7048
7321
|
// at least `limit` better-or-equal threads exist ahead of it account-wide alone.
|
|
7049
|
-
const perAccount = await Promise.all(
|
|
7322
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7050
7323
|
const refresh = await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7051
7324
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7052
7325
|
folder: "INBOX",
|
|
@@ -7102,7 +7375,7 @@ export function createServer(config, options = {}) {
|
|
|
7102
7375
|
// top `limit` of the MERGED list (never per-account — see get_actionable_threads
|
|
7103
7376
|
// for why requesting `limit`, not limit/accounts, per account is what makes this
|
|
7104
7377
|
// correct). `counts` is summed across accounts field-by-field.
|
|
7105
|
-
const perAccount = await Promise.all(
|
|
7378
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7106
7379
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7107
7380
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7108
7381
|
folder: "INBOX",
|
|
@@ -7137,15 +7410,18 @@ export function createServer(config, options = {}) {
|
|
|
7137
7410
|
for (const key of countKeys) {
|
|
7138
7411
|
counts[key] = perAccount.reduce((sum, entry) => sum + (entry.counts[key] ?? 0), 0);
|
|
7139
7412
|
}
|
|
7413
|
+
// Reply reminders whose date has passed with no answer: they belong in a triage summary.
|
|
7414
|
+
const dueReminders = (await evaluateReminders(selectedBundles(args))).filter((reminder) => reminder.state === "due");
|
|
7140
7415
|
const result = {
|
|
7141
7416
|
generatedAt: new Date().toISOString(),
|
|
7142
7417
|
indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
|
|
7143
7418
|
counts,
|
|
7144
7419
|
...(perAccount.some((entry) => entry.countsCapped)
|
|
7145
|
-
? { countsCapped: true, countsNote: "counts and topThreads cover only the newest 5000 indexed messages per account." }
|
|
7420
|
+
? { countsCapped: true, countsNote: "counts and topThreads cover only the newest 5000 indexed messages per account; counts.staleAwaitingYou covers every thread in the index, so it is not comparable with them." }
|
|
7146
7421
|
: {}),
|
|
7147
7422
|
topThreads,
|
|
7148
7423
|
staleAwaitingYou,
|
|
7424
|
+
...(dueReminders.length > 0 ? { repliesDue: dueReminders.slice(0, 10), repliesDueTotal: dueReminders.length } : {}),
|
|
7149
7425
|
// Each section pages independently with the same offset/limit: pass nextOffset back as
|
|
7150
7426
|
// `offset` to continue (a section with hasMore:false is exhausted).
|
|
7151
7427
|
paging: {
|
|
@@ -7165,7 +7441,7 @@ export function createServer(config, options = {}) {
|
|
|
7165
7441
|
// score), tag ids with the account's slug, concatenate, re-sort by that same
|
|
7166
7442
|
// (ageHours, then score) order, and take the top `limit` of the MERGED set —
|
|
7167
7443
|
// same correctness argument as get_actionable_threads' fan-out.
|
|
7168
|
-
const perAccount = await Promise.all(
|
|
7444
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7169
7445
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7170
7446
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7171
7447
|
folder: "INBOX",
|
|
@@ -7224,7 +7500,7 @@ export function createServer(config, options = {}) {
|
|
|
7224
7500
|
args.category === "calendar"
|
|
7225
7501
|
? args.category
|
|
7226
7502
|
: "document";
|
|
7227
|
-
const perAccount = await Promise.all(
|
|
7503
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7228
7504
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7229
7505
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7230
7506
|
folder: "INBOX",
|
|
@@ -7268,7 +7544,7 @@ export function createServer(config, options = {}) {
|
|
|
7268
7544
|
// local-index-service.ts), tag ids with the account's slug, concatenate,
|
|
7269
7545
|
// re-sort by that same recency order, and take the top `limit` of the
|
|
7270
7546
|
// MERGED set — same correctness argument as get_actionable_threads' fan-out.
|
|
7271
|
-
const perAccount = await Promise.all(
|
|
7547
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7272
7548
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7273
7549
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7274
7550
|
folder: "INBOX",
|