proton-mail-bridge-client 2.0.0 → 2.0.2

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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +6 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +83 -37
  7. package/dist/index.js.map +1 -1
  8. package/dist/services/delivery-queue-service.d.ts +5 -0
  9. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  10. package/dist/services/delivery-queue-service.js +110 -15
  11. package/dist/services/delivery-queue-service.js.map +1 -1
  12. package/dist/services/draft-store-service.d.ts +3 -0
  13. package/dist/services/draft-store-service.d.ts.map +1 -1
  14. package/dist/services/draft-store-service.js +64 -1
  15. package/dist/services/draft-store-service.js.map +1 -1
  16. package/dist/services/local-index-service.d.ts +5 -0
  17. package/dist/services/local-index-service.d.ts.map +1 -1
  18. package/dist/services/local-index-service.js +191 -18
  19. package/dist/services/local-index-service.js.map +1 -1
  20. package/dist/services/simple-imap-service.d.ts +7 -1
  21. package/dist/services/simple-imap-service.d.ts.map +1 -1
  22. package/dist/services/simple-imap-service.js +163 -23
  23. package/dist/services/simple-imap-service.js.map +1 -1
  24. package/dist/services/snooze-service.d.ts +3 -0
  25. package/dist/services/snooze-service.d.ts.map +1 -1
  26. package/dist/services/snooze-service.js +103 -7
  27. package/dist/services/snooze-service.js.map +1 -1
  28. package/dist/services/template-service.d.ts +1 -0
  29. package/dist/services/template-service.d.ts.map +1 -1
  30. package/dist/services/template-service.js +8 -0
  31. package/dist/services/template-service.js.map +1 -1
  32. package/dist/types/index.d.ts +7 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/utils/account-identity.d.ts +8 -0
  35. package/dist/utils/account-identity.d.ts.map +1 -0
  36. package/dist/utils/account-identity.js +78 -0
  37. package/dist/utils/account-identity.js.map +1 -0
  38. package/dist/utils/file-lock.d.ts +1 -0
  39. package/dist/utils/file-lock.d.ts.map +1 -1
  40. package/dist/utils/file-lock.js +24 -4
  41. package/dist/utils/file-lock.js.map +1 -1
  42. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -135,13 +135,14 @@ const TOOLS = [
135
135
  },
