proton-mail-bridge-client 2.0.1 → 2.0.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.
Files changed (48) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +6 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +228 -73
  6. package/dist/index.js.map +1 -1
  7. package/dist/services/audit-service.d.ts +2 -0
  8. package/dist/services/audit-service.d.ts.map +1 -1
  9. package/dist/services/audit-service.js +16 -0
  10. package/dist/services/audit-service.js.map +1 -1
  11. package/dist/services/delivery-queue-service.d.ts +4 -0
  12. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  13. package/dist/services/delivery-queue-service.js +82 -0
  14. package/dist/services/delivery-queue-service.js.map +1 -1
  15. package/dist/services/draft-store-service.d.ts +3 -0
  16. package/dist/services/draft-store-service.d.ts.map +1 -1
  17. package/dist/services/draft-store-service.js +64 -1
  18. package/dist/services/draft-store-service.js.map +1 -1
  19. package/dist/services/local-index-service.d.ts +6 -0
  20. package/dist/services/local-index-service.d.ts.map +1 -1
  21. package/dist/services/local-index-service.js +181 -11
  22. package/dist/services/local-index-service.js.map +1 -1
  23. package/dist/services/simple-imap-service.d.ts +11 -5
  24. package/dist/services/simple-imap-service.d.ts.map +1 -1
  25. package/dist/services/simple-imap-service.js +337 -69
  26. package/dist/services/simple-imap-service.js.map +1 -1
  27. package/dist/services/snooze-service.d.ts +1 -0
  28. package/dist/services/snooze-service.d.ts.map +1 -1
  29. package/dist/services/snooze-service.js +8 -0
  30. package/dist/services/snooze-service.js.map +1 -1
  31. package/dist/services/template-service.d.ts +1 -0
  32. package/dist/services/template-service.d.ts.map +1 -1
  33. package/dist/services/template-service.js +8 -0
  34. package/dist/services/template-service.js.map +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/utils/account-identity.d.ts +8 -0
  38. package/dist/utils/account-identity.d.ts.map +1 -0
  39. package/dist/utils/account-identity.js +105 -0
  40. package/dist/utils/account-identity.js.map +1 -0
  41. package/dist/utils/file-lock.d.ts.map +1 -1
  42. package/dist/utils/file-lock.js +15 -3
  43. package/dist/utils/file-lock.js.map +1 -1
  44. package/dist/utils/helpers.d.ts +2 -1
  45. package/dist/utils/helpers.d.ts.map +1 -1
  46. package/dist/utils/helpers.js +65 -9
  47. package/dist/utils/helpers.js.map +1 -1
  48. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { BackgroundSyncService } from "./services/background-sync-service.js";
16
16
  import { DeliveryQueueService } from "./services/delivery-queue-service.js";
17
17
  import { DraftStoreService } from "./services/draft-store-service.js";
18
18
  import { LocalIndexService } from "./services/local-index-service.js";
19
- import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, SimpleIMAPService } from "./services/simple-imap-service.js";
19
+ import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, SimpleIMAPService, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
20
20
  import { applySignature, SMTPService } from "./services/smtp-service.js";
21
21
  import { SnoozeService } from "./services/snooze-service.js";
22
22
  import { TemplateService } from "./services/template-service.js";
@@ -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
  },
@@ -2369,27 +2370,32 @@ async function getAnalyticsSampleFromIndex(imapService, localIndexService, days,
2369
2370
  return result.emails;
2370
2371
  }
