proton-mail-bridge-client 2.1.2 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +232 -63
- package/dist/index.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +11 -1
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts +2 -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/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare function extractUnsubscribeInfo(detail: EmailDetail): {
|
|
|
23
23
|
mailto?: string;
|
|
24
24
|
url?: string;
|
|
25
25
|
};
|
|
26
|
+
export declare function buildReplyHtml(detail: EmailDetail, htmlBody: string): string;
|
|
27
|
+
export declare function buildForwardHtml(detail: EmailDetail, htmlBody?: string): string;
|
|
26
28
|
export declare function getReplyRecipients(detail: EmailDetail, ownerEmail: string, replyAll: boolean): {
|
|
27
29
|
to: string[];
|
|
28
30
|
cc: string[];
|
|
@@ -63,6 +65,10 @@ export declare function redactDraftAttachmentsForListing(draft: DraftRecord): {
|
|
|
63
65
|
remoteDraft?: import("./types/index.js").RemoteDraftRef;
|
|
64
66
|
lastSendResult?: import("./types/index.js").DraftSendResult;
|
|
65
67
|
};
|
|
68
|
+
export declare function paginateMergedAccountResults<T>(sortedMerged: T[], offset: number, limit: number): {
|
|
69
|
+
page: T[];
|
|
70
|
+
hasMore: boolean;
|
|
71
|
+
};
|
|
66
72
|
export declare function getBulkMaxBatchSize(args: Record<string, unknown>): number;
|
|
67
73
|
export declare function ensureBulkBatchSize(uidsLength: number, max: number): void;
|
|
68
74
|
interface BulkExcludedEmailId {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAYnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAA2H,iBAAiB,EAA+B,MAAM,mCAAmC,CAAC;AAC5N,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResources
|
|
|
12
12
|
import { isMainModule } from "./is-main.js";
|
|
13
13
|
import { AnalyticsService } from "./services/analytics-service.js";
|
|
14
14
|
import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
|
|
15
|
-
import { applySignature } from "./services/smtp-service.js";
|
|
15
|
+
import { applySignature, plainTextToHtml } from "./services/smtp-service.js";
|
|
16
16
|
import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmailId, parseEmails, projectFields, renderMarkdown, slugifyAccountAddress, splitAccountPrefix, stringifyForJson, withAccountPrefix, } from "./utils/helpers.js";
|
|
17
17
|
import { AccountManager } from "./services/account-manager.js";
|
|
18
18
|
import { logger } from "./utils/logger.js";
|
|
@@ -2069,6 +2069,53 @@ function buildForwardText(detail, body) {
|
|
|
2069
2069
|
.filter((line, index, array) => line !== "" || (index > 0 && array[index - 1] !== ""))
|
|
2070
2070
|
.join("\n");
|
|
2071
2071
|
}
|
|
2072
|
+
// HTML counterparts of buildReplyText/buildForwardText — needed because a
|
|
2073
|
+
// markdown-authored reply/forward (markdownBody) renders its OWN new text to a
|
|
2074
|
+
// real, separate htmlBody, but buildReplyText/buildForwardText only ever wrapped
|
|
2075
|
+
// the PLAIN TEXT `body` with the quoted/forwarded original. That original never
|
|
2076
|
+
// made it into htmlBody at all — an HTML-viewing recipient of a markdown reply
|
|
2077
|
+
// saw only the new text and signature, with the entire original message missing
|
|
2078
|
+
// (confirmed on generated MIME messages), while the text/plain part had it via
|
|
2079
|
+
// buildReplyText. Uses the original message's own detail.html when present
|
|
2080
|
+
// (real markup, not reconstructed); falls back to escaping detail.text/preview
|
|
2081
|
+
// through plainTextToHtml otherwise, same as a plain-text send's own html
|
|
2082
|
+
// alternative (see smtp-service.ts's plainTextToHtml comment).
|
|
2083
|
+
export function buildReplyHtml(detail, htmlBody) {
|
|
2084
|
+
const originalHtml = typeof detail.html === "string" && detail.html
|
|
2085
|
+
? detail.html
|
|
2086
|
+
: plainTextToHtml(detail.text || detail.preview || "");
|
|
2087
|
+
const fromText = formatAddressList(detail.from);
|
|
2088
|
+
const dateText = detail.date || detail.internalDate || "an unknown date";
|
|
2089
|
+
return [
|
|
2090
|
+
htmlBody,
|
|
2091
|
+
`<p>On ${escapeHtmlForQuote(dateText)}, ${escapeHtmlForQuote(fromText || "the sender")} wrote:</p>`,
|
|
2092
|
+
`<blockquote>${originalHtml}</blockquote>`,
|
|
2093
|
+
].join("\n");
|
|
2094
|
+
}
|
|
2095
|
+
export function buildForwardHtml(detail, htmlBody) {
|
|
2096
|
+
const originalHtml = typeof detail.html === "string" && detail.html
|
|
2097
|
+
? detail.html
|
|
2098
|
+
: plainTextToHtml(detail.text || detail.preview || "");
|
|
2099
|
+
return [
|
|
2100
|
+
htmlBody?.trim() || "",
|
|
2101
|
+
"<p>---------- Forwarded message ---------</p>",
|
|
2102
|
+
`<p>From: ${escapeHtmlForQuote(formatAddressList(detail.from))}<br>`,
|
|
2103
|
+
`Date: ${escapeHtmlForQuote(detail.date || detail.internalDate || "")}<br>`,
|
|
2104
|
+
`Subject: ${escapeHtmlForQuote(detail.subject)}<br>`,
|
|
2105
|
+
`To: ${escapeHtmlForQuote(formatAddressList(detail.to))}${detail.cc.length > 0 ? `<br>Cc: ${escapeHtmlForQuote(formatAddressList(detail.cc))}` : ""}</p>`,
|
|
2106
|
+
originalHtml,
|
|
2107
|
+
]
|
|
2108
|
+
.filter((part) => part !== "")
|
|
2109
|
+
.join("\n");
|
|
2110
|
+
}
|
|
2111
|
+
function escapeHtmlForQuote(value) {
|
|
2112
|
+
return value
|
|
2113
|
+
.replace(/&/g, "&")
|
|
2114
|
+
.replace(/</g, "<")
|
|
2115
|
+
.replace(/>/g, ">")
|
|
2116
|
+
.replace(/"/g, """)
|
|
2117
|
+
.replace(/'/g, "'");
|
|
2118
|
+
}
|
|
2072
2119
|
export function getReplyRecipients(detail, ownerEmail, replyAll) {
|
|
2073
2120
|
const owner = lowerCaseAddress(ownerEmail);
|
|
2074
2121
|
const primary = addressValues(detail.replyTo).length > 0 ? detail.replyTo : detail.from;
|
|
@@ -2495,6 +2542,23 @@ function sortByDateDesc(items, dateOf) {
|
|
|
2495
2542
|
return rightTime - leftTime;
|
|
2496
2543
|
});
|
|
2497
2544
|
}
|
|
2545
|
+
// get_emails' multi-account offset/limit pagination math, pulled out for direct
|
|
2546
|
+
// testing. Applying `offset` separately to EACH account's own page before merging
|
|
2547
|
+
// is wrong — page 2 (offset=25,limit=25) would skip each account's own first 25
|
|
2548
|
+
// messages independently, then concatenate their page-2 results, which drops
|
|
2549
|
+
// items relative to page 2 of the globally-merged, newest-first timeline (found
|
|
2550
|
+
// live with interleaved messages across two accounts: page 2 skipped the
|
|
2551
|
+
// second-newest message overall). The caller is expected to have already fetched
|
|
2552
|
+
// each account from offset 0 up to (offset + limit) — since every account's own
|
|
2553
|
+
// list already arrives newest-first, that's guaranteed to include every item that
|
|
2554
|
+
// could rank in the global top (offset + limit) — and passes the sorted,
|
|
2555
|
+
// concatenated superset in here to be paginated as one timeline.
|
|
2556
|
+
export function paginateMergedAccountResults(sortedMerged, offset, limit) {
|
|
2557
|
+
return {
|
|
2558
|
+
page: sortedMerged.slice(offset, offset + limit),
|
|
2559
|
+
hasMore: sortedMerged.length > offset + limit,
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2498
2562
|
function mergeMailboxLabels(perAccountLabels) {
|
|
2499
2563
|
const merged = new Map();
|
|
2500
2564
|
for (const labels of perAccountLabels) {
|
|
@@ -3170,6 +3234,12 @@ export function createServer(config, options = {}) {
|
|
|
3170
3234
|
// string-prefix stripping over the non-primary slugs), so draftId resolution reuses
|
|
3171
3235
|
// resolveAccountForEmailId itself rather than a second, parallel mechanism.
|
|
3172
3236
|
const resolveAccountForDraftId = resolveAccountForEmailId;
|
|
3237
|
+
// Same scheme again for delivery-queue ids (cancel_send/list_scheduled_sends) and
|
|
3238
|
+
// snooze ids (cancel_snooze/list_snoozed) — both are per-account services (their own
|
|
3239
|
+
// JSON file under that account's dataDir), so a queued/snoozed item's id needs the
|
|
3240
|
+
// same slug-prefix round-trip as an emailId/draftId to be found again by account.
|
|
3241
|
+
const resolveAccountForQueueId = resolveAccountForEmailId;
|
|
3242
|
+
const resolveAccountForSnoozeId = resolveAccountForEmailId;
|
|
3173
3243
|
// The slug to prefix a response id with for this bundle — undefined for the
|
|
3174
3244
|
// primary account, so a primary-account id round-trips with NO prefix at all,
|
|
3175
3245
|
// exactly as it did before multi-account support existed.
|
|
@@ -3429,22 +3499,6 @@ export function createServer(config, options = {}) {
|
|
|
3429
3499
|
}
|
|
3430
3500
|
undoWindowSeconds = requested;
|
|
3431
3501
|
}
|
|
3432
|
-
if (undoWindowSeconds > 0) {
|
|
3433
|
-
// Scope limitation (follow-up round): the undo-send queue is always the
|
|
3434
|
-
// primary's deliveryQueueService, so a delayed send with a non-primary
|
|
3435
|
-
// `from` still only gets the header-override-on-primary fallback at fire
|
|
3436
|
-
// time, not this account's own SMTP connection — unlike the immediate-send
|
|
3437
|
-
// path above. Fine for now since `from` is still passed through in the
|
|
3438
|
-
// payload and Proton accepts any verified address on the account.
|
|
3439
|
-
const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
|
|
3440
|
-
const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
|
|
3441
|
-
return createTextResult({
|
|
3442
|
-
queued: true,
|
|
3443
|
-
id: queued.id,
|
|
3444
|
-
sendAt: queued.sendAt,
|
|
3445
|
-
note: `Not sent yet — will send in ~${undoWindowSeconds}s unless canceled with cancel_send. This server must stay running for the send to fire; if it's restarted before sendAt, the send fires on next startup instead.`,
|
|
3446
|
-
});
|
|
3447
|
-
}
|
|
3448
3502
|
// A `from` matching a configured additional account routes through that
|
|
3449
3503
|
// account's own SMTP connection — required under Bridge's Split Addresses
|
|
3450
3504
|
// feature, where each address is its own separate IMAP/SMTP login, not just
|
|
@@ -3453,8 +3507,26 @@ export function createServer(config, options = {}) {
|
|
|
3453
3507
|
// the primary connection with `from` passed through as a plain header
|
|
3454
3508
|
// override (still valid for a genuine alias only reachable that way).
|
|
3455
3509
|
const sendBundle = from ? accountManager.byAddress(from) : undefined;
|
|
3510
|
+
const effectiveSendBundle = sendBundle ?? primaryBundle;
|
|
3456
3511
|
const sendSmtp = sendBundle ? sendBundle.smtpService : smtpService;
|
|
3457
3512
|
const sendImap = sendBundle ? sendBundle.imapService : imapService;
|
|
3513
|
+
if (undoWindowSeconds > 0) {
|
|
3514
|
+
// The undo-send queue now goes through the resolved account's OWN
|
|
3515
|
+
// deliveryQueueService (same routing as the immediate-send path above),
|
|
3516
|
+
// so it fires later via that account's real SMTP connection rather than
|
|
3517
|
+
// always the primary's — a delayed send with a non-primary `from` used
|
|
3518
|
+
// to only ever get the header-override-on-primary fallback at fire time.
|
|
3519
|
+
// The returned id is prefixed with that account's slug (same scheme as
|
|
3520
|
+
// emailId/draftId) so cancel_send/list_scheduled_sends can find it again.
|
|
3521
|
+
const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
|
|
3522
|
+
const queued = await withAudit(auditService, name, args, async () => effectiveSendBundle.deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
|
|
3523
|
+
return createTextResult({
|
|
3524
|
+
queued: true,
|
|
3525
|
+
id: withAccountPrefix(responseSlug(effectiveSendBundle), queued.id),
|
|
3526
|
+
sendAt: queued.sendAt,
|
|
3527
|
+
note: `Not sent yet — will send in ~${undoWindowSeconds}s unless canceled with cancel_send. This server must stay running for the send to fire; if it's restarted before sendAt, the send fires on next startup instead.`,
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3458
3530
|
const result = await withAudit(auditService, name, args, async () => sendSmtp.sendEmail(emailPayload));
|
|
3459
3531
|
let sentCopyTokenSend = "[sent-copy:unverified]";
|
|
3460
3532
|
try {
|
|
@@ -3475,13 +3547,31 @@ export function createServer(config, options = {}) {
|
|
|
3475
3547
|
});
|
|
3476
3548
|
}
|
|
3477
3549
|
case "cancel_send": {
|
|
3478
|
-
|
|
3479
|
-
|
|
3550
|
+
// The queue id carries an account-slug prefix when the item was enqueued
|
|
3551
|
+
// through a non-primary account (see send_email/schedule_draft) — resolve
|
|
3552
|
+
// it back to that account's own deliveryQueueService, same as an
|
|
3553
|
+
// emailId/draftId, instead of always checking the primary's queue.
|
|
3554
|
+
const { bundle: cancelSendBundle, rest: cancelSendIdRest } = resolveAccountForQueueId(requireString(args, "id"));
|
|
3555
|
+
const result = await withAudit(auditService, name, args, async () => cancelSendBundle.deliveryQueueService.cancel(cancelSendIdRest));
|
|
3556
|
+
return createTextResult({ ...result, id: withAccountPrefix(responseSlug(cancelSendBundle), result.id) });
|
|
3480
3557
|
}
|
|
3481
3558
|
case "list_scheduled_sends": {
|
|
3482
3559
|
const statusFilter = optionalString(args, "status");
|
|
3483
|
-
|
|
3484
|
-
|
|
3560
|
+
// Fan out across every configured account's own delivery queue and tag each
|
|
3561
|
+
// item's id with its owning account's slug — mirrors the read-tool fan-out
|
|
3562
|
+
// pattern (get_emails etc.). Single-account guard keeps byte-identical output
|
|
3563
|
+
// for the common case.
|
|
3564
|
+
if (accountManager.all().length === 1) {
|
|
3565
|
+
const all = await withAudit(auditService, name, args, async () => deliveryQueueService.list());
|
|
3566
|
+
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
3567
|
+
return createTextResult(filtered);
|
|
3568
|
+
}
|
|
3569
|
+
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
|
|
3570
|
+
bundle,
|
|
3571
|
+
items: await bundle.deliveryQueueService.list(),
|
|
3572
|
+
}))));
|
|
3573
|
+
const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({ ...item, id: withAccountPrefix(responseSlug(bundle), item.id) })));
|
|
3574
|
+
const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
|
|
3485
3575
|
return createTextResult(filtered);
|
|
3486
3576
|
}
|
|
3487
3577
|
case "get_unsubscribe_info": {
|
|
@@ -3591,8 +3681,14 @@ export function createServer(config, options = {}) {
|
|
|
3591
3681
|
const includeQuoteReply = normalizeBoolean(args.includeQuote, true);
|
|
3592
3682
|
// Signature goes right after the user's own reply text, before the
|
|
3593
3683
|
// quoted original — not at the very end, after the quote.
|
|
3594
|
-
const signedReply = applySignature(body, htmlBody, normalizeBoolean(args.appendSignature, true));
|
|
3684
|
+
const signedReply = applySignature(body, htmlBody, normalizeBoolean(args.appendSignature, true), isHtml);
|
|
3595
3685
|
const replyBody = includeQuoteReply ? buildReplyText(detail, signedReply.body) : signedReply.body;
|
|
3686
|
+
// A separate htmlBody (the markdown-rendered case) needs the SAME quoted
|
|
3687
|
+
// original merged in — see buildReplyHtml's comment for why this was
|
|
3688
|
+
// previously missing entirely from the html part.
|
|
3689
|
+
const replyHtmlBody = includeQuoteReply && signedReply.htmlBody !== undefined
|
|
3690
|
+
? buildReplyHtml(detail, signedReply.htmlBody)
|
|
3691
|
+
: signedReply.htmlBody;
|
|
3596
3692
|
if (config.runtime.restrictOutboundToSelf) {
|
|
3597
3693
|
const selfAddr = config.smtp.username.toLowerCase();
|
|
3598
3694
|
const allR = [...to, ...cc, ...extraBcc];
|
|
@@ -3611,7 +3707,7 @@ export function createServer(config, options = {}) {
|
|
|
3611
3707
|
subject: prefixedSubject(detail.subject, "Re:"),
|
|
3612
3708
|
body: replyBody,
|
|
3613
3709
|
isHtml,
|
|
3614
|
-
htmlBody:
|
|
3710
|
+
htmlBody: replyHtmlBody,
|
|
3615
3711
|
fromName: fromNameReply,
|
|
3616
3712
|
from: fromReply,
|
|
3617
3713
|
sanitizeHtml: sanitizeHtmlReply,
|
|
@@ -3687,8 +3783,12 @@ export function createServer(config, options = {}) {
|
|
|
3687
3783
|
const includeQuoteRa = normalizeBoolean(args.includeQuote, true);
|
|
3688
3784
|
// Signature goes right after the user's own reply text, before the
|
|
3689
3785
|
// quoted original — not at the very end, after the quote.
|
|
3690
|
-
const signedReplyRa = applySignature(bodyRa, htmlBodyRa, normalizeBoolean(args.appendSignature, true));
|
|
3786
|
+
const signedReplyRa = applySignature(bodyRa, htmlBodyRa, normalizeBoolean(args.appendSignature, true), isHtmlRa);
|
|
3691
3787
|
const replyBodyRa = includeQuoteRa ? buildReplyText(detailRa, signedReplyRa.body) : signedReplyRa.body;
|
|
3788
|
+
// Same fix as reply_to_email — see buildReplyHtml's comment.
|
|
3789
|
+
const replyHtmlBodyRa = includeQuoteRa && signedReplyRa.htmlBody !== undefined
|
|
3790
|
+
? buildReplyHtml(detailRa, signedReplyRa.htmlBody)
|
|
3791
|
+
: signedReplyRa.htmlBody;
|
|
3692
3792
|
const resultRa = await withAudit(auditService, name, args, async () => sendBundleForRa.smtpService.sendEmail({
|
|
3693
3793
|
to: toRa,
|
|
3694
3794
|
cc: ccRa,
|
|
@@ -3696,7 +3796,7 @@ export function createServer(config, options = {}) {
|
|
|
3696
3796
|
subject: prefixedSubject(detailRa.subject, "Re:"),
|
|
3697
3797
|
body: replyBodyRa,
|
|
3698
3798
|
isHtml: isHtmlRa,
|
|
3699
|
-
htmlBody:
|
|
3799
|
+
htmlBody: replyHtmlBodyRa,
|
|
3700
3800
|
fromName: fromNameRa,
|
|
3701
3801
|
from: fromRa,
|
|
3702
3802
|
sanitizeHtml: sanitizeHtmlRa,
|
|
@@ -3795,7 +3895,13 @@ export function createServer(config, options = {}) {
|
|
|
3795
3895
|
// Signature goes right after the user's own note, before the
|
|
3796
3896
|
// "---------- Forwarded message ---------" block — not at the very
|
|
3797
3897
|
// end, after the forwarded content.
|
|
3798
|
-
const signedFwd = applySignature(body ?? "", htmlBody, normalizeBoolean(args.appendSignature, true));
|
|
3898
|
+
const signedFwd = applySignature(body ?? "", htmlBody, normalizeBoolean(args.appendSignature, true), isHtml);
|
|
3899
|
+
// Same fix as reply_to_email/reply_all_email — see buildReplyHtml's
|
|
3900
|
+
// comment (applies equally to a markdown-authored forward's separate
|
|
3901
|
+
// htmlBody, which buildForwardText never touches).
|
|
3902
|
+
const forwardHtmlBody = signedFwd.htmlBody !== undefined
|
|
3903
|
+
? buildForwardHtml(detail, signedFwd.htmlBody)
|
|
3904
|
+
: signedFwd.htmlBody;
|
|
3799
3905
|
const result = await withAudit(auditService, name, args, async () => sendBundleForFwd.smtpService.sendEmail({
|
|
3800
3906
|
to,
|
|
3801
3907
|
cc,
|
|
@@ -3803,7 +3909,7 @@ export function createServer(config, options = {}) {
|
|
|
3803
3909
|
subject: prefixedSubject(detail.subject, "Fwd:"),
|
|
3804
3910
|
body: buildForwardText(detail, signedFwd.body),
|
|
3805
3911
|
isHtml,
|
|
3806
|
-
htmlBody:
|
|
3912
|
+
htmlBody: forwardHtmlBody,
|
|
3807
3913
|
fromName: fromNameFwd,
|
|
3808
3914
|
from: fromFwd,
|
|
3809
3915
|
sanitizeHtml: sanitizeHtmlFwd,
|
|
@@ -3901,14 +4007,19 @@ export function createServer(config, options = {}) {
|
|
|
3901
4007
|
]);
|
|
3902
4008
|
}
|
|
3903
4009
|
case "create_reply_draft": {
|
|
3904
|
-
|
|
4010
|
+
// emailId carries the same "<slug>::" account prefix as everywhere else —
|
|
4011
|
+
// this previously always read via the PRIMARY account's imapService
|
|
4012
|
+
// regardless of that prefix, so replying to a non-primary account's email
|
|
4013
|
+
// either resolved the wrong message or threw not-found.
|
|
4014
|
+
const { bundle: createReplyReadBundle, rest: createReplyEmailIdRest } = resolveAccountForEmailId(requireString(args, "emailId"));
|
|
4015
|
+
const detail = await createReplyReadBundle.imapService.getEmailById(createReplyEmailIdRest);
|
|
3905
4016
|
const body = requireString(args, "body");
|
|
3906
4017
|
const isHtml = normalizeBoolean(args.isHtml, false);
|
|
3907
4018
|
const replyAll = normalizeBoolean(args.replyAll, false);
|
|
3908
4019
|
const attachments = optionalAttachmentList(args.attachments);
|
|
3909
4020
|
const extraCc = parseEmails(optionalString(args, "cc"));
|
|
3910
4021
|
const extraBcc = parseEmails(optionalString(args, "bcc"));
|
|
3911
|
-
const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
|
|
4022
|
+
const recipients = getReplyRecipients(detail, createReplyReadBundle.config.smtp.username, replyAll);
|
|
3912
4023
|
const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
|
|
3913
4024
|
const to = uniqueAddresses(recipients.to);
|
|
3914
4025
|
ensureValidEmails(to, "to");
|
|
@@ -3917,10 +4028,15 @@ export function createServer(config, options = {}) {
|
|
|
3917
4028
|
if (to.length === 0) {
|
|
3918
4029
|
throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
|
|
3919
4030
|
}
|
|
3920
|
-
// Same from-based draftStore routing as create_draft — see its comment
|
|
4031
|
+
// Same from-based draftStore routing as create_draft — see its comment —
|
|
4032
|
+
// except the no-`from` default is the ACCOUNT THE ORIGINAL MESSAGE BELONGS
|
|
4033
|
+
// TO, not always the primary: a reply drafted against a non-primary
|
|
4034
|
+
// account's email with no explicit `from` should default to replying as
|
|
4035
|
+
// that same account, not silently switch to primary. `from` still wins
|
|
4036
|
+
// when it names a different configured account.
|
|
3921
4037
|
const createReplyDraftBundle = optionalString(args, "from") !== undefined
|
|
3922
|
-
? accountManager.byAddress(optionalString(args, "from")) ??
|
|
3923
|
-
:
|
|
4038
|
+
? accountManager.byAddress(optionalString(args, "from")) ?? createReplyReadBundle
|
|
4039
|
+
: createReplyReadBundle;
|
|
3924
4040
|
const result = await withAudit(auditService, name, args, async () => {
|
|
3925
4041
|
const draft = await createReplyDraftBundle.draftStore.createDraft({
|
|
3926
4042
|
mode: "reply",
|
|
@@ -3966,7 +4082,9 @@ export function createServer(config, options = {}) {
|
|
|
3966
4082
|
]);
|
|
3967
4083
|
}
|
|
3968
4084
|
case "create_forward_draft": {
|
|
3969
|
-
|
|
4085
|
+
// Same emailId account resolution as create_reply_draft — see its comment.
|
|
4086
|
+
const { bundle: createForwardReadBundle, rest: createForwardEmailIdRest } = resolveAccountForEmailId(requireString(args, "emailId"));
|
|
4087
|
+
const detail = await createForwardReadBundle.imapService.getEmailById(createForwardEmailIdRest);
|
|
3970
4088
|
const to = parseEmails(requireString(args, "to"));
|
|
3971
4089
|
const cc = parseEmails(optionalString(args, "cc"));
|
|
3972
4090
|
const bcc = parseEmails(optionalString(args, "bcc"));
|
|
@@ -3984,13 +4102,15 @@ export function createServer(config, options = {}) {
|
|
|
3984
4102
|
const originalAttachments = includeAttachments
|
|
3985
4103
|
? await Promise.all(detail.attachments
|
|
3986
4104
|
.filter((a) => a.id)
|
|
3987
|
-
.map((a) => imapService.getAttachmentForForward(detail.id, a.id)))
|
|
4105
|
+
.map((a) => createForwardReadBundle.imapService.getAttachmentForForward(detail.id, a.id)))
|
|
3988
4106
|
: [];
|
|
3989
4107
|
const attachments = [...originalAttachments, ...callerAttachments];
|
|
3990
|
-
// Same from-based draftStore routing as
|
|
4108
|
+
// Same from-based draftStore routing as create_reply_draft — defaults to
|
|
4109
|
+
// the ORIGINAL MESSAGE's own account, not always primary, when `from`
|
|
4110
|
+
// isn't given.
|
|
3991
4111
|
const createForwardDraftBundle = optionalString(args, "from") !== undefined
|
|
3992
|
-
? accountManager.byAddress(optionalString(args, "from")) ??
|
|
3993
|
-
:
|
|
4112
|
+
? accountManager.byAddress(optionalString(args, "from")) ?? createForwardReadBundle
|
|
4113
|
+
: createForwardReadBundle;
|
|
3994
4114
|
const result = await withAudit(auditService, name, args, async () => {
|
|
3995
4115
|
const draft = await createForwardDraftBundle.draftStore.createDraft({
|
|
3996
4116
|
mode: "forward",
|
|
@@ -4177,7 +4297,7 @@ export function createServer(config, options = {}) {
|
|
|
4177
4297
|
// immediate send is actually what's wanted.
|
|
4178
4298
|
const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4179
4299
|
if (pendingScheduled) {
|
|
4180
|
-
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${pendingScheduled.id}, sendAt ${pendingScheduled.sendAt}). Sending now would deliver it twice. Cancel that scheduled send with cancel_send first, or wait for it to fire.`);
|
|
4300
|
+
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${withAccountPrefix(responseSlug(sendDraftBundle), pendingScheduled.id)}, sendAt ${pendingScheduled.sendAt}). Sending now would deliver it twice. Cancel that scheduled send with cancel_send first, or wait for it to fire.`);
|
|
4181
4301
|
}
|
|
4182
4302
|
ensureValidEmails(draft.to, "to");
|
|
4183
4303
|
ensureValidEmails(draft.cc, "cc");
|
|
@@ -4358,7 +4478,7 @@ export function createServer(config, options = {}) {
|
|
|
4358
4478
|
// in the common non-racing case.
|
|
4359
4479
|
const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4360
4480
|
if (alreadyPendingScheduled) {
|
|
4361
|
-
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${alreadyPendingScheduled.id}, sendAt ${alreadyPendingScheduled.sendAt}). Scheduling it again would deliver it twice. Cancel the existing one with cancel_send first if you want a different sendAt.`);
|
|
4481
|
+
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${withAccountPrefix(responseSlug(scheduleDraftBundle), alreadyPendingScheduled.id)}, sendAt ${alreadyPendingScheduled.sendAt}). Scheduling it again would deliver it twice. Cancel the existing one with cancel_send first if you want a different sendAt.`);
|
|
4362
4482
|
}
|
|
4363
4483
|
const sendAt = requireString(args, "sendAt");
|
|
4364
4484
|
const sendAtTime = new Date(sendAt).getTime();
|
|
@@ -4401,7 +4521,7 @@ export function createServer(config, options = {}) {
|
|
|
4401
4521
|
}, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
|
|
4402
4522
|
return createTextResult({
|
|
4403
4523
|
queued: true,
|
|
4404
|
-
id: queued.id,
|
|
4524
|
+
id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
|
|
4405
4525
|
draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
|
|
4406
4526
|
sendAt: queued.sendAt,
|
|
4407
4527
|
note: "The draft's content was snapshotted now and queued. This server must stay running for the send to fire at sendAt — if restarted first, it fires on next startup instead. The draft record itself is not automatically marked sent; use cancel_send with this id to abort before it fires.",
|
|
@@ -4452,26 +4572,36 @@ export function createServer(config, options = {}) {
|
|
|
4452
4572
|
hasMore: result.emails.length === effectiveLimit,
|
|
4453
4573
|
}, false, result.emails.map(emailSource));
|
|
4454
4574
|
}
|
|
4455
|
-
// Merge strategy:
|
|
4456
|
-
//
|
|
4457
|
-
//
|
|
4458
|
-
//
|
|
4575
|
+
// Merge strategy: an `offset` applied separately to each account's own page
|
|
4576
|
+
// BEFORE merging is wrong — e.g. offset=25,limit=25 (page 2) would skip each
|
|
4577
|
+
// account's own first 25 messages independently, then concatenate their
|
|
4578
|
+
// page-2 results, which is not the same as page 2 of the globally-merged,
|
|
4579
|
+
// newest-first timeline (found live with interleaved messages across two
|
|
4580
|
+
// accounts: page 2 skipped the second-newest message overall). Fixed by
|
|
4581
|
+
// always fetching from offset 0 up to (requestedOffset + limit) from EVERY
|
|
4582
|
+
// account — enough that each account's true contribution to the global top
|
|
4583
|
+
// (requestedOffset + limit) is fully captured, since getEmails already
|
|
4584
|
+
// returns newest-first — then merging, re-sorting, and applying the
|
|
4585
|
+
// requested offset/limit to that merged superset instead of per account.
|
|
4459
4586
|
const primarySlug = accountManager.primary().account.slug;
|
|
4587
|
+
const requestedOffset = getEmailsInput.offset ?? 0;
|
|
4588
|
+
const perAccountFetchLimit = requestedOffset + effectiveLimit;
|
|
4460
4589
|
const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
|
|
4461
|
-
const result = await bundle.imapService.getEmails(getEmailsInput);
|
|
4590
|
+
const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
|
|
4462
4591
|
return { bundle, result };
|
|
4463
4592
|
}));
|
|
4464
4593
|
const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
|
|
4465
|
-
const
|
|
4594
|
+
const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
|
|
4595
|
+
const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
|
|
4466
4596
|
const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
|
|
4467
4597
|
return createTextResult({
|
|
4468
4598
|
folder: getEmailsInput.folder?.trim() || "INBOX",
|
|
4469
4599
|
total,
|
|
4470
4600
|
limit: effectiveLimit,
|
|
4471
|
-
offset:
|
|
4601
|
+
offset: requestedOffset,
|
|
4472
4602
|
emails: projectFields(merged, parseFieldsArg(args.fields)),
|
|
4473
4603
|
returned: merged.length,
|
|
4474
|
-
hasMore
|
|
4604
|
+
hasMore,
|
|
4475
4605
|
byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
|
|
4476
4606
|
}, false, merged.map(emailSource));
|
|
4477
4607
|
}
|
|
@@ -5222,10 +5352,15 @@ export function createServer(config, options = {}) {
|
|
|
5222
5352
|
if (wakeAtTime <= Date.now()) {
|
|
5223
5353
|
throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
|
|
5224
5354
|
}
|
|
5225
|
-
|
|
5355
|
+
// emailId carries the same "<slug>::" prefix as everywhere else — resolve it
|
|
5356
|
+
// to that account's own snoozeService instead of always the primary's, and
|
|
5357
|
+
// prefix the returned ids (the snooze's own id, and the email id) the same
|
|
5358
|
+
// way so cancel_snooze/list_snoozed can find them again by account.
|
|
5359
|
+
const { bundle: snoozeBundle, rest: snoozeEmailIdRest } = resolveAccountForSnoozeId(requireString(args, "emailId"));
|
|
5360
|
+
const snoozed = await withAudit(auditService, name, args, async () => snoozeBundle.snoozeService.snooze(snoozeEmailIdRest, new Date(wakeAtTime).toISOString()));
|
|
5226
5361
|
return createTextResult({
|
|
5227
|
-
id: snoozed.id,
|
|
5228
|
-
emailId: snoozed.currentEmailId,
|
|
5362
|
+
id: withAccountPrefix(responseSlug(snoozeBundle), snoozed.id),
|
|
5363
|
+
emailId: withAccountPrefix(responseSlug(snoozeBundle), snoozed.currentEmailId),
|
|
5229
5364
|
wakeAt: snoozed.wakeAt,
|
|
5230
5365
|
note: "This server must stay running for the wake to fire at wakeAt — if restarted first, it wakes on next startup instead. Cancelable via cancel_snooze.",
|
|
5231
5366
|
});
|
|
@@ -5234,13 +5369,34 @@ export function createServer(config, options = {}) {
|
|
|
5234
5369
|
// cancel moves the mailbox back to its original folder, same as
|
|
5235
5370
|
// snooze_email's own move — gate it the same way.
|
|
5236
5371
|
ensureEmailActionAllowed(config.runtime, "archive");
|
|
5237
|
-
const
|
|
5238
|
-
|
|
5372
|
+
const { bundle: cancelSnoozeBundle, rest: cancelSnoozeIdRest } = resolveAccountForSnoozeId(requireString(args, "id"));
|
|
5373
|
+
const result = await withAudit(auditService, name, args, async () => cancelSnoozeBundle.snoozeService.cancel(cancelSnoozeIdRest));
|
|
5374
|
+
return createTextResult({
|
|
5375
|
+
...result,
|
|
5376
|
+
id: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.id),
|
|
5377
|
+
currentEmailId: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.currentEmailId),
|
|
5378
|
+
});
|
|
5239
5379
|
}
|
|
5240
5380
|
case "list_snoozed": {
|
|
5241
5381
|
const statusFilter = optionalString(args, "status");
|
|
5242
|
-
|
|
5243
|
-
|
|
5382
|
+
// Same fan-out pattern as list_scheduled_sends — each account has its own
|
|
5383
|
+
// independent snoozeService/JSON file. Single-account guard keeps
|
|
5384
|
+
// byte-identical output for the common case.
|
|
5385
|
+
if (accountManager.all().length === 1) {
|
|
5386
|
+
const all = await withAudit(auditService, name, args, async () => snoozeService.list());
|
|
5387
|
+
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
5388
|
+
return createTextResult(filtered);
|
|
5389
|
+
}
|
|
5390
|
+
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
|
|
5391
|
+
bundle,
|
|
5392
|
+
items: await bundle.snoozeService.list(),
|
|
5393
|
+
}))));
|
|
5394
|
+
const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({
|
|
5395
|
+
...item,
|
|
5396
|
+
id: withAccountPrefix(responseSlug(bundle), item.id),
|
|
5397
|
+
currentEmailId: withAccountPrefix(responseSlug(bundle), item.currentEmailId),
|
|
5398
|
+
})));
|
|
5399
|
+
const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
|
|
5244
5400
|
return createTextResult(filtered);
|
|
5245
5401
|
}
|
|
5246
5402
|
case "create_template": {
|
|
@@ -6222,24 +6378,32 @@ export function createServer(config, options = {}) {
|
|
|
6222
6378
|
}
|
|
6223
6379
|
case "create_thread_reply_draft":
|
|
6224
6380
|
{
|
|
6225
|
-
|
|
6381
|
+
// A threadId is only ever valid within the single account it was produced
|
|
6382
|
+
// by — same "<slug>::" resolution as get_thread_by_id/move_thread. This was
|
|
6383
|
+
// previously always reading via the PRIMARY account's localIndexService/
|
|
6384
|
+
// imapService regardless of the threadId's own prefix, so a reply drafted
|
|
6385
|
+
// against a non-primary account's thread either resolved the wrong thread
|
|
6386
|
+
// (if the primary happened to have a same-shaped local id) or threw not
|
|
6387
|
+
// found. Every read below now goes through the resolved threadBundle.
|
|
6388
|
+
const { bundle: threadReplyBundle, rest: threadReplyIdRest } = resolveAccountForEmailId(requireString(args, "threadId"));
|
|
6389
|
+
await maybeRefreshLocalIndex(threadReplyBundle.imapService, threadReplyBundle.localIndexService, {
|
|
6226
6390
|
force: normalizeBoolean(args.syncBefore, false),
|
|
6227
6391
|
folder: "INBOX",
|
|
6228
6392
|
limitPerFolder: 100,
|
|
6229
6393
|
});
|
|
6230
|
-
const thread = await localIndexService.getThreadById(
|
|
6231
|
-
const targetMessage = pickReplyTargetFromThread(thread, config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
|
|
6394
|
+
const thread = await threadReplyBundle.localIndexService.getThreadById(threadReplyIdRest);
|
|
6395
|
+
const targetMessage = pickReplyTargetFromThread(thread, threadReplyBundle.config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
|
|
6232
6396
|
if (!targetMessage?.primaryEmailId) {
|
|
6233
6397
|
throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
|
|
6234
6398
|
}
|
|
6235
|
-
const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6399
|
+
const detail = await threadReplyBundle.imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6236
6400
|
const body = requireString(args, "body");
|
|
6237
6401
|
const isHtml = normalizeBoolean(args.isHtml, false);
|
|
6238
6402
|
const replyAll = normalizeBoolean(args.replyAll, false);
|
|
6239
6403
|
const attachments = optionalAttachmentList(args.attachments);
|
|
6240
6404
|
const extraCc = parseEmails(optionalString(args, "cc"));
|
|
6241
6405
|
const extraBcc = parseEmails(optionalString(args, "bcc"));
|
|
6242
|
-
const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
|
|
6406
|
+
const recipients = getReplyRecipients(detail, threadReplyBundle.config.smtp.username, replyAll);
|
|
6243
6407
|
const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
|
|
6244
6408
|
const to = uniqueAddresses(recipients.to);
|
|
6245
6409
|
ensureValidEmails(to, "to");
|
|
@@ -6248,10 +6412,15 @@ export function createServer(config, options = {}) {
|
|
|
6248
6412
|
if (to.length === 0) {
|
|
6249
6413
|
throw new McpError(ErrorCode.InvalidParams, "Unable to infer reply recipient.");
|
|
6250
6414
|
}
|
|
6251
|
-
// Same from-based draftStore routing as
|
|
6415
|
+
// Same from-based draftStore routing as create_reply_draft — defaults to
|
|
6416
|
+
// the THREAD's own account (threadReplyBundle, already resolved above from
|
|
6417
|
+
// the threadId's own prefix), not always primary, when `from` isn't given.
|
|
6418
|
+
// Previously always defaulted to primary regardless of which account the
|
|
6419
|
+
// thread actually belonged to — a reply drafted with no explicit `from`
|
|
6420
|
+
// against a non-primary account's thread silently switched accounts.
|
|
6252
6421
|
const createThreadReplyDraftBundle = optionalString(args, "from") !== undefined
|
|
6253
|
-
? accountManager.byAddress(optionalString(args, "from")) ??
|
|
6254
|
-
:
|
|
6422
|
+
? accountManager.byAddress(optionalString(args, "from")) ?? threadReplyBundle
|
|
6423
|
+
: threadReplyBundle;
|
|
6255
6424
|
const result = await withAudit(auditService, name, args, async () => {
|
|
6256
6425
|
const draft = await createThreadReplyDraftBundle.draftStore.createDraft({
|
|
6257
6426
|
mode: "reply",
|