136
136
  {
137
137
  name: "send_test_email",
138
- description: "Send a minimal diagnostic email to confirm Proton Bridge SMTP credentials and connectivity. Use before relying on send_email in a new environment. Prefer get_connection_status for a connectivity check that does not actually send mail. Returns transport debug info and delivery status.",
138
+ description: "Send a minimal diagnostic email to confirm Proton Bridge SMTP credentials and connectivity. Use before relying on send_email in a new environment. Prefer get_connection_status for a connectivity check that does not actually send mail. Returns transport debug info and delivery status. Requires PROTONMAIL_ALLOW_SEND, and confirmed:true when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled; subject to PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF like every other send path.",
139
139
  annotations: { destructiveHint: true },
140
140
  inputSchema: {
141
141
  type: "object",
142
142
  properties: {
143
143
  to: { type: "string", description: "Recipient email address." },
144
144
  customMessage: { type: "string", description: "Optional custom test body." },
145
+ confirmed: { type: "boolean", description: "Set to true to confirm this send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
145
146
  },
146
147
  required: ["to"],
147
148
  },
@@ -2776,6 +2777,9 @@ export function createServer(config, options = {}) {
2776
2777
  const draftStore = new DraftStoreService(config, logger);
2777
2778
  const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
2778
2779
  const deliveryQueueService = new DeliveryQueueService(config, smtpService, logger);
2780
+ // Lets a fired scheduled_send close the loop back to its source draft —
2781
+ // see DeliveryQueueService.checkDue()'s draftStore usage.
2782
+ deliveryQueueService.setDraftStore(draftStore);
2779
2783
  const snoozeService = new SnoozeService(config, imapService, logger);
2780
2784
  const templateService = new TemplateService(config, logger);
2781
2785
  if (options.startBackgroundSync) {
@@ -3075,11 +3079,13 @@ export function createServer(config, options = {}) {
3075
3079
  });
3076
3080
  }
3077
3081
  case "send_test_email": {
3078
- ensureSendAllowed(config.runtime);
3079
3082
  const to = requireString(args, "to");
3080
3083
  if (!isValidEmail(to)) {
3081
3084
  throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
3082
3085
  }
3086
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send test email to ${to}`);
3087
+ ensureSendAllowed(config.runtime);
3088
+ ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [to]);
3083
3089
  const result = await withAudit(auditService, name, args, async () => smtpService.sendTestEmail(to, optionalString(args, "customMessage")));
3084
3090
  return createTextResult({
3085
3091
  messageId: result.messageId,
@@ -3676,24 +3682,41 @@ export function createServer(config, options = {}) {
3676
3682
  note: "No email was sent.",
3677
3683
  }, false, [draftSource(draft)]);
3678
3684
  }
3679
- const result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3680
- to: draft.to,
3681
- cc: draft.cc,
3682
- bcc: draft.bcc,
3683
- subject: draft.subject,
3684
- body: draft.body,
3685
- isHtml: draft.isHtml,
3686
- priority: draft.priority,
3687
- replyTo: draft.replyTo,
3688
- inReplyTo: draft.inReplyTo,
3689
- references: draft.references,
3690
- attachments: draft.attachments,
3691
- // Draft content is already finalized and reviewed — appending a
3692
- // signature invisibly at send time would change what the user
3693
- // saw and approved. If a signature is wanted, it belongs in the
3694
- // draft body itself.
3695
- appendSignature: false,
3696
- }));
3685
+ // Atomic claim: the status/pending-scheduled checks above are read
3686
+ // then acted on non-atomically, so two concurrent send_draft calls
3687
+ // (or a scheduled send firing at the same instant) could both pass
3688
+ // them. claimForSending() re-reads status under the draft store's
3689
+ // lock and flips "draft" -> "sending" in that same critical
3690
+ // section — only the call that wins the claim proceeds to SMTP.
3691
+ await draftStore.claimForSending(draft.id);
3692
+ let result;
3693
+ try {
3694
+ result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3695
+ to: draft.to,
3696
+ cc: draft.cc,
3697
+ bcc: draft.bcc,
3698
+ subject: draft.subject,
3699
+ body: draft.body,
3700
+ isHtml: draft.isHtml,
3701
+ priority: draft.priority,
3702
+ replyTo: draft.replyTo,
3703
+ inReplyTo: draft.inReplyTo,
3704
+ references: draft.references,
3705
+ attachments: draft.attachments,
3706
+ // Draft content is already finalized and reviewed — appending a
3707
+ // signature invisibly at send time would change what the user
3708
+ // saw and approved. If a signature is wanted, it belongs in the
3709
+ // draft body itself.
3710
+ appendSignature: false,
3711
+ }));
3712
+ }
3713
+ catch (error) {
3714
+ // Mirrors SnoozeService.wake()'s catch handler: revert the claim
3715
+ // so the draft is retryable instead of stuck in "sending"
3716
+ // forever because SMTP failed.
3717
+ await draftStore.revertSending(draft.id);
3718
+ throw error;
3719
+ }
3697
3720
  let sentDraft = await draftStore.markSent(draft.id, {
3698
3721
  messageId: result.messageId,
3699
3722
  accepted: result.accepted,
@@ -4002,7 +4025,7 @@ export function createServer(config, options = {}) {
4002
4025
  return createTextResult(result);
4003
4026
  }
4004
4027
  case "bulk_move": {
4005
- ensureMailboxWriteAllowed(config.runtime);
4028
+ ensureEmailActionAllowed(config.runtime, "move");
4006
4029
  const emailIds = Array.isArray(args.emailIds)
4007
4030
  ? args.emailIds.map(String) : undefined;
4008
4031
  const match = args.match && typeof args.match === "object"
@@ -4055,15 +4078,21 @@ export function createServer(config, options = {}) {
4055
4078
  const folder = optionalString(args, "folder") ?? "INBOX";
4056
4079
  const max = getBulkMaxBatchSize(args);
4057
4080
  const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4058
- const preview = await imapService.bulkDelete({
4059
- emailIds,
4060
- match,
4061
- folder,
4062
- permanent,
4063
- dryRun: true,
4064
- });
4065
- ensureBulkBatchSize(preview.total, max);
4081
+ // Resolve the match/emailIds set exactly once and reuse it for both
4082
+ // the preview and the real run — see resolveUidsForBulkOp's
4083
+ // comment for why re-resolving `match` a second time (the old
4084
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4085
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
4086
+ ensureBulkBatchSize(uids.length, max);
4066
4087
  if (normalizeBoolean(args.dryRun, false)) {
4088
+ const preview = await imapService.bulkDelete({
4089
+ emailIds,
4090
+ match,
4091
+ folder,
4092
+ permanent,
4093
+ resolvedUids: uids,
4094
+ dryRun: true,
4095
+ });
4067
4096
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4068
4097
  }
4069
4098
  const result = await withAudit(auditService, name, args, () => imapService.bulkDelete({
@@ -4071,6 +4100,7 @@ export function createServer(config, options = {}) {
4071
4100
  match,
4072
4101
  folder,
4073
4102
  permanent,
4103
+ resolvedUids: uids,
4074
4104
  dryRun: false,
4075
4105
  }));
4076
4106
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
@@ -4094,12 +4124,17 @@ export function createServer(config, options = {}) {
4094
4124
  const folder = optionalString(args, "folder") ?? "INBOX";
4095
4125
  const max = getBulkMaxBatchSize(args);
4096
4126
  const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4097
- const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, dryRun: true });
4098
- ensureBulkBatchSize(preview.total, max);
4127
+ // Resolve the match/emailIds set exactly once and reuse it for both
4128
+ // the preview and the real run — see resolveUidsForBulkOp's
4129
+ // comment for why re-resolving `match` a second time (the old
4130
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4131
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
4132
+ ensureBulkBatchSize(uids.length, max);
4099
4133
  if (normalizeBoolean(args.dryRun, false)) {
4134
+ const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: true });
4100
4135
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4101
4136
  }
4102
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, dryRun: false }));
4137
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: false }));
4103
4138
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4104
4139
  }
4105
4140
  case "bulk_update_labels": {
@@ -4119,12 +4154,17 @@ export function createServer(config, options = {}) {
4119
4154
  const folder = optionalString(args, "folder") ?? "INBOX";
4120
4155
  const max = getBulkMaxBatchSize(args);
4121
4156
  const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4122
- const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, dryRun: true });
4123
- ensureBulkBatchSize(preview.total, max);
4157
+ // Resolve the match/emailIds set exactly once and reuse it for both
4158
+ // the preview and the real run — see resolveUidsForBulkOp's
4159
+ // comment for why re-resolving `match` a second time (the old
4160
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4161
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
4162
+ ensureBulkBatchSize(uids.length, max);
4124
4163
  if (normalizeBoolean(args.dryRun, false)) {
4164
+ const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: true });
4125
4165
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4126
4166
  }
4127
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, dryRun: false }));
4167
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: false }));
4128
4168
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4129
4169
  }
4130
4170
  case "top_senders": {
@@ -4230,7 +4270,7 @@ export function createServer(config, options = {}) {
4230
4270
  return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(requireString(args, "emailId"), normalizeBoolean(args.isStarred, true))));
4231
4271
  case "move_email":
4232
4272
  {
4233
- ensureMailboxWriteAllowed(config.runtime);
4273
+ ensureEmailActionAllowed(config.runtime, "move");
4234
4274
  const emailId = requireString(args, "emailId");
4235
4275
  const targetFolder = requireString(args, "targetFolder");
4236
4276
  const result = await withAudit(auditService, name, args, async () => {
@@ -4379,6 +4419,9 @@ export function createServer(config, options = {}) {
4379
4419
  }
4380
4420
  const action = requireEmailAction(args);
4381
4421
  ensureEmailActionAllowed(config.runtime, action);
4422
+ if (action === "delete") {
4423
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) (cannot be recovered)`);
4424
+ }
4382
4425
  const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
4383
4426
  emailIds,
4384
4427
  action,
@@ -4606,7 +4649,7 @@ export function createServer(config, options = {}) {
4606
4649
  },
4607
4650
  drafts: {
4608
4651
  total: drafts.length,
4609
- active: drafts.filter((draft) => draft.status === "draft").length,
4652
+ active: drafts.filter((draft) => draft.status !== "sent").length,
4610
4653
  remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
4611
4654
  syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
4612
4655
  },
@@ -4956,6 +4999,9 @@ export function createServer(config, options = {}) {
4956
4999
  const emailIds = [...new Set(thread.messages
4957
5000
  .filter((message) => !unreadOnly || !message.isRead)
4958
5001
  .map((message) => message.primaryEmailId))];
5002
+ if (action === "delete") {
5003
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${emailIds.length} email(s) in thread (cannot be recovered)`);
5004
+ }
4959
5005
  const result = await withAudit(auditService, name, args, async () => applyBatchEmailAction(imapService, [], {
4960
5006
  emailIds,
4961
5007
  action,