proton-mail-bridge-client 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAYnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAA2H,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAC/L,OAAO,EAAkB,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAUV,WAAW,EAEX,gBAAgB,EAEjB,MAAM,kBAAkB,CAAC;AA2qD1B,eAAO,MAAM,eAAe,aAyB1B,CAAC;AAgGH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAC1B,OAAO,CAAC,CAAC,CAAC,CAwBZ;AAmED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK3G;AAKD,wBAAgB,0BAA0B,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAI1G;AASD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa9E;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAS7F;AA4KD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,OAAO,GAChB;IAAE,EAAE,EAAE,MAAM,EAAE,CAAC;IAAC,EAAE,EAAE,MAAM,EAAE,CAAA;CAAE,CAyBhC;AA4wBD,wBAAgB,kBAAkB,IAAI,gBAAgB,CAkGrD;AAED,wBAAgB,YAAY,CAC1B,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE;IACP,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA07FP;AAED,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmE1C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAYnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAA2H,iBAAiB,EAA+B,MAAM,mCAAmC,CAAC;AAC5N,OAAO,EAAkB,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAUV,WAAW,EAEX,gBAAgB,EAEjB,MAAM,kBAAkB,CAAC;AA2qD1B,eAAO,MAAM,eAAe,aAyB1B,CAAC;AAgGH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAC1B,OAAO,CAAC,CAAC,CAAC,CAwBZ;AAmED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK3G;AAKD,wBAAgB,0BAA0B,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAI1G;AASD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa9E;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAS7F;AA4KD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,OAAO,GAChB;IAAE,EAAE,EAAE,MAAM,EAAE,CAAC;IAAC,EAAE,EAAE,MAAM,EAAE,CAAA;CAAE,CAyBhC;AAwzBD,wBAAgB,kBAAkB,IAAI,gBAAgB,CAkGrD;AAED,wBAAgB,YAAY,CAC1B,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE;IACP,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqhGP;AAED,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmE1C"}
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";
@@ -2370,27 +2370,32 @@ async function getAnalyticsSampleFromIndex(imapService, localIndexService, days,
2370
2370
  return result.emails;
2371
2371
  }
2372
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;
2373
2378
  switch (action) {
2374
2379
  case "mark_read":
2375
- return imapService.markEmailRead(emailId, true);
2380
+ return imapService.markEmailRead(emailId, true, uidValidity);
2376
2381
  case "mark_unread":
2377
- return imapService.markEmailRead(emailId, false);
2382
+ return imapService.markEmailRead(emailId, false, uidValidity);
2378
2383
  case "star":
2379
- return imapService.starEmail(emailId, true);
2384
+ return imapService.starEmail(emailId, true, uidValidity);
2380
2385
  case "unstar":
2381
- return imapService.starEmail(emailId, false);
2386
+ return imapService.starEmail(emailId, false, uidValidity);
2382
2387
  case "archive":
2383
- return imapService.archiveEmail(emailId);
2388
+ return imapService.archiveEmail(emailId, uidValidity);
2384
2389
  case "trash":
2385
- return imapService.trashEmail(emailId);
2390
+ return imapService.trashEmail(emailId, uidValidity);
2386
2391
  case "restore":
2387
- return imapService.restoreEmail(emailId, targetFolder);
2392
+ return imapService.restoreEmail(emailId, targetFolder, uidValidity);
2388
2393
  case "move":
2389
2394
  if (!targetFolder)
2390
2395
  throw new McpError(ErrorCode.InvalidParams, "targetFolder is required for action 'move'.");
2391
- return imapService.moveEmail(emailId, targetFolder);
2396
+ return imapService.moveEmail(emailId, targetFolder, uidValidity);
2392
2397
  case "delete":
2393
- return imapService.deleteEmail(emailId);
2398
+ return imapService.deleteEmail(emailId, uidValidity);
2394
2399
  }
2395
2400
  }
2396
2401
  function emailSourceFromActionResult(result) {
@@ -2501,11 +2506,19 @@ function ensureBulkBatchSize(uidsLength, max) {
2501
2506
  throw new McpError(ErrorCode.InvalidParams, "Batch size " + uidsLength + " exceeds limit " + max + ". Use match criteria to narrow the scope, or increase maxBatchSize.");
2502
2507
  }
2503
2508
  }
