proton-mail-bridge-client 2.0.2 → 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.
@@ -95,7 +95,20 @@ const BULK_BATCH_TIMEOUT_MS = 60_000;
95
95
  // single-message fetch.
96
96
  const LABEL_RESOLVE_PER_FOLDER_TIMEOUT_MS = 5_000;
97
97
  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";
98
+ // Deliberately not phrased like a generic integrity/checksum failure (that's
99
+ // EMAIL_ID_INVALID_MESSAGE's job in helpers.ts, for a genuinely corrupted
100
+ // id) — this is a *different* failure mode: the id's folder+uid are
101
+ // internally consistent and correctly formed, they just no longer identify
102
+ // a real message, because the mailbox they were minted against was
103
+ // recreated (full recreation, some migration scenarios) between then and
104
+ // now. "Stale index, re-sync" undersold the risk here: re-syncing doesn't
105
+ // make the *old* id valid again, it can only mint new, current ones — the
106
+ // old one must never be silently honored against the new generation.
107
+ // Exported so index.ts's bulk-operation resolution path (getBulkNotFoundEmailIds)
108
+ // can report a stale-generation id with the exact same wording a single-message
109
+ // mutation would raise via assertMailboxUidValidity, rather than drifting into
110
+ // two subtly different phrasings for what is the same underlying failure.
111
+ 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
112
  function collectErrorText(error) {
100
113
  const values = [];
101
114
  if (error instanceof Error) {
@@ -895,6 +908,7 @@ export class SimpleIMAPService {
895
908
  if (total === 0 || offset >= total) {
896
909
  return { folder, total, limit, offset, emails: [] };
897
910
  }
911
+ const uidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
898
912
  const emails = [];
899
913
  const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
900
914
  if (input.beforeUid !== undefined) {
@@ -917,7 +931,7 @@ export class SimpleIMAPService {
917
931
  }
918
932
  const uidSet = page.join(",");
919
933
  for await (const message of client.fetch(uidSet, fetchQuery, { uid: true })) {
920
- const summary = this.toSummary(folder, message);
934
+ const summary = this.toSummary(folder, message, uidValidity);
921
935
  const enriched = input.includeSnippet && message.source
922
936
  ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
923
937
  : summary;
@@ -946,7 +960,7 @@ export class SimpleIMAPService {
946
960
  startSeq = Math.max(1, endSeq - limit + 1);
947
961
  }
948
962
  for await (const message of client.fetch(`${startSeq}:${endSeq}`, fetchQuery)) {
949
- const summary = this.toSummary(folder, message);
963
+ const summary = this.toSummary(folder, message, uidValidity);
950
964
  const enriched = input.includeSnippet && message.source
951
965
  ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
952
966
  : summary;
@@ -977,51 +991,125 @@ export class SimpleIMAPService {
977
991
  const searchQuery = this.buildSearchQuery(input);
978
992
  const collected = [];
979
993
  let totalMatched = 0;
994
+ let anyCandidatesUnexamined = false;
995
+ // Local-only filters (hasAttachment/attachmentName/label/threadId/senderDomain/
996
+ // mailboxRole) are everything matchesLocalSearchFilters checks — IMAP SEARCH
997
+ // (buildSearchQuery above) can't express any of them server-side, so they can
998
+ // only be evaluated after a candidate's full data is fetched.
999
+ const hasLocalOnlyFilters = typeof input.hasAttachment === "boolean" ||
1000
+ Boolean(input.threadId) ||
1001
+ Boolean(input.label) ||
1002
+ Boolean(input.attachmentName) ||
1003
+ Boolean(input.senderDomain) ||
1004
+ Boolean(input.mailboxRole);
980
1005
  for (const folder of folders) {
981
- const emails = await this.withMailbox(folder, true, async (client) => {
1006
+ const { results: emails, moreRemain } = await this.withMailbox(folder, true, async (client) => {
982
1007
  const searchResult = await client.search(searchQuery, { uid: true });
983
1008
  const uids = searchResult || [];
984
1009
  totalMatched += uids.length;
985
1010
  if (uids.length === 0) {
986
- return [];
1011
+ return { results: [], moreRemain: false };
987
1012
  }
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() });
1013
+ const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
1014
+ const uidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1015
+ if (!hasLocalOnlyFilters) {
1016
+ // UID order does not track date order (e.g. after a cross-provider import),
1017
+ // so a naive slice(-limit) on UIDs can silently drop the newest messages.
1018
+ // Fetch cheap INTERNALDATE-only headers first, sort by date, then pick the target UIDs.
1019
+ let targetUids = uids;
1020
+ if (uids.length > limit) {
1021
+ const dated = [];
1022
+ for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
1023
+ dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
1024
+ }
1025
+ targetUids = pickNewestUids(dated, limit);
1026
+ }
1027
+ else {
1028
+ targetUids = [...uids].reverse();
1029
+ }
1030
+ const results = [];
1031
+ for await (const message of client.fetch(targetUids, fetchQuery, { uid: true })) {
1032
+ const summary = this.toSummary(folder, message, uidValidity);
1033
+ const enriched = input.includeSnippet && message.source
1034
+ ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
1035
+ : summary;
1036
+ results.push(enriched);
1037
+ this.messageCache.set(enriched.id, enriched);
1038
+ this.capMessageCache();
996
1039
  }
997
- targetUids = pickNewestUids(dated, limit);
1040
+ return { results, moreRemain: false };
998
1041
  }
999
- else {
1000
- targetUids = [...uids].reverse();
1042
+ // A local-only filter narrows the FINAL result, not the IMAP-SEARCH candidate
1043
+ // set — applying it after picking the newest `limit` UIDs (as the branch above
1044
+ // does) can silently drop a genuine match that isn't among the newest `limit`
1045
+ // candidates by date, because it never even gets fetched. E.g.: an older
1046
+ // message with a real attachment, behind a newer attachment-less one, and
1047
+ // limit:1 — the old newest-N selection would pick only the newer non-match and
1048
+ // return nothing, even though a true match exists.
1049
+ //
1050
+ // Fix: order every candidate newest-first by INTERNALDATE (same cheap header
1051
+ // fetch as above), then walk it in `limit`-sized bounded batches — fetch a
1052
+ // batch, apply the FULL filter (matchesLocalSearchFilters included) to it, keep
1053
+ // genuine matches, and continue to the next batch only if `limit` genuine
1054
+ // matches haven't been found yet. This mirrors the bounded-batch/resume-cursor
1055
+ // reasoning used for large incremental-sync gaps: bound the work done per call
1056
+ // instead of fetching the entire broad candidate set regardless of `limit`.
1057
+ const dated = [];
1058
+ for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
1059
+ dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
1001
1060
  }
1061
+ const orderedUids = pickNewestUids(dated, dated.length);
1002
1062
  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();
1063
+ let moreRemain = false;
1064
+ for (let offset = 0; offset < orderedUids.length; offset += limit) {
1065
+ const batch = orderedUids.slice(offset, offset + limit);
1066
+ for await (const message of client.fetch(batch, fetchQuery, { uid: true })) {
1067
+ const summary = this.toSummary(folder, message, uidValidity);
1068
+ const enriched = input.includeSnippet && message.source
1069
+ ? await this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), false)
1070
+ : summary;
1071
+ this.messageCache.set(enriched.id, enriched);
1072
+ this.capMessageCache();
1073
+ if (matchesLocalSearchFilters(enriched, input)) {
1074
+ results.push(enriched);
1075
+ }
1076
+ }
1077
+ if (results.length >= limit) {
1078
+ moreRemain = offset + limit < orderedUids.length;
1079
+ break;
1080
+ }
1012
1081
  }
1013
- // FIX #3: verified — hasAttachment, attachmentName, label, threadId handled above
1014
- return results.filter((email) => matchesLocalSearchFilters(email, input));
1082
+ return { results, moreRemain };
1015
1083
  });
1016
1084
  collected.push(...emails);
1085
+ anyCandidatesUnexamined = anyCandidatesUnexamined || moreRemain;
1017
1086
  }
1018
1087
  const sorted = sortEmailsByNewest(dedupeEmails(collected)).slice(0, limit);
1088
+ // totalMatched/hasMore mean different things depending on whether a local-only
1089
+ // filter is present:
1090
+ // - No local-only filters: every IMAP-SEARCH candidate is a genuine match (IMAP
1091
+ // already applied every requested filter), so the raw SEARCH count (totalMatched)
1092
+ // is exact, and totalMatched > sorted.length is an exact "more exist" signal.
1093
+ // - A local-only filter is present: the raw SEARCH count is pre-filter and can be
1094
+ // wildly misleading here — e.g. this method's own repro (hasAttachment:true over
1095
+ // 2 candidates where only 1 genuinely matches) would otherwise report
1096
+ // totalMatched:2 and hasMore:true for a query with nothing further to find.
1097
+ // Report the exact number of genuine matches found among the candidates actually
1098
+ // examined instead (not the full universe — an exact post-filter total would
1099
+ // require scanning every candidate in every folder regardless of `limit`, which
1100
+ // defeats the bounded-batch fix above), and derive hasMore from whether any
1101
+ // folder's bounded scan stopped early with candidates still unexamined, or the
1102
+ // combined cross-folder result was itself truncated to `limit`.
1103
+ const totalMatchedForResponse = hasLocalOnlyFilters ? collected.length : totalMatched;
1104
+ const hasMore = hasLocalOnlyFilters
1105
+ ? anyCandidatesUnexamined || collected.length > sorted.length
1106
+ : totalMatched > sorted.length;
1019
1107
  return {
1020
1108
  folders,
1021
1109
  limit,
1022
1110
  total: sorted.length,
1023
- totalMatched,
1024
- hasMore: totalMatched > sorted.length,
1111
+ totalMatched: totalMatchedForResponse,
1112
+ hasMore,
1025
1113
  emails: sorted,
1026
1114
  };
1027
1115
  }
@@ -1265,6 +1353,7 @@ export class SimpleIMAPService {
1265
1353
  async moveEmail(emailId, targetFolder, uidValidity) {
1266
1354
  const { folder, uid } = parseEmailId(emailId);
1267
1355
  let targetUid;
1356
+ let targetFolderUidValidity;
1268
1357
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1269
1358
  this.assertMailboxUidValidity(client, uidValidity);
1270
1359
  const moved = await client.messageMove(String(uid), targetFolder, { uid: true });
@@ -1287,10 +1376,25 @@ export class SimpleIMAPService {
1287
1376
  if (targetUid === undefined && client.capabilities.has("UIDPLUS")) {
1288
1377
  throw new Error(`Email not found for id ${emailId}`);
1289
1378
  }
1379
+ // targetEmailId (below) must embed the *target* folder's own
1380
+ // UIDVALIDITY, not the source folder's — they're different mailboxes
1381
+ // and can carry unrelated values. STATUS works against a
1382
+ // non-selected mailbox (same primitive getFolderStats already uses),
1383
+ // so this doesn't require a second SELECT/lock on targetFolder.
1384
+ // Best-effort: a failure here shouldn't fail an otherwise-successful
1385
+ // move, it just means the returned targetEmailId falls back to the
1386
+ // no-uidValidity (still fully valid, just not staleness-checkable)
1387
+ // format, same as it always did before this field existed.
1388
+ if (targetUid !== undefined) {
1389
+ const targetStatus = await client
1390
+ .status(targetFolder, { uidValidity: true })
1391
+ .catch(() => undefined);
1392
+ targetFolderUidValidity = targetStatus?.uidValidity?.toString();
1393
+ }
1290
1394
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving email ${emailId} to ${targetFolder}`);
1291
1395
  const cached = this.messageCache.get(emailId);
1292
1396
  this.messageCache.delete(emailId);
1293
- const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid) : undefined;
1397
+ const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid, targetFolderUidValidity) : undefined;
1294
1398
  if (cached && targetUid && targetEmailId) {
1295
1399
  this.messageCache.set(targetEmailId, {
1296
1400
  ...cached,
@@ -1349,7 +1453,7 @@ export class SimpleIMAPService {
1349
1453
  deleted: true,
1350
1454
  };
1351
1455
  }
1352
- async updateMessageLabels(emailId, labelsToAdd, labelsToRemove) {
1456
+ async updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity) {
1353
1457
  const { folder, uid } = parseEmailId(emailId);
1354
1458
  const added = [];
1355
1459
  const removed = [];
@@ -1369,6 +1473,7 @@ export class SimpleIMAPService {
1369
1473
  let messageId;
1370
1474
  let sourceExists = false;
1371
1475
  await this.withTimeout(this.withMailbox(folder, true, async (client) => {
1476
+ this.assertMailboxUidValidity(client, uidValidity);
1372
1477
  const msg = await client.fetchOne(String(uid), { uid: true, envelope: true }, { uid: true });
1373
1478
  if (msg !== false) {
1374
1479
  sourceExists = true;
@@ -1495,11 +1600,26 @@ export class SimpleIMAPService {
1495
1600
  };
1496
1601
  });
1497
1602
  }
1603
+ // Lightweight, lock-free UIDVALIDITY lookup — IMAP STATUS works against a
1604
+ // mailbox that isn't currently SELECTed, so this doesn't need (and
1605
+ // deliberately avoids) taking a getMailboxLock the way withMailbox does.
1606
+ // Used at bulk-operation resolution time (resolveUidsForBulkOp) to learn
1607
+ // the folder's *current* generation once, up front, so every id in the
1608
+ // batch can be checked against that single point-in-time value rather than
1609
+ // each mutation discovering staleness independently and inconsistently
1610
+ // partway through the batch.
1611
+ async getMailboxUidValidity(folder) {
1612
+ const client = await this.ensureConnected();
1613
+ const status = await client.status(folder, { uidValidity: true });
1614
+ return status?.uidValidity?.toString();
1615
+ }
1498
1616
  async emptyFolder(folder) {
1499
1617
  if (folder.toUpperCase() === "INBOX") {
1500
1618
  throw new Error("emptyFolder cannot be used on INBOX. Move messages to Trash first.");
1501
1619
  }
1620
+ let folderUidValidity;
1502
1621
  const uids = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
1622
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1503
1623
  const found = await client.search({ all: true }, { uid: true });
1504
1624
  return Array.isArray(found) ? found : [];
1505
1625
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms listing messages to empty in ${folder}`);
@@ -1510,9 +1630,11 @@ export class SimpleIMAPService {
1510
1630
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1511
1631
  await client.messageDelete(uidSet, { uid: true });
1512
1632
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms emptying folder ${folder}`);
1513
- // Purge from cache
1633
+ // Purge from cache — must reconstruct the exact same id toSummary/etc.
1634
+ // would have cached these messages under (including the folder's
1635
+ // UIDVALIDITY at the time), or a stale cache entry survives this purge.
1514
1636
  for (const uid of uids) {
1515
- this.messageCache.delete(createEmailId(folder, uid));
1637
+ this.messageCache.delete(createEmailId(folder, uid, folderUidValidity));
1516
1638
  }
1517
1639
  this.folderCache = undefined;
1518
1640
  return { folder, deleted: uids.length };
@@ -1528,7 +1650,18 @@ export class SimpleIMAPService {
1528
1650
  // maxBatchSize 1, a first resolution returned [1], a second (between the
1529
1651
  // dry-run check and the real run) returned [1,2], and the real run acted
1530
1652
  // on both — silently exceeding the limit.
1531
- async resolveUidsForBulkOp(folder, emailIds, match) {
1653
+ async resolveUidsForBulkOp(folder, emailIds, match,
1654
+ // Pre-fetched current UIDVALIDITY for `folder`, from a single shared
1655
+ // getMailboxUidValidity call at the top of the caller's bulk handler
1656
+ // (see index.ts's bulk_delete/bulk_update_flags/bulk_update_labels
1657
+ // cases) — reused here instead of resolveUidsForBulkOp fetching its own,
1658
+ // so the exact same point-in-time value backs both the uid resolution
1659
+ // below and index.ts's own stale-id reporting (getBulkNotFoundEmailIds).
1660
+ // Callers with no pre-fetched value (e.g. bulkMove's internal call,
1661
+ // which has no resolve-once wiring of its own) leave this undefined and
1662
+ // resolveUidsForBulkOp fetches it itself, so the staleness check below
1663
+ // still applies uniformly to every bulk entry point.
1664
+ currentUidValidity) {
1532
1665
  if (emailIds !== undefined && match !== undefined) {
1533
1666
  throw new Error("Provide either emailIds or match, not both");
1534
1667
  }
@@ -1536,6 +1669,7 @@ export class SimpleIMAPService {
1536
1669
  throw new Error("Provide either emailIds or match");
1537
1670
  }
1538
1671
  if (emailIds !== undefined) {
1672
+ const uidValidity = currentUidValidity ?? (await this.getMailboxUidValidity(folder));
1539
1673
  // parseEmailId throws on anything malformed. index.ts's
1540
1674
  // getBulkNotFoundEmailIds already expects a bad/wrong-folder id to be
1541
1675
  // silently excluded here and reported separately as notFound — but a
@@ -1543,6 +1677,16 @@ export class SimpleIMAPService {
1543
1677
  // live: bulk_move with 10 valid ids and one malformed one threw
1544
1678
  // "Invalid emailId" and moved none of the 10 valid ones, instead of
1545
1679
  // moving them and reporting just the bad one as notFound.
1680
+ //
1681
+ // Same exclude-not-throw treatment now applies to a *stale* id (one
1682
+ // whose embedded uidValidity doesn't match the folder's current
1683
+ // generation) — excluded from the uids this batch actually acts on,
1684
+ // rather than either silently resolving against the wrong-generation
1685
+ // UID or failing the whole batch over one stale entry. An id with no
1686
+ // embedded uidValidity at all (pre-this-fix format) is unverifiable,
1687
+ // not stale — same "can't check it, so don't block on it" stance
1688
+ // assertMailboxUidValidity already takes for the single-message
1689
+ // mutation path.
1546
1690
  return emailIds
1547
1691
  .map((id) => {
1548
1692
  try {
@@ -1552,7 +1696,9 @@ export class SimpleIMAPService {
1552
1696
  return undefined;
1553
1697
  }
1554
1698
  })
1555
- .filter((parsed) => parsed !== undefined && parsed.folder === folder)
1699
+ .filter((parsed) => parsed !== undefined &&
1700
+ parsed.folder === folder &&
1701
+ (parsed.uidValidity === undefined || parsed.uidValidity === uidValidity))
1556
1702
  .map((parsed) => parsed.uid);
1557
1703
  }
1558
1704
  // match path
@@ -1611,10 +1757,17 @@ export class SimpleIMAPService {
1611
1757
  const results = [];
1612
1758
  if (uids.length > 0) {
1613
1759
  const uidSet = uids.join(",");
1760
+ // Captured for the source-folder emailIds built below — these
1761
+ // identify the *pre-move* message (source folder + uid), matching
1762
+ // sourceEmailId's role in the single-email moveEmail, not a
1763
+ // resolvable post-move reference, so the source folder's own
1764
+ // UIDVALIDITY (not the target's) is the correct one to embed.
1765
+ let sourceUidValidity;
1614
1766
  try {
1615
1767
  let uidMap;
1616
1768
  let hasUidPlus = false;
1617
1769
  await this.withTimeout(this.withMailbox(folder, false, async (client) => {
1770
+ sourceUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1618
1771
  const moved = await client.messageMove(uidSet, input.targetFolder, { uid: true });
1619
1772
  if (moved === false) {
1620
1773
  throw new Error(`Server did not move uid set ${uidSet}`);
@@ -1623,7 +1776,7 @@ export class SimpleIMAPService {
1623
1776
  hasUidPlus = client.capabilities.has("UIDPLUS");
1624
1777
  }), BULK_BATCH_TIMEOUT_MS, `Timed out after ${BULK_BATCH_TIMEOUT_MS}ms moving uid set ${uidSet}`);
1625
1778
  for (const uid of uids) {
1626
- const emailId = createEmailId(folder, uid);
1779
+ const emailId = createEmailId(folder, uid, sourceUidValidity);
1627
1780
  // `moved === false` above only catches an empty/invalid range,
1628
1781
  // not a syntactically valid UID that doesn't match any message —
1629
1782
  // IMAP's MOVE silently succeeds with nothing moved in that case
@@ -1646,7 +1799,7 @@ export class SimpleIMAPService {
1646
1799
  }
1647
1800
  catch (err) {
1648
1801
  for (const uid of uids) {
1649
- const emailId = createEmailId(folder, uid);
1802
+ const emailId = createEmailId(folder, uid, sourceUidValidity);
1650
1803
  results.push({ uid, emailId, ok: false, error: String(err) });
1651
1804
  failed++;
1652
1805
  }
@@ -1682,6 +1835,7 @@ export class SimpleIMAPService {
1682
1835
  const results = [];
1683
1836
  if (uids.length > 0) {
1684
1837
  const uidSet = uids.join(",");
1838
+ let folderUidValidity;
1685
1839
  try {
1686
1840
  let existingUids;
1687
1841
  if (input.permanent || !trashFolder) {
@@ -1694,6 +1848,7 @@ export class SimpleIMAPService {
1694
1848
  // after the fact. Found live: bulk_delete with one real id and
1695
1849
  // one deliberately fake one reported ok:true for both.
1696
1850
  existingUids = await this.withMailbox(folder, true, async (client) => {
1851
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1697
1852
  const found = await client.search({ uid: uidSet }, { uid: true });
1698
1853
  return new Set(Array.isArray(found) ? found : []);
1699
1854
  });
@@ -1707,6 +1862,7 @@ export class SimpleIMAPService {
1707
1862
  let uidMap;
1708
1863
  let hasUidPlus = false;
1709
1864
  await this.withMailbox(folder, false, async (client) => {
1865
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1710
1866
  const moved = await client.messageMove(uidSet, trashFolder, { uid: true });
1711
1867
  if (moved === false)
1712
1868
  throw new Error(`Server did not move uid set ${uidSet} to trash`);
@@ -1718,7 +1874,7 @@ export class SimpleIMAPService {
1718
1874
  existingUids = hasUidPlus && uidMap ? new Set(uidMap.keys()) : new Set(uids);
1719
1875
  }
1720
1876
  for (const uid of uids) {
1721
- const emailId = createEmailId(folder, uid);
1877
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1722
1878
  if (!existingUids.has(uid)) {
1723
1879
  results.push({ uid, emailId, ok: false, error: `Email not found for uid ${uid}` });
1724
1880
  failed++;
@@ -1731,7 +1887,7 @@ export class SimpleIMAPService {
1731
1887
  }
1732
1888
  catch (err) {
1733
1889
  for (const uid of uids) {
1734
- const emailId = createEmailId(folder, uid);
1890
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1735
1891
  results.push({ uid, emailId, ok: false, error: String(err) });
1736
1892
  failed++;
1737
1893
  }
@@ -1766,9 +1922,11 @@ export class SimpleIMAPService {
1766
1922
  const results = [];
1767
1923
  if (uids.length > 0) {
1768
1924
  const uidSet = uids.join(",");
1925
+ let folderUidValidity;
1769
1926
  try {
1770
1927
  const notAppliedByUid = new Map();
1771
1928
  await this.withMailbox(folder, false, async (client) => {
1929
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1772
1930
  if (flagsToAdd.length > 0) {
1773
1931
  await client.messageFlagsAdd(uidSet, flagsToAdd, { uid: true });
1774
1932
  }
@@ -1785,7 +1943,7 @@ export class SimpleIMAPService {
1785
1943
  }
1786
1944
  });
1787
1945
  for (const uid of uids) {
1788
- const emailId = createEmailId(folder, uid);
1946
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1789
1947
  // A UID absent from the fetch loop above (never got a
1790
1948
  // notAppliedByUid entry) means the IMAP FETCH found no message
1791
1949
  // for it — the earlier STORE call already silently no-op'd for
@@ -1805,7 +1963,7 @@ export class SimpleIMAPService {
1805
1963
  }
1806
1964
  catch (err) {
1807
1965
  for (const uid of uids) {
1808
- const emailId = createEmailId(folder, uid);
1966
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1809
1967
  results.push({ uid, emailId, ok: false, error: String(err) });
1810
1968
  failed++;
1811
1969
  }
@@ -1837,8 +1995,16 @@ export class SimpleIMAPService {
1837
1995
  let succeeded = 0;
1838
1996
  let failed = 0;
1839
1997
  const results = [];
1998
+ // Fetched once for the whole batch rather than inside the per-uid loop
1999
+ // below — resolveUidsForBulkOp already validated every uid against this
2000
+ // exact generation at resolution time; re-passing that same value into
2001
+ // each updateMessageLabels call below is a cheap, no-extra-round-trip
2002
+ // defense-in-depth recheck (updateMessageLabels's own withMailbox call
2003
+ // re-verifies it against the *live* mailbox at execution time), not a
2004
+ // second independent resolution.
2005
+ const folderUidValidity = await this.getMailboxUidValidity(folder).catch(() => undefined);
1840
2006
  for (const uid of uids) {
1841
- const emailId = createEmailId(folder, uid);
2007
+ const emailId = createEmailId(folder, uid, folderUidValidity);
1842
2008
  try {
1843
2009
  // updateMessageLabels never throws for a failed COPY/removal — it
1844
2010
  // catches per-label and reports failures via its own return value
@@ -1850,7 +2016,7 @@ export class SimpleIMAPService {
1850
2016
  // adding a brand-new label reported ok:true for an item whose label
1851
2017
  // folder was never actually created — get_folders afterward showed
1852
2018
  // 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}`);
2019
+ 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
2020
  if (result.failedLabels && result.failedLabels.length > 0) {
1855
2021
  results.push({
1856
2022
  uid,
@@ -1953,8 +2119,10 @@ export class SimpleIMAPService {
1953
2119
  }
1954
2120
  for (const folder of foldersToSearch) {
1955
2121
  try {
2122
+ let folderUidValidity;
1956
2123
  const [byMsgId, byRefs] = await Promise.all([
1957
2124
  this.withMailbox(folder, true, async (client) => {
2125
+ folderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
1958
2126
  const found = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
1959
2127
  return Array.isArray(found) ? found : [];
1960
2128
  }).catch(() => []),
@@ -1967,7 +2135,7 @@ export class SimpleIMAPService {
1967
2135
  for (const uid of [...byMsgId, ...byRefs]) {
1968
2136
  if (!seen.has(uid)) {
1969
2137
  seen.add(uid);
1970
- results.push({ folder, uid, emailId: createEmailId(folder, uid) });
2138
+ results.push({ folder, uid, emailId: createEmailId(folder, uid, folderUidValidity) });
1971
2139
  }
1972
2140
  }
1973
2141
  }
@@ -2165,7 +2333,7 @@ export class SimpleIMAPService {
2165
2333
  const fetchQuery = needsFullDetail ? FETCH_INDEX_DETAIL_QUERY : FETCH_INDEX_QUERY;
2166
2334
  const emails = [];
2167
2335
  for await (const message of client.fetch(`${plan.startUid}:${plan.endUid}`, fetchQuery, { uid: true })) {
2168
- const summary = this.toSummary(folder, message);
2336
+ const summary = this.toSummary(folder, message, uidValidity);
2169
2337
  const enriched = message.source
2170
2338
  ? this.enrichSummaryFromParsed(summary, await this.parseSource(message.source), input.includeAttachmentText)
2171
2339
  : summary;
@@ -2211,17 +2379,17 @@ export class SimpleIMAPService {
2211
2379
  };
2212
2380
  });
2213
2381
  }
2214
- async archiveEmail(emailId) {
2382
+ async archiveEmail(emailId, uidValidity) {
2215
2383
  const targetFolder = await this.resolveSpecialFolder("\\Archive", ["Archive", "All Mail"]);
2216
- return this.moveEmail(emailId, targetFolder);
2384
+ return this.moveEmail(emailId, targetFolder, uidValidity);
2217
2385
  }
2218
- async trashEmail(emailId) {
2386
+ async trashEmail(emailId, uidValidity) {
2219
2387
  const targetFolder = await this.resolveSpecialFolder("\\Trash", ["Trash"]);
2220
- return this.moveEmail(emailId, targetFolder);
2388
+ return this.moveEmail(emailId, targetFolder, uidValidity);
2221
2389
  }
2222
- async restoreEmail(emailId, targetFolder) {
2390
+ async restoreEmail(emailId, targetFolder, uidValidity) {
2223
2391
  const destination = targetFolder?.trim() || (await this.resolveSpecialFolder("\\Inbox", ["INBOX"]));
2224
- return this.moveEmail(emailId, destination);
2392
+ return this.moveEmail(emailId, destination, uidValidity);
2225
2393
  }
2226
2394
  async getAnalyticsSample(days = 30, limitPerFolder = 100) {
2227
2395
  const dateFrom = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
@@ -2260,6 +2428,15 @@ export class SimpleIMAPService {
2260
2428
  if (!uid) {
2261
2429
  uid = await this.findUidByHeader(folder, "message-id", input.messageId);
2262
2430
  }
2431
+ // STATUS works against a mailbox that isn't currently SELECTed (same
2432
+ // reasoning as getMailboxUidValidity/getFolderStats) — append() above
2433
+ // doesn't guarantee `folder` is selected on this client, so this can't
2434
+ // just read client.mailbox.uidValidity the way a withMailbox callback
2435
+ // would. Best-effort: a failure here shouldn't fail an otherwise-
2436
+ // successful append, it just means the returned emailId falls back to
2437
+ // the no-uidValidity format, same as it always did before this field
2438
+ // existed.
2439
+ const uidValidity = uid ? await this.getMailboxUidValidity(folder).catch(() => undefined) : undefined;
2263
2440
  this.folderCache = undefined;
2264
2441
  this.lastSyncAt = new Date().toISOString();
2265
2442
  // Build the result BEFORE attempting to clean up the superseded old
@@ -2276,7 +2453,7 @@ export class SimpleIMAPService {
2276
2453
  const result = {
2277
2454
  folder,
2278
2455
  uid,
2279
- emailId: uid ? createEmailId(folder, uid) : undefined,
2456
+ emailId: uid ? createEmailId(folder, uid, uidValidity) : undefined,
2280
2457
  messageId: input.messageId,
2281
2458
  syncedAt: this.lastSyncAt,
2282
2459
  };
@@ -2426,11 +2603,11 @@ export class SimpleIMAPService {
2426
2603
  return [...result].sort((left, right) => right - left)[0];
2427
2604
  });
2428
2605
  }
2429
- toSummary(folder, message) {
2606
+ toSummary(folder, message, uidValidity) {
2430
2607
  const flags = [...(message.flags ?? [])];
2431
2608
  const attachments = extractAttachments(message.bodyStructure);
2432
2609
  return {
2433
- id: createEmailId(folder, message.uid),
2610
+ id: createEmailId(folder, message.uid, uidValidity),
2434
2611
  folder,
2435
2612
  uid: message.uid,
2436
2613
  seq: message.seq,
@@ -2494,7 +2671,7 @@ export class SimpleIMAPService {
2494
2671
  if (!message || !message.source) {
2495
2672
  throw new Error(`Email not found for id ${emailId}`);
2496
2673
  }
2497
- const summary = this.toSummary(folder, message);
2674
+ const summary = this.toSummary(folder, message, (client.mailbox || undefined)?.uidValidity?.toString());
2498
2675
  const parsed = await this.parseSource(message.source);
2499
2676
  const enriched = this.enrichSummaryFromParsed(summary, parsed, true);
2500
2677
  return { enriched, parsed };
@@ -3034,12 +3211,14 @@ export class SimpleIMAPService {
3034
3211
  // Best-effort — an unparseable message still gets imported below.
3035
3212
  }
3036
3213
  if (messageId) {
3214
+ let existingFolderUidValidity;
3037
3215
  const existingUid = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
3216
+ existingFolderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
3038
3217
  const uids = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
3039
3218
  return Array.isArray(uids) && uids.length > 0 ? uids[uids.length - 1] : undefined;
3040
3219
  }), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms checking for an existing import of ${messageId} in ${folder}`).catch(() => undefined);
3041
3220
  if (existingUid !== undefined) {
3042
- const existingEmailId = createEmailId(folder, existingUid);
3221
+ const existingEmailId = createEmailId(folder, existingUid, existingFolderUidValidity);
3043
3222
  return { folder, uid: existingUid, emailId: existingEmailId, alreadyExists: true, existingEmailId };
3044
3223
  }
3045
3224
  }
@@ -3052,9 +3231,15 @@ export class SimpleIMAPService {
3052
3231
  if (!uid && messageId) {
3053
3232
  uid = await this.findUidByHeader(folder, "message-id", messageId);
3054
3233
  }
3234
+ // Best-effort, same reasoning as upsertRemoteDraft's identical lookup —
3235
+ // append() doesn't guarantee `folder` stays selected on this client, so
3236
+ // STATUS (works on a non-selected mailbox) is used instead of trusting
3237
+ // client.mailbox here. A failure just falls back to the no-uidValidity
3238
+ // format, same as before this field existed.
3239
+ const uidValidity = uid ? await this.getMailboxUidValidity(folder).catch(() => undefined) : undefined;
3055
3240
  this.folderCache = undefined;
3056
3241
  this.lastSyncAt = new Date().toISOString();
3057
- return { folder, uid, emailId: uid ? createEmailId(folder, uid) : undefined };
3242
+ return { folder, uid, emailId: uid ? createEmailId(folder, uid, uidValidity) : undefined };
3058
3243
  }
3059
3244
  }
3060
3245
  //# sourceMappingURL=simple-imap-service.js.map