proton-mail-bridge-client 2.1.2 → 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
@@ -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": {
@@ -4177,7 +4220,7 @@ export function createServer(config, options = {}) {
4177
4220
  // immediate send is actually what's wanted.
4178
4221
  const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4179
4222
  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.`);
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.`);
4181
4224
  }
4182
4225
  ensureValidEmails(draft.to, "to");
4183
4226
  ensureValidEmails(draft.cc, "cc");
@@ -4358,7 +4401,7 @@ export function createServer(config, options = {}) {
4358
4401
  // in the common non-racing case.
4359
4402
  const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4360
4403
  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.`);
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.`);
4362
4405
  }
4363
4406
  const sendAt = requireString(args, "sendAt");
4364
4407
  const sendAtTime = new Date(sendAt).getTime();
@@ -4401,7 +4444,7 @@ export function createServer(config, options = {}) {
4401
4444
  }, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
4402
4445
  return createTextResult({
4403
4446
  queued: true,
4404
- id: queued.id,
4447
+ id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
4405
4448
  draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
4406
4449
  sendAt: queued.sendAt,
4407
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.",
@@ -4452,26 +4495,36 @@ export function createServer(config, options = {}) {
4452
4495
  hasMore: result.emails.length === effectiveLimit,
4453
4496
  }, false, result.emails.map(emailSource));
4454
4497
  }
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.
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.
4459
4509
  const primarySlug = accountManager.primary().account.slug;
4510
+ const requestedOffset = getEmailsInput.offset ?? 0;
4511
+ const perAccountFetchLimit = requestedOffset + effectiveLimit;
4460
4512
  const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
4461
- const result = await bundle.imapService.getEmails(getEmailsInput);
4513
+ const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
4462
4514
  return { bundle, result };
4463
4515
  }));
4464
4516
  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);
4517
+ const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
4518
+ const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
4466
4519
  const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
4467
4520
  return createTextResult({
4468
4521
  folder: getEmailsInput.folder?.trim() || "INBOX",
4469
4522
  total,
4470
4523
  limit: effectiveLimit,
4471
- offset: getEmailsInput.offset ?? 0,
4524
+ offset: requestedOffset,
4472
4525
  emails: projectFields(merged, parseFieldsArg(args.fields)),
4473
4526
  returned: merged.length,
4474
- hasMore: merged.length === effectiveLimit,
4527
+ hasMore,
4475
4528
  byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
4476
4529
  }, false, merged.map(emailSource));
4477
4530
  }
@@ -5222,10 +5275,15 @@ export function createServer(config, options = {}) {
5222
5275
  if (wakeAtTime <= Date.now()) {
5223
5276
  throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
5224
5277
  }
5225
- 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()));
5226
5284
  return createTextResult({
5227
- id: snoozed.id,
5228
- emailId: snoozed.currentEmailId,
5285
+ id: withAccountPrefix(responseSlug(snoozeBundle), snoozed.id),
5286
+ emailId: withAccountPrefix(responseSlug(snoozeBundle), snoozed.currentEmailId),
5229
5287
  wakeAt: snoozed.wakeAt,
5230
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.",
5231
5289
  });
@@ -5234,13 +5292,34 @@ export function createServer(config, options = {}) {
5234
5292
  // cancel moves the mailbox back to its original folder, same as
5235
5293
  // snooze_email's own move — gate it the same way.
5236
5294
  ensureEmailActionAllowed(config.runtime, "archive");
5237
- const result = await withAudit(auditService, name, args, async () => snoozeService.cancel(requireString(args, "id")));
5238
- 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
+ });
5239
5302
  }
5240
5303
  case "list_snoozed": {
5241
5304
  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;
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;
5244
5323
  return createTextResult(filtered);
5245
5324
  }
5246
5325
  case "create_template": {
@@ -6222,24 +6301,32 @@ export function createServer(config, options = {}) {
6222
6301
  }
6223
6302
  case "create_thread_reply_draft":
6224
6303
  {
6225
- 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, {
6226
6313
  force: normalizeBoolean(args.syncBefore, false),
6227
6314
  folder: "INBOX",
6228
6315
  limitPerFolder: 100,
6229
6316
  });
6230
- const thread = await localIndexService.getThreadById(requireString(args, "threadId"));
6231
- 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));
6232
6319
  if (!targetMessage?.primaryEmailId) {
6233
6320
  throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
6234
6321
  }
6235
- const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
6322
+ const detail = await threadReplyBundle.imapService.getEmailById(targetMessage.primaryEmailId);
6236
6323
  const body = requireString(args, "body");
6237
6324
  const isHtml = normalizeBoolean(args.isHtml, false);
6238
6325
  const replyAll = normalizeBoolean(args.replyAll, false);
6239
6326
  const attachments = optionalAttachmentList(args.attachments);
6240
6327
  const extraCc = parseEmails(optionalString(args, "cc"));
6241
6328
  const extraBcc = parseEmails(optionalString(args, "bcc"));
6242
- const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
6329
+ const recipients = getReplyRecipients(detail, threadReplyBundle.config.smtp.username, replyAll);
6243
6330
  const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
6244
6331
  const to = uniqueAddresses(recipients.to);
6245
6332
  ensureValidEmails(to, "to");