2504
- 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) {
2505
2517
  if (!emailIds) {
2506
2518
  return [];
2507
2519
  }
2508
- return emailIds.filter((emailId) => {
2520
+ const excluded = [];
2521
+ for (const emailId of emailIds) {
2509
2522
  // Found live: this compared the emailId's folder segment against
2510
2523
  // `folder` as raw, still-percent-encoded text — createEmailId encodes
2511
2524
  // it (Folders/MCP-Snoozed -> Folders%2FMCP-Snoozed) but `folder` here
@@ -2516,30 +2529,50 @@ function getBulkNotFoundEmailIds(emailIds, folder) {
2516
2529
  // reported genuinely valid ids as notFound. parseEmailId already does
2517
2530
  // the matching decodeURIComponent correctly — reuse it instead of
2518
2531
  // hand-rolling the same parse a second, inconsistent way.
2532
+ let parsed;
2519
2533
  try {
2520
- const parsed = parseEmailId(emailId);
2521
- return parsed.folder !== folder;
2534
+ parsed = parseEmailId(emailId);
2522
2535
  }
2523
2536
  catch {
2524
- return true;
2537
+ excluded.push({ emailId, error: "Email ID could not be resolved to a UID." });
2538
+ continue;
2525
2539
  }
2526
- });
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;
2527
2560
  }
2528
- function withBulkNotFound(result, notFoundEmailIds) {
2529
- if (notFoundEmailIds.length === 0) {
2561
+ function withBulkNotFound(result, excludedEmailIds) {
2562
+ if (excludedEmailIds.length === 0) {
2530
2563
  return result;
2531
2564
  }
2532
2565
  return {
2533
2566
  ...result,
2534
- total: result.total + notFoundEmailIds.length,
2535
- notFound: result.notFound + notFoundEmailIds.length,
2567
+ total: result.total + excludedEmailIds.length,
2568
+ notFound: result.notFound + excludedEmailIds.length,
2536
2569
  results: [
2537
2570
  ...result.results,
2538
- ...notFoundEmailIds.map((emailId) => ({
2571
+ ...excludedEmailIds.map(({ emailId, error }) => ({
2539
2572
  uid: 0,
2540
2573
  emailId,
2541
2574
  ok: false,
2542
- error: "Email ID could not be resolved to a UID.",
2575
+ error,
2543
2576
  })),
2544
2577
  ],
2545
2578
  };
@@ -3689,9 +3722,20 @@ export function createServer(config, options = {}) {
3689
3722
  // lock and flips "draft" -> "sending" in that same critical
3690
3723
  // section — only the call that wins the claim proceeds to SMTP.
3691
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();
3692
3736
  let result;
3693
3737
  try {
3694
- result = await withAudit(auditService, name, args, async () => smtpService.sendEmail({
3738
+ result = await smtpService.sendEmail({
3695
3739
  to: draft.to,
3696
3740
  cc: draft.cc,
3697
3741
  bcc: draft.bcc,
@@ -3708,15 +3752,41 @@ export function createServer(config, options = {}) {
3708
3752
  // saw and approved. If a signature is wanted, it belongs in the
3709
3753
  // draft body itself.
3710
3754
  appendSignature: false,
3711
- }));
3755
+ });
3712
3756
  }
3713
3757
  catch (error) {
3714
3758
  // Mirrors SnoozeService.wake()'s catch handler: revert the claim
3715
3759
  // so the draft is retryable instead of stuck in "sending"
3716
3760
  // forever because SMTP failed.
3717
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
+ }));
3718
3775
  throw error;
3719
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
+ }
3720
3790
  let sentDraft = await draftStore.markSent(draft.id, {
3721
3791
  messageId: result.messageId,
3722
3792
  accepted: result.accepted,
@@ -3961,7 +4031,12 @@ export function createServer(config, options = {}) {
3961
4031
  if (labelsToAdd.length === 0 && labelsToRemove.length === 0) {
3962
4032
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one label in labelsToAdd or labelsToRemove.");
3963
4033
  }
3964
- 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));
3965
4040
  return createTextResult(result, false, [emailSource({ id: emailId })]);
3966
4041
  }
3967
4042
  case "update_message_flags": {
@@ -3976,7 +4051,8 @@ export function createServer(config, options = {}) {
3976
4051
  if (flagsToAdd.length === 0 && flagsToRemove.length === 0) {
3977
4052
  throw new McpError(ErrorCode.InvalidParams, "Provide at least one flag in flagsToAdd or flagsToRemove.");
3978
4053
  }
3979
- 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));
3980
4056
  return createTextResult(result, false, [emailSource({ id: emailId })]);
3981
4057
  }
3982
4058
  case "count_messages": {
@@ -4039,7 +4115,12 @@ export function createServer(config, options = {}) {
4039
4115
  const folder = optionalString(args, "folder") ?? "INBOX";
4040
4116
  const targetFolder = requireString(args, "targetFolder");
4041
4117
  const max = getBulkMaxBatchSize(args);
4042
- 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);
4043
4124
  const preview = await imapService.bulkMove({
4044
4125
  emailIds,
4045
4126
  match,
@@ -4077,12 +4158,22 @@ export function createServer(config, options = {}) {
4077
4158
  throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
4078
4159
  const folder = optionalString(args, "folder") ?? "INBOX";
4079
4160
  const max = getBulkMaxBatchSize(args);
4080
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
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);
4081
4170
  // Resolve the match/emailIds set exactly once and reuse it for both
4082
4171
  // the preview and the real run — see resolveUidsForBulkOp's
4083
4172
  // comment for why re-resolving `match` a second time (the old
4084
4173
  // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4085
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
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);
4086
4177
  ensureBulkBatchSize(uids.length, max);
4087
4178
  if (normalizeBoolean(args.dryRun, false)) {
4088
4179
  const preview = await imapService.bulkDelete({
@@ -4091,6 +4182,7 @@ export function createServer(config, options = {}) {
4091
4182
  folder,
4092
4183
  permanent,
4093
4184
  resolvedUids: uids,
4185
+ uidValidity: currentUidValidity,
4094
4186
  dryRun: true,
4095
4187
  });
4096
4188
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
@@ -4101,6 +4193,7 @@ export function createServer(config, options = {}) {
4101
4193
  folder,
4102
4194
  permanent,
4103
4195
  resolvedUids: uids,
4196
+ uidValidity: currentUidValidity,
4104
4197
  dryRun: false,
4105
4198
  }));
4106
4199
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
@@ -4123,18 +4216,22 @@ export function createServer(config, options = {}) {
4123
4216
  throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
4124
4217
  const folder = optionalString(args, "folder") ?? "INBOX";
4125
4218
  const max = getBulkMaxBatchSize(args);
4126
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4219
+ // See the bulk_delete case above for why this is fetched once and
4220
+ // shared between notFound reporting and uid resolution.
4221
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4222
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4127
4223
  // Resolve the match/emailIds set exactly once and reuse it for both
4128
4224
  // the preview and the real run — see resolveUidsForBulkOp's
4129
4225
  // comment for why re-resolving `match` a second time (the old
4130
4226
  // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4131
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
4227
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4228
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4132
4229
  ensureBulkBatchSize(uids.length, max);
4133
4230
  if (normalizeBoolean(args.dryRun, false)) {
4134
- const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: true });
4231
+ const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4135
4232
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4136
4233
  }
4137
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: false }));
4234
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4138
4235
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4139
4236
  }
4140
4237
  case "bulk_update_labels": {
@@ -4153,18 +4250,22 @@ export function createServer(config, options = {}) {
4153
4250
  const labelsToRemove = Array.isArray(args.labelsToRemove) ? args.labelsToRemove.map(String) : [];
4154
4251
  const folder = optionalString(args, "folder") ?? "INBOX";
4155
4252
  const max = getBulkMaxBatchSize(args);
4156
- const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder);
4253
+ // See the bulk_delete case above for why this is fetched once and
4254
+ // shared between notFound reporting and uid resolution.
4255
+ const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
4256
+ const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
4157
4257
  // Resolve the match/emailIds set exactly once and reuse it for both
4158
4258
  // the preview and the real run — see resolveUidsForBulkOp's
4159
4259
  // comment for why re-resolving `match` a second time (the old
4160
4260
  // dry-run-then-real-run pattern) could silently exceed maxBatchSize.
4161
- const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match);
4261
+ // Also excludes any id whose embedded UIDVALIDITY is stale.
4262
+ const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
4162
4263
  ensureBulkBatchSize(uids.length, max);
4163
4264
  if (normalizeBoolean(args.dryRun, false)) {
4164
- const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: true });
4265
+ const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: true });
4165
4266
  return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