2371
2372
  async function runEmailAction(imapService, emailId, action, targetFolder) {
2373
+ // undefined for an old-format id (no embedded UIDVALIDITY) — every method
2374
+ // below's own assertMailboxUidValidity guard already treats that as
2375
+ // "unverifiable, don't block on it", same as every direct single-message
2376
+ // tool handler.
2377
+ const uidValidity = parseEmailId(emailId).uidValidity;
2372
2378
  switch (action) {
2373
2379
  case "mark_read":
2374
- return imapService.markEmailRead(emailId, true);
2380
+ return imapService.markEmailRead(emailId, true, uidValidity);
2375
2381
  case "mark_unread":
2376
- return imapService.markEmailRead(emailId, false);
2382
+ return imapService.markEmailRead(emailId, false, uidValidity);
2377
2383
  case "star":
2378
- return imapService.starEmail(emailId, true);
2384
+ return imapService.starEmail(emailId, true, uidValidity);
2379
2385
  case "unstar":
2380
- return imapService.starEmail(emailId, false);
2386
+ return imapService.starEmail(emailId, false, uidValidity);
2381
2387
  case "archive":
2382
- return imapService.archiveEmail(emailId);
2388
+ return imapService.archiveEmail(emailId, uidValidity);
2383
2389
  case "trash":
2384
- return imapService.trashEmail(emailId);
2390
+ return imapService.trashEmail(emailId, uidValidity);
2385
2391
  case "restore":
2386
- return imapService.restoreEmail(emailId, targetFolder);
2392
+ return imapService.restoreEmail(emailId, targetFolder, uidValidity);
2387
2393
  case "move":
2388
2394
  if (!targetFolder)
2389
2395
  throw new McpError(ErrorCode.InvalidParams, "targetFolder is required for action 'move'.");
2390
- return imapService.moveEmail(emailId, targetFolder);
2396
+ return imapService.moveEmail(emailId, targetFolder, uidValidity);
2391
2397
  case "delete":
2392
- return imapService.deleteEmail(emailId);
2398
+ return imapService.deleteEmail(emailId, uidValidity);
2393
2399
  }
2394
2400
  }
2395
2401
  function emailSourceFromActionResult(result) {
@@ -2500,11 +2506,19 @@ function ensureBulkBatchSize(uidsLength, max) {
2500
2506
  throw new McpError(ErrorCode.InvalidParams, "Batch size " + uidsLength + " exceeds limit " + max + ". Use match criteria to narrow the scope, or increase maxBatchSize.");
2501
2507
  }
2502
2508
  }
