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 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 {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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, "&amp;")
2114
+ .replace(/</g, "&lt;")
2115
+ .replace(/>/g, "&gt;")
2116
+ .replace(/"/g, "&quot;")
2117
+ .replace(/'/g, "&#39;");
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
- const result = await withAudit(auditService, name, args, async () => deliveryQueueService.cancel(requireString(args, "id")));
3479
- return createTextResult(result);
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
- const all = await withAudit(auditService, name, args, async () => deliveryQueueService.list());
3484
- const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
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: signedReply.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: signedReplyRa.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: signedFwd.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
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
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")) ?? primaryBundle
3923
- : primaryBundle;
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
- const detail = await imapService.getEmailById(requireString(args, "emailId"));
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 create_draftsee its comment.
4108
+ // Same from-based draftStore routing as create_reply_draftdefaults 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")) ?? primaryBundle
3993
- : primaryBundle;
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: live-fetch each account's own folder page (LIVE IMAP,
4456
- // one call per account), tag ids with the account slug, concatenate,
4457
- // re-sort newest-first, then re-apply the original limit across the
4458
- // merged set (not per account) and sum totals across accounts.
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 merged = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date).slice(0, effectiveLimit);
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: getEmailsInput.offset ?? 0,
4601
+ offset: requestedOffset,
4472
4602
  emails: projectFields(merged, parseFieldsArg(args.fields)),
4473
4603
  returned: merged.length,
4474
- hasMore: merged.length === effectiveLimit,
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
- const snoozed = await withAudit(auditService, name, args, async () => snoozeService.snooze(requireString(args, "emailId"), new Date(wakeAtTime).toISOString()));
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 result = await withAudit(auditService, name, args, async () => snoozeService.cancel(requireString(args, "id")));
5238
- return createTextResult(result);
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
- const all = await withAudit(auditService, name, args, async () => snoozeService.list());
5243
- const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
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
- await maybeRefreshLocalIndex(imapService, localIndexService, {
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(requireString(args, "threadId"));
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 create_draftsee its comment.
6415
+ // Same from-based draftStore routing as create_reply_draftdefaults 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")) ?? primaryBundle
6254
- : primaryBundle;
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",