4166
4267
  }
4167
- const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: false }));
4268
+ const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, uidValidity: currentUidValidity, dryRun: false }));
4168
4269
  return createTextResult(withBulkNotFound(result, notFoundEmailIds));
4169
4270
  }
4170
4271
  case "top_senders": {
@@ -4262,20 +4363,27 @@ export function createServer(config, options = {}) {
4262
4363
  ensureMailboxWriteAllowed(config.runtime);
4263
4364
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args?.confirmed, false), "Permanently delete folder and all messages in it: " + requireString(args, "path"));
4264
4365
  return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
4265
- case "mark_email_read":
4366
+ case "mark_email_read": {
4266
4367
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
4267
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(requireString(args, "emailId"), normalizeBoolean(args.isRead, true))));
4268
- case "star_email":
4368
+ const emailId = requireString(args, "emailId");
4369
+ const uidValidity = parseEmailId(emailId).uidValidity;
4370
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(emailId, normalizeBoolean(args.isRead, true), uidValidity)));
4371
+ }
4372
+ case "star_email": {
4269
4373
  ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
4270
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(requireString(args, "emailId"), normalizeBoolean(args.isStarred, true))));
4374
+ const emailId = requireString(args, "emailId");
4375
+ const uidValidity = parseEmailId(emailId).uidValidity;
4376
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(emailId, normalizeBoolean(args.isStarred, true), uidValidity)));
4377
+ }
4271
4378
  case "move_email":
