proton-mail-bridge-client 2.3.1 → 2.4.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 +8 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +348 -202
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -143
- package/dist/index.js.map +1 -1
- package/dist/scripts/check-claude-desktop.d.ts +18 -0
- package/dist/scripts/check-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/check-claude-desktop.js +91 -30
- package/dist/scripts/check-claude-desktop.js.map +1 -1
- package/dist/scripts/install-claude-desktop.d.ts +12 -0
- package/dist/scripts/install-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/install-claude-desktop.js +211 -43
- package/dist/scripts/install-claude-desktop.js.map +1 -1
- package/dist/services/analytics-service.d.ts +1 -1
- package/dist/services/analytics-service.d.ts.map +1 -1
- package/dist/services/analytics-service.js +2 -2
- package/dist/services/analytics-service.js.map +1 -1
- package/dist/services/audit-service.d.ts.map +1 -1
- package/dist/services/audit-service.js +7 -2
- package/dist/services/audit-service.js.map +1 -1
- package/dist/services/background-sync-service.d.ts.map +1 -1
- package/dist/services/background-sync-service.js +10 -2
- package/dist/services/background-sync-service.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +4 -1
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +21 -27
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +13 -25
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +2 -1
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +91 -33
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +22 -2
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +295 -107
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +24 -2
- package/dist/services/smtp-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +43 -30
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +4 -2
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +46 -20
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/atomic-write.d.ts +2 -0
- package/dist/utils/atomic-write.d.ts.map +1 -0
- package/dist/utils/atomic-write.js +36 -0
- package/dist/utils/atomic-write.js.map +1 -0
- package/dist/utils/corrupt-store.d.ts +4 -0
- package/dist/utils/corrupt-store.d.ts.map +1 -0
- package/dist/utils/corrupt-store.js +29 -0
- package/dist/utils/corrupt-store.js.map +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +39 -4
- package/dist/utils/file-lock.js.map +1 -1
- package/dist/utils/helpers.d.ts +14 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +281 -27
- package/dist/utils/helpers.js.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +29 -12
- package/dist/utils/logger.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { AnalyticsService } from "./services/analytics-service.js";
|
|
|
14
14
|
import { applyBodyEdits, draftSyncFingerprint } from "./services/draft-store-service.js";
|
|
15
15
|
import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
|
|
16
16
|
import { applySignature, plainTextToHtml } from "./services/smtp-service.js";
|
|
17
|
-
import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, isSelfAddress, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmailId, parseEmails, projectFields, trimAttachmentsForListing, renderMarkdown, slugifyAccountAddress, splitAccountPrefix, stringifyForJson, withAccountPrefix, } from "./utils/helpers.js";
|
|
17
|
+
import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, isSelfAddress, lowerCaseAddress, InvalidArgumentError, isOutgoingMessage, isPathInside, normalizeBoolean, normalizeLimit, optionalBoolean, optionalInteger, optionalNumber, normalizeJsonValue, parseEmailId, parseEmails, projectFields, trimAttachmentsForListing, renderMarkdown, slugifyAccountAddress, splitAccountPrefix, stringifyForJson, withAccountPrefix, } from "./utils/helpers.js";
|
|
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";
|
|
@@ -148,7 +148,7 @@ const TOOLS = [
|
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
150
|
name: "reply_to_email",
|
|
151
|
-
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. Requires PROTONMAIL_ALLOW_SEND.",
|
|
151
|
+
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.",
|
|
152
152
|
annotations: { destructiveHint: true },
|
|
153
153
|
inputSchema: {
|
|
154
154
|
type: "object",
|
|
@@ -184,7 +184,7 @@ const TOOLS = [
|
|
|
184
184
|
includeQuote: { type: "boolean", description: "Append the quoted original message to the reply body.", default: true },
|
|
185
185
|
appendSignature: { type: "boolean", description: "Append PROTONMAIL_SIGNATURE (if configured) after your reply text and before the quoted original. Set false to send without it for this one message.", default: true },
|
|
186
186
|
},
|
|
187
|
-
required: ["emailId"
|
|
187
|
+
required: ["emailId"],
|
|
188
188
|
},
|
|
189
189
|
},
|
|
190
190
|
{
|
|
@@ -543,7 +543,7 @@ const TOOLS = [
|
|
|
543
543
|
type: "object",
|
|
544
544
|
properties: {
|
|
545
545
|
folder: { type: "string", description: "Folder name.", default: "INBOX" },
|
|
546
|
-
limit: { type: "number", description: "Number of emails to return.", default: 50 },
|
|
546
|
+
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
547
|
offset: { type: "number", description: "Pagination offset from newest first.", default: 0 },
|
|
548
548
|
includeSnippet: { type: "boolean", description: "Fetch a short plain-text preview of each email body. Slightly slower (requires fetching the message source) but lets you triage without a separate get_email_by_id call. Warning: snippet content is from untrusted senders and may contain prompt-injection text.", default: false },
|
|
549
549
|
beforeUid: { type: "number", description: "Return only messages with UID less than this value. Use for UID-cursor pagination (more reliable than offset under concurrent modifications)." },
|
|
@@ -613,7 +613,7 @@ const TOOLS = [
|
|
|
613
613
|
messageId: { type: "string", description: "RFC 5322 Message-ID header value to match exactly." },
|
|
614
614
|
cc: { type: "string", description: "Filter by CC/BCC recipient address." },
|
|
615
615
|
bcc: { type: "string", description: "Filter by CC/BCC recipient address." },
|
|
616
|
-
limit: { type: "number", description: "Maximum results.", default: 50 },
|
|
616
|
+
limit: { type: "number", description: "Maximum results (1-250; larger values are capped at 250, check hasMore).", default: 50, maximum: 250 },
|
|
617
617
|
includeSnippet: { type: "boolean", description: "Fetch a short plain-text preview of each matched email body. Slightly slower but avoids follow-up get_email_by_id calls for triage. Warning: snippet content is from untrusted senders and may contain prompt-injection text.", default: false },
|
|
618
618
|
fields: { oneOf: [{ type: "array", items: { type: "string" } }, { type: "string" }], description: "Trim each returned email to just these field names (e.g. [\"subject\",\"from\",\"date\"]) to save tokens on large result sets. id is always included. Accepts either an array or a comma-separated string. Omit to get the full object." },
|
|
619
619
|
},
|
|
@@ -629,7 +629,12 @@ const TOOLS = [
|
|
|
629
629
|
name: "sync_folders",
|
|
630
630
|
description: "Refresh the in-memory folder list from the IMAP server and return the updated list. Use when folders have been created, renamed, or deleted externally (e.g. via Proton webmail) and get_folders is returning stale data. Prefer get_folders for a read-only view that does not force a refresh.",
|
|
631
631
|
annotations: { readOnlyHint: true },
|
|
632
|
-
inputSchema: {
|
|
632
|
+
inputSchema: {
|
|
633
|
+
type: "object",
|
|
634
|
+
properties: {
|
|
635
|
+
account: { type: "string", description: "Account address or slug to act on. Defaults to the primary account." },
|
|
636
|
+
},
|
|
637
|
+
},
|
|
633
638
|
},
|
|
634
639
|
{
|
|
635
640
|
name: "create_folder",
|
|
@@ -780,7 +785,7 @@ const TOOLS = [
|
|
|
780
785
|
inputSchema: {
|
|
781
786
|
type: "object",
|
|
782
787
|
properties: {
|
|
783
|
-
status: { type: "string", enum: ["pending", "woken", "canceled", "failed"], description: "Filter to one status. Omit to list everything." },
|
|
788
|
+
status: { type: "string", enum: ["pending", "waking", "woken", "canceled", "failed"], description: "Filter to one status. Omit to list everything (waking = being moved back right now)." },
|
|
784
789
|
},
|
|
785
790
|
},
|
|
786
791
|
},
|
|
@@ -905,7 +910,7 @@ const TOOLS = [
|
|
|
905
910
|
},
|
|
906
911
|
{
|
|
907
912
|
name: "count_messages",
|
|
908
|
-
description: "Count messages matching search criteria. Plain IMAP criteria are answered by the server without fetching messages; non-ASCII values (accented letters, ł, ß) and local-only filters (hasAttachment, senderDomain,
|
|
913
|
+
description: "Count messages matching search criteria. Plain IMAP criteria are answered by the server without fetching messages; non-ASCII values (accented letters, ł, ß) and local-only filters (hasAttachment, senderDomain, threadId, attachmentName, mailboxRole) are checked locally, and the result carries approximate: true when only the newest 500 candidates could be checked. A label that names a folder (Proton: Labels/<name>) counts that folder, as search_emails does. Across several accounts the counts are summed and an account that fails (for example one without that folder) is listed in failedAccounts instead of failing the whole call. Use to preview how many results a search would return before running it. Prefer folder_stats for a simple unread/total count on one folder without filters.",
|
|
909
914
|
annotations: { readOnlyHint: true },
|
|
910
915
|
inputSchema: {
|
|
911
916
|
type: "object",
|
|
@@ -1154,6 +1159,10 @@ const TOOLS = [
|
|
|
1154
1159
|
description: "Continue applying the action after an individual failure.",
|
|
1155
1160
|
default: true,
|
|
1156
1161
|
},
|
|
1162
|
+
confirmed: {
|
|
1163
|
+
type: "boolean",
|
|
1164
|
+
description: "Set to true to confirm a permanent delete (action \"delete\") when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled.",
|
|
1165
|
+
},
|
|
1157
1166
|
dryRun: {
|
|
1158
1167
|
type: "boolean",
|
|
1159
1168
|
description: "Preview the impact without mutating the mailbox.",
|
|
@@ -1190,6 +1199,10 @@ const TOOLS = [
|
|
|
1190
1199
|
description: "Continue applying the action after an individual failure.",
|
|
1191
1200
|
default: true,
|
|
1192
1201
|
},
|
|
1202
|
+
confirmed: {
|
|
1203
|
+
type: "boolean",
|
|
1204
|
+
description: "Set to true to confirm a permanent delete (action \"delete\") when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled.",
|
|
1205
|
+
},
|
|
1193
1206
|
dryRun: {
|
|
1194
1207
|
type: "boolean",
|
|
1195
1208
|
description: "Preview the impact without mutating the mailbox.",
|
|
@@ -1300,7 +1313,7 @@ const TOOLS = [
|
|
|
1300
1313
|
name: "run_background_sync",
|
|
1301
1314
|
description: "Immediately trigger the configured background mailbox sync cycle outside its normal schedule and return its updated status. Use to force a sync when the index may be stale. Does nothing useful if PROTONMAIL_AUTO_SYNC is disabled. Prefer sync_emails for an on-demand, configurable sync with folder and depth options.",
|
|
1302
1315
|
annotations: { destructiveHint: false },
|
|
1303
|
-
inputSchema: { type: "object", properties: {} },
|
|
1316
|
+
inputSchema: { type: "object", properties: { account: { type: "string", description: "Account address or slug to act on. Defaults to the primary account." }, } },
|
|
1304
1317
|
},
|
|
1305
1318
|
{
|
|
1306
1319
|
name: "wait_for_mailbox_changes",
|
|
@@ -1309,6 +1322,7 @@ const TOOLS = [
|
|
|
1309
1322
|
inputSchema: {
|
|
1310
1323
|
type: "object",
|
|
1311
1324
|
properties: {
|
|
1325
|
+
account: { type: "string", description: "Account address or slug to act on. Defaults to the primary account." },
|
|
1312
1326
|
folder: { type: "string", description: "Mailbox to watch during IDLE.", default: "INBOX" },
|
|
1313
1327
|
timeoutSeconds: { type: "number", description: "Maximum watch duration in seconds.", default: 15 },
|
|
1314
1328
|
},
|
|
@@ -1321,6 +1335,7 @@ const TOOLS = [
|
|
|
1321
1335
|
inputSchema: {
|
|
1322
1336
|
type: "object",
|
|
1323
1337
|
properties: {
|
|
1338
|
+
account: { type: "string", description: "Account address or slug to act on. Defaults to the primary account." },
|
|
1324
1339
|
folder: { type: "string", description: "Folder to sync. Defaults to all folders." },
|
|
1325
1340
|
timeBudgetSeconds: {
|
|
1326
1341
|
type: "number",
|
|
@@ -1605,7 +1620,7 @@ const TOOLS = [
|
|
|
1605
1620
|
{
|
|
1606
1621
|
name: "get_attachment_content",
|
|
1607
1622
|
description: "Fetch metadata for a specific email attachment and optionally return its base64-encoded content inline. Use when you need to read or process attachment data in-memory. Set includeBase64:false (default) to retrieve metadata only without loading the full payload. Prefer save_attachment to write the file to disk instead.",
|
|
1608
|
-
annotations: { readOnlyHint:
|
|
1623
|
+
annotations: { readOnlyHint: false },
|
|
1609
1624
|
inputSchema: {
|
|
1610
1625
|
type: "object",
|
|
1611
1626
|
properties: {
|
|
@@ -1827,6 +1842,14 @@ function createTextResult(value, isError = false, sources = []) {
|
|
|
1827
1842
|
// size exceeded"), failing the whole tool call. So: never descend into class instances
|
|
1828
1843
|
// (services, sockets, clients), never into an account bundle, stop on cycles, and cap depth.
|
|
1829
1844
|
const AUDIT_MAX_DEPTH = 8;
|
|
1845
|
+
// The audit log records what was done, not the content of the mail or any secret. Message text can arrive under
|
|
1846
|
+
// many names (body, markdownBody, htmlBody, a draft's notes, the find/replace of body edits, a raw .eml...), so
|
|
1847
|
+
// every one of them is redacted by key; credential-like keys are caught by pattern.
|
|
1848
|
+
const AUDIT_REDACTED_KEYS = new Set([
|
|
1849
|
+
"body", "html", "text", "htmlBody", "textBody", "markdownBody", "customMessage", "notes", "bodyEdits",
|
|
1850
|
+
"raw", "rawBase64", "base64", "content", "find", "replace", "signature",
|
|
1851
|
+
]);
|
|
1852
|
+
const AUDIT_SECRET_KEY = /pass(word|wd|phrase)?$|secret|token|api[-_]?key|authoriz|credential|cookie|private[-_]?key/i;
|
|
1830
1853
|
function sanitizeAuditValue(value, depth = 0, seen = new WeakSet()) {
|
|
1831
1854
|
if (value === undefined || value === null) {
|
|
1832
1855
|
return value;
|
|
@@ -1853,12 +1876,7 @@ function sanitizeAuditValue(value, depth = 0, seen = new WeakSet()) {
|
|
|
1853
1876
|
return `[${value.constructor?.name ?? "object"}]`;
|
|
1854
1877
|
}
|
|
1855
1878
|
return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => {
|
|
1856
|
-
if (key
|
|
1857
|
-
key === "html" ||
|
|
1858
|
-
key === "text" ||
|
|
1859
|
-
key === "base64" ||
|
|
1860
|
-
key === "customMessage" ||
|
|
1861
|
-
/password|secret|token/i.test(key)) {
|
|
1879
|
+
if (AUDIT_REDACTED_KEYS.has(key) || AUDIT_SECRET_KEY.test(key)) {
|
|
1862
1880
|
return [key, "[redacted]"];
|
|
1863
1881
|
}
|
|
1864
1882
|
if (key === "bundle") {
|
|
@@ -1887,20 +1905,23 @@ function sanitizeAuditValue(value, depth = 0, seen = new WeakSet()) {
|
|
|
1887
1905
|
}
|
|
1888
1906
|
export async function withAudit(auditService, tool, input, operation) {
|
|
1889
1907
|
const startedAt = Date.now();
|
|
1908
|
+
// Writing the audit entry is bookkeeping about an operation that has already happened. If it fails (disk
|
|
1909
|
+
// full, a locked file), the failure is logged and swallowed: raising it turned a successful move or send
|
|
1910
|
+
// into a tool error, so a client would retry it, and on the error path it would hide the real error.
|
|
1911
|
+
const record = async (entry) => {
|
|
1912
|
+
try {
|
|
1913
|
+
await auditService.record(entry);
|
|
1914
|
+
}
|
|
1915
|
+
catch (auditError) {
|
|
1916
|
+
logger.error("Could not write the audit entry", "MCPServer", { tool, error: auditError });
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
let result;
|
|
1890
1920
|
try {
|
|
1891
|
-
|
|
1892
|
-
await auditService.record({
|
|
1893
|
-
timestamp: new Date().toISOString(),
|
|
1894
|
-
tool,
|
|
1895
|
-
status: "success",
|
|
1896
|
-
durationMs: Date.now() - startedAt,
|
|
1897
|
-
input: sanitizeAuditValue(input),
|
|
1898
|
-
result: sanitizeAuditValue(result),
|
|
1899
|
-
});
|
|
1900
|
-
return result;
|
|
1921
|
+
result = await operation();
|
|
1901
1922
|
}
|
|
1902
1923
|
catch (error) {
|
|
1903
|
-
await
|
|
1924
|
+
await record({
|
|
1904
1925
|
timestamp: new Date().toISOString(),
|
|
1905
1926
|
tool,
|
|
1906
1927
|
status: "error",
|
|
@@ -1910,6 +1931,15 @@ export async function withAudit(auditService, tool, input, operation) {
|
|
|
1910
1931
|
});
|
|
1911
1932
|
throw error;
|
|
1912
1933
|
}
|
|
1934
|
+
await record({
|
|
1935
|
+
timestamp: new Date().toISOString(),
|
|
1936
|
+
tool,
|
|
1937
|
+
status: "success",
|
|
1938
|
+
durationMs: Date.now() - startedAt,
|
|
1939
|
+
input: sanitizeAuditValue(input),
|
|
1940
|
+
result: sanitizeAuditValue(result),
|
|
1941
|
+
});
|
|
1942
|
+
return result;
|
|
1913
1943
|
}
|
|
1914
1944
|
// Validates update_draft's `bodyEdits` argument into BodyEdit[] (shape only; whether each
|
|
1915
1945
|
// find text is present/unambiguous is decided against the stored body by applyBodyEdits).
|
|
@@ -2040,13 +2070,42 @@ export function buildSecurityInfo(detail) {
|
|
|
2040
2070
|
dmarc: auth.dmarc,
|
|
2041
2071
|
};
|
|
2042
2072
|
}
|
|
2073
|
+
// "unsub@list.example?subject=Remove%20me&body=..." as mailparser hands over a List-Unsubscribe mailto: one
|
|
2074
|
+
// address plus the subject and body the sender asked for. Anything with more than one recipient, or an
|
|
2075
|
+
// address part that is not an address, is refused; the requested text is cut to a sane length and stripped of
|
|
2076
|
+
// line breaks, because it ends up in a header and a body of a mail this server sends.
|
|
2077
|
+
export function parseUnsubscribeMailto(value) {
|
|
2078
|
+
const cleaned = value.trim().replace(/^mailto:/i, "");
|
|
2079
|
+
const questionMark = cleaned.indexOf("?");
|
|
2080
|
+
const addressPart = questionMark === -1 ? cleaned : cleaned.slice(0, questionMark);
|
|
2081
|
+
let address;
|
|
2082
|
+
try {
|
|
2083
|
+
address = decodeURIComponent(addressPart).trim();
|
|
2084
|
+
}
|
|
2085
|
+
catch {
|
|
2086
|
+
return undefined;
|
|
2087
|
+
}
|
|
2088
|
+
if (!address || /[\s,;<>]/.test(address) || !isValidEmail(address))
|
|
2089
|
+
return undefined;
|
|
2090
|
+
const params = new URLSearchParams(questionMark === -1 ? "" : cleaned.slice(questionMark + 1));
|
|
2091
|
+
const text = (key, max, keepNewlines) => {
|
|
2092
|
+
const raw = params.get(key);
|
|
2093
|
+
if (!raw)
|
|
2094
|
+
return undefined;
|
|
2095
|
+
const clean = (keepNewlines ? raw.replace(/\r/g, "") : raw.replace(/[\r\n]+/g, " ")).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
|
|
2096
|
+
return clean ? clean.slice(0, max) : undefined;
|
|
2097
|
+
};
|
|
2098
|
+
return { address, subject: text("subject", 200, false), body: text("body", 2000, true) };
|
|
2099
|
+
}
|
|
2043
2100
|
export function extractUnsubscribeInfo(detail) {
|
|
2044
2101
|
const list = detail.headers?.list;
|
|
2045
2102
|
const unsubscribe = list?.unsubscribe;
|
|
2046
|
-
const mail = typeof unsubscribe?.mail === "string" ? unsubscribe.mail : undefined;
|
|
2103
|
+
const mail = typeof unsubscribe?.mail === "string" ? parseUnsubscribeMailto(unsubscribe.mail) : undefined;
|
|
2047
2104
|
const url = typeof unsubscribe?.url === "string" ? unsubscribe.url : undefined;
|
|
2048
2105
|
return {
|
|
2049
|
-
mailto: mail
|
|
2106
|
+
mailto: mail?.address,
|
|
2107
|
+
...(mail?.subject ? { mailtoSubject: mail.subject } : {}),
|
|
2108
|
+
...(mail?.body ? { mailtoBody: mail.body } : {}),
|
|
2050
2109
|
url,
|
|
2051
2110
|
};
|
|
2052
2111
|
}
|
|
@@ -2179,7 +2238,9 @@ export function formatQuoteDate(value) {
|
|
|
2179
2238
|
// (a broken-image box) and copies its base64 into every reply, and a cid: image refers to a part
|
|
2180
2239
|
// that is not attached here. Replace each with its alt text, or nothing.
|
|
2181
2240
|
export function stripUnshippableImages(html) {
|
|
2182
|
-
|
|
2241
|
+
// A tag is a run of non-quote characters and quoted strings, so a ">" inside alt="a>b" does not end
|
|
2242
|
+
// it and an unclosed "<img" stops at the next "<" instead of scanning to the end of the text.
|
|
2243
|
+
return html.replace(/<img\b(?:[^<>"']|"[^"]*"|'[^']*')*>/gi, (tag) => {
|
|
2183
2244
|
const src = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
2184
2245
|
const value = (src?.[1] ?? src?.[2] ?? src?.[3] ?? "").trim().toLowerCase();
|
|
2185
2246
|
if (!value.startsWith("data:") && !value.startsWith("cid:"))
|
|
@@ -2189,22 +2250,33 @@ export function stripUnshippableImages(html) {
|
|
|
2189
2250
|
return text ? `[${text}]` : "";
|
|
2190
2251
|
});
|
|
2191
2252
|
}
|
|
2192
|
-
|
|
2253
|
+
// References for a reply: the original's own chain followed by the original's Message-ID (RFC 5322),
|
|
2254
|
+
// so mail clients that thread by References keep the whole conversation together.
|
|
2255
|
+
function replyReferences(detail) {
|
|
2256
|
+
const chain = [...(detail.references ?? []), detail.messageId].filter((id) => Boolean(id));
|
|
2257
|
+
const unique = [...new Set(chain)];
|
|
2258
|
+
return unique.length > 0 ? unique : undefined;
|
|
2259
|
+
}
|
|
2260
|
+
// Drops blank lines around the body but keeps the first line's own indentation (code, lists).
|
|
2261
|
+
function trimBodyEdges(body) {
|
|
2262
|
+
return body.replace(/^(?:[ \t]*\r?\n)+/, "").trimEnd();
|
|
2263
|
+
}
|
|
2264
|
+
export function buildReplyText(detail, body) {
|
|
2193
2265
|
const originalText = detail.text || detail.preview || "";
|
|
2194
2266
|
const fromText = formatAddressList(detail.from);
|
|
2195
2267
|
const dateText = formatQuoteDate(detail.date || detail.internalDate || "an unknown date");
|
|
2196
2268
|
return [
|
|
2197
|
-
body
|
|
2269
|
+
trimBodyEdges(body),
|
|
2198
2270
|
"",
|
|
2199
2271
|
`On ${dateText}, ${fromText || "the sender"} wrote:`,
|
|
2200
2272
|
quotePlainText(originalText),
|
|
2201
2273
|
].join("\n");
|
|
2202
2274
|
}
|
|
2203
|
-
function buildForwardText(detail, body) {
|
|
2275
|
+
export function buildForwardText(detail, body) {
|
|
2204
2276
|
const originalText = detail.text || detail.preview || "";
|
|
2205
2277
|
return [
|
|
2206
|
-
body?.trim()
|
|
2207
|
-
|
|
2278
|
+
body?.trim() ? trimBodyEdges(body) : "",
|
|
2279
|
+
"",
|
|
2208
2280
|
"---------- Forwarded message ---------",
|
|
2209
2281
|
`From: ${formatAddressList(detail.from)}`,
|
|
2210
2282
|
`Date: ${detail.date || detail.internalDate || ""}`,
|
|
@@ -2285,7 +2357,8 @@ export function getReplyRecipients(detail, ownerEmail, replyAll, otherSelfAddres
|
|
|
2285
2357
|
if (!replyAll) {
|
|
2286
2358
|
return { to, cc: [] };
|
|
2287
2359
|
}
|
|
2288
|
-
|
|
2360
|
+
// Reply-To redirected the primary recipient; reply-all must still reach the original sender.
|
|
2361
|
+
const ccPool = notSelf([...addressValues(detail.from), ...addressValues(detail.to), ...addressValues(detail.cc)]).filter((address) => !to.some((recipient) => lowerCaseAddress(recipient) === lowerCaseAddress(address)));
|
|
2289
2362
|
return { to, cc: ccPool };
|
|
2290
2363
|
}
|
|
2291
2364
|
function buildEmailResourceUri(emailId) {
|
|
@@ -2636,17 +2709,35 @@ function parseResourceUri(uri) {
|
|
|
2636
2709
|
// created here are owner-only (0700) and the file is 0600, existing files included.
|
|
2637
2710
|
export async function writeAttachmentToDownloadDir(downloadDir, saveTo, data) {
|
|
2638
2711
|
const { resolve: pathResolve, join: pathJoin, dirname, basename, sep } = await import("node:path");
|
|
2639
|
-
const { realpathSync } = await import("node:fs");
|
|
2712
|
+
const { realpathSync, existsSync } = await import("node:fs");
|
|
2640
2713
|
const { writeFile: wf, mkdir: mkd, chmod: chm } = await import("node:fs/promises");
|
|
2641
2714
|
const absDir = pathResolve(downloadDir);
|
|
2642
2715
|
const absTarget = pathJoin(absDir, saveTo);
|
|
2643
2716
|
// A hardcoded "/" never matched on win32 (path.resolve/join produce
|
|
2644
2717
|
// backslash-separated paths there), hence `sep`.
|
|
2645
|
-
if (!
|
|
2718
|
+
if (!isPathInside(absDir, absTarget, sep)) {
|
|
2646
2719
|
throw new McpError(ErrorCode.InvalidParams, "saveTo path escapes the allowed directory.");
|
|
2647
2720
|
}
|
|
2648
|
-
|
|
2721
|
+
// The allowed directory itself may be created. Then check, BEFORE creating anything below it, that the
|
|
2722
|
+
// deepest directory that already exists on the way to the target really is inside it: otherwise a
|
|
2723
|
+
// symlink in the allowed dir could make mkdir create new directories outside it before the final
|
|
2724
|
+
// containment check below refuses the save.
|
|
2725
|
+
await mkd(absDir, { recursive: true, mode: 0o700 });
|
|
2649
2726
|
const realDir = realpathSync(absDir);
|
|
2727
|
+
let probe = pathResolve(absTarget, "..");
|
|
2728
|
+
while (!existsSync(probe))
|
|
2729
|
+
probe = dirname(probe);
|
|
2730
|
+
let realProbe;
|
|
2731
|
+
try {
|
|
2732
|
+
realProbe = realpathSync(probe);
|
|
2733
|
+
}
|
|
2734
|
+
catch {
|
|
2735
|
+
throw new McpError(ErrorCode.InvalidParams, "saveTo path escapes the allowed directory.");
|
|
2736
|
+
}
|
|
2737
|
+
if (!isPathInside(realDir, realProbe, sep)) {
|
|
2738
|
+
throw new McpError(ErrorCode.InvalidParams, "saveTo path escapes the allowed directory.");
|
|
2739
|
+
}
|
|
2740
|
+
await mkd(pathResolve(absTarget, ".."), { recursive: true, mode: 0o700 });
|
|
2650
2741
|
let realTarget;
|
|
2651
2742
|
try {
|
|
2652
2743
|
realTarget = realpathSync(absTarget);
|
|
@@ -2659,7 +2750,7 @@ export async function writeAttachmentToDownloadDir(downloadDir, saveTo, data) {
|
|
|
2659
2750
|
throw error;
|
|
2660
2751
|
}
|
|
2661
2752
|
}
|
|
2662
|
-
if (!
|
|
2753
|
+
if (!isPathInside(realDir, realTarget, sep)) {
|
|
2663
2754
|
throw new McpError(ErrorCode.InvalidParams, "saveTo path escapes the allowed directory.");
|
|
2664
2755
|
}
|
|
2665
2756
|
await wf(absTarget, data, { mode: 0o600 });
|
|
@@ -3105,12 +3196,14 @@ async function applyBatchEmailAction(imapService, entries, input) {
|
|
|
3105
3196
|
}
|
|
3106
3197
|
}
|
|
3107
3198
|
const succeeded = entries.filter((entry) => entry.ok).length;
|
|
3199
|
+
const notAttempted = input.emailIds.slice(entries.length);
|
|
3108
3200
|
return {
|
|
3109
3201
|
action: input.action,
|
|
3110
3202
|
total: input.emailIds.length,
|
|
3111
3203
|
succeeded,
|
|
3112
3204
|
failed: entries.length - succeeded,
|
|
3113
3205
|
results: entries,
|
|
3206
|
+
...(notAttempted.length > 0 ? { notAttempted } : {}),
|
|
3114
3207
|
};
|
|
3115
3208
|
}
|
|
3116
3209
|
async function previewEmailAction(imapService, emailId, action, targetFolder) {
|
|
@@ -3153,7 +3246,7 @@ async function verifySentCopy(imapService, messageId) {
|
|
|
3153
3246
|
return imapService.sentCopyVerify(messageId, "Sent", 8_000);
|
|
3154
3247
|
}
|
|
3155
3248
|
export function getBulkMaxBatchSize(args) {
|
|
3156
|
-
return
|
|
3249
|
+
return normalizeLimit(args.maxBatchSize, 500, 1, 2000);
|
|
3157
3250
|
}
|
|
3158
3251
|
export function ensureBulkBatchSize(uidsLength, max) {
|
|
3159
3252
|
if (uidsLength > max) {
|
|
@@ -3293,7 +3386,7 @@ function pickReplyTargetFromThread(thread, ownerEmail, preferLatestInbound) {
|
|
|
3293
3386
|
if (preferLatestInbound) {
|
|
3294
3387
|
const inbound = [...messages]
|
|
3295
3388
|
.reverse()
|
|
3296
|
-
.find((message) => !message
|
|
3389
|
+
.find((message) => !isOutgoingMessage(message, ownerEmail));
|
|
3297
3390
|
if (inbound) {
|
|
3298
3391
|
return inbound;
|
|
3299
3392
|
}
|
|
@@ -3305,12 +3398,12 @@ function buildThreadBrief(thread, ownerEmail) {
|
|
|
3305
3398
|
const latestMessage = messages[messages.length - 1];
|
|
3306
3399
|
const latestInbound = [...messages]
|
|
3307
3400
|
.reverse()
|
|
3308
|
-
.find((message) => !message
|
|
3401
|
+
.find((message) => !isOutgoingMessage(message, ownerEmail));
|
|
3309
3402
|
const latestOutbound = [...messages]
|
|
3310
3403
|
.reverse()
|
|
3311
|
-
.find((message) => message
|
|
3404
|
+
.find((message) => isOutgoingMessage(message, ownerEmail));
|
|
3312
3405
|
const pendingOn = latestMessage
|
|
3313
|
-
? latestMessage
|
|
3406
|
+
? isOutgoingMessage(latestMessage, ownerEmail)
|
|
3314
3407
|
? "them"
|
|
3315
3408
|
: "you"
|
|
3316
3409
|
: "unknown";
|
|
@@ -4041,6 +4134,8 @@ export function createServer(config, options = {}) {
|
|
|
4041
4134
|
emailId: prefixedIdFor(bundle, detail.id),
|
|
4042
4135
|
hasUnsubscribeHeader: Boolean(info.mailto || info.url),
|
|
4043
4136
|
mailto: info.mailto,
|
|
4137
|
+
...(info.mailtoSubject ? { mailtoSubject: info.mailtoSubject } : {}),
|
|
4138
|
+
...(info.mailtoBody ? { mailtoBody: info.mailtoBody } : {}),
|
|
4044
4139
|
url: info.url,
|
|
4045
4140
|
note: info.url
|
|
4046
4141
|
? "This server never auto-fetches unsubscribe URLs — open the url yourself, or use unsubscribe_sender if mailto is also set."
|
|
@@ -4065,8 +4160,8 @@ export function createServer(config, options = {}) {
|
|
|
4065
4160
|
ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [info.mailto]);
|
|
4066
4161
|
const result = await withAudit(auditService, name, args, () => bundle.smtpService.sendEmail({
|
|
4067
4162
|
to: [info.mailto],
|
|
4068
|
-
subject: "unsubscribe",
|
|
4069
|
-
body: "unsubscribe",
|
|
4163
|
+
subject: info.mailtoSubject ?? "unsubscribe",
|
|
4164
|
+
body: info.mailtoBody ?? "unsubscribe",
|
|
4070
4165
|
isHtml: false,
|
|
4071
4166
|
}));
|
|
4072
4167
|
return createTextResult({
|
|
@@ -4140,7 +4235,11 @@ export function createServer(config, options = {}) {
|
|
|
4140
4235
|
// Signature goes right after the user's own reply text, before the
|
|
4141
4236
|
// quoted original — not at the very end, after the quote.
|
|
4142
4237
|
const signedReply = applySignature(body, htmlBody, normalizeBoolean(args.appendSignature, true), isHtml);
|
|
4143
|
-
|
|
4238
|
+
// With isHtml the body is HTML source, so the quoted original (untrusted text) must be HTML-escaped
|
|
4239
|
+
// by the HTML builder, not appended as raw plain text.
|
|
4240
|
+
const replyBody = includeQuoteReply
|
|
4241
|
+
? (isHtml ? buildReplyHtml(detail, signedReply.body) : buildReplyText(detail, signedReply.body))
|
|
4242
|
+
: signedReply.body;
|
|
4144
4243
|
// A separate htmlBody (the markdown-rendered case) needs the SAME quoted
|
|
4145
4244
|
// original merged in — see buildReplyHtml's comment for why this was
|
|
4146
4245
|
// previously missing entirely from the html part.
|
|
@@ -4165,7 +4264,7 @@ export function createServer(config, options = {}) {
|
|
|
4165
4264
|
from: fromReply,
|
|
4166
4265
|
sanitizeHtml: sanitizeHtmlReply,
|
|
4167
4266
|
inReplyTo: detail.messageId,
|
|
4168
|
-
references: detail
|
|
4267
|
+
references: replyReferences(detail),
|
|
4169
4268
|
attachments,
|
|
4170
4269
|
// Already applied above, before quote-wrapping.
|
|
4171
4270
|
appendSignature: false,
|
|
@@ -4237,7 +4336,9 @@ export function createServer(config, options = {}) {
|
|
|
4237
4336
|
// Signature goes right after the user's own reply text, before the
|
|
4238
4337
|
// quoted original — not at the very end, after the quote.
|
|
4239
4338
|
const signedReplyRa = applySignature(bodyRa, htmlBodyRa, normalizeBoolean(args.appendSignature, true), isHtmlRa);
|
|
4240
|
-
const replyBodyRa = includeQuoteRa
|
|
4339
|
+
const replyBodyRa = includeQuoteRa
|
|
4340
|
+
? (isHtmlRa ? buildReplyHtml(detailRa, signedReplyRa.body) : buildReplyText(detailRa, signedReplyRa.body))
|
|
4341
|
+
: signedReplyRa.body;
|
|
4241
4342
|
// Same fix as reply_to_email — see buildReplyHtml's comment.
|
|
4242
4343
|
const replyHtmlBodyRa = includeQuoteRa && signedReplyRa.htmlBody !== undefined
|
|
4243
4344
|
? buildReplyHtml(detailRa, signedReplyRa.htmlBody)
|
|
@@ -4360,7 +4461,7 @@ export function createServer(config, options = {}) {
|
|
|
4360
4461
|
cc,
|
|
4361
4462
|
bcc,
|
|
4362
4463
|
subject: prefixedSubject(detail.subject, "Fwd:"),
|
|
4363
|
-
body: buildForwardText(detail, signedFwd.body),
|
|
4464
|
+
body: isHtml ? buildForwardHtml(detail, signedFwd.body) : buildForwardText(detail, signedFwd.body),
|
|
4364
4465
|
isHtml,
|
|
4365
4466
|
htmlBody: forwardHtmlBody,
|
|
4366
4467
|
fromName: fromNameFwd,
|
|
@@ -4523,7 +4624,7 @@ export function createServer(config, options = {}) {
|
|
|
4523
4624
|
// sender instead of the alias the caller asked for.
|
|
4524
4625
|
from: optionalString(args, "from"),
|
|
4525
4626
|
inReplyTo: detail.messageId,
|
|
4526
|
-
references: detail
|
|
4627
|
+
references: replyReferences(detail),
|
|
4527
4628
|
attachments,
|
|
4528
4629
|
sourceEmailId: detail.id,
|
|
4529
4630
|
sourceMessageId: detail.messageId,
|
|
@@ -4749,7 +4850,7 @@ export function createServer(config, options = {}) {
|
|
|
4749
4850
|
subject: optionalString(args, "subject"),
|
|
4750
4851
|
body,
|
|
4751
4852
|
bodyEdits,
|
|
4752
|
-
isHtml:
|
|
4853
|
+
isHtml: optionalBoolean(args.isHtml),
|
|
4753
4854
|
priority: priority === "high" || priority === "low" || priority === "normal"
|
|
4754
4855
|
? priority
|
|
4755
4856
|
: undefined,
|
|
@@ -5149,7 +5250,7 @@ export function createServer(config, options = {}) {
|
|
|
5149
5250
|
const getEmailsInput = {
|
|
5150
5251
|
folder: optionalString(args, "folder"),
|
|
5151
5252
|
limit: effectiveLimit,
|
|
5152
|
-
offset:
|
|
5253
|
+
offset: optionalInteger(args.offset, 0, 1_000_000),
|
|
5153
5254
|
beforeUid: typeof args?.beforeUid === "number" ? args.beforeUid : undefined,
|
|
5154
5255
|
sortByUid: (args?.sortByUid === "asc" || args?.sortByUid === "desc" ? args.sortByUid : undefined),
|
|
5155
5256
|
includeSnippet: normalizeBoolean(args.includeSnippet, false),
|
|
@@ -5282,14 +5383,14 @@ export function createServer(config, options = {}) {
|
|
|
5282
5383
|
cc: optionalString(args, "cc"),
|
|
5283
5384
|
bcc: optionalString(args, "bcc"),
|
|
5284
5385
|
subject: optionalString(args, "subject"),
|
|
5285
|
-
hasAttachment:
|
|
5386
|
+
hasAttachment: optionalBoolean(args.hasAttachment),
|
|
5286
5387
|
attachmentName: optionalString(args, "attachmentName"),
|
|
5287
|
-
isRead:
|
|
5288
|
-
isStarred:
|
|
5388
|
+
isRead: optionalBoolean(args.isRead),
|
|
5389
|
+
isStarred: optionalBoolean(args.isStarred),
|
|
5289
5390
|
dateFrom: optionalString(args, "dateFrom"),
|
|
5290
5391
|
dateTo: optionalString(args, "dateTo"),
|
|
5291
|
-
sizeLarger:
|
|
5292
|
-
sizeSmaller:
|
|
5392
|
+
sizeLarger: optionalNumber(args.sizeLarger, 0, Number.MAX_SAFE_INTEGER),
|
|
5393
|
+
sizeSmaller: optionalNumber(args.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
|
|
5293
5394
|
listId: optionalString(args, "listId"),
|
|
5294
5395
|
limit: effectiveLimit,
|
|
5295
5396
|
includeSnippet: normalizeBoolean(args.includeSnippet, false),
|
|
@@ -5404,16 +5505,16 @@ export function createServer(config, options = {}) {
|
|
|
5404
5505
|
from: optionalString(args, "from"),
|
|
5405
5506
|
to: optionalString(args, "to"),
|
|
5406
5507
|
subject: optionalString(args, "subject"),
|
|
5407
|
-
hasAttachment:
|
|
5508
|
+
hasAttachment: optionalBoolean(args.hasAttachment),
|
|
5408
5509
|
label: optionalString(args, "label"),
|
|
5409
5510
|
threadId: optionalString(args, "threadId"),
|
|
5410
5511
|
senderDomain: optionalString(args, "senderDomain"),
|
|
5411
|
-
isRead:
|
|
5412
|
-
isStarred:
|
|
5512
|
+
isRead: optionalBoolean(args.isRead),
|
|
5513
|
+
isStarred: optionalBoolean(args.isStarred),
|
|
5413
5514
|
dateFrom: optionalString(args, "dateFrom"),
|
|
5414
5515
|
dateTo: optionalString(args, "dateTo"),
|
|
5415
|
-
sizeLarger:
|
|
5416
|
-
sizeSmaller:
|
|
5516
|
+
sizeLarger: optionalNumber(args.sizeLarger, 0, Number.MAX_SAFE_INTEGER),
|
|
5517
|
+
sizeSmaller: optionalNumber(args.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
|
|
5417
5518
|
};
|
|
5418
5519
|
if (accountManager.all().length === 1) {
|
|
5419
5520
|
const result = await imapService.countMessages(countInput);
|
|
@@ -5422,14 +5523,24 @@ export function createServer(config, options = {}) {
|
|
|
5422
5523
|
// Merge strategy: count in every account's mailbox and sum — folder is
|
|
5423
5524
|
// reported from the request (all accounts are counted against the same
|
|
5424
5525
|
// folder name), with a per-account breakdown alongside the total.
|
|
5425
|
-
|
|
5526
|
+
// One account lacking the folder or label (or unreachable) must not fail the whole count, as in
|
|
5527
|
+
// search_emails: report it and return the rest. Every account failing still throws.
|
|
5528
|
+
const settled = await Promise.allSettled(accountManager.all().map(async (bundle) => ({
|
|
5426
5529
|
slug: bundle.account.slug,
|
|
5427
5530
|
...(await bundle.imapService.countMessages(countInput)),
|
|
5428
5531
|
})));
|
|
5532
|
+
const perAccount = settled.flatMap((entry) => (entry.status === "fulfilled" ? [entry.value] : []));
|
|
5533
|
+
if (perAccount.length === 0) {
|
|
5534
|
+
throw settled[0].reason;
|
|
5535
|
+
}
|
|
5536
|
+
const failedAccounts = settled.flatMap((entry, index) => entry.status === "rejected"
|
|
5537
|
+
? [{ account: accountManager.all()[index].account.slug, error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason) }]
|
|
5538
|
+
: []);
|
|
5429
5539
|
return createTextResult({
|
|
5430
5540
|
folder: countInput.folder ?? "INBOX",
|
|
5431
5541
|
count: perAccount.reduce((sum, entry) => sum + entry.count, 0),
|
|
5432
5542
|
...(perAccount.some((entry) => entry.approximate) ? { approximate: true } : {}),
|
|
5543
|
+
...(failedAccounts.length > 0 ? { failedAccounts } : {}),
|
|
5433
5544
|
byAccount: perAccount,
|
|
5434
5545
|
});
|
|
5435
5546
|
}
|
|
@@ -5508,7 +5619,7 @@ export function createServer(config, options = {}) {
|
|
|
5508
5619
|
// check against it — a match-only bulk_move has no ids that could
|
|
5509
5620
|
// be stale, since match resolves directly against the live
|
|
5510
5621
|
// mailbox each time.
|
|
5511
|
-
const currentUidValidity =
|
|
5622
|
+
const currentUidValidity = await group.bundle.imapService.getMailboxUidValidity(folder);
|
|
5512
5623
|
const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
|
|
5513
5624
|
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
5514
5625
|
// the preview and the real run — see resolveUidsForBulkOp's
|
|
@@ -5582,7 +5693,7 @@ export function createServer(config, options = {}) {
|
|
|
5582
5693
|
// excluded and the uids it actually acts on can never disagree
|
|
5583
5694
|
// about which generation was current. Only fetched when there are
|
|
5584
5695
|
// ids to check against it (a match-only call has none).
|
|
5585
|
-
const currentUidValidity =
|
|
5696
|
+
const currentUidValidity = await group.bundle.imapService.getMailboxUidValidity(folder);
|
|
5586
5697
|
const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
|
|
5587
5698
|
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
5588
5699
|
// the preview and the real run — see resolveUidsForBulkOp's
|
|
@@ -5650,7 +5761,7 @@ export function createServer(config, options = {}) {
|
|
|
5650
5761
|
for (const [slug, group] of groups) {
|
|
5651
5762
|
// See the bulk_delete case above for why this is fetched once and
|
|
5652
5763
|
// shared between notFound reporting and uid resolution.
|
|
5653
|
-
const currentUidValidity =
|
|
5764
|
+
const currentUidValidity = await group.bundle.imapService.getMailboxUidValidity(folder);
|
|
5654
5765
|
const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
|
|
5655
5766
|
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
5656
5767
|
// the preview and the real run — see resolveUidsForBulkOp's
|
|
@@ -5716,7 +5827,7 @@ export function createServer(config, options = {}) {
|
|
|
5716
5827
|
for (const [slug, group] of groups) {
|
|
5717
5828
|
// See the bulk_delete case above for why this is fetched once and
|
|
5718
5829
|
// shared between notFound reporting and uid resolution.
|
|
5719
|
-
const currentUidValidity =
|
|
5830
|
+
const currentUidValidity = await group.bundle.imapService.getMailboxUidValidity(folder);
|
|
5720
5831
|
const notFoundEmailIds = getBulkNotFoundEmailIds(group.restIds, folder, currentUidValidity);
|
|
5721
5832
|
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
5722
5833
|
// the preview and the real run — see resolveUidsForBulkOp's
|
|
@@ -5751,13 +5862,13 @@ export function createServer(config, options = {}) {
|
|
|
5751
5862
|
return createTextResult(mergeBulkResults(outputs));
|
|
5752
5863
|
}
|
|
5753
5864
|
case "top_senders": {
|
|
5754
|
-
const topSendersLimit =
|
|
5865
|
+
const topSendersLimit = optionalInteger(args.limit, 1, 200);
|
|
5755
5866
|
const topSendersInput = {
|
|
5756
5867
|
folder: optionalString(args, "folder"),
|
|
5757
5868
|
since: optionalString(args, "since"),
|
|
5758
5869
|
before: optionalString(args, "before"),
|
|
5759
5870
|
limit: topSendersLimit,
|
|
5760
|
-
scanLimit:
|
|
5871
|
+
scanLimit: optionalInteger(args.scanLimit, 1, 20_000),
|
|
5761
5872
|
excludeSelf: normalizeBoolean(args.excludeSelf, true),
|
|
5762
5873
|
};
|
|
5763
5874
|
if (accountManager.all().length === 1) {
|
|
@@ -5767,9 +5878,11 @@ export function createServer(config, options = {}) {
|
|
|
5767
5878
|
// Merge strategy: scan each account's folder independently, merge
|
|
5768
5879
|
// sender frequency by address (summing counts across accounts), then
|
|
5769
5880
|
// re-sort and re-apply the requested top-N limit over the merged set.
|
|
5881
|
+
// Each account is asked for its WHOLE table, not just its own top N: a sender in the middle of every
|
|
5882
|
+
// account's table can still be first in total, and cutting each table first made it disappear.
|
|
5770
5883
|
const perAccount = await Promise.all(accountManager.all().map(async (bundle) => ({
|
|
5771
5884
|
bundle,
|
|
5772
|
-
result: await bundle.imapService.topSenders(topSendersInput),
|
|
5885
|
+
result: await bundle.imapService.topSenders({ ...topSendersInput, limit: 10_000 }),
|
|
5773
5886
|
})));
|
|
5774
5887
|
const senderMap = new Map();
|
|
5775
5888
|
for (const { result } of perAccount) {
|
|
@@ -5808,6 +5921,7 @@ export function createServer(config, options = {}) {
|
|
|
5808
5921
|
destination: requireString(args, "destination"),
|
|
5809
5922
|
acrossFolders: normalizeBoolean(args.acrossFolders, false),
|
|
5810
5923
|
dryRun: normalizeBoolean(args.dryRun, false),
|
|
5924
|
+
maxBatchSize: getBulkMaxBatchSize(args),
|
|
5811
5925
|
}));
|
|
5812
5926
|
return createTextResult(result);
|
|
5813
5927
|
}
|
|
@@ -5826,6 +5940,7 @@ export function createServer(config, options = {}) {
|
|
|
5826
5940
|
permanent: permanentThread,
|
|
5827
5941
|
acrossFolders: normalizeBoolean(args.acrossFolders, false),
|
|
5828
5942
|
dryRun: normalizeBoolean(args.dryRun, false),
|
|
5943
|
+
maxBatchSize: getBulkMaxBatchSize(args),
|
|
5829
5944
|
}));
|
|
5830
5945
|
return createTextResult(result);
|
|
5831
5946
|
}
|
|
@@ -5845,6 +5960,7 @@ export function createServer(config, options = {}) {
|
|
|
5845
5960
|
flagsToRemove,
|
|
5846
5961
|
acrossFolders: normalizeBoolean(args.acrossFolders, false),
|
|
5847
5962
|
dryRun: normalizeBoolean(args.dryRun, false),
|
|
5963
|
+
maxBatchSize: getBulkMaxBatchSize(args),
|
|
5848
5964
|
}));
|
|
5849
5965
|
return createTextResult(result);
|
|
5850
5966
|
}
|
|
@@ -5897,7 +6013,7 @@ export function createServer(config, options = {}) {
|
|
|
5897
6013
|
return createTextResult(merged);
|
|
5898
6014
|
}
|
|
5899
6015
|
case "sync_folders":
|
|
5900
|
-
return createTextResult(await imapService.syncFolders());
|
|
6016
|
+
return createTextResult(await (resolveAccountArg(args) ?? primaryBundle).imapService.syncFolders());
|
|
5901
6017
|
case "create_folder":
|
|
5902
6018
|
ensureMailboxWriteAllowed(config.runtime);
|
|
5903
6019
|
return createTextResult(await withAudit(auditService, name, args, async () => imapService.createFolder(requireString(args, "path"))));
|
|
@@ -6020,13 +6136,18 @@ export function createServer(config, options = {}) {
|
|
|
6020
6136
|
case "snooze_email": {
|
|
6021
6137
|
ensureEmailActionAllowed(config.runtime, "archive");
|
|
6022
6138
|
const wakeAt = requireString(args, "wakeAt");
|
|
6023
|
-
|
|
6139
|
+
// A date-time with no zone ("2026-03-05T09:30:00") is read by JavaScript in the server's local time,
|
|
6140
|
+
// which is not what a caller reading "ISO 8601" expects and changes with the machine. Read it as UTC.
|
|
6141
|
+
const wakeAtTime = new Date(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(wakeAt.trim()) ? `${wakeAt.trim()}Z` : wakeAt).getTime();
|
|
6024
6142
|
if (Number.isNaN(wakeAtTime)) {
|
|
6025
6143
|
throw new McpError(ErrorCode.InvalidParams, `wakeAt is not a valid ISO 8601 timestamp: ${wakeAt}`);
|
|
6026
6144
|
}
|
|
6027
6145
|
if (wakeAtTime <= Date.now()) {
|
|
6028
6146
|
throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
|
|
6029
6147
|
}
|
|
6148
|
+
if (wakeAtTime > Date.now() + 10 * 365 * 24 * 60 * 60 * 1000) {
|
|
6149
|
+
throw new McpError(ErrorCode.InvalidParams, "wakeAt is more than ten years away.");
|
|
6150
|
+
}
|
|
6030
6151
|
// emailId carries the same "<slug>::" prefix as everywhere else — resolve it
|
|
6031
6152
|
// to that account's own snoozeService instead of always the primary's, and
|
|
6032
6153
|
// prefix the returned ids (the snooze's own id, and the email id) the same
|
|
@@ -6102,7 +6223,10 @@ export function createServer(config, options = {}) {
|
|
|
6102
6223
|
return createTextResult(result);
|
|
6103
6224
|
}
|
|
6104
6225
|
case "render_template": {
|
|
6105
|
-
|
|
6226
|
+
if (args.variables !== undefined && args.variables !== null && (typeof args.variables !== "object" || Array.isArray(args.variables))) {
|
|
6227
|
+
throw new McpError(ErrorCode.InvalidParams, "variables must be an object of name: value pairs.");
|
|
6228
|
+
}
|
|
6229
|
+
const variables = (args.variables ?? {});
|
|
6106
6230
|
const result = await withAudit(auditService, name, args, async () => templateService.render(requireString(args, "id"), variables));
|
|
6107
6231
|
return createTextResult(result);
|
|
6108
6232
|
}
|
|
@@ -6165,19 +6289,21 @@ export function createServer(config, options = {}) {
|
|
|
6165
6289
|
return entry ? [entry] : [];
|
|
6166
6290
|
});
|
|
6167
6291
|
const succeeded = orderedResults.filter((entry) => entry.ok).length;
|
|
6292
|
+
const notAttempted = groupResults.flatMap(({ slug, result: groupResult }) => (groupResult.notAttempted ?? []).map((id) => withAccountPrefix(outputSlugFor(slug), id)));
|
|
6168
6293
|
const result = {
|
|
6169
6294
|
action,
|
|
6170
6295
|
total: orderedResults.length,
|
|
6171
6296
|
succeeded,
|
|
6172
6297
|
failed: orderedResults.length - succeeded,
|
|
6173
6298
|
results: orderedResults,
|
|
6299
|
+
...(notAttempted.length > 0 ? { notAttempted } : {}),
|
|
6174
6300
|
};
|
|
6175
6301
|
const sources = result.results.flatMap((entry) => entry.ok ? emailSourceFromActionResult(entry.result) : []);
|
|
6176
6302
|
return createTextResult(result, false, sources);
|
|
6177
6303
|
}
|
|
6178
6304
|
case "get_email_stats": {
|
|
6179
|
-
const statsDays =
|
|
6180
|
-
const statsLimit =
|
|
6305
|
+
const statsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6306
|
+
const statsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6181
6307
|
if (accountManager.all().length === 1) {
|
|
6182
6308
|
const folders = await imapService.getFolders();
|
|
6183
6309
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, statsDays, statsLimit);
|
|
@@ -6217,11 +6343,11 @@ export function createServer(config, options = {}) {
|
|
|
6217
6343
|
});
|
|
6218
6344
|
}
|
|
6219
6345
|
case "get_email_analytics": {
|
|
6220
|
-
const analyticsDays =
|
|
6221
|
-
const analyticsLimit =
|
|
6346
|
+
const analyticsDays = normalizeLimit(args.days, 30, 1, 365);
|
|
6347
|
+
const analyticsLimit = normalizeLimit(args.limit, 2000, 1, 10_000);
|
|
6222
6348
|
if (accountManager.all().length === 1) {
|
|
6223
6349
|
const sample = await getAnalyticsSampleFromIndex(imapService, localIndexService, analyticsDays, analyticsLimit);
|
|
6224
|
-
return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username));
|
|
6350
|
+
return createTextResult(analyticsService.getEmailAnalytics(sample, config.smtp.username, analyticsDays));
|
|
6225
6351
|
}
|
|
6226
6352
|
// Merge strategy: compute each account's own analytics independently
|
|
6227
6353
|
// (self-detection needs each account's own address), then merge the
|
|
@@ -6230,7 +6356,7 @@ export function createServer(config, options = {}) {
|
|
|
6230
6356
|
// results.
|
|
6231
6357
|
const analyticsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
|
|
6232
6358
|
const sample = await getAnalyticsSampleFromIndex(bundle.imapService, bundle.localIndexService, analyticsDays, analyticsLimit);
|
|
6233
|
-
return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username);
|
|
6359
|
+
return analyticsService.getEmailAnalytics(sample, bundle.config.smtp.username, analyticsDays);
|
|
6234
6360
|
}));
|
|
6235
6361
|
const busiestHours = mergeCountedEntries(analyticsPerAccount.map((entry) => entry.busiestHours), (entry) => entry.hour, 5);
|
|
6236
6362
|
const topSenders = mergeCountedEntries(analyticsPerAccount.map((entry) => entry.topSenders), (entry) => entry.address, 10);
|
|
@@ -6283,16 +6409,17 @@ export function createServer(config, options = {}) {
|
|
|
6283
6409
|
}
|
|
6284
6410
|
case "get_volume_trends": {
|
|
6285
6411
|
const days = normalizeLimit(args.days, 30, 1, 365);
|
|
6412
|
+
// Counted in SQL over the whole index (a sample of the newest 3000 messages cut the older days short).
|
|
6286
6413
|
if (accountManager.all().length === 1) {
|
|
6287
|
-
|
|
6288
|
-
return createTextResult(
|
|
6414
|
+
await maybeRefreshLocalIndex(imapService, localIndexService, {});
|
|
6415
|
+
return createTextResult(await localIndexService.getDailyVolume(days));
|
|
6289
6416
|
}
|
|
6290
6417
|
// Merge strategy: compute each account's own daily trend points
|
|
6291
6418
|
// independently, then sum count/unreadCount/starredCount/attachmentCount
|
|
6292
6419
|
// for matching dates across accounts.
|
|
6293
6420
|
const trendsPerAccount = await Promise.all(accountManager.all().map(async (bundle) => {
|
|
6294
|
-
|
|
6295
|
-
return
|
|
6421
|
+
await maybeRefreshLocalIndex(bundle.imapService, bundle.localIndexService, {});
|
|
6422
|
+
return bundle.localIndexService.getDailyVolume(days);
|
|
6296
6423
|
}));
|
|
6297
6424
|
return createTextResult(mergeVolumeTrends(trendsPerAccount));
|
|
6298
6425
|
}
|
|
@@ -6572,23 +6699,30 @@ export function createServer(config, options = {}) {
|
|
|
6572
6699
|
});
|
|
6573
6700
|
}
|
|
6574
6701
|
case "run_background_sync":
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6702
|
+
{
|
|
6703
|
+
const target = resolveAccountArg(args) ?? primaryBundle;
|
|
6704
|
+
return createTextResult({
|
|
6705
|
+
checkedAt: new Date().toISOString(),
|
|
6706
|
+
backgroundSync: await target.backgroundSyncService.runNow(),
|
|
6707
|
+
index: await target.localIndexService.getStatus(),
|
|
6708
|
+
});
|
|
6709
|
+
}
|
|
6580
6710
|
case "wait_for_mailbox_changes":
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
6711
|
+
{
|
|
6712
|
+
const target = resolveAccountArg(args) ?? primaryBundle;
|
|
6713
|
+
return createTextResult(await target.imapService.waitForMailboxChanges({
|
|
6714
|
+
folder: optionalString(args, "folder"),
|
|
6715
|
+
timeoutMs: normalizeLimit(args.timeoutSeconds, 15, 1, 300) * 1000,
|
|
6716
|
+
}));
|
|
6717
|
+
}
|
|
6585
6718
|
case "sync_emails":
|
|
6586
6719
|
{
|
|
6720
|
+
const target = resolveAccountArg(args) ?? primaryBundle;
|
|
6587
6721
|
const folder = optionalString(args, "folder");
|
|
6588
|
-
const full =
|
|
6589
|
-
const limitPerFolder =
|
|
6590
|
-
const includeAttachmentText =
|
|
6591
|
-
// backgroundSyncService.runNow() always runs the *fixed* background-
|
|
6722
|
+
const full = optionalBoolean(args.full);
|
|
6723
|
+
const limitPerFolder = optionalInteger(args.limitPerFolder, 1, 50_000);
|
|
6724
|
+
const includeAttachmentText = optionalBoolean(args.includeAttachmentText);
|
|
6725
|
+
// target.backgroundSyncService.runNow() always runs the *fixed* background-
|
|
6592
6726
|
// sync config (autoSyncFolder/autoSyncFull/autoSyncLimitPerFolder —
|
|
6593
6727
|
// typically just "INBOX,Sent", incremental, 100/folder) and ignores
|
|
6594
6728
|
// any argument entirely. This tool's own schema advertises folder/
|
|
@@ -6609,15 +6743,15 @@ export function createServer(config, options = {}) {
|
|
|
6609
6743
|
// 60s MCP client timeouts; a single folder in flight still runs to completion.
|
|
6610
6744
|
const budgetMs = normalizeLimit(args.timeBudgetSeconds, 45, 1, 300) * 1000;
|
|
6611
6745
|
const startedAtMs = Date.now();
|
|
6612
|
-
const snapshot = await imapService.collectEmailsForIndex({
|
|
6746
|
+
const snapshot = await target.imapService.collectEmailsForIndex({
|
|
6613
6747
|
folder,
|
|
6614
6748
|
full,
|
|
6615
6749
|
limitPerFolder,
|
|
6616
6750
|
includeAttachmentText,
|
|
6617
|
-
checkpoints: await localIndexService.getSyncCheckpointMap(),
|
|
6751
|
+
checkpoints: await target.localIndexService.getSyncCheckpointMap(),
|
|
6618
6752
|
deadlineAt: startedAtMs + budgetMs,
|
|
6619
6753
|
onFolderCollected: async (batch) => {
|
|
6620
|
-
await localIndexService.recordSnapshot({
|
|
6754
|
+
await target.localIndexService.recordSnapshot({
|
|
6621
6755
|
folders: [],
|
|
6622
6756
|
emails: batch.emails,
|
|
6623
6757
|
syncedAt: batch.checkpoint.lastSyncAt ?? new Date().toISOString(),
|
|
@@ -6627,7 +6761,7 @@ export function createServer(config, options = {}) {
|
|
|
6627
6761
|
});
|
|
6628
6762
|
// Final commit carries the complete folder list (folder counts, pruning of folders
|
|
6629
6763
|
// deleted server-side) — no messages, those were committed per folder above.
|
|
6630
|
-
const indexStatus = await localIndexService.recordSnapshot({
|
|
6764
|
+
const indexStatus = await target.localIndexService.recordSnapshot({
|
|
6631
6765
|
folders: snapshot.folders,
|
|
6632
6766
|
folderListComplete: true,
|
|
6633
6767
|
emails: [],
|
|
@@ -6669,8 +6803,8 @@ export function createServer(config, options = {}) {
|
|
|
6669
6803
|
},
|
|
6670
6804
|
});
|
|
6671
6805
|
}
|
|
6672
|
-
const syncStatus = await backgroundSyncService.runNow("sync_emails");
|
|
6673
|
-
const indexStatus = await localIndexService.getStatus();
|
|
6806
|
+
const syncStatus = await target.backgroundSyncService.runNow("sync_emails");
|
|
6807
|
+
const indexStatus = await target.localIndexService.getStatus();
|
|
6674
6808
|
return createTextResult({
|
|
6675
6809
|
checkedAt: new Date().toISOString(),
|
|
6676
6810
|
backgroundSync: syncStatus,
|
|
@@ -6711,14 +6845,14 @@ export function createServer(config, options = {}) {
|
|
|
6711
6845
|
to: optionalString(args, "to"),
|
|
6712
6846
|
senderDomain: optionalString(args, "senderDomain"),
|
|
6713
6847
|
subject: optionalString(args, "subject"),
|
|
6714
|
-
hasAttachment:
|
|
6848
|
+
hasAttachment: optionalBoolean(args.hasAttachment),
|
|
6715
6849
|
attachmentName: optionalString(args, "attachmentName"),
|
|
6716
|
-
isRead:
|
|
6717
|
-
isStarred:
|
|
6850
|
+
isRead: optionalBoolean(args.isRead),
|
|
6851
|
+
isStarred: optionalBoolean(args.isStarred),
|
|
6718
6852
|
mailboxRole: optionalString(args, "mailboxRole"),
|
|
6719
6853
|
dateFrom: optionalString(args, "dateFrom"),
|
|
6720
6854
|
dateTo: optionalString(args, "dateTo"),
|
|
6721
|
-
limit:
|
|
6855
|
+
limit: optionalInteger(args.limit, 1, 1000),
|
|
6722
6856
|
};
|
|
6723
6857
|
const indexedAccount = resolveAccountArg(args);
|
|
6724
6858
|
// Serve fresh data: a cheap UIDNEXT/message-count probe, refreshing only when the
|
|
@@ -6778,8 +6912,8 @@ export function createServer(config, options = {}) {
|
|
|
6778
6912
|
const threadsInput = {
|
|
6779
6913
|
query: optionalString(args, "query"),
|
|
6780
6914
|
label: optionalString(args, "label"),
|
|
6781
|
-
limit:
|
|
6782
|
-
offset:
|
|
6915
|
+
limit: optionalInteger(args.limit, 1, 1000),
|
|
6916
|
+
offset: optionalInteger(args.offset, 0, 1_000_000),
|
|
6783
6917
|
};
|
|
6784
6918
|
if (accountManager.all().length === 1) {
|
|
6785
6919
|
await maybeRefreshLocalIndex(imapService, localIndexService, {
|
|
@@ -6828,8 +6962,8 @@ export function createServer(config, options = {}) {
|
|
|
6828
6962
|
}
|
|
6829
6963
|
case "get_actionable_threads":
|
|
6830
6964
|
{
|
|
6831
|
-
const limit =
|
|
6832
|
-
const offset =
|
|
6965
|
+
const limit = normalizeLimit(args.limit, 50, 1, 1000);
|
|
6966
|
+
const offset = normalizeLimit(args.offset, 0, 0, 1_000_000);
|
|
6833
6967
|
// Fan-out merge: ask every account for its own top-`limit` actionable threads
|
|
6834
6968
|
// (each account's own getActionableThreads already sorts by score, then
|
|
6835
6969
|
// recency — see local-index-service.ts), tag every thread/message id with its
|
|
@@ -6881,8 +7015,8 @@ export function createServer(config, options = {}) {
|
|
|
6881
7015
|
}
|
|
6882
7016
|
case "get_inbox_digest":
|
|
6883
7017
|
{
|
|
6884
|
-
const limit =
|
|
6885
|
-
const offset =
|
|
7018
|
+
const limit = normalizeLimit(args.limit, 10, 1, 1000);
|
|
7019
|
+
const offset = normalizeLimit(args.offset, 0, 0, 1_000_000);
|
|
6886
7020
|
// Each account is asked for offset+limit+1 rows per section: enough to slice the requested
|
|
6887
7021
|
// page from the merged list and to know whether another page exists.
|
|
6888
7022
|
const perAccountWindow = offset + limit + 1;
|
|
@@ -6903,11 +7037,12 @@ export function createServer(config, options = {}) {
|
|
|
6903
7037
|
});
|
|
6904
7038
|
const result = await bundle.localIndexService.getInboxDigest({
|
|
6905
7039
|
limit: perAccountWindow,
|
|
6906
|
-
minAgeHours:
|
|
7040
|
+
minAgeHours: optionalNumber(args.minAgeHours, 0, 87_600),
|
|
6907
7041
|
});
|
|
6908
7042
|
const slug = slugForBundle(bundle);
|
|
6909
7043
|
return {
|
|
6910
7044
|
counts: (result.counts ?? {}),
|
|
7045
|
+
countsCapped: result.countsCapped === true,
|
|
6911
7046
|
indexUpdatedAt: result.indexUpdatedAt,
|
|
6912
7047
|
topThreads: tagAccountIds(slug, (result.topThreads ?? [])),
|
|
6913
7048
|
staleAwaitingYou: tagAccountIds(slug, (result.staleAwaitingYou ?? [])),
|
|
@@ -6933,6 +7068,9 @@ export function createServer(config, options = {}) {
|
|
|
6933
7068
|
generatedAt: new Date().toISOString(),
|
|
6934
7069
|
indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
|
|
6935
7070
|
counts,
|
|
7071
|
+
...(perAccount.some((entry) => entry.countsCapped)
|
|
7072
|
+
? { countsCapped: true, countsNote: "counts and topThreads cover only the newest 5000 indexed messages per account." }
|
|
7073
|
+
: {}),
|
|
6936
7074
|
topThreads,
|
|
6937
7075
|
staleAwaitingYou,
|
|
6938
7076
|
// Each section pages independently with the same offset/limit: pass nextOffset back as
|
|
@@ -6947,8 +7085,8 @@ export function createServer(config, options = {}) {
|
|
|
6947
7085
|
}
|
|
6948
7086
|
case "get_follow_up_candidates":
|
|
6949
7087
|
{
|
|
6950
|
-
const limit =
|
|
6951
|
-
const offset =
|
|
7088
|
+
const limit = normalizeLimit(args.limit, 25, 1, 1000);
|
|
7089
|
+
const offset = normalizeLimit(args.offset, 0, 0, 1_000_000);
|
|
6952
7090
|
// Fan-out merge: ask every account for its own top-`limit` follow-up
|
|
6953
7091
|
// candidates (each account's own getFollowUpCandidates sorts by ageHours, then
|
|
6954
7092
|
// score), tag ids with the account's slug, concatenate, re-sort by that same
|
|
@@ -6962,7 +7100,7 @@ export function createServer(config, options = {}) {
|
|
|
6962
7100
|
});
|
|
6963
7101
|
const result = await bundle.localIndexService.getFollowUpCandidates({
|
|
6964
7102
|
limit: limit + offset,
|
|
6965
|
-
minAgeHours:
|
|
7103
|
+
minAgeHours: optionalNumber(args.minAgeHours, 0, 87_600),
|
|
6966
7104
|
pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any"
|
|
6967
7105
|
? args.pendingOn
|
|
6968
7106
|
: undefined,
|
|
@@ -6987,7 +7125,7 @@ export function createServer(config, options = {}) {
|
|
|
6987
7125
|
const result = {
|
|
6988
7126
|
generatedAt: new Date().toISOString(),
|
|
6989
7127
|
indexUpdatedAt: perAccount.map((entry) => entry.indexUpdatedAt).filter(Boolean).sort().reverse()[0],
|
|
6990
|
-
minAgeHours:
|
|
7128
|
+
minAgeHours: (optionalNumber(args.minAgeHours, 0, 87_600) ?? 24),
|
|
6991
7129
|
pendingOn: args.pendingOn === "you" || args.pendingOn === "them" || args.pendingOn === "any" ? args.pendingOn : "you",
|
|
6992
7130
|
total: totalCount,
|
|
6993
7131
|
...paginationFields(totalCount, offset, followUpBudget.threads.length, followUpBudget.trimmed),
|
|
@@ -6999,7 +7137,7 @@ export function createServer(config, options = {}) {
|
|
|
6999
7137
|
}
|
|
7000
7138
|
case "find_document_threads":
|
|
7001
7139
|
{
|
|
7002
|
-
const limit =
|
|
7140
|
+
const limit = normalizeLimit(args.limit, 25, 1, 1000);
|
|
7003
7141
|
// Fan-out merge: ask every account for its own top-`limit` document threads
|
|
7004
7142
|
// (each account's own findDocumentThreads sorts by document count, then
|
|
7005
7143
|
// recency), tag ids (including each document's own emailId) with the
|
|
@@ -7051,7 +7189,7 @@ export function createServer(config, options = {}) {
|
|
|
7051
7189
|
}
|
|
7052
7190
|
case "prepare_meeting_context":
|
|
7053
7191
|
{
|
|
7054
|
-
const limit =
|
|
7192
|
+
const limit = normalizeLimit(args.limit, 10, 1, 1000);
|
|
7055
7193
|
// Fan-out merge: ask every account for its own top-`limit` meeting-prep
|
|
7056
7194
|
// threads (each account's own getMeetingPrep sorts by recency — see
|
|
7057
7195
|
// local-index-service.ts), tag ids with the account's slug, concatenate,
|
|
@@ -7226,7 +7364,7 @@ export function createServer(config, options = {}) {
|
|
|
7226
7364
|
// Same fix as create_reply_draft — see its comment.
|
|
7227
7365
|
from: optionalString(args, "from"),
|
|
7228
7366
|
inReplyTo: detail.messageId,
|
|
7229
|
-
references: detail
|
|
7367
|
+
references: replyReferences(detail),
|
|
7230
7368
|
attachments,
|
|
7231
7369
|
sourceEmailId: detail.id,
|
|
7232
7370
|
sourceMessageId: detail.messageId,
|
|
@@ -7348,25 +7486,28 @@ export function createServer(config, options = {}) {
|
|
|
7348
7486
|
{
|
|
7349
7487
|
const rawEmailId = requireString(args, "emailId");
|
|
7350
7488
|
const { bundle, rest: emailId } = resolveAccountForEmailId(rawEmailId);
|
|
7489
|
+
const saveTo = optionalString(args, "saveTo");
|
|
7490
|
+
if (saveTo) {
|
|
7491
|
+
// A save writes to disk: it needs the download directory, and the inline-size limit (which bounds
|
|
7492
|
+
// what is returned in the reply) does not apply. Checked before the attachment is fetched.
|
|
7493
|
+
const downloadDir = config.runtime.allowFileDownloadDir;
|
|
7494
|
+
if (!downloadDir) {
|
|
7495
|
+
throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR env var is not set.");
|
|
7496
|
+
}
|
|
7497
|
+
const saving = await bundle.imapService.getAttachmentContent(emailId, requireString(args, "attachmentId"), { forSave: true });
|
|
7498
|
+
const buf = Buffer.from(saving.base64 ?? "", "base64");
|
|
7499
|
+
const savedPath = await writeAttachmentToDownloadDir(downloadDir, saveTo, buf);
|
|
7500
|
+
return createTextResult({ saved: true, path: savedPath, bytes: buf.length, filename: saving.attachment?.filename });
|
|
7501
|
+
}
|
|
7351
7502
|
const result = await bundle.imapService.getAttachmentContent(emailId, requireString(args, "attachmentId"), normalizeBoolean(args.includeBase64, false));
|
|
7352
7503
|
result.emailId = prefixedIdFor(bundle, result.emailId);
|
|
7353
|
-
|
|
7354
|
-
if (!saveTo && result.base64) {
|
|
7504
|
+
if (result.base64) {
|
|
7355
7505
|
const MAX_INLINE_BYTES = (config.runtime.maxInlineBytes ?? 40) * 1024;
|
|
7356
7506
|
const decodedSize = Math.floor(result.base64.length * 0.75);
|
|
7357
7507
|
if (decodedSize > MAX_INLINE_BYTES) {
|
|
7358
7508
|
throw new McpError(ErrorCode.InvalidParams, `Attachment is ~${Math.round(decodedSize / 1024)}KB decoded. Inline limit is ${config.runtime.maxInlineBytes ?? 40}KB. Set PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR and pass saveTo to write to disk instead, or increase the limit with PROTONMAIL_MAX_INLINE_BYTES.`);
|
|
7359
7509
|
}
|
|
7360
7510
|
}
|
|
7361
|
-
if (saveTo && result.base64) {
|
|
7362
|
-
const downloadDir = config.runtime.allowFileDownloadDir;
|
|
7363
|
-
if (!downloadDir) {
|
|
7364
|
-
throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR env var is not set.");
|
|
7365
|
-
}
|
|
7366
|
-
const buf = Buffer.from(result.base64, "base64");
|
|
7367
|
-
const savedPath = await writeAttachmentToDownloadDir(downloadDir, saveTo, buf);
|
|
7368
|
-
return createTextResult({ saved: true, path: savedPath, bytes: buf.length, filename: result.attachment?.filename });
|
|
7369
|
-
}
|
|
7370
7511
|
return createTextResult(result, false, [attachmentSource(result.emailId, result.attachment)]);
|
|
7371
7512
|
}
|
|
7372
7513
|
case "get_attachment_text": {
|
|
@@ -7391,7 +7532,7 @@ export function createServer(config, options = {}) {
|
|
|
7391
7532
|
const absTarget = pathResolve(join(absDir, saveTo));
|
|
7392
7533
|
// See the identical fix/comment in get_attachment_content above —
|
|
7393
7534
|
// hardcoded "/" never matched a real subdirectory path on win32.
|
|
7394
|
-
if (!
|
|
7535
|
+
if (!isPathInside(absDir, absTarget, pathSep)) {
|
|
7395
7536
|
throw new McpError(ErrorCode.InvalidParams, "saveTo path escapes the allowed directory.");
|
|
7396
7537
|
}
|
|
7397
7538
|
resolvedPath = absTarget;
|
|
@@ -7422,6 +7563,10 @@ export function createServer(config, options = {}) {
|
|
|
7422
7563
|
// those bytes as UTF-8 either mangles them or throws outright.
|
|
7423
7564
|
// rawBase64 preserves the message byte-for-byte regardless of
|
|
7424
7565
|
// its original encoding.
|
|
7566
|
+
// Bounded before the bytes are decoded: base64 is about a third larger than what it encodes.
|
|
7567
|
+
if ((rawBase64 ?? rawText ?? "").length > MAX_IMPORT_MESSAGE_BYTES * (rawBase64 ? 4 / 3 : 1)) {
|
|
7568
|
+
throw new McpError(ErrorCode.InvalidParams, `The message is larger than the ${Math.round(MAX_IMPORT_MESSAGE_BYTES / 1024 / 1024)} MB import limit.`);
|
|
7569
|
+
}
|
|
7425
7570
|
const raw = rawBase64
|
|
7426
7571
|
? Buffer.from(rawBase64, "base64")
|
|
7427
7572
|
: Buffer.from(rawText, "utf8");
|
|
@@ -7494,6 +7639,9 @@ export function createServer(config, options = {}) {
|
|
|
7494
7639
|
if (error instanceof McpError) {
|
|
7495
7640
|
throw error;
|
|
7496
7641
|
}
|
|
7642
|
+
if (error instanceof InvalidArgumentError) {
|
|
7643
|
+
throw new McpError(ErrorCode.InvalidParams, error.message);
|
|
7644
|
+
}
|
|
7497
7645
|
logger.error("Tool call failed", "MCPServer", { name, error });
|
|
7498
7646
|
if (isLikelyAuthenticationError(error)) {
|
|
7499
7647
|
throw new McpError(ErrorCode.InternalError, "IMAP authentication failed. Check that PROTONMAIL_PASSWORD is your Proton Bridge password (not your Proton account password) and that you're signed in inside the Bridge app. Run run_doctor for a full connectivity check.");
|
|
@@ -7528,6 +7676,26 @@ export function createServer(config, options = {}) {
|
|
|
7528
7676
|
accountManager,
|
|
7529
7677
|
};
|
|
7530
7678
|
}
|
|
7679
|
+
// Stops every account's timers, then closes connections and indexes, waiting at most `timeoutMs` for the
|
|
7680
|
+
// closing to finish. A Bridge that never answers LOGOUT must not hold the process up (imapflow's own socket
|
|
7681
|
+
// timeout is minutes, and a pending shutdown ignores further signals).
|
|
7682
|
+
const MAX_IMPORT_MESSAGE_BYTES = 50 * 1024 * 1024;
|
|
7683
|
+
export async function stopAllAccounts(bundles, timeoutMs = 3_000) {
|
|
7684
|
+
for (const bundle of bundles) {
|
|
7685
|
+
bundle.backgroundSyncService.stop();
|
|
7686
|
+
bundle.deliveryQueueService.stop();
|
|
7687
|
+
bundle.snoozeService.stop();
|
|
7688
|
+
}
|
|
7689
|
+
const closing = Promise.allSettled(bundles.flatMap((bundle) => [
|
|
7690
|
+
Promise.resolve().then(() => bundle.imapService.disconnect()),
|
|
7691
|
+
Promise.resolve().then(() => bundle.smtpService.close()),
|
|
7692
|
+
Promise.resolve().then(() => bundle.localIndexService.close()),
|
|
7693
|
+
]));
|
|
7694
|
+
let timer;
|
|
7695
|
+
await Promise.race([closing, new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
7696
|
+
if (timer)
|
|
7697
|
+
clearTimeout(timer);
|
|
7698
|
+
}
|
|
7531
7699
|
export async function main() {
|
|
7532
7700
|
const config = buildConfigFromEnv();
|
|
7533
7701
|
// Create the data directory explicitly with a restrictive mode as its very
|
|
@@ -7544,7 +7712,7 @@ export async function main() {
|
|
|
7544
7712
|
// Explicitly chmod it too so the restriction actually takes effect on
|
|
7545
7713
|
// upgrade, not only on a brand-new dataDir.
|
|
7546
7714
|
await chmod(config.dataDir, 0o700).catch(() => { });
|
|
7547
|
-
const { server,
|
|
7715
|
+
const { server, accountManager } = createServer(config, {
|
|
7548
7716
|
startBackgroundSync: true,
|
|
7549
7717
|
});
|
|
7550
7718
|
logger.info("Starting ProtonMail MCP server", "MCPServer");
|
|
@@ -7558,14 +7726,7 @@ export async function main() {
|
|
|
7558
7726
|
}
|
|
7559
7727
|
shuttingDown = true;
|
|
7560
7728
|
logger.info(`Received ${reason}, shutting down`, "MCPServer");
|
|
7561
|
-
|
|
7562
|
-
deliveryQueueService.stop();
|
|
7563
|
-
snoozeService.stop();
|
|
7564
|
-
await Promise.allSettled([
|
|
7565
|
-
imapService.disconnect(),
|
|
7566
|
-
smtpService.close(),
|
|
7567
|
-
...accountManager.all().map((bundle) => bundle.localIndexService.close()),
|
|
7568
|
-
]);
|
|
7729
|
+
await stopAllAccounts(accountManager.all());
|
|
7569
7730
|
process.exit(0);
|
|
7570
7731
|
};
|
|
7571
7732
|
process.on("SIGINT", () => {
|