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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +158 -43
- package/dist/index.js.map +1 -1
- package/dist/services/audit-service.d.ts +2 -0
- package/dist/services/audit-service.d.ts.map +1 -1
- package/dist/services/audit-service.js +16 -0
- package/dist/services/audit-service.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +61 -28
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +1 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +38 -7
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +7 -5
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +242 -57
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/utils/account-identity.d.ts.map +1 -1
- package/dist/utils/account-identity.js +49 -22
- package/dist/utils/account-identity.js.map +1 -1
- package/dist/utils/helpers.d.ts +2 -1
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +65 -9
- package/dist/utils/helpers.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1040
|
+
return { results, moreRemain: false };
|
|
998
1041
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1004
|
-
for
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|