proton-mail-bridge-client 2.7.3 → 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.map +1 -1
- package/dist/index.js +302 -25
- 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 +18 -1
- 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 +236 -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);
|
|
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,151 @@ 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 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
|
+
}
|
|
4454
4710
|
case "forward_email": {
|
|
4455
4711
|
ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Forward email ${String(args.emailId ?? "?")} to ${String(args.to ?? "?")}`);
|
|
4456
4712
|
ensureSendAllowed(config.runtime);
|
|
@@ -4814,9 +5070,10 @@ export function createServer(config, options = {}) {
|
|
|
4814
5070
|
// account's own list already arrives that way). Single-account guard
|
|
4815
5071
|
// keeps byte-identical output for the common case.
|
|
4816
5072
|
const includeSent = normalizeBoolean(args.includeSent, false);
|
|
5073
|
+
resolveAccountArg(args);
|
|
4817
5074
|
const allDrafts = accountManager.all().length === 1
|
|
4818
5075
|
? (await draftStore.listDrafts(includeSent)).map((draft) => ({ bundle: primaryBundle, draft }))
|
|
4819
|
-
: (await Promise.all(
|
|
5076
|
+
: (await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
4820
5077
|
const drafts = await bundle.draftStore.listDrafts(includeSent);
|
|
4821
5078
|
return drafts.map((draft) => ({ bundle, draft: presentDraft(bundle, draft) }));
|
|
4822
5079
|
})))
|
|
@@ -4843,7 +5100,11 @@ export function createServer(config, options = {}) {
|
|
|
4843
5100
|
case "list_remote_drafts": {
|
|
4844
5101
|
// Scope limitation (follow-up round): stays scoped to the primary account's
|
|
4845
5102
|
// remote Drafts mailbox only — see list_drafts' identical note.
|
|
4846
|
-
|
|
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) })) };
|
|
4847
5108
|
// projectFields (called with no fields arg) still trims each email's
|
|
4848
5109
|
// attachments to the essentials — same fix as every other list-style
|
|
4849
5110
|
// tool, this one just didn't route through projectFields at all before.
|
|
@@ -5321,6 +5582,7 @@ export function createServer(config, options = {}) {
|
|
|
5321
5582
|
includeSnippet: normalizeBoolean(args.includeSnippet, false),
|
|
5322
5583
|
};
|
|
5323
5584
|
if (accountManager.all().length === 1) {
|
|
5585
|
+
resolveAccountArg(args);
|
|
5324
5586
|
const result = await imapService.getEmails(getEmailsInput);
|
|
5325
5587
|
return createTextResult({
|
|
5326
5588
|
...result,
|
|
@@ -5362,7 +5624,7 @@ export function createServer(config, options = {}) {
|
|
|
5362
5624
|
// than inferring it from a count that can be either capped-by-fetch or
|
|
5363
5625
|
// filter-blind depending on which one you pick.
|
|
5364
5626
|
const perAccountFetchLimit = requestedOffset + effectiveLimit + 1;
|
|
5365
|
-
const perAccount = await Promise.all(
|
|
5627
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
5366
5628
|
const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
|
|
5367
5629
|
return { bundle, result };
|
|
5368
5630
|
}));
|
|
@@ -5582,6 +5844,7 @@ export function createServer(config, options = {}) {
|
|
|
5582
5844
|
sizeSmaller: optionalNumber(args.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
|
|
5583
5845
|
};
|
|
5584
5846
|
if (accountManager.all().length === 1) {
|
|
5847
|
+
resolveAccountArg(args);
|
|
5585
5848
|
const result = await imapService.countMessages(countInput);
|
|
5586
5849
|
return createTextResult(result);
|
|
5587
5850
|
}
|
|
@@ -5590,7 +5853,7 @@ export function createServer(config, options = {}) {
|
|
|
5590
5853
|
// folder name), with a per-account breakdown alongside the total.
|
|
5591
5854
|
// One account lacking the folder or label (or unreachable) must not fail the whole count, as in
|
|
5592
5855
|
// search_emails: report it and return the rest. Every account failing still throws.
|
|
5593
|
-
const settled = await Promise.allSettled(
|
|
5856
|
+
const settled = await Promise.allSettled(selectedBundles(args).map(async (bundle) => ({
|
|
5594
5857
|
slug: bundle.account.slug,
|
|
5595
5858
|
...(await bundle.imapService.countMessages(countInput)),
|
|
5596
5859
|
})));
|
|
@@ -5599,7 +5862,7 @@ export function createServer(config, options = {}) {
|
|
|
5599
5862
|
throw settled[0].reason;
|
|
5600
5863
|
}
|
|
5601
5864
|
const failedAccounts = settled.flatMap((entry, index) => entry.status === "rejected"
|
|
5602
|
-
? [{ account:
|
|
5865
|
+
? [{ account: selectedBundles(args)[index].account.slug, error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason) }]
|
|
5603
5866
|
: []);
|
|
5604
5867
|
return createTextResult({
|
|
5605
5868
|
folder: countInput.folder ?? "INBOX",
|
|
@@ -5612,13 +5875,14 @@ export function createServer(config, options = {}) {
|
|
|
5612
5875
|
case "folder_stats": {
|
|
5613
5876
|
const folderStatsFolder = optionalString(args, "folder");
|
|
5614
5877
|
if (accountManager.all().length === 1) {
|
|
5878
|
+
resolveAccountArg(args);
|
|
5615
5879
|
const result = await imapService.getFolderStats(folderStatsFolder);
|
|
5616
5880
|
return createTextResult(result);
|
|
5617
5881
|
}
|
|
5618
5882
|
// Merge strategy: fetch stats for the same folder in every account and
|
|
5619
5883
|
// sum the numeric fields (total/unseen); uidNext/uidValidity are
|
|
5620
5884
|
// per-account concepts and only make sense in the breakdown.
|
|
5621
|
-
const perAccount = await Promise.all(
|
|
5885
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
|
|
5622
5886
|
slug: bundle.account.slug,
|
|
5623
5887
|
...(await bundle.imapService.getFolderStats(folderStatsFolder)),
|
|
5624
5888
|
})));
|
|
@@ -5938,6 +6202,7 @@ export function createServer(config, options = {}) {
|
|
|
5938
6202
|
excludeSelf: normalizeBoolean(args.excludeSelf, true),
|
|
5939
6203
|
};
|
|
5940
6204
|
if (accountManager.all().length === 1) {
|
|
6205
|
+
resolveAccountArg(args);
|
|
5941
6206
|
const result = await imapService.topSenders(topSendersInput);
|
|
5942
6207
|
return createTextResult(result);
|
|
5943
6208
|
}
|
|
@@ -5946,7 +6211,7 @@ export function createServer(config, options = {}) {
|
|
|
5946
6211
|
// re-sort and re-apply the requested top-N limit over the merged set.
|
|
5947
6212
|
// Each account is asked for its WHOLE table, not just its own top N: a sender in the middle of every
|
|
5948
6213
|
// account's table can still be first in total, and cutting each table first made it disappear.
|
|
5949
|
-
const perAccount = await Promise.all(
|
|
6214
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({
|
|
5950
6215
|
bundle,
|
|
5951
6216
|
result: await bundle.imapService.topSenders({ ...topSendersInput, limit: 10_000 }),
|
|
5952
6217
|
})));
|
|
@@ -6065,6 +6330,7 @@ export function createServer(config, options = {}) {
|
|
|
6065
6330
|
}
|
|
6066
6331
|
case "get_folders": {
|
|
6067
6332
|
if (accountManager.all().length === 1) {
|
|
6333
|
+
resolveAccountArg(args);
|
|
6068
6334
|
return createTextResult(await imapService.getFolders());
|
|
6069
6335
|
}
|
|
6070
6336
|
// Merge strategy: fetch each account's own folder list (LIVE IMAP) and
|
|
@@ -6072,7 +6338,7 @@ export function createServer(config, options = {}) {
|
|
|
6072
6338
|
// which is left bare) so the merged list stays unambiguous while a
|
|
6073
6339
|
// single-account setup's own folder paths are completely untouched.
|
|
6074
6340
|
const primarySlug = accountManager.primary().account.slug;
|
|
6075
|
-
const perAccount = await Promise.all(
|
|
6341
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => ({ bundle, folders: await bundle.imapService.getFolders() })));
|
|
6076
6342
|
const merged = perAccount.flatMap(({ bundle, folders }) => folders.map((folder) => {
|
|
6077
6343
|
const slug = accountSlugForTag(bundle, primarySlug);
|
|
6078
6344
|
return slug ? { ...folder, path: `${slug}::${folder.path}`, account: slug } : folder;
|
|
@@ -6248,11 +6514,12 @@ export function createServer(config, options = {}) {
|
|
|
6248
6514
|
// independent snoozeService/JSON file. Single-account guard keeps
|
|
6249
6515
|
// byte-identical output for the common case.
|
|
6250
6516
|
if (accountManager.all().length === 1) {
|
|
6517
|
+
resolveAccountArg(args);
|
|
6251
6518
|
const all = await withAudit(auditService, name, args, async () => snoozeService.list());
|
|
6252
6519
|
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
6253
6520
|
return createTextResult(filtered);
|
|
6254
6521
|
}
|
|
6255
|
-
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) => ({
|
|
6256
6523
|
bundle,
|
|
6257
6524
|
items: await bundle.snoozeService.list(),
|
|
6258
6525
|
}))));
|
|
@@ -6377,6 +6644,7 @@ export function createServer(config, options = {}) {
|
|
|
6377
6644
|
const statsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6378
6645
|
const statsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6379
6646
|
if (accountManager.all().length === 1) {
|
|
6647
|
+
resolveAccountArg(args);
|
|
6380
6648
|
const folders = await imapService.getFolders();
|
|
6381
6649
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, statsDays, statsLimit);
|
|
6382
6650
|
return createTextResult(analyticsService.getEmailStats(sample, folders, config.smtp.username));
|
|
@@ -6386,7 +6654,7 @@ export function createServer(config, options = {}) {
|
|
|
6386
6654
|
// numeric mailbox/sample fields and merge folder lists (non-primary
|
|
6387
6655
|
// folder paths tagged with their account slug to avoid collisions).
|
|
6388
6656
|
const primarySlugForStats = accountManager.primary().account.slug;
|
|
6389
|
-
const statsPerAccount = await Promise.all(
|
|
6657
|
+
const statsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6390
6658
|
const folders = await bundle.imapService.getFolders();
|
|
6391
6659
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, statsDays, statsLimit);
|
|
6392
6660
|
return {
|
|
@@ -6418,6 +6686,7 @@ export function createServer(config, options = {}) {
|
|
|
6418
6686
|
const analyticsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6419
6687
|
const analyticsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6420
6688
|
if (accountManager.all().length === 1) {
|
|
6689
|
+
resolveAccountArg(args);
|
|
6421
6690
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, analyticsDays, analyticsLimit);
|
|
6422
6691
|
return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username, analyticsDays));
|
|
6423
6692
|
}
|
|
@@ -6426,7 +6695,7 @@ export function createServer(config, options = {}) {
|
|
|
6426
6695
|
// top-N breakdowns (hours/senders/domains) by summing counts per key
|
|
6427
6696
|
// and re-sorting, and recompute busiestDay/insights from the merged
|
|
6428
6697
|
// results.
|
|
6429
|
-
const analyticsPerAccount = await Promise.all(
|
|
6698
|
+
const analyticsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6430
6699
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, analyticsDays, analyticsLimit);
|
|
6431
6700
|
return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username, analyticsDays);
|
|
6432
6701
|
}));
|
|
@@ -6465,6 +6734,7 @@ export function createServer(config, options = {}) {
|
|
|
6465
6734
|
// meant limit: 5 silently ranked contacts from only 5 messages total.
|
|
6466
6735
|
const limit = normalizeLimit(args.limit, 100);
|
|
6467
6736
|
if (accountManager.all().length === 1) {
|
|
6737
|
+
resolveAccountArg(args);
|
|
6468
6738
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, 30, 3000);
|
|
6469
6739
|
return createTextResult(analyticsService.getContacts(sample, limit, config.smtp.username));
|
|
6470
6740
|
}
|
|
@@ -6473,7 +6743,7 @@ export function createServer(config, options = {}) {
|
|
|
6473
6743
|
// address across accounts by summing incoming/outgoing/totalMessages
|
|
6474
6744
|
// and keeping the most recent lastContactAt, and re-apply the
|
|
6475
6745
|
// requested limit over the merged, re-sorted set.
|
|
6476
|
-
const contactsPerAccount = await Promise.all(
|
|
6746
|
+
const contactsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6477
6747
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, 30, 3000);
|
|
6478
6748
|
return analyticsService.getContacts(sample, Number.MAX_SAFE_INTEGER, bundle.config.smtp.username);
|
|
6479
6749
|
}));
|
|
@@ -6483,13 +6753,14 @@ export function createServer(config, options = {}) {
|
|
|
6483
6753
|
const days = normalizeLimit(args.days, 30, 1, 365);
|
|
6484
6754
|
// Counted in SQL over the whole index (a sample of the newest 3000 messages cut the older days short).
|
|
6485
6755
|
if (accountManager.all().length === 1) {
|
|
6756
|
+
resolveAccountArg(args);
|
|
6486
6757
|
await maybeRefreshLocalIndex(imapService, localIndexService, {});
|
|
6487
6758
|
return createTextResult(await localIndexService.getDailyVolume(days));
|
|
6488
6759
|
}
|
|
6489
6760
|
// Merge strategy: compute each account's own daily trend points
|
|
6490
6761
|
// independently, then sum count/unreadCount/starredCount/attachmentCount
|
|
6491
6762
|
// for matching dates across accounts.
|
|
6492
|
-
const trendsPerAccount = await Promise.all(
|
|
6763
|
+
const trendsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
6493
6764
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {});
|
|
6494
6765
|
return bundle.localIndexService.getDailyVolume(days);
|
|
6495
6766
|
}));
|
|
@@ -6890,8 +7161,9 @@ export function createServer(config, options = {}) {
|
|
|
6890
7161
|
}
|
|
6891
7162
|
case "get_index_status":
|
|
6892
7163
|
{
|
|
6893
|
-
const
|
|
6894
|
-
const
|
|
7164
|
+
const statusBundle = resolveAccountArg(args) ?? primaryBundle;
|
|
7165
|
+
const indexStatus = await statusBundle.localIndexService.getStatus();
|
|
7166
|
+
const syncCfg = statusBundle.backgroundSyncService.getStatus();
|
|
6895
7167
|
return createTextResult({
|
|
6896
7168
|
...indexStatus,
|
|
6897
7169
|
// What keeps this index current, so a stale/missing message can be explained:
|
|
@@ -6962,6 +7234,7 @@ export function createServer(config, options = {}) {
|
|
|
6962
7234
|
{
|
|
6963
7235
|
const labelsLimit = normalizeLimit(args.limit, 250);
|
|
6964
7236
|
if (accountManager.all().length === 1) {
|
|
7237
|
+
resolveAccountArg(args);
|
|
6965
7238
|
const labels = await localIndexService.getLabels(labelsLimit);
|
|
6966
7239
|
return createTextResult({
|
|
6967
7240
|
total: labels.length,
|
|
@@ -6972,7 +7245,7 @@ export function createServer(config, options = {}) {
|
|
|
6972
7245
|
// messageCount/unreadCount/threadCount for labels that share the same
|
|
6973
7246
|
// name+type across accounts, so e.g. "INBOX" folders from two accounts
|
|
6974
7247
|
// combine into one row rather than appearing as unrelated duplicates.
|
|
6975
|
-
const perAccountLabels = await Promise.all(
|
|
7248
|
+
const perAccountLabels = await Promise.all(selectedBundles(args).map((bundle) => bundle.localIndexService.getLabels(labelsLimit)));
|
|
6976
7249
|
const mergedLabels = mergeMailboxLabels(perAccountLabels).slice(0, labelsLimit);
|
|
6977
7250
|
return createTextResult({
|
|
6978
7251
|
total: mergedLabels.length,
|
|
@@ -6989,6 +7262,7 @@ export function createServer(config, options = {}) {
|
|
|
6989
7262
|
offset: optionalInteger(args.offset, 0, 1_000_000),
|
|
6990
7263
|
};
|
|
6991
7264
|
if (accountManager.all().length === 1) {
|
|
7265
|
+
resolveAccountArg(args);
|
|
6992
7266
|
await maybeRefreshLocalIndex(imapService, localIndexService, {
|
|
6993
7267
|
folder: "INBOX",
|
|
6994
7268
|
limitPerFolder: 100,
|
|
@@ -7002,7 +7276,7 @@ export function createServer(config, options = {}) {
|
|
|
7002
7276
|
// re-sort newest-first by latestDate, then re-apply the requested
|
|
7003
7277
|
// limit across the merged set and sum totals/hasMore.
|
|
7004
7278
|
const primarySlugForThreads = accountManager.primary().account.slug;
|
|
7005
|
-
const threadsPerAccount = await Promise.all(
|
|
7279
|
+
const threadsPerAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7006
7280
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7007
7281
|
folder: "INBOX",
|
|
7008
7282
|
limitPerFolder: 100,
|
|
@@ -7046,7 +7320,7 @@ export function createServer(config, options = {}) {
|
|
|
7046
7320
|
// makes this correct: any thread in the true global top-`limit` must already
|
|
7047
7321
|
// rank within its own account's top-`limit`, since a lower rank there would mean
|
|
7048
7322
|
// at least `limit` better-or-equal threads exist ahead of it account-wide alone.
|
|
7049
|
-
const perAccount = await Promise.all(
|
|
7323
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7050
7324
|
const refresh = await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7051
7325
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7052
7326
|
folder: "INBOX",
|
|
@@ -7102,7 +7376,7 @@ export function createServer(config, options = {}) {
|
|
|
7102
7376
|
// top `limit` of the MERGED list (never per-account — see get_actionable_threads
|
|
7103
7377
|
// for why requesting `limit`, not limit/accounts, per account is what makes this
|
|
7104
7378
|
// correct). `counts` is summed across accounts field-by-field.
|
|
7105
|
-
const perAccount = await Promise.all(
|
|
7379
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7106
7380
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7107
7381
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7108
7382
|
folder: "INBOX",
|
|
@@ -7137,6 +7411,8 @@ export function createServer(config, options = {}) {
|
|
|
7137
7411
|
for (const key of countKeys) {
|
|
7138
7412
|
counts[key] = perAccount.reduce((sum, entry) => sum + (entry.counts[key] ?? 0), 0);
|
|
7139
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");
|
|
7140
7416
|
const result = {
|
|
7141
7417
|
generatedAt: new Date().toISOString(),
|
|
7142
7418
|
indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
|
|
@@ -7146,6 +7422,7 @@ export function createServer(config, options = {}) {
|
|
|
7146
7422
|
: {}),
|
|
7147
7423
|
topThreads,
|
|
7148
7424
|
staleAwaitingYou,
|
|
7425
|
+
...(dueReminders.length > 0 ? { repliesDue: dueReminders.slice(0, 10), repliesDueTotal: dueReminders.length } : {}),
|
|
7149
7426
|
// Each section pages independently with the same offset/limit: pass nextOffset back as
|
|
7150
7427
|
// `offset` to continue (a section with hasMore:false is exhausted).
|
|
7151
7428
|
paging: {
|
|
@@ -7165,7 +7442,7 @@ export function createServer(config, options = {}) {
|
|
|
7165
7442
|
// score), tag ids with the account's slug, concatenate, re-sort by that same
|
|
7166
7443
|
// (ageHours, then score) order, and take the top `limit` of the MERGED set —
|
|
7167
7444
|
// same correctness argument as get_actionable_threads' fan-out.
|
|
7168
|
-
const perAccount = await Promise.all(
|
|
7445
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7169
7446
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7170
7447
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7171
7448
|
folder: "INBOX",
|
|
@@ -7224,7 +7501,7 @@ export function createServer(config, options = {}) {
|
|
|
7224
7501
|
args.category === "calendar"
|
|
7225
7502
|
? args.category
|
|
7226
7503
|
: "document";
|
|
7227
|
-
const perAccount = await Promise.all(
|
|
7504
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7228
7505
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7229
7506
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7230
7507
|
folder: "INBOX",
|
|
@@ -7268,7 +7545,7 @@ export function createServer(config, options = {}) {
|
|
|
7268
7545
|
// local-index-service.ts), tag ids with the account's slug, concatenate,
|
|
7269
7546
|
// re-sort by that same recency order, and take the top `limit` of the
|
|
7270
7547
|
// MERGED set — same correctness argument as get_actionable_threads' fan-out.
|
|
7271
|
-
const perAccount = await Promise.all(
|
|
7548
|
+
const perAccount = await Promise.all(selectedBundles(args).map(async (bundle) => {
|
|
7272
7549
|
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {
|
|
7273
7550
|
force: normalizeBoolean(args.syncBefore, false),
|
|
7274
7551
|
folder: "INBOX",
|