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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -47
- 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 +88 -26
- 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 +84 -10
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +12 -5
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +369 -76
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1045
|
+
return { results, moreRemain: false };
|
|
998
1046
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1004
|
-
for
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2490
|
-
//
|
|
2491
|
-
//
|
|
2492
|
-
//
|
|
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
|