proton-mail-bridge-client 2.1.1 → 2.1.3

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
@@ -63,6 +63,10 @@ export declare function redactDraftAttachmentsForListing(draft: DraftRecord): {
63
63
  remoteDraft?: import("./types/index.js").RemoteDraftRef;
64
64
  lastSendResult?: import("./types/index.js").DraftSendResult;
65
65
  };
66
+ export declare function paginateMergedAccountResults<T>(sortedMerged: T[], offset: number, limit: number): {
67
+ page: T[];
68
+ hasMore: boolean;
69
+ };
66
70
  export declare function getBulkMaxBatchSize(args: Record<string, unknown>): number;
67
71
  export declare function ensureBulkBatchSize(uidsLength: number, max: number): void;
68
72
  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
@@ -420,7 +420,7 @@ const TOOLS = [
420
420
  },
421
421
  {
422
422
  name: "update_draft",
423
- description: "Update an existing locally saved draft's recipients, subject, body, or other fields. Use to edit a draft before sending. Only provided fields are updated — omitted fields retain their current values. After updating, call send_draft to send or sync_draft_to_remote to push to Proton Drafts.",
423
+ description: "Update an existing locally saved draft's recipients, subject, body, or other fields. Use to edit a draft before sending. Only provided fields are updated — omitted fields retain their current values. After updating, call send_draft to send or sync_draft_to_remote to push to Proton Drafts. The response's attachments are metadata only (filename/contentType/size, no base64 content) even if the draft has attachments from a previous call — use get_draft if you need the actual attachment bytes back.",
424
424
  annotations: { destructiveHint: false },
425
425
  inputSchema: {
426
426
  type: "object",
@@ -2495,6 +2495,23 @@ function sortByDateDesc(items, dateOf) {
2495
2495
  return rightTime - leftTime;
2496
2496
  });
2497
2497
  }