4272
4379
  {
4273
4380
  ensureEmailActionAllowed(config.runtime, "move");
4274
4381
  const emailId = requireString(args, "emailId");
4275
4382
  const targetFolder = requireString(args, "targetFolder");
4383
+ const uidValidity = parseEmailId(emailId).uidValidity;
4276
4384
  const result = await withAudit(auditService, name, args, async () => {
4277
4385
  try {
4278
- return await imapService.moveEmail(emailId, targetFolder);
4386
+ return await imapService.moveEmail(emailId, targetFolder, uidValidity);
4279
4387
  }
4280
4388
  catch (error) {
4281
4389
  if (isMissingTargetFolderError(error)) {
@@ -4300,7 +4408,9 @@ export function createServer(config, options = {}) {
4300
4408
  case "archive_email":
4301
4409
  {
4302
4410
  ensureEmailActionAllowed(config.runtime, "archive");
4303
- const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(requireString(args, "emailId")));
4411
+ const emailId = requireString(args, "emailId");
4412
+ const uidValidity = parseEmailId(emailId).uidValidity;
4413
+ const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(emailId, uidValidity));
4304
4414
  const sources = result.targetEmailId
4305
4415
  ? [
4306
4416
  {
@@ -4317,7 +4427,9 @@ export function createServer(config, options = {}) {
4317
4427
  case "trash_email":
4318
4428
  {
4319
4429
  ensureEmailActionAllowed(config.runtime, "trash");
4320
- const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(requireString(args, "emailId")));
4430
+ const emailId = requireString(args, "emailId");
4431
+ const uidValidity = parseEmailId(emailId).uidValidity;
4432
+ const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(emailId, uidValidity));
4321
4433
  const sources = result.targetEmailId
4322
4434
  ? [
4323
4435
  {
@@ -4334,7 +4446,9 @@ export function createServer(config, options = {}) {
4334
4446
  case "restore_email":
4335
4447
  {
4336
4448
  ensureEmailActionAllowed(config.runtime, "restore");
4337
- const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(requireString(args, "emailId"), optionalString(args, "targetFolder")));
4449
+ const emailId = requireString(args, "emailId");
4450
+ const uidValidity = parseEmailId(emailId).uidValidity;
4451
+ const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
4338
4452
  const sources = result.targetEmailId
4339
4453
  ? [
4340
4454
  {
@@ -4407,10 +4521,13 @@ export function createServer(config, options = {}) {
4407
4521
  const result = await withAudit(auditService, name, args, async () => templateService.render(requireString(args, "id"), variables));
4408
4522
  return createTextResult(result);
4409
4523
  }
4410
- case "delete_email":
4524
+ case "delete_email": {
4411
4525
  ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
4412
4526
  ensureMailboxWriteAllowed(config.runtime);
4413
- return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(requireString(args, "emailId"))));
4527
+ const emailId = requireString(args, "emailId");
4528
+ const uidValidity = parseEmailId(emailId).uidValidity;
4529
+ return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(emailId, uidValidity)));
4530
+ }
4414
4531
  case "batch_email_action":
4415
4532
  {
4416
4533
  const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];