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.
@@ -35,6 +35,11 @@ const FETCH_INDEX_DETAIL_QUERY = {
35
35
  source: true,
36
36
  };
37
37
  const MAX_ATTACHMENT_TEXT_BYTES = 512_000;
38
+ // searchEmails' local-filter branch batches its detail fetch independently of the
39
+ // caller's result `limit` (see the comment above that loop) — this is the floor on
40
+ // each network batch so a small limit (e.g. 1) can't degrade into one `fetch` call
41
+ // per candidate.
42
+ export const SEARCH_FILTER_BATCH_SIZE = 50;
38
43
  // A healthy IMAP IDLE blocks until a mailbox change or the requested timeout.
39
44
  // If client.idle() returns faster than this with no events, IDLE never actually
40
45
  // engaged (e.g. imapflow's `idling` flag stuck true after Proton Bridge ended
@@ -95,7 +100,20 @@ const BULK_BATCH_TIMEOUT_MS = 60_000;
95
100
  // single-message fetch.
96
101
  const LABEL_RESOLVE_PER_FOLDER_TIMEOUT_MS = 5_000;
97
102
  const LABEL_RESOLVE_BUDGET_MS = 15_000;
98
- const UID_VALIDITY_MISMATCH_ERROR = "UID validity mismatch - local index is stale, run sync_emails to refresh";
103
+ // Deliberately not phrased like a generic integrity/checksum failure (that's
104
+ // EMAIL_ID_INVALID_MESSAGE's job in helpers.ts, for a genuinely corrupted
105
+ // id) — this is a *different* failure mode: the id's folder+uid are
106
+ // internally consistent and correctly formed, they just no longer identify
107
+ // a real message, because the mailbox they were minted against was
108
+ // recreated (full recreation, some migration scenarios) between then and
109
+ // now. "Stale index, re-sync" undersold the risk here: re-syncing doesn't
110
+ // make the *old* id valid again, it can only mint new, current ones — the
111
+ // old one must never be silently honored against the new generation.
112
+ // Exported so index.ts's bulk-operation resolution path (getBulkNotFoundEmailIds)
113
+ // can report a stale-generation id with the exact same wording a single-message
114
+ // mutation would raise via assertMailboxUidValidity, rather than drifting into
115
+ // two subtly different phrasings for what is the same underlying failure.
116
+ export const UID_VALIDITY_MISMATCH_ERROR = "This email reference is from before the mailbox changed (folder was recreated or migrated) and no longer points to a valid message. Re-fetch the email (e.g. via search_emails or get_emails) to get a current reference.";
99
117
  function collectErrorText(error) {
100
118
  const values = [];
101
119
  if (error instanceof Error) {
@@ -895,6 +913,7 @@ export class SimpleIMAPService {
895
913
  if (total === 0 || offset >= total) {
896
914
  return { folder, total, limit, offset, emails: [] };
897
915
  }
916
+ const uidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
898
917
  const emails = [];
899
918
  const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
900
919
  if (input.beforeUid !== undefined) {
@@ -917,7 +936,7 @@ export class SimpleIMAPService {
917
936
  }
918
937
  const uidSet = page.join(",");
919
938
  for await (const message of client.fetch(uidSet, fetchQuery, { uid: true })) {
920
- const summary = this.toSummary(folder, message);
939
+ const summary = this.toSummary(folder, message, uidValidity);
921
940
  const enriched = input.includeSnippet && message.source
922
941
  ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
923
942
  : summary;
@@ -946,7 +965,7 @@ export class SimpleIMAPService {
946
965
  startSeq = Math.max(1, endSeq - limit + 1);
947
966
  }
948
967
  for await (const message of client.fetch(`${startSeq}:${endSeq}`, fetchQuery)) {
949
- const summary = this.toSummary(folder, message);
968
+ const summary = this.toSummary(folder, message, uidValidity);
950
969
  const enriched = input.includeSnippet && message.source
951
970
  ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
952
971
  : summary;
@@ -977,51 +996,145 @@ export class SimpleIMAPService {
977
996
  const searchQuery = this.buildSearchQuery(input);
978
997
  const collected = [];
979
998
  let totalMatched = 0;
999
+ let anyCandidatesUnexamined = false;
1000
+ // Local-only filters (hasAttachment/attachmentName/label/threadId/senderDomain/
1001
+ // mailboxRole) are everything matchesLocalSearchFilters checks — IMAP SEARCH
1002
+ // (buildSearchQuery above) can't express any of them server-side, so they can
1003
+ // only be evaluated after a candidate's full data is fetched.
1004
+ const hasLocalOnlyFilters = typeof input.hasAttachment === "boolean" ||
1005
+ Boolean(input.threadId) ||
1006
+ Boolean(input.label) ||
1007
+ Boolean(input.attachmentName) ||
1008
+ Boolean(input.senderDomain) ||
1009
+ Boolean(input.mailboxRole);
980
1010
  for (const folder of folders) {
981
- const emails = await this.withMailbox(folder, true, async (client) => {
1011
+ const { results: emails, moreRemain } = await this.withMailbox(folder, true, async (client) => {
982
1012
  const searchResult = await client.search(searchQuery, { uid: true });
983
1013
  const uids = searchResult || [];
984
1014
  totalMatched += uids.length;
985
1015
  if (uids.length === 0) {
986
- return [];
1016
+ return { results: [], moreRemain: false };
987
1017
  }
988
- // UID order does not track date order (e.g. after a cross-provider import),
989
- // so a naive slice(-limit) on UIDs can silently drop the newest messages.
990
- // Fetch cheap INTERNALDATE-only headers first, sort by date, then pick the target UIDs.
991
- let targetUids = uids;
992
- if (uids.length > limit) {
993
- const dated = [];
994
- for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
995
- dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
1018
+ const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
1019
+ const uidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1020
+ if (!hasLocalOnlyFilters) {
1021
+ // UID order does not track date order (e.g. after a cross-provider import),
1022
+ // so a naive slice(-limit) on UIDs can silently drop the newest messages.
1023
+ // Fetch cheap INTERNALDATE-only headers first, sort by date, then pick the target UIDs.
1024
+ let targetUids = uids;
1025
+ if (uids.length > limit) {
1026
+ const dated = [];
1027
+ for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
1028
+ dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
1029
+ }
1030
+ targetUids = pickNewestUids(dated, limit);
1031
+ }
1032
+ else {
1033
+ targetUids = [...uids].reverse();
1034
+ }
1035
+ const results = [];
1036
+ for await (const message of client.fetch(targetUids, fetchQuery, { uid: true })) {
1037
+ const summary = this.toSummary(folder, message, uidValidity);
1038
+ const enriched = input.includeSnippet && message.source
1039
+ ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
1040
+ : summary;
1041
+ results.push(enriched);
1042
+ this.messageCache.set(enriched.id, enriched);
1043
+ this.capMessageCache();
996
1044
  }
997
- targetUids = pickNewestUids(dated, limit);
1045
+ return { results, moreRemain: false };
998
1046
  }
999
- else {
1000
- targetUids = [...uids].reverse();
1047
+ // A local-only filter narrows the FINAL result, not the IMAP-SEARCH candidate
1048
+ // set — applying it after picking the newest `limit` UIDs (as the branch above
1049
+ // does) can silently drop a genuine match that isn't among the newest `limit`
1050
+ // candidates by date, because it never even gets fetched. E.g.: an older
1051
+ // message with a real attachment, behind a newer attachment-less one, and
1052
+ // limit:1 — the old newest-N selection would pick only the newer non-match and
1053
+ // return nothing, even though a true match exists.
1054
+ //
1055
+ // Fix: order every candidate newest-first by INTERNALDATE (same cheap header
1056
+ // fetch as above), then walk it in bounded batches — fetch a batch, apply the
1057
+ // FULL filter (matchesLocalSearchFilters included) to it, keep genuine matches,
1058
+ // and continue to the next batch only if `limit` genuine matches haven't been
1059
+ // found yet. This mirrors the bounded-batch/resume-cursor reasoning used for
1060
+ // large incremental-sync gaps: bound the work done per call instead of fetching
1061
+ // the entire broad candidate set regardless of `limit`.
1062
+ //
1063
+ // The batch SIZE is deliberately not just `limit` — a caller asking for a small
1064
+ // number of RESULTS (e.g. limit:1) shouldn't force pathologically small network
1065
+ // batches when nothing matches (worst case: one `fetch` call per candidate).
1066
+ // SEARCH_FILTER_BATCH_SIZE is a floor under `limit` for the actual batch size;
1067
+ // the early-stop-once-`limit`-matches-are-found behavior above is unaffected.
1068
+ const batchSize = Math.max(limit, SEARCH_FILTER_BATCH_SIZE);
1069
+ // FETCH_INDEX_QUERY's response already includes BODYSTRUCTURE, which is enough
1070
+ // to resolve hasAttachment (see extractAttachments/toSummary) without fetching
1071
+ // full detail again — so when hasAttachment is the filter, resolve it here and
1072
+ // drop the candidates that already fail it, before they ever reach the detail
1073
+ // fetch loop below.
1074
+ const needsAttachmentPrefilter = typeof input.hasAttachment === "boolean";
1075
+ const dated = [];
1076
+ const hasAttachmentByUid = needsAttachmentPrefilter ? new Map() : undefined;
1077
+ for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
1078
+ dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
1079
+ if (hasAttachmentByUid) {
1080
+ hasAttachmentByUid.set(message.uid, extractAttachments(message.bodyStructure).length > 0);
1081
+ }
1082
+ }
1083
+ let orderedUids = pickNewestUids(dated, dated.length);
1084
+ if (hasAttachmentByUid) {
1085
+ orderedUids = orderedUids.filter((uid) => hasAttachmentByUid.get(uid) === input.hasAttachment);
1001
1086
  }
1002
1087
  const results = [];
1003
- const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
1004
- for await (const message of client.fetch(targetUids, fetchQuery, { uid: true })) {
1005
- const summary = this.toSummary(folder, message);
1006
- const enriched = input.includeSnippet && message.source
1007
- ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
1008
- : summary;
1009
- results.push(enriched);
1010
- this.messageCache.set(enriched.id, enriched);
1011
- this.capMessageCache();
1088
+ let moreRemain = false;
1089
+ for (let offset = 0; offset < orderedUids.length; offset += batchSize) {
1090
+ const batch = orderedUids.slice(offset, offset + batchSize);
1091
+ for await (const message of client.fetch(batch, fetchQuery, { uid: true })) {
1092
+ const summary = this.toSummary(folder, message, uidValidity);
1093
+ const enriched = input.includeSnippet && message.source
1094
+ ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
1095
+ : summary;
1096
+ this.messageCache.set(enriched.id, enriched);
1097
+ this.capMessageCache();
1098
+ if (matchesLocalSearchFilters(enriched, input)) {
1099
+ results.push(enriched);
1100
+ }
1101
+ }
1102
+ if (results.length >= limit) {
1103
+ moreRemain = offset + batchSize < orderedUids.length;
1104
+ break;
1105
+ }
1012
1106
  }
1013
- // FIX #3: verified — hasAttachment, attachmentName, label, threadId handled above
1014
- return results.filter((email) => matchesLocalSearchFilters(email, input));
1107
+ return { results, moreRemain };
1015
1108
  });
1016
1109
  collected.push(...emails);
1110
+ anyCandidatesUnexamined = anyCandidatesUnexamined || moreRemain;
1017
1111
  }
1018
1112
  const sorted = sortEmailsByNewest(dedupeEmails(collected)).slice(0, limit);
1113
+ // totalMatched/hasMore mean different things depending on whether a local-only
1114
+ // filter is present:
1115
+ // - No local-only filters: every IMAP-SEARCH candidate is a genuine match (IMAP
1116
+ // already applied every requested filter), so the raw SEARCH count (totalMatched)
1117
+ // is exact, and totalMatched > sorted.length is an exact "more exist" signal.
1118
+ // - A local-only filter is present: the raw SEARCH count is pre-filter and can be
1119
+ // wildly misleading here — e.g. this method's own repro (hasAttachment:true over
1120
+ // 2 candidates where only 1 genuinely matches) would otherwise report
1121
+ // totalMatched:2 and hasMore:true for a query with nothing further to find.
1122
+ // Report the exact number of genuine matches found among the candidates actually
1123
+ // examined instead (not the full universe — an exact post-filter total would
1124
+ // require scanning every candidate in every folder regardless of `limit`, which
1125
+ // defeats the bounded-batch fix above), and derive hasMore from whether any
1126
+ // folder's bounded scan stopped early with candidates still unexamined, or the
1127
+ // combined cross-folder result was itself truncated to `limit`.
1128
+ const totalMatchedForResponse = hasLocalOnlyFilters ? collected.length : totalMatched;
1129
+ const hasMore = hasLocalOnlyFilters
1130
+ ? anyCandidatesUnexamined || collected.length > sorted.length
1131
+ : totalMatched > sorted.length;
1019
1132
  return {
1020
1133
  folders,
1021
1134
  limit,
1022
1135
  total: sorted.length,
1023
- totalMatched,
1024
- hasMore: totalMatched > sorted.length,
1136
+ totalMatched: totalMatchedForResponse,
1137
+ hasMore,
1025
1138
  emails: sorted,
1026
1139
  };
1027
1140
  }
@@ -1214,10 +1327,19 @@ export class SimpleIMAPService {
1214
1327
  return notApplied;
1215
1328
  }
1216
1329
  async markEmailRead(emailId, isRead = true, uidValidity) {
1217
- const { folder, uid } = parseEmailId(emailId);
1330
+ // The id's own embedded uidValidity (parsed above) is the actual source
1331
+ // of protection — the `uidValidity` parameter only tightens it further
1332
+ // for a caller with some other reason to supply one. Previously this was
1333
+ // backwards: a caller that omitted the parameter (e.g. cli.ts, which
1334
+ // never threaded it) got zero staleness protection even though the id it
1335
+ // passed in already carried a perfectly valid generation. Found live:
1336
+ // delete_email's CLI shortcut on a generation-1 id against a
1337
+ // generation-2 mailbox deleted the message with no error.
1338
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1339
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1218
1340
  let notApplied = [];
1219
1341
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1220
- this.assertMailboxUidValidity(client, uidValidity);
1342
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1221
1343
  if (isRead) {
1222
1344
  await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true });
1223
1345
  }
@@ -1243,10 +1365,14 @@ export class SimpleIMAPService {
1243
1365
  return { emailId, folder, uid, isRead, notApplied };
1244
1366
  }
1245
1367
  async starEmail(emailId, isStarred = true, uidValidity) {
1246
- const { folder, uid } = parseEmailId(emailId);
1368
+ // Same reasoning as markEmailRead above: the id's own embedded
1369
+ // uidValidity is what actually protects this call, the parameter only
1370
+ // tightens it further.
1371
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1372
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1247
1373
  let notApplied = [];
1248
1374
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1249
- this.assertMailboxUidValidity(client, uidValidity);
1375
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1250
1376
  if (isStarred) {
1251
1377
  await client.messageFlagsAdd(String(uid), ["\\Flagged"], { uid: true });
1252
1378
  }
@@ -1263,10 +1389,16 @@ export class SimpleIMAPService {
1263
1389
  return { emailId, folder, uid, isStarred, notApplied };
1264
1390
  }
1265
1391
  async moveEmail(emailId, targetFolder, uidValidity) {
1266
- const { folder, uid } = parseEmailId(emailId);
1392
+ // Same reasoning as markEmailRead above: the id's own embedded
1393
+ // uidValidity is what actually protects this call, the parameter only
1394
+ // tightens it further. archiveEmail/trashEmail/restoreEmail all delegate
1395
+ // to this method, so they inherit the same protection.
1396
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1397
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1267
1398
  let targetUid;
1399
+ let targetFolderUidValidity;
1268
1400
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1269
- this.assertMailboxUidValidity(client, uidValidity);
1401
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1270
1402
  const moved = await client.messageMove(String(uid), targetFolder, { uid: true });
1271
1403
  if (moved === false) {
1272
1404
  throw new Error(`Server did not move email ${emailId} to ${targetFolder}`);
@@ -1287,10 +1419,25 @@ export class SimpleIMAPService {
1287
1419
  if (targetUid === undefined && client.capabilities.has("UIDPLUS")) {
1288
1420
  throw new Error(`Email not found for id ${emailId}`);
1289
1421
  }
1422
+ // targetEmailId (below) must embed the *target* folder's own
1423
+ // UIDVALIDITY, not the source folder's — they're different mailboxes
1424
+ // and can carry unrelated values. STATUS works against a
1425
+ // non-selected mailbox (same primitive getFolderStats already uses),
1426
+ // so this doesn't require a second SELECT/lock on targetFolder.
1427
+ // Best-effort: a failure here shouldn't fail an otherwise-successful
1428
+ // move, it just means the returned targetEmailId falls back to the
1429
+ // no-uidValidity (still fully valid, just not staleness-checkable)
1430
+ // format, same as it always did before this field existed.
1431
+ if (targetUid !== undefined) {
1432
+ const targetStatus = await client
1433
+ .status(targetFolder, { uidValidity: true })
1434
+ .catch(() => undefined);
1435
+ targetFolderUidValidity = targetStatus?.uidValidity?.toString();
1436
+ }
1290
1437
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving email ${emailId} to ${targetFolder}`);
1291
1438
  const cached = this.messageCache.get(emailId);
1292
1439
  this.messageCache.delete(emailId);
1293
- const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid) : undefined;
1440
+ const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid, targetFolderUidValidity) : undefined;
1294
1441
  if (cached && targetUid && targetEmailId) {
1295
1442
  this.messageCache.set(targetEmailId, {
1296
1443
  ...cached,
@@ -1316,9 +1463,13 @@ export class SimpleIMAPService {
1316
1463
  };
1317
1464
  }
1318
1465
  async deleteEmail(emailId, uidValidity) {
1319
- const { folder, uid } = parseEmailId(emailId);
1466
+ // Same reasoning as markEmailRead above: the id's own embedded
1467
+ // uidValidity is what actually protects this call, the parameter only
1468
+ // tightens it further.
1469
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1470
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1320
1471
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1321
- this.assertMailboxUidValidity(client, uidValidity);
1472
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1322
1473
  // messageDelete's own truthy/falsy result only reflects whether the
1323
1474
  // server accepted the EXPUNGE command, not whether any message
1324
1475
  // actually matched — a nonexistent UID's preceding \Deleted flag add
@@ -1349,8 +1500,12 @@ export class SimpleIMAPService {
1349
1500
  deleted: true,
1350
1501
  };
1351
1502
  }
1352
- async updateMessageLabels(emailId, labelsToAdd, labelsToRemove) {
1353
- const { folder, uid } = parseEmailId(emailId);
1503
+ async updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity) {
1504
+ // Same reasoning as markEmailRead above: the id's own embedded
1505
+ // uidValidity is what actually protects this call, the parameter only
1506
+ // tightens it further.
1507
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1508
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1354
1509
  const added = [];
1355
1510
  const removed = [];
1356
1511
  const notFound = [];
@@ -1369,6 +1524,7 @@ export class SimpleIMAPService {
1369
1524
  let messageId;
1370
1525
  let sourceExists = false;
1371
1526
  await this.withTimeout(this.withMailbox(folder, true, async (client) => {
1527
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1372
1528
  const msg = await client.fetchOne(String(uid), { uid: true, envelope: true }, { uid: true });
1373
1529
  if (msg !== false) {
1374
1530
  sourceExists = true;
@@ -1433,10 +1589,14 @@ export class SimpleIMAPService {
1433
1589
  return { emailId, added, removed, notFound, failedLabels: failedLabels.length > 0 ? failedLabels : undefined };
1434
1590
  }
1435
1591
  async updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity) {
1436
- const { folder, uid } = parseEmailId(emailId);
1592
+ // Same reasoning as markEmailRead above: the id's own embedded
1593
+ // uidValidity is what actually protects this call, the parameter only
1594
+ // tightens it further.
1595
+ const { folder, uid, uidValidity: idUidValidity } = parseEmailId(emailId);
1596
+ const expectedUidValidity = uidValidity ?? idUidValidity;
1437
1597
  let notApplied = [];
1438
1598
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1439
- this.assertMailboxUidValidity(client, uidValidity);
1599
+ this.assertMailboxUidValidity(client, expectedUidValidity);
1440
1600
  if (flagsToAdd.length > 0) {
1441
1601
  await client.messageFlagsAdd(String(uid), flagsToAdd, { uid: true });
1442
1602
  }
@@ -1495,11 +1655,26 @@ export class SimpleIMAPService {
1495
1655
  };
1496
1656
  });
1497
1657
  }
1658
+ // Lightweight, lock-free UIDVALIDITY lookup — IMAP STATUS works against a
1659
+ // mailbox that isn't currently SELECTed, so this doesn't need (and
1660
+ // deliberately avoids) taking a getMailboxLock the way withMailbox does.
1661
+ // Used at bulk-operation resolution time (resolveUidsForBulkOp) to learn
1662
+ // the folder's *current* generation once, up front, so every id in the
1663
+ // batch can be checked against that single point-in-time value rather than
1664
+ // each mutation discovering staleness independently and inconsistently
1665
+ // partway through the batch.
1666
+ async getMailboxUidValidity(folder) {
1667
+ const client = await this.ensureConnected();
1668
+ const status = await client.status(folder, { uidValidity: true });
1669
+ return status?.uidValidity?.toString();
1670
+ }
1498
1671
  async emptyFolder(folder) {
1499
1672
  if (folder.toUpperCase() === "INBOX") {
1500
1673
  throw new Error("emptyFolder cannot be used on INBOX. Move messages to Trash first.");
1501
1674
  }
1675
+ let folderUidValidity;
1502
1676
  const uids = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
1677
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1503
1678
  const found = await client.search({ all: true }, { uid: true });
1504
1679
  return Array.isArray(found) ? found : [];
1505
1680
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms listing messages to empty in ${folder}`);
@@ -1510,9 +1685,11 @@ export class SimpleIMAPService {
1510
1685
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1511
1686
  await client.messageDelete(uidSet, { uid: true });
1512
1687
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms emptying folder ${folder}`);
1513
- // Purge from cache
1688
+ // Purge from cache — must reconstruct the exact same id toSummary/etc.
1689
+ // would have cached these messages under (including the folder's
1690
+ // UIDVALIDITY at the time), or a stale cache entry survives this purge.
1514
1691
  for (const uid of uids) {
1515
- this.messageCache.delete(createEmailId(folder, uid));
1692
+ this.messageCache.delete(createEmailId(folder, uid, folderUidValidity));
1516
1693
  }
1517
1694
  this.folderCache = undefined;
1518
1695
  return { folder, deleted: uids.length };
@@ -1528,7 +1705,18 @@ export class SimpleIMAPService {
1528
1705
  // maxBatchSize 1, a first resolution returned [1], a second (between the
1529
1706
  // dry-run check and the real run) returned [1,2], and the real run acted
1530
1707
  // on both — silently exceeding the limit.
1531
- async resolveUidsForBulkOp(folder, emailIds, match) {
1708
+ async resolveUidsForBulkOp(folder, emailIds, match,
1709
+ // Pre-fetched current UIDVALIDITY for `folder`, from a single shared
1710
+ // getMailboxUidValidity call at the top of the caller's bulk handler
1711
+ // (see index.ts's bulk_delete/bulk_update_flags/bulk_update_labels
1712
+ // cases) — reused here instead of resolveUidsForBulkOp fetching its own,
1713
+ // so the exact same point-in-time value backs both the uid resolution
1714
+ // below and index.ts's own stale-id reporting (getBulkNotFoundEmailIds).
1715
+ // Callers with no pre-fetched value (e.g. bulkMove's internal call,
1716
+ // which has no resolve-once wiring of its own) leave this undefined and
1717
+ // resolveUidsForBulkOp fetches it itself, so the staleness check below
1718
+ // still applies uniformly to every bulk entry point.
1719
+ currentUidValidity) {
1532
1720
  if (emailIds !== undefined && match !== undefined) {
1533
1721
  throw new Error("Provide either emailIds or match, not both");
1534
1722
  }
@@ -1536,6 +1724,7 @@ export class SimpleIMAPService {
1536
1724
  throw new Error("Provide either emailIds or match");
1537
1725
  }
1538
1726
  if (emailIds !== undefined) {
1727
+ const uidValidity = currentUidValidity ?? (await this.getMailboxUidValidity(folder));
1539
1728
  // parseEmailId throws on anything malformed. index.ts's
1540
1729
  // getBulkNotFoundEmailIds already expects a bad/wrong-folder id to be
1541
1730
  // silently excluded here and reported separately as notFound — but a
@@ -1543,6 +1732,16 @@ export class SimpleIMAPService {
1543
1732
  // live: bulk_move with 10 valid ids and one malformed one threw
1544
1733
  // "Invalid emailId" and moved none of the 10 valid ones, instead of
1545
1734
  // moving them and reporting just the bad one as notFound.
1735
+ //
1736
+ // Same exclude-not-throw treatment now applies to a *stale* id (one
1737
+ // whose embedded uidValidity doesn't match the folder's current
1738
+ // generation) — excluded from the uids this batch actually acts on,
1739
+ // rather than either silently resolving against the wrong-generation
1740
+ // UID or failing the whole batch over one stale entry. An id with no
1741
+ // embedded uidValidity at all (pre-this-fix format) is unverifiable,
1742
+ // not stale — same "can't check it, so don't block on it" stance
1743
+ // assertMailboxUidValidity already takes for the single-message
1744
+ // mutation path.
1546
1745
  return emailIds
1547
1746
  .map((id) => {
1548
1747
  try {
@@ -1552,7 +1751,9 @@ export class SimpleIMAPService {
1552
1751
  return undefined;
1553
1752
  }
1554
1753
  })
1555
- .filter((parsed) => parsed !== undefined && parsed.folder === folder)
1754
+ .filter((parsed) => parsed !== undefined &&
1755
+ parsed.folder === folder &&
1756
+ (parsed.uidValidity === undefined || parsed.uidValidity === uidValidity))
1556
1757
  .map((parsed) => parsed.uid);
1557
1758
  }
1558
1759
  // match path
@@ -1611,10 +1812,17 @@ export class SimpleIMAPService {
1611
1812
  const results = [];
1612
1813
  if (uids.length > 0) {
1613
1814
  const uidSet = uids.join(",");
1815
+ // Captured for the source-folder emailIds built below — these
1816
+ // identify the *pre-move* message (source folder + uid), matching
1817
+ // sourceEmailId's role in the single-email moveEmail, not a
1818
+ // resolvable post-move reference, so the source folder's own
1819
+ // UIDVALIDITY (not the target's) is the correct one to embed.
1820
+ let sourceUidValidity;
1614
1821
  try {
1615
1822
  let uidMap;
1616
1823
  let hasUidPlus = false;
1617
1824
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1825
+ sourceUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1618
1826
  const moved = await client.messageMove(uidSet, input.targetFolder, { uid: true });
1619
1827
  if (moved === false) {
1620
1828
  throw new Error(`Server did not move uid set ${uidSet}`);
@@ -1623,7 +1831,7 @@ export class SimpleIMAPService {
1623
1831
  hasUidPlus = client.capabilities.has("UIDPLUS");
1624
1832
  }), BULK_BATCH_TIMEOUT_MS, `Timed out after ${BULK_BATCH_TIMEOUT_MS}ms moving uid set ${uidSet}`);
1625
1833
  for (const uid of uids) {
1626
- const emailId = createEmailId(folder, uid);
1834
+ const emailId = createEmailId(folder, uid, sourceUidValidity);
1627
1835
  // `moved === false` above only catches an empty/invalid range,
1628
1836
  // not a syntactically valid UID that doesn't match any message —
1629
1837
  // IMAP's MOVE silently succeeds with nothing moved in that case
@@ -1646,7 +1854,7 @@ export class SimpleIMAPService {
1646
1854
  }
1647
1855
  catch (err) {
1648
1856
  for (const uid of uids) {
1649
- const emailId = createEmailId(folder, uid);
1857
+ const emailId = createEmailId(folder, uid, sourceUidValidity);
1650
1858
  results.push({ uid, emailId, ok: false, error: String(err) });
1651
1859
  failed++;
1652
1860
  }
@@ -1682,6 +1890,7 @@ export class SimpleIMAPService {
1682
1890
  const results = [];
1683
1891
  if (uids.length > 0) {
1684
1892
  const uidSet = uids.join(",");
1893
+ let folderUidValidity;
1685
1894
  try {
1686
1895
  let existingUids;
1687
1896
  if (input.permanent || !trashFolder) {
@@ -1694,10 +1903,17 @@ export class SimpleIMAPService {
1694
1903
  // after the fact. Found live: bulk_delete with one real id and
1695
1904
  // one deliberately fake one reported ok:true for both.
1696
1905
  existingUids = await this.withMailbox(folder, true, async (client) => {
1906
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1697
1907
  const found = await client.search({ uid: uidSet }, { uid: true });
1698
1908
  return new Set(Array.isArray(found) ? found : []);
1699
1909
  });
1700
1910
  await this.withMailbox(folder, false, async (client) => {
1911
+ // Re-verify the generation resolvedUids was resolved under,
1912
+ // inside the same lock the actual delete runs under — the
1913
+ // filtering in resolveUidsForBulkOp only caught a stale id at
1914
+ // resolution time, not a mailbox change in the gap between then
1915
+ // and this lock being acquired.
1916
+ this.assertMailboxUidValidity(client, input.uidValidity);
1701
1917
  const deleted = await client.messageDelete(uidSet, { uid: true });
1702
1918
  if (!deleted)
1703
1919
  throw new Error(`Server did not delete uid set ${uidSet}`);
@@ -1707,6 +1923,9 @@ export class SimpleIMAPService {
1707
1923
  let uidMap;
1708
1924
  let hasUidPlus = false;
1709
1925
  await this.withMailbox(folder, false, async (client) => {
1926
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1927
+ // Same lock-scoped re-check as the permanent-delete branch above.
1928
+ this.assertMailboxUidValidity(client, input.uidValidity);
1710
1929
  const moved = await client.messageMove(uidSet, trashFolder, { uid: true });
1711
1930
  if (moved === false)
1712
1931
  throw new Error(`Server did not move uid set ${uidSet} to trash`);
@@ -1718,7 +1937,7 @@ export class SimpleIMAPService {
1718
1937
  existingUids = hasUidPlus && uidMap ? new Set(uidMap.keys()) : new Set(uids);
1719
1938
  }
1720
1939
  for (const uid of uids) {
1721
- const emailId = createEmailId(folder, uid);
1940
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1722
1941
  if (!existingUids.has(uid)) {
1723
1942
  results.push({ uid, emailId, ok: false, error: `Email not found for uid ${uid}` });
1724
1943
  failed++;
@@ -1730,9 +1949,10 @@ export class SimpleIMAPService {
1730
1949
  }
1731
1950
  }
1732
1951
  catch (err) {
1952
+ const error = this.bulkExecutionErrorMessage(err);
1733
1953
  for (const uid of uids) {
1734
- const emailId = createEmailId(folder, uid);
1735
- results.push({ uid, emailId, ok: false, error: String(err) });
1954
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1955
+ results.push({ uid, emailId, ok: false, error });
1736
1956
  failed++;
1737
1957
  }
1738
1958
  }
@@ -1766,9 +1986,15 @@ export class SimpleIMAPService {
1766
1986
  const results = [];
1767
1987
  if (uids.length > 0) {
1768
1988
  const uidSet = uids.join(",");
1989
+ let folderUidValidity;
1769
1990
  try {
1770
1991
  const notAppliedByUid = new Map();
1771
1992
  await this.withMailbox(folder, false, async (client) => {
1993
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1994
+ // Re-verify the generation resolvedUids was resolved under,
1995
+ // inside the same lock the flag mutation runs under — see
1996
+ // bulkDelete's identical check for the full rationale.
1997
+ this.assertMailboxUidValidity(client, input.uidValidity);
1772
1998
  if (flagsToAdd.length > 0) {
1773
1999
  await client.messageFlagsAdd(uidSet, flagsToAdd, { uid: true });
1774
2000
  }
@@ -1785,7 +2011,7 @@ export class SimpleIMAPService {
1785
2011
  }
1786
2012
  });
1787
2013
  for (const uid of uids) {
1788
- const emailId = createEmailId(folder, uid);
2014
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1789
2015
  // A UID absent from the fetch loop above (never got a
1790
2016
  // notAppliedByUid entry) means the IMAP FETCH found no message
1791
2017
  // for it — the earlier STORE call already silently no-op'd for
@@ -1804,9 +2030,10 @@ export class SimpleIMAPService {
1804
2030
  }
1805
2031
  }
1806
2032
  catch (err) {
2033
+ const error = this.bulkExecutionErrorMessage(err);
1807
2034
  for (const uid of uids) {
1808
- const emailId = createEmailId(folder, uid);
1809
- results.push({ uid, emailId, ok: false, error: String(err) });
2035
+ const emailId = createEmailId(folder, uid, folderUidValidity);
2036
+ results.push({ uid, emailId, ok: false, error });
1810
2037
  failed++;
1811
2038
  }
1812
2039
  }
@@ -1837,8 +2064,30 @@ export class SimpleIMAPService {
1837
2064
  let succeeded = 0;
1838
2065
  let failed = 0;
1839
2066
  const results = [];
2067
+ // Fetched once for the whole batch rather than inside the per-uid loop
2068
+ // below — resolveUidsForBulkOp already validated every uid against this
2069
+ // exact generation at resolution time; re-passing that same value into
2070
+ // each updateMessageLabels call below is a cheap, no-extra-round-trip
2071
+ // defense-in-depth recheck (updateMessageLabels's own withMailbox call
2072
+ // re-verifies it against the *live* mailbox at execution time), not a
2073
+ // second independent resolution.
2074
+ const folderUidValidity = input.uidValidity ?? await this.getMailboxUidValidity(folder).catch(() => undefined);
2075
+ // Unlike bulkDelete/bulkUpdateFlags (one command mutating the whole uid
2076
+ // set under a single withMailbox lock), labels are applied per-uid,
2077
+ // each under its own lock via updateMessageLabels. So a lock-scoped
2078
+ // generation mismatch can only be caught per-uid, not once up front —
2079
+ // but per the same "fail the whole batch, not just this id" contract,
2080
+ // once one is caught the rest of the batch is aborted too rather than
2081
+ // silently continuing to mutate under a mailbox that's no longer the
2082
+ // one this batch was resolved against.
2083
+ let batchAborted = false;
1840
2084
  for (const uid of uids) {
1841
- const emailId = createEmailId(folder, uid);
2085
+ const emailId = createEmailId(folder, uid, folderUidValidity);
2086
+ if (batchAborted) {
2087
+ results.push({ uid, emailId, ok: false, error: this.bulkExecutionErrorMessage(new Error(UID_VALIDITY_MISMATCH_ERROR)) });
2088
+ failed++;
2089
+ continue;
2090
+ }
1842
2091
  try {
1843
2092
  // updateMessageLabels never throws for a failed COPY/removal — it
1844
2093
  // catches per-label and reports failures via its own return value
@@ -1850,7 +2099,7 @@ export class SimpleIMAPService {
1850
2099
  // adding a brand-new label reported ok:true for an item whose label
1851
2100
  // folder was never actually created — get_folders afterward showed
1852
2101
  // no such folder at all.
1853
- const result = await this.withTimeout(this.updateMessageLabels(emailId, labelsToAdd, labelsToRemove), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms updating labels for ${emailId}`);
2102
+ const result = await this.withTimeout(this.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, folderUidValidity), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms updating labels for ${emailId}`);
1854
2103
  if (result.failedLabels && result.failedLabels.length > 0) {
1855
2104
  results.push({
1856
2105
  uid,
@@ -1866,7 +2115,10 @@ export class SimpleIMAPService {
1866
2115
  }
1867
2116
  }
1868
2117
  catch (err) {
1869
- results.push({ uid, emailId, ok: false, error: String(err) });
2118
+ if (err instanceof Error && err.message === UID_VALIDITY_MISMATCH_ERROR) {
2119
+ batchAborted = true;
2120
+ }
2121
+ results.push({ uid, emailId, ok: false, error: this.bulkExecutionErrorMessage(err) });
1870
2122
  failed++;
1871
2123
  }
1872
2124
  }
@@ -1953,8 +2205,10 @@ export class SimpleIMAPService {
1953
2205
  }
1954
2206
  for (const folder of foldersToSearch) {
1955
2207
  try {
2208
+ let folderUidValidity;
1956
2209
  const [byMsgId, byRefs] = await Promise.all([
1957
2210
  this.withMailbox(folder, true, async (client) => {
2211
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1958
2212
  const found = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
1959
2213
  return Array.isArray(found) ? found : [];
1960
2214
  }).catch(() => []),
@@ -1967,7 +2221,7 @@ export class SimpleIMAPService {
1967
2221
  for (const uid of [...byMsgId, ...byRefs]) {
1968
2222
  if (!seen.has(uid)) {
1969
2223
  seen.add(uid);
1970
- results.push({ folder, uid, emailId: createEmailId(folder, uid) });
2224
+ results.push({ folder, uid, emailId: createEmailId(folder, uid, folderUidValidity) });
1971
2225
  }
1972
2226
  }
1973
2227
  }
@@ -2165,7 +2419,7 @@ export class SimpleIMAPService {
2165
2419
  const fetchQuery = needsFullDetail ? FETCH_INDEX_DETAIL_QUERY : FETCH_INDEX_QUERY;
2166
2420
  const emails = [];
2167
2421
  for await (const message of client.fetch(`${plan.startUid}:${plan.endUid}`, fetchQuery, { uid: true })) {
2168
- const summary = this.toSummary(folder, message);
2422
+ const summary = this.toSummary(folder, message, uidValidity);
2169
2423
  const enriched = message.source
2170
2424
  ? this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), input.includeAttachmentText)
2171
2425
  : summary;
@@ -2211,17 +2465,17 @@ export class SimpleIMAPService {
2211
2465
  };
2212
2466
  });
2213
2467
  }
2214
- async archiveEmail(emailId) {
2468
+ async archiveEmail(emailId, uidValidity) {
2215
2469
  const targetFolder = await this.resolveSpecialFolder("\\Archive", ["Archive", "All Mail"]);
2216
- return this.moveEmail(emailId, targetFolder);
2470
+ return this.moveEmail(emailId, targetFolder, uidValidity);
2217
2471
  }
2218
- async trashEmail(emailId) {
2472
+ async trashEmail(emailId, uidValidity) {
2219
2473
  const targetFolder = await this.resolveSpecialFolder("\\Trash", ["Trash"]);
2220
- return this.moveEmail(emailId, targetFolder);
2474
+ return this.moveEmail(emailId, targetFolder, uidValidity);
2221
2475
  }
2222
- async restoreEmail(emailId, targetFolder) {
2476
+ async restoreEmail(emailId, targetFolder, uidValidity) {
2223
2477
  const destination = targetFolder?.trim() || (await this.resolveSpecialFolder("\\Inbox", ["INBOX"]));
2224
- return this.moveEmail(emailId, destination);
2478
+ return this.moveEmail(emailId, destination, uidValidity);
2225
2479
  }
2226
2480
  async getAnalyticsSample(days = 30, limitPerFolder = 100) {
2227
2481
  const dateFrom = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
@@ -2260,6 +2514,15 @@ export class SimpleIMAPService {
2260
2514
  if (!uid) {
2261
2515
  uid = await this.findUidByHeader(folder, "message-id", input.messageId);
2262
2516
  }
2517
+ // STATUS works against a mailbox that isn't currently SELECTed (same
2518
+ // reasoning as getMailboxUidValidity/getFolderStats) — append() above
2519
+ // doesn't guarantee `folder` is selected on this client, so this can't
2520
+ // just read client.mailbox.uidValidity the way a withMailbox callback
2521
+ // would. Best-effort: a failure here shouldn't fail an otherwise-
2522
+ // successful append, it just means the returned emailId falls back to
2523
+ // the no-uidValidity format, same as it always did before this field
2524
+ // existed.
2525
+ const uidValidity = uid ? await this.getMailboxUidValidity(folder).catch(() => undefined) : undefined;
2263
2526
  this.folderCache = undefined;
2264
2527
  this.lastSyncAt = new Date().toISOString();
2265
2528
  // Build the result BEFORE attempting to clean up the superseded old
@@ -2276,7 +2539,7 @@ export class SimpleIMAPService {
2276
2539
  const result = {
2277
2540
  folder,
2278
2541
  uid,
2279
- emailId: uid ? createEmailId(folder, uid) : undefined,
2542
+ emailId: uid ? createEmailId(folder, uid, uidValidity) : undefined,
2280
2543
  messageId: input.messageId,
2281
2544
  syncedAt: this.lastSyncAt,
2282
2545
  };
@@ -2426,11 +2689,11 @@ export class SimpleIMAPService {
2426
2689
  return [...result].sort((left, right) => right - left)[0];
2427
2690
  });
2428
2691
  }
2429
- toSummary(folder, message) {
2692
+ toSummary(folder, message, uidValidity) {
2430
2693
  const flags = [...(message.flags ?? [])];
2431
2694
  const attachments = extractAttachments(message.bodyStructure);
2432
2695
  return {
2433
- id: createEmailId(folder, message.uid),
2696
+ id: createEmailId(folder, message.uid, uidValidity),
2434
2697
  folder,
2435
2698
  uid: message.uid,
2436
2699
  seq: message.seq,
@@ -2484,17 +2747,25 @@ export class SimpleIMAPService {
2484
2747
  return simpleParser(source);
2485
2748
  }
2486
2749
  async getParsedMailDetail(emailId) {
2487
- const { folder, uid } = parseEmailId(emailId);
2750
+ const { folder, uid, uidValidity: expectedUidValidity } = parseEmailId(emailId);
2488
2751
  const { enriched, parsed } = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
2489
- // NOTE: UIDs can be reused after mailbox recreation (UIDVALIDITY change).
2490
- // assertMailboxUidValidity handles this at the withMailbox level for mutating
2491
- // ops. For read-only fetches, callers should re-sync after a UIDVALIDITY
2492
- // change to avoid fetching the wrong message with a recycled UID.
2752
+ // UIDs can be reused after mailbox recreation (UIDVALIDITY change).
2753
+ // This backs content reads used for quoting/forwarding/replying, so a
2754
+ // stale id here isn't just a failed fetch — it silently returns a
2755
+ // *different real message's* content under a freshly-recomputed,
2756
+ // correct-looking-for-that-message new id, with no error at all.
2757
+ // Found live: get_email_by_id on a generation-1 id for UID 42 against
2758
+ // a generation-2 mailbox with a different message at UID 42 returned
2759
+ // that other message's subject/content with no exception. Enforce the
2760
+ // same check mutations already do; an id with no embedded
2761
+ // uidValidity (pre-this-fix format) still no-ops here via
2762
+ // assertMailboxUidValidity's own `if (!expectedUidValidity) return`.
2763
+ this.assertMailboxUidValidity(client, expectedUidValidity);
2493
2764
  const message = await client.fetchOne(String(uid), FETCH_DETAIL_QUERY, { uid: true });
2494
2765
  if (!message || !message.source) {
2495
2766
  throw new Error(`Email not found for id ${emailId}`);
2496
2767
  }
2497
- const summary = this.toSummary(folder, message);
2768
+ const summary = this.toSummary(folder, message, (client.mailbox || undefined)?.uidValidity?.toString());
2498
2769
  const parsed = await this.parseSource(message.source);
2499
2770
  const enriched = this.enrichSummaryFromParsed(summary, parsed, true);
2500
2771
  return { enriched, parsed };
@@ -2844,6 +3115,20 @@ export class SimpleIMAPService {
2844
3115
  throw new Error(UID_VALIDITY_MISMATCH_ERROR);
2845
3116
  }
2846
3117
  }
3118
+ // Distinguishes a bulk batch's execution-time generation mismatch (caught
3119
+ // here, from assertMailboxUidValidity re-checking the whole resolved set
3120
+ // right before the mutation) from the per-id "this one id's own embedded
3121
+ // generation was stale" filtering resolveUidsForBulkOp already does before
3122
+ // the batch ever gets here. Both ultimately trace back to the same
3123
+ // UID_VALIDITY_MISMATCH_ERROR wording, but callers need to tell them apart:
3124
+ // this one means the *entire* resolved batch was invalidated by a mailbox
3125
+ // change after resolution, not just some individual ids.
3126
+ bulkExecutionErrorMessage(err) {
3127
+ if (err instanceof Error && err.message === UID_VALIDITY_MISMATCH_ERROR) {
3128
+ return `Bulk operation aborted: the mailbox's UIDVALIDITY changed after this batch's UIDs were resolved and before the mutation ran, invalidating the entire resolved batch (not just individual ids). ${UID_VALIDITY_MISMATCH_ERROR}`;
3129
+ }
3130
+ return String(err);
3131
+ }
2847
3132
  async writeAttachmentToPath(emailId, attachment, outputPath) {
2848
3133
  let outputFilePath;
2849
3134
  if (!outputPath) {
@@ -3034,12 +3319,14 @@ export class SimpleIMAPService {
3034
3319
  // Best-effort — an unparseable message still gets imported below.
3035
3320
  }
3036
3321
  if (messageId) {
3322
+ let existingFolderUidValidity;
3037
3323
  const existingUid = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
3324
+ existingFolderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
3038
3325
  const uids = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
3039
3326
  return Array.isArray(uids) && uids.length > 0 ? uids[uids.length - 1] : undefined;
3040
3327
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms checking for an existing import of ${messageId} in ${folder}`).catch(() => undefined);
3041
3328
  if (existingUid !== undefined) {
3042
- const existingEmailId = createEmailId(folder, existingUid);
3329
+ const existingEmailId = createEmailId(folder, existingUid, existingFolderUidValidity);
3043
3330
  return { folder, uid: existingUid, emailId: existingEmailId, alreadyExists: true, existingEmailId };
3044
3331
  }
3045
3332
  }
@@ -3052,9 +3339,15 @@ export class SimpleIMAPService {
3052
3339
  if (!uid && messageId) {
3053
3340
  uid = await this.findUidByHeader(folder, "message-id", messageId);
3054
3341
  }
3342
+ // Best-effort, same reasoning as upsertRemoteDraft's identical lookup —
3343
+ // append() doesn't guarantee `folder` stays selected on this client, so
3344
+ // STATUS (works on a non-selected mailbox) is used instead of trusting
3345
+ // client.mailbox here. A failure just falls back to the no-uidValidity
3346
+ // format, same as before this field existed.
3347
+ const uidValidity = uid ? await this.getMailboxUidValidity(folder).catch(() => undefined) : undefined;
3055
3348
  this.folderCache = undefined;
3056
3349
  this.lastSyncAt = new Date().toISOString();
3057
- return { folder, uid, emailId: uid ? createEmailId(folder, uid) : undefined };
3350
+ return { folder, uid, emailId: uid ? createEmailId(folder, uid, uidValidity) : undefined };
3058
3351
  }
3059
3352
  }
3060
3353
  //# sourceMappingURL=simple-imap-service.js.map