2503
- function getBulkNotFoundEmailIds(emailIds, folder) {
2509
+ // `currentUidValidity` is optional and, when omitted, preserves the exact
2510
+ // original behavior (folder/format check only) — every caller that already
2511
+ // knows the folder's current UIDVALIDITY (the bulk_delete/bulk_update_flags/
2512
+ // bulk_update_labels handlers below, via a single shared getMailboxUidValidity
2513
+ // call reused for both this and resolveUidsForBulkOp) should pass it, so a
2514
+ // stale-generation id gets its own clear error instead of being lumped in
2515
+ // with a genuinely malformed one.
2516
+ function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
2504
2517
  if (!emailIds) {
2505
2518
  return [];
2506
2519
  }
2507
- return emailIds.filter((emailId) => {
2520
+ const excluded = [];
2521
+ for (const emailId of emailIds) {
2508
2522
  // Found live: this compared the emailId's folder segment against
2509
2523
  // `folder` as raw, still-percent-encoded text — createEmailId encodes
2510
2524
  // it (Folders/MCP-Snoozed -> Folders%2FMCP-Snoozed) but `folder` here
@@ -2515,30 +2529,50 @@ function getBulkNotFoundEmailIds(emailIds, folder) {
2515
2529
  // reported genuinely valid ids as notFound. parseEmailId already does
2516
2530
  // the matching decodeURIComponent correctly — reuse it instead of
2517
2531
  // hand-rolling the same parse a second, inconsistent way.
2532
+ let parsed;
2518
2533
  try {
2519
- const parsed = parseEmailId(emailId);
2520
- return parsed.folder !== folder;
2534
+ parsed = parseEmailId(emailId);
2521
2535
  }
2522
2536
  catch {
2523
- return true;
2537
+ excluded.push({ emailId, error: "Email ID could not be resolved to a UID." });
2538
+ continue;
2524
2539
  }
2525
- });
2540
+ if (parsed.folder !== folder) {
2541
+ excluded.push({ emailId, error: "Email ID could not be resolved to a UID." });
2542
+ continue;
2543
+ }
2544
+ // An id with no embedded uidValidity (pre-this-fix format) is
2545
+ // unverifiable, not stale — same "can't check it, so don't block on
2546
+ // it" stance assertMailboxUidValidity already takes for the
2547
+ // single-message mutation path. Only a *checkable* mismatch is
2548
+ // treated as a stale reference. resolveUidsForBulkOp applies this
2549
+ // exact same exclusion (see its own comment) against the exact same
2550
+ // currentUidValidity value, so the uid this method silently drops from
2551
+ // execution and the id this method reports as excluded here always
2552
+ // agree.
2553
+ if (currentUidValidity !== undefined &&
2554
+ parsed.uidValidity !== undefined &&
2555
+ parsed.uidValidity !== currentUidValidity) {
2556
+ excluded.push({ emailId, error: UID_VALIDITY_MISMATCH_ERROR });
2557
+ }
2558
+ }
2559
+ return excluded;
2526
2560
  }
2527
- function withBulkNotFound(result, notFoundEmailIds) {
2528
- if (notFoundEmailIds.length === 0) {
2561
+ function withBulkNotFound(result, excludedEmailIds) {
2562
+ if (excludedEmailIds.length === 0) {
2529
2563
  return result;
2530
2564
  }
2531
2565
  return {
2532
2566
  ...result,
2533
- total: result.total + notFoundEmailIds.length,
2534
- notFound: result.notFound + notFoundEmailIds.length,
2567
+ total: result.total + excludedEmailIds.length,
2568
+ notFound: result.notFound + excludedEmailIds.length,
2535
2569
  results: [
2536
2570
  ...result.results,
2537
- ...notFoundEmailIds.map((emailId) => ({
2571
+ ...excludedEmailIds.map(({ emailId, error }) => ({
2538
2572
  uid: 0,
2539
2573
  emailId,
2540
2574
  ok: false,
2541
- error: "Email ID could not be resolved to a UID.",
2575
+ error,
2542
2576
  })),
2543
2577
  ],
2544
2578
  };
@@ -2776,6 +2810,9 @@ export function createServer(config, options = {}) {
2776
2810
  const draftStore = new DraftStoreService(config, logger);
2777
2811
  const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
2778
2812
  const deliveryQueueService = new DeliveryQueueService(config, smtpService, logger);
2813
+ // Lets a fired scheduled_send close the loop back to its source draft —
2814
+ // see DeliveryQueueService.checkDue()'s draftStore usage.
2815
+ deliveryQueueService.setDraftStore(draftStore);
2779
2816
  const snoozeService = new SnoozeService(config, imapService, logger);
2780
2817
  const templateService = new TemplateService(config, logger);
2781
2818
  if (options.startBackgroundSync) {
@@ -3075,11 +3112,13 @@ export function createServer(config, options = {}) {
3075
3112
  });
3076
3113
  }
3077
3114
  case "send_test_email": {
3078
- ensureSendAllowed(config.runtime);
3079
3115
  const to = requireString(args, "to");
3080
3116
  if (!isValidEmail(to)) {
3081
3117
  throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
3082
3118
  }
3119
+ ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send test email to ${to}`);
3120
+ ensureSendAllowed(config.runtime);
3121
+ ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [to]);
3083
3122
  const result = await withAudit(auditService, name, args, async () => smtpService.sendTestEmail(to, optionalString(args, "customMessage")));
3084
3123
  return createTextResult({
3085
3124
  messageId: result.messageId,
@@ -3676,24 +3715,78 @@ export function createServer(config, options = {}) {
3676
3715
  note: "No email was sent.",
3677
3716
  }, false, [draftSource(draft)]);
3678
3717
  }
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
- }));
3718
+ // Atomic claim: the status/pending-scheduled checks above are read
3719
+ // then acted on non-atomically, so two concurrent send_draft calls
3720
+ // (or a scheduled send firing at the same instant) could both pass
3721
+ // them. claimForSending() re-reads status under the draft store's
3722
+ // lock and flips "draft" -> "sending" in that same critical
3723
+ // section — only the call that wins the claim proceeds to SMTP.
3724
+ await draftStore.claimForSending(draft.id);
3725
+ // Found live: withAudit wrapped the SMTP call, so an audit-log
3726
+ // write failure AFTER a successful send (e.g. disk full) was
3727
+ // indistinguishable from a send failure — it threw out of
3728
+ // withAudit, and the catch below reverted an already-sent draft
3729
+ // back to "draft", making it resendable and enabling a genuine
3730
+ // duplicate delivery. SMTP is called directly here so its outcome
3731
+ // (and the revert-on-failure below) can't be corrupted by a
3732
+ // separate audit-write failure — the audit record for the success
3733
+ // case is written afterward, on its own, with a failure there only
3734
+ // logged, never reverting a send that already happened.
3735
+ const sendStartedAt = Date.now();
3736
+ let result;
3737
+ try {
3738
+ result = await smtpService.sendEmail({
3739
+ to: draft.to,
3740
+ cc: draft.cc,
3741
+ bcc: draft.bcc,
3742
+ subject: draft.subject,
3743
+ body: draft.body,
3744
+ isHtml: draft.isHtml,
3745
+ priority: draft.priority,
3746
+ replyTo: draft.replyTo,
3747
+ inReplyTo: draft.inReplyTo,
3748
+ references: draft.references,
3749
+ attachments: draft.attachments,
3750
+ // Draft content is already finalized and reviewed — appending a
3751
+ // signature invisibly at send time would change what the user
3752
+ // saw and approved. If a signature is wanted, it belongs in the
3753
+ // draft body itself.
3754
+ appendSignature: false,
3755
+ });
3756
+ }
3757
+ catch (error) {
3758
+ // Mirrors SnoozeService.wake()'s catch handler: revert the claim
3759
+ // so the draft is retryable instead of stuck in "sending"
3760
+ // forever because SMTP failed.
3761
+ await draftStore.revertSending(draft.id);
3762
+ await auditService
3763
+ .record({
3764
+ timestamp: new Date().toISOString(),
3765
+ tool: name,
3766
+ status: "error",
3767
+ durationMs: Date.now() - sendStartedAt,
3768
+ input: sanitizeAuditValue(args),
3769
+ error: error instanceof Error ? error.message : String(error),
3770
+ })
3771
+ .catch((auditError) => logger.error("Failed to record audit log for a failed send_draft", "MCPServer", {
3772
+ draftId: draft.id,
3773
+ error: auditError,
3774
+ }));
3775
+ throw error;
3776
+ }
3777
+ try {
3778
+ await auditService.record({
3779
+ timestamp: new Date().toISOString(),
3780
+ tool: name,
3781
+ status: "success",
3782
+ durationMs: Date.now() - sendStartedAt,
3783
+ input: sanitizeAuditValue(args),
3784
+ result: sanitizeAuditValue(result),
3785
+ });
3786
+ }
3787
+ catch (auditError) {
3788
+ logger.error("Failed to record audit log for a successful send_draft — the email was already sent, so the draft is not being reverted", "MCPServer", { draftId: draft.id, error: auditError });
3789
+ }
3697
3790
  let sentDraft = await draftStore.markSent(draft.id, {
3698
3791
  messageId: result.messageId,
3699
3792
  accepted: result.accepted,
@@ -3938,7 +4031,12 @@ export function createServer(config, options = {}) {
3938
4031
  if (labelsToAdd.length === 0 && labelsToRemove.length === 0) {
3939
4032
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one label in labelsToAdd or labelsToRemove.");
3940
4033
  }
3941
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove));
4034
+ // undefined for an old-format id (no embedded UIDVALIDITY) —
4035
+ // updateMessageLabels's own assertMailboxUidValidity guard already
4036
+ // treats that as "unverifiable, don't block on it", same as every
4037
+ // other mutation handler below.
4038
+ const uidValidity = parseEmailId(emailId).uidValidity;
4039
+ const result = await withAudit(auditService, name, args, async () => imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity));
3942
4040
  return createTextResult(result, false, [emailSource({ id: emailId })]);
3943
4041
  }
3944
4042
  case "update_message_flags": {
@@ -3953,7 +4051,8 @@ export function createServer(config, options = {}) {
3953
4051
  if (flagsToAdd.length === 0 && flagsToRemove.length === 0) {
3954
4052
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one flag in flagsToAdd or flagsToRemove.");
3955
4053
  }
3956
- const result = await withAudit(auditService, name, args, async () => imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove));
4054
+ const uidValidity = parseEmailId(emailId).uidValidity;
4055
+ const result = await withAudit(auditService, name, args, async () => imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity));
3957
4056
  return createTextResult(result, false, [emailSource({ id: emailId })]);
3958
4057
  }
3959
4058
  case "count_messages": {
@@ -4016,7 +4115,12 @@ export function createServer(config, options = {}) {
4016
4115
  const folder = optionalString(args, "folder") ?? "INBOX";
4017
4116
  const targetFolder = requireString(args, "targetFolder");
4018
4117
  const max = getBulkMaxBatchSize(args);
4019
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4118
+ // Only fetched (an extra STATUS round trip) when there are ids to
4119
+ // check against it — a match-only bulk_move has no ids that could
4120
+ // be stale, since match resolves directly against the live
4121
+ // mailbox each time.
4122
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4123
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4020
4124
  const preview = await imapService.bulkMove({
4021
4125
  emailIds,
4022
4126
  match,
@@ -4054,16 +4158,32 @@ export function createServer(config, options = {}) {
4054
4158
  throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
4055
4159
  const folder = optionalString(args, "folder") ?? "INBOX";
4056
4160
  const max = getBulkMaxBatchSize(args);
4057
- 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);
4161
+ // Fetched once, up front, and reused for both the notFound
4162
+ // reporting below and resolveUidsForBulkOp's own filtering — a
4163
+ // single shared point-in-time value, same "resolve once" spirit
4164
+ // as the uids resolution itself, so the ids this call reports as
4165
+ // excluded and the uids it actually acts on can never disagree
4166
+ // about which generation was current. Only fetched when there are
4167
+ // ids to check against it (a match-only call has none).
4168
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4169
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4170
+ // Resolve the match/emailIds set exactly once and reuse it for both
4171
+ // the preview and the real run — see resolveUidsForBulkOp's
4172
+ // comment for why re-resolving `match` a second time (the old
4173
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4174
+ // Also excludes any id whose embedded UIDVALIDITY is stale — see
4175
+ // resolveUidsForBulkOp's comment.
4176
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4177
+ ensureBulkBatchSize(uids.length, max);
4066
4178
  if (normalizeBoolean(args.dryRun, false)) {
4179
+ const preview = await imapService.bulkDelete({
4180
+ emailIds,
4181
+ match,
4182
+ folder,
4183
+ permanent,
4184
+ resolvedUids: uids,
4185
+ dryRun: true,
4186
+ });
4067
4187
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4068
4188
  }
4069
4189
  const result = await withAudit(auditService, name, args, () => imapService.bulkDelete({
@@ -4071,6 +4191,7 @@ export function createServer(config, options = {}) {
4071
4191
  match,
4072
4192
  folder,
4073
4193
  permanent,
4194
+ resolvedUids: uids,
4074
4195
  dryRun: false,
4075
4196
  }));
4076
4197
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
@@ -4093,13 +4214,22 @@ export function createServer(config, options = {}) {
4093
4214
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4094
4215
  const folder = optionalString(args, "folder") ?? "INBOX";
4095
4216
  const max = getBulkMaxBatchSize(args);
4096
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4097
- const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, dryRun: true });
4098
- ensureBulkBatchSize(preview.total, max);
4217
+ // See the bulk_delete case above for why this is fetched once and
4218
+ // shared between notFound reporting and uid resolution.
4219
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4220
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4221
+ // Resolve the match/emailIds set exactly once and reuse it for both
4222
+ // the preview and the real run — see resolveUidsForBulkOp's
4223
+ // comment for why re-resolving `match` a second time (the old
4224
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4225
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4226
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4227
+ ensureBulkBatchSize(uids.length, max);
4099
4228
  if (normalizeBoolean(args.dryRun, false)) {
4229
+ const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: true });
4100
4230
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4101
4231
  }
4102
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, dryRun: false }));
4232
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: false }));
4103
4233
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4104
4234
  }
4105
4235
  case "bulk_update_labels": {
@@ -4118,13 +4248,22 @@ export function createServer(config, options = {}) {
4118
4248
  const labelsToRemove = Array.isArray(args.labelsToRemove) ? args.labelsToRemove.map(String) : [];
4119
4249
  const folder = optionalString(args, "folder") ?? "INBOX";
4120
4250
  const max = getBulkMaxBatchSize(args);
4121
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4122
- const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, dryRun: true });
4123
- ensureBulkBatchSize(preview.total, max);
4251
+ // See the bulk_delete case above for why this is fetched once and
4252
+ // shared between notFound reporting and uid resolution.
4253
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4254
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4255
+ // Resolve the match/emailIds set exactly once and reuse it for both
4256
+ // the preview and the real run — see resolveUidsForBulkOp's
4257
+ // comment for why re-resolving `match` a second time (the old
4258
+ // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4259
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4260
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4261
+ ensureBulkBatchSize(uids.length, max);
4124
4262
  if (normalizeBoolean(args.dryRun, false)) {
4263
+ const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: true });
4125
4264
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4126
4265
  }
4127
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, dryRun: false }));
4266
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: false }));
4128
4267
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4129
4268
  }
4130
4269
  case "top_senders": {
@@ -4222,20 +4361,27 @@ export function createServer(config, options = {}) {
4222
4361
  ensureMailboxWriteAllowed(config.runtime);
4223
4362
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args?.confirmed, false), "Permanently delete folder and all messages in it: " + requireString(args, "path"));
4224
4363
  return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
4225
- case "mark_email_read":
4364
+ case "mark_email_read": {
4226
4365
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
4227
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(requireString(args, "emailId"), normalizeBoolean(args.isRead, true))));
4228
- case "star_email":
4366
+ const emailId = requireString(args, "emailId");
4367
+ const uidValidity = parseEmailId(emailId).uidValidity;
4368
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(emailId, normalizeBoolean(args.isRead, true), uidValidity)));
4369
+ }
4370
+ case "star_email": {
4229
4371
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
4230
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(requireString(args, "emailId"), normalizeBoolean(args.isStarred, true))));
4372
+ const emailId = requireString(args, "emailId");
4373
+ const uidValidity = parseEmailId(emailId).uidValidity;
4374
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(emailId, normalizeBoolean(args.isStarred, true), uidValidity)));
4375
+ }
4231
4376
  case "move_email":
4232
4377
  {
4233
4378
  ensureEmailActionAllowed(config.runtime, "move");
4234
4379
  const emailId = requireString(args, "emailId");
4235
4380
  const targetFolder = requireString(args, "targetFolder");
4381
+ const uidValidity = parseEmailId(emailId).uidValidity;
4236
4382
  const result = await withAudit(auditService, name, args, async () => {
4237
4383
  try {
4238
- return await imapService.moveEmail(emailId, targetFolder);
4384
+ return await imapService.moveEmail(emailId, targetFolder, uidValidity);
4239
4385
  }
4240
4386
  catch (error) {
4241
4387
  if (isMissingTargetFolderError(error)) {
@@ -4260,7 +4406,9 @@ export function createServer(config, options = {}) {
4260
4406
  case "archive_email":
4261
4407
  {
4262
4408
  ensureEmailActionAllowed(config.runtime, "archive");
4263
- const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(requireString(args, "emailId")));
4409
+ const emailId = requireString(args, "emailId");
4410
+ const uidValidity = parseEmailId(emailId).uidValidity;
4411
+ const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(emailId, uidValidity));
4264
4412
  const sources = result.targetEmailId
4265
4413
  ? [
4266
4414
  {
@@ -4277,7 +4425,9 @@ export function createServer(config, options = {}) {
4277
4425
  case "trash_email":
4278
4426
  {
4279
4427
  ensureEmailActionAllowed(config.runtime, "trash");
4280
- const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(requireString(args, "emailId")));
4428
+ const emailId = requireString(args, "emailId");
4429
+ const uidValidity = parseEmailId(emailId).uidValidity;
4430
+ const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(emailId, uidValidity));
4281
4431
  const sources = result.targetEmailId
4282
4432
  ? [
4283
4433
  {
@@ -4294,7 +4444,9 @@ export function createServer(config, options = {}) {
4294
4444
  case "restore_email":
4295
4445
  {
4296
4446
  ensureEmailActionAllowed(config.runtime, "restore");
4297
- const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(requireString(args, "emailId"), optionalString(args, "targetFolder")));
4447
+ const emailId = requireString(args, "emailId");
4448
+ const uidValidity = parseEmailId(emailId).uidValidity;
4449
+ const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
4298
4450
  const sources = result.targetEmailId
4299
4451
  ? [
4300
4452
  {
@@ -4367,10 +4519,13 @@ export function createServer(config, options = {}) {
4367
4519
  const result = await withAudit(auditService, name, args, async () => templateService.render(requireString(args, "id"), variables));
4368
4520
  return createTextResult(result);
4369
4521
  }
4370
- case "delete_email":
4522
+ case "delete_email": {
4371
4523
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
4372
4524
  ensureMailboxWriteAllowed(config.runtime);
4373
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(requireString(args, "emailId"))));
4525
+ const emailId = requireString(args, "emailId");
4526
+ const uidValidity = parseEmailId(emailId).uidValidity;
4527
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(emailId, uidValidity)));
4528
+ }
4374
4529
  case "batch_email_action":
4375
4530
  {
4376
4531
  const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];
@@ -4609,7 +4764,7 @@ export function createServer(config, options = {}) {
4609
4764
  },
4610
4765
  drafts: {
4611
4766
  total: drafts.length,
4612
- active: drafts.filter((draft) => draft.status === "draft").length,
4767
+ active: drafts.filter((draft) => draft.status !== "sent").length,
4613
4768
  remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
4614
4769
  syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
4615
4770
  },