2498
+ // get_emails' multi-account offset/limit pagination math, pulled out for direct
2499
+ // testing. Applying `offset` separately to EACH account's own page before merging
2500
+ // is wrong — page 2 (offset=25,limit=25) would skip each account's own first 25
2501
+ // messages independently, then concatenate their page-2 results, which drops
2502
+ // items relative to page 2 of the globally-merged, newest-first timeline (found
2503
+ // live with interleaved messages across two accounts: page 2 skipped the
2504
+ // second-newest message overall). The caller is expected to have already fetched
2505
+ // each account from offset 0 up to (offset + limit) — since every account's own
2506
+ // list already arrives newest-first, that's guaranteed to include every item that
2507
+ // could rank in the global top (offset + limit) — and passes the sorted,
2508
+ // concatenated superset in here to be paginated as one timeline.
2509
+ export function paginateMergedAccountResults(sortedMerged, offset, limit) {
2510
+ return {
2511
+ page: sortedMerged.slice(offset, offset + limit),
2512
+ hasMore: sortedMerged.length > offset + limit,
2513
+ };
2514
+ }
2498
2515
  function mergeMailboxLabels(perAccountLabels) {
2499
2516
  const merged = new Map();
2500
2517
  for (const labels of perAccountLabels) {
@@ -3170,6 +3187,12 @@ export function createServer(config, options = {}) {
3170
3187
  // string-prefix stripping over the non-primary slugs), so draftId resolution reuses
3171
3188
  // resolveAccountForEmailId itself rather than a second, parallel mechanism.
3172
3189
  const resolveAccountForDraftId = resolveAccountForEmailId;
3190
+ // Same scheme again for delivery-queue ids (cancel_send/list_scheduled_sends) and
3191
+ // snooze ids (cancel_snooze/list_snoozed) — both are per-account services (their own
3192
+ // JSON file under that account's dataDir), so a queued/snoozed item's id needs the
3193
+ // same slug-prefix round-trip as an emailId/draftId to be found again by account.
3194
+ const resolveAccountForQueueId = resolveAccountForEmailId;
3195
+ const resolveAccountForSnoozeId = resolveAccountForEmailId;
3173
3196
  // The slug to prefix a response id with for this bundle — undefined for the
3174
3197
  // primary account, so a primary-account id round-trips with NO prefix at all,
3175
3198
  // exactly as it did before multi-account support existed.
@@ -3429,22 +3452,6 @@ export function createServer(config, options = {}) {
3429
3452
  }
3430
3453
  undoWindowSeconds = requested;
3431
3454
  }
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
3455
  // A `from` matching a configured additional account routes through that
3449
3456
  // account's own SMTP connection — required under Bridge's Split Addresses
3450
3457
  // feature, where each address is its own separate IMAP/SMTP login, not just
@@ -3453,8 +3460,26 @@ export function createServer(config, options = {}) {
3453
3460
  // the primary connection with `from` passed through as a plain header
3454
3461
  // override (still valid for a genuine alias only reachable that way).
3455
3462
  const sendBundle = from ? accountManager.byAddress(from) : undefined;
3463
+ const effectiveSendBundle = sendBundle ?? primaryBundle;
3456
3464
  const sendSmtp = sendBundle ? sendBundle.smtpService : smtpService;
3457
3465
  const sendImap = sendBundle ? sendBundle.imapService : imapService;
3466
+ if (undoWindowSeconds > 0) {
3467
+ // The undo-send queue now goes through the resolved account's OWN
3468
+ // deliveryQueueService (same routing as the immediate-send path above),
3469
+ // so it fires later via that account's real SMTP connection rather than
3470
+ // always the primary's — a delayed send with a non-primary `from` used
3471
+ // to only ever get the header-override-on-primary fallback at fire time.
3472
+ // The returned id is prefixed with that account's slug (same scheme as
3473
+ // emailId/draftId) so cancel_send/list_scheduled_sends can find it again.
3474
+ const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
3475
+ const queued = await withAudit(auditService, name, args, async () => effectiveSendBundle.deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
3476
+ return createTextResult({
3477
+ queued: true,
3478
+ id: withAccountPrefix(responseSlug(effectiveSendBundle), queued.id),
3479
+ sendAt: queued.sendAt,
3480
+ 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.`,
3481
+ });
3482
+ }
3458
3483
  const result = await withAudit(auditService, name, args, async () => sendSmtp.sendEmail(emailPayload));
3459
3484
  let sentCopyTokenSend = "[sent-copy:unverified]";
3460
3485
  try {
@@ -3475,13 +3500,31 @@ export function createServer(config, options = {}) {
3475
3500
  });
3476
3501
  }
3477
3502
  case "cancel_send": {
3478
- const result = await withAudit(auditService, name, args, async () => deliveryQueueService.cancel(requireString(args, "id")));
3479
- return createTextResult(result);
3503
+ // The queue id carries an account-slug prefix when the item was enqueued
3504
+ // through a non-primary account (see send_email/schedule_draft) — resolve
3505
+ // it back to that account's own deliveryQueueService, same as an
3506
+ // emailId/draftId, instead of always checking the primary's queue.
3507
+ const { bundle: cancelSendBundle, rest: cancelSendIdRest } = resolveAccountForQueueId(requireString(args, "id"));
3508
+ const result = await withAudit(auditService, name, args, async () => cancelSendBundle.deliveryQueueService.cancel(cancelSendIdRest));
3509
+ return createTextResult({ ...result, id: withAccountPrefix(responseSlug(cancelSendBundle), result.id) });
3480
3510
  }
3481
3511
  case "list_scheduled_sends": {
3482
3512
  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;
3513
+ // Fan out across every configured account's own delivery queue and tag each
3514
+ // item's id with its owning account's slug mirrors the read-tool fan-out
3515
+ // pattern (get_emails etc.). Single-account guard keeps byte-identical output
3516
+ // for the common case.
3517
+ if (accountManager.all().length === 1) {
3518
+ const all = await withAudit(auditService, name, args, async () => deliveryQueueService.list());
3519
+ const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
3520
+ return createTextResult(filtered);
3521
+ }
3522
+ const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
3523
+ bundle,
3524
+ items: await bundle.deliveryQueueService.list(),
3525
+ }))));
3526
+ const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({ ...item, id: withAccountPrefix(responseSlug(bundle), item.id) })));
3527
+ const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
3485
3528
  return createTextResult(filtered);
3486
3529
  }
3487
3530
  case "get_unsubscribe_info": {
@@ -4119,7 +4162,14 @@ export function createServer(config, options = {}) {
4119
4162
  : undefined);
4120
4163
  return presentDraft(updateDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
4121
4164
  });
4122
- return createTextResult(result, false, [
4165
+ // update_draft is typically called repeatedly against the same draft to tweak
4166
+ // subject/body — the attachments usually aren't touched by this call at all, so
4167
+ // re-echoing their full base64 `content` (serialized twice by createTextResult,
4168
+ // same issue redactDraftAttachmentsForListing already fixed for list_drafts)
4169
+ // burns tokens on every single edit for a draft that has any attachment. The
4170
+ // caller already knows what it attached; get_draft remains full-content for the
4171
+ // "let me look at this draft" case, which is a single call rather than a loop.
4172
+ return createTextResult(redactDraftAttachmentsForListing(result), false, [
4123
4173
  draftSource(result),
4124
4174
  ...(result.remoteDraft?.emailId
4125
4175
  ? [
@@ -4170,7 +4220,7 @@ export function createServer(config, options = {}) {
4170
4220
  // immediate send is actually what's wanted.
4171
4221
  const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4172
4222
  if (pendingScheduled) {
4173
- 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.`);
4223
+ 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.`);
4174
4224
  }
4175
4225
  ensureValidEmails(draft.to, "to");
4176
4226
  ensureValidEmails(draft.cc, "cc");
@@ -4351,7 +4401,7 @@ export function createServer(config, options = {}) {
4351
4401
  // in the common non-racing case.
4352
4402
  const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4353
4403
  if (alreadyPendingScheduled) {
4354
- 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.`);
4404
+ 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.`);
4355
4405
  }
4356
4406
  const sendAt = requireString(args, "sendAt");
4357
4407
  const sendAtTime = new Date(sendAt).getTime();
@@ -4394,7 +4444,7 @@ export function createServer(config, options = {}) {
4394
4444
  }, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
4395
4445
  return createTextResult({
4396
4446
  queued: true,
4397
- id: queued.id,
4447
+ id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
4398
4448
  draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
4399
4449
  sendAt: queued.sendAt,
4400
4450
  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.",
@@ -4445,26 +4495,36 @@ export function createServer(config, options = {}) {
4445
4495
  hasMore: result.emails.length === effectiveLimit,
4446
4496
  }, false, result.emails.map(emailSource));
4447
4497
  }
4448
- // Merge strategy: live-fetch each account's own folder page (LIVE IMAP,
4449
- // one call per account), tag ids with the account slug, concatenate,
4450
- // re-sort newest-first, then re-apply the original limit across the
4451
- // merged set (not per account) and sum totals across accounts.
4498
+ // Merge strategy: an `offset` applied separately to each account's own page
4499
+ // BEFORE merging is wrong e.g. offset=25,limit=25 (page 2) would skip each
4500
+ // account's own first 25 messages independently, then concatenate their
4501
+ // page-2 results, which is not the same as page 2 of the globally-merged,
4502
+ // newest-first timeline (found live with interleaved messages across two
4503
+ // accounts: page 2 skipped the second-newest message overall). Fixed by
4504
+ // always fetching from offset 0 up to (requestedOffset + limit) from EVERY
4505
+ // account — enough that each account's true contribution to the global top
4506
+ // (requestedOffset + limit) is fully captured, since getEmails already
4507
+ // returns newest-first — then merging, re-sorting, and applying the
4508
+ // requested offset/limit to that merged superset instead of per account.
4452
4509
  const primarySlug = accountManager.primary().account.slug;
4510
+ const requestedOffset = getEmailsInput.offset ?? 0;
4511
+ const perAccountFetchLimit = requestedOffset + effectiveLimit;
4453
4512
  const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
4454
- const result = await bundle.imapService.getEmails(getEmailsInput);
4513
+ const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
4455
4514
  return { bundle, result };
4456
4515
  }));
4457
4516
  const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
4458
- const merged = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date).slice(0, effectiveLimit);
4517
+ const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
4518
+ const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
4459
4519
  const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
4460
4520
  return createTextResult({
4461
4521
  folder: getEmailsInput.folder?.trim() || "INBOX",
4462
4522
  total,
4463
4523
  limit: effectiveLimit,
4464
- offset: getEmailsInput.offset ?? 0,
4524
+ offset: requestedOffset,
4465
4525
  emails: projectFields(merged, parseFieldsArg(args.fields)),
4466
4526
  returned: merged.length,
4467
- hasMore: merged.length === effectiveLimit,
4527
+ hasMore,
4468
4528
  byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
4469
4529
  }, false, merged.map(emailSource));
4470
4530
  }
@@ -5215,10 +5275,15 @@ export function createServer(config, options = {}) {
5215
5275
  if (wakeAtTime <= Date.now()) {
5216
5276
  throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
5217
5277
  }
5218
- const snoozed = await withAudit(auditService, name, args, async () => snoozeService.snooze(requireString(args, "emailId"), new Date(wakeAtTime).toISOString()));
5278
+ // emailId carries the same "<slug>::" prefix as everywhere else resolve it
5279
+ // to that account's own snoozeService instead of always the primary's, and
5280
+ // prefix the returned ids (the snooze's own id, and the email id) the same
5281
+ // way so cancel_snooze/list_snoozed can find them again by account.
5282
+ const { bundle: snoozeBundle, rest: snoozeEmailIdRest } = resolveAccountForSnoozeId(requireString(args, "emailId"));
5283
+ const snoozed = await withAudit(auditService, name, args, async () => snoozeBundle.snoozeService.snooze(snoozeEmailIdRest, new Date(wakeAtTime).toISOString()));
5219
5284
  return createTextResult({
5220
- id: snoozed.id,
5221
- emailId: snoozed.currentEmailId,
5285
+ id: withAccountPrefix(responseSlug(snoozeBundle), snoozed.id),
5286
+ emailId: withAccountPrefix(responseSlug(snoozeBundle), snoozed.currentEmailId),
5222
5287
  wakeAt: snoozed.wakeAt,
5223
5288
  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.",
5224
5289
  });
@@ -5227,13 +5292,34 @@ export function createServer(config, options = {}) {
5227
5292
  // cancel moves the mailbox back to its original folder, same as
5228
5293
  // snooze_email's own move — gate it the same way.
5229
5294
  ensureEmailActionAllowed(config.runtime, "archive");
5230
- const result = await withAudit(auditService, name, args, async () => snoozeService.cancel(requireString(args, "id")));
5231
- return createTextResult(result);
5295
+ const { bundle: cancelSnoozeBundle, rest: cancelSnoozeIdRest } = resolveAccountForSnoozeId(requireString(args, "id"));
5296
+ const result = await withAudit(auditService, name, args, async () => cancelSnoozeBundle.snoozeService.cancel(cancelSnoozeIdRest));
5297
+ return createTextResult({
5298
+ ...result,
5299
+ id: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.id),
5300
+ currentEmailId: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.currentEmailId),
5301
+ });
5232
5302
  }
5233
5303
  case "list_snoozed": {
5234
5304
  const statusFilter = optionalString(args, "status");
5235
- const all = await withAudit(auditService, name, args, async () => snoozeService.list());
5236
- const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
5305
+ // Same fan-out pattern as list_scheduled_sends each account has its own
5306
+ // independent snoozeService/JSON file. Single-account guard keeps
5307
+ // byte-identical output for the common case.
5308
+ if (accountManager.all().length === 1) {
5309
+ const all = await withAudit(auditService, name, args, async () => snoozeService.list());
5310
+ const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
5311
+ return createTextResult(filtered);
5312
+ }
5313
+ const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
5314
+ bundle,
5315
+ items: await bundle.snoozeService.list(),
5316
+ }))));
5317
+ const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({
5318
+ ...item,
5319
+ id: withAccountPrefix(responseSlug(bundle), item.id),
5320
+ currentEmailId: withAccountPrefix(responseSlug(bundle), item.currentEmailId),
5321
+ })));
5322
+ const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
5237
5323
  return createTextResult(filtered);
5238
5324
  }
5239
5325
  case "create_template": {
@@ -6215,24 +6301,32 @@ export function createServer(config, options = {}) {
6215
6301
  }
6216
6302
  case "create_thread_reply_draft":
6217
6303
  {
6218
- await maybeRefreshLocalIndex(imapService, localIndexService, {
6304
+ // A threadId is only ever valid within the single account it was produced
6305
+ // by — same "<slug>::" resolution as get_thread_by_id/move_thread. This was
6306
+ // previously always reading via the PRIMARY account's localIndexService/
6307
+ // imapService regardless of the threadId's own prefix, so a reply drafted
6308
+ // against a non-primary account's thread either resolved the wrong thread
6309
+ // (if the primary happened to have a same-shaped local id) or threw not
6310
+ // found. Every read below now goes through the resolved threadBundle.
6311
+ const { bundle: threadReplyBundle, rest: threadReplyIdRest } = resolveAccountForEmailId(requireString(args, "threadId"));
6312
+ await maybeRefreshLocalIndex(threadReplyBundle.imapService, threadReplyBundle.localIndexService, {
6219
6313
  force: normalizeBoolean(args.syncBefore, false),
6220
6314
  folder: "INBOX",
6221
6315
  limitPerFolder: 100,
6222
6316
  });
6223
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
6224
- const targetMessage = pickReplyTargetFromThread(thread, config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
6317
+ const thread = await threadReplyBundle.localIndexService.getThreadById(threadReplyIdRest);
6318
+ const targetMessage = pickReplyTargetFromThread(thread, threadReplyBundle.config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
6225
6319
  if (!targetMessage?.primaryEmailId) {
6226
6320
  throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
6227
6321
  }
6228
- const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
6322
+ const detail = await threadReplyBundle.imapService.getEmailById(targetMessage.primaryEmailId);
6229
6323
  const body = requireString(args, "body");
6230
6324
  const isHtml = normalizeBoolean(args.isHtml, false);
6231
6325
  const replyAll = normalizeBoolean(args.replyAll, false);
6232
6326
  const attachments = optionalAttachmentList(args.attachments);
6233
6327
  const extraCc = parseEmails(optionalString(args, "cc"));
6234
6328
  const extraBcc = parseEmails(optionalString(args, "bcc"));
6235
- const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
6329
+ const recipients = getReplyRecipients(detail, threadReplyBundle.config.smtp.username, replyAll);
6236
6330
  const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
6237
6331
  const to = uniqueAddresses(recipients.to);
6238
6332
  ensureValidEmails(to, "to");