@nocobase/app-plugin-mail 1.0.0-beta.24 → 1.0.0-beta.25
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/CHANGELOG.md +19 -0
- package/dist/server/adapters/imap-smtp/adapter.d.ts +13 -0
- package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/adapter.js +391 -43
- package/dist/server/adapters/imap-smtp/adapter.js.map +1 -1
- package/dist/server/adapters/imap-smtp/command-metrics.d.ts +24 -0
- package/dist/server/adapters/imap-smtp/command-metrics.d.ts.map +1 -0
- package/dist/server/adapters/imap-smtp/command-metrics.js +116 -0
- package/dist/server/adapters/imap-smtp/command-metrics.js.map +1 -0
- package/dist/server/adapters/imap-smtp/connection.d.ts +1 -1
- package/dist/server/adapters/imap-smtp/connection.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/connection.js +4 -2
- package/dist/server/adapters/imap-smtp/connection.js.map +1 -1
- package/dist/server/adapters/imap-smtp/constants.d.ts +2 -0
- package/dist/server/adapters/imap-smtp/constants.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/constants.js +2 -0
- package/dist/server/adapters/imap-smtp/constants.js.map +1 -1
- package/dist/server/adapters/imap-smtp/locators.d.ts +4 -0
- package/dist/server/adapters/imap-smtp/locators.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/locators.js +120 -6
- package/dist/server/adapters/imap-smtp/locators.js.map +1 -1
- package/dist/server/adapters/imap-smtp/types.d.ts +18 -2
- package/dist/server/adapters/imap-smtp/types.d.ts.map +1 -1
- package/dist/server/operations/sync-mailbox.d.ts.map +1 -1
- package/dist/server/operations/sync-mailbox.js +19 -1
- package/dist/server/operations/sync-mailbox.js.map +1 -1
- package/dist/server/providers/mail-core.d.ts.map +1 -1
- package/dist/server/providers/mail-core.js +1 -0
- package/dist/server/providers/mail-core.js.map +1 -1
- package/dist/server/runtime/imap-idle.d.ts +29 -0
- package/dist/server/runtime/imap-idle.d.ts.map +1 -0
- package/dist/server/runtime/imap-idle.js +164 -0
- package/dist/server/runtime/imap-idle.js.map +1 -0
- package/dist/server/runtime/push-timing.d.ts +9 -0
- package/dist/server/runtime/push-timing.d.ts.map +1 -0
- package/dist/server/runtime/push-timing.js +3 -0
- package/dist/server/runtime/push-timing.js.map +1 -0
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.d.ts.map +1 -1
- package/dist/server/runtime.js +56 -6
- package/dist/server/runtime.js.map +1 -1
- package/dist/server/services/accounts.d.ts.map +1 -1
- package/dist/server/services/accounts.js +5 -0
- package/dist/server/services/accounts.js.map +1 -1
- package/dist/server/services/authorization.d.ts +1 -1
- package/dist/server/services/authorization.d.ts.map +1 -1
- package/dist/server/services/authorization.js +1 -0
- package/dist/server/services/authorization.js.map +1 -1
- package/dist/server/services/dependencies.d.ts +1 -0
- package/dist/server/services/dependencies.d.ts.map +1 -1
- package/dist/server/store/sync.d.ts.map +1 -1
- package/dist/server/store/sync.js +29 -6
- package/dist/server/store/sync.js.map +1 -1
- package/package.json +4 -5
|
@@ -5,9 +5,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { simpleParser } from 'mailparser';
|
|
6
6
|
import { Readable, addAbortSignal } from 'node:stream';
|
|
7
7
|
import { attachmentParts, bodyParts, readBody, readRawSource, rawComparable, metadataQuery, attachmentMetadata, MAX_HEADER_BYTES, } from './content.js';
|
|
8
|
-
import { IMAP_SMTP_CAPABILITIES, MAX_IMAP_BODY_BYTES, MAX_IMAP_DETAIL_BODY_BYTES, MAX_IMAP_PROVIDER_ID_LENGTH, MAX_IMAP_RAW_COMPARE_BUDGET, MAX_IMAP_RAW_COMPARE_BYTES, MAX_IMAP_VERIFY_ATTEMPTS, MAX_IMAP_VERIFY_STATE_BYTES, } from './constants.js';
|
|
8
|
+
import { IMAP_SMTP_CAPABILITIES, MAX_IMAP_BODY_BYTES, MAX_IMAP_DETAIL_BODY_BYTES, MAX_IMAP_PROVIDER_ID_LENGTH, MAX_IMAP_RAW_COMPARE_BUDGET, MAX_IMAP_RAW_COMPARE_BYTES, MAX_IMAP_VERIFY_ATTEMPTS, MAX_IMAP_VERIFY_STATE_BYTES, MAX_IMAP_RECHECK_IDENTITIES, IMAP_RECHECK_BATCH_SIZE, } from './constants.js';
|
|
9
9
|
import { createImapClient, createSmtpTransport } from './connection.js';
|
|
10
|
-
import {
|
|
10
|
+
import { ImapCommandMetrics, observeImapCommands } from './command-metrics.js';
|
|
11
|
+
import { compressUids, matchingUids, subtractUids, encodeCopyLocator, encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor, parseSyncCursor, rangeFor, requireAttachmentLocator, requireMessageLocator, syncCursor, } from './locators.js';
|
|
11
12
|
import { classifyFolderType, folderTypeFromPath } from './folders.js';
|
|
12
13
|
function classifyConfiguredFolder(mailbox, sentFolder) {
|
|
13
14
|
if (sentFolder && mailbox.path === sentFolder)
|
|
@@ -29,6 +30,9 @@ export class ImapSmtpAdapter {
|
|
|
29
30
|
capabilities = IMAP_SMTP_CAPABILITIES;
|
|
30
31
|
folderTypes = new Map();
|
|
31
32
|
imapClient;
|
|
33
|
+
commandMetrics = new ImapCommandMetrics();
|
|
34
|
+
stopCommandObservation;
|
|
35
|
+
yieldReasons = new Set();
|
|
32
36
|
smtpTransport;
|
|
33
37
|
// Evidence and byte budget of the current step only; never reused across steps.
|
|
34
38
|
copies = [];
|
|
@@ -109,6 +113,9 @@ export class ImapSmtpAdapter {
|
|
|
109
113
|
input.run.leaseToken !== this.syncRun.leaseToken))
|
|
110
114
|
throw new Error('The IMAP sync operation snapshot changed.');
|
|
111
115
|
const incomplete = this.verificationIncomplete;
|
|
116
|
+
const changeCursor = incomplete && input.changeCursor
|
|
117
|
+
? parseSyncCursor(input.changeCursor)
|
|
118
|
+
: undefined;
|
|
112
119
|
const evidence = {
|
|
113
120
|
copies: this.copies.filter((copy) => copy.flagsOnly || ids.has(copy.providerMessageId)),
|
|
114
121
|
changes: this.changes,
|
|
@@ -116,7 +123,19 @@ export class ImapSmtpAdapter {
|
|
|
116
123
|
};
|
|
117
124
|
this.beginStep();
|
|
118
125
|
return this.locations.commitSyncStep(incomplete
|
|
119
|
-
? {
|
|
126
|
+
? {
|
|
127
|
+
...input,
|
|
128
|
+
status: 'partial',
|
|
129
|
+
createNextTask: false,
|
|
130
|
+
changeCursor: changeCursor &&
|
|
131
|
+
syncCursor({
|
|
132
|
+
...changeCursor,
|
|
133
|
+
verify: {
|
|
134
|
+
excluded: changeCursor.verify?.excluded,
|
|
135
|
+
rechecks: changeCursor.verify?.rechecks,
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
120
139
|
: input, evidence);
|
|
121
140
|
}
|
|
122
141
|
async listFolders(input) {
|
|
@@ -145,7 +164,14 @@ export class ImapSmtpAdapter {
|
|
|
145
164
|
throwIfAborted(signal);
|
|
146
165
|
folders[mailbox.path] = await this.folderState(mailbox.path, mailbox);
|
|
147
166
|
}
|
|
148
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
value: syncCursor({
|
|
170
|
+
version: 1,
|
|
171
|
+
folders,
|
|
172
|
+
receivedAfter: this.syncRun?.policy.receivedAfter,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
149
175
|
}
|
|
150
176
|
catch (error) {
|
|
151
177
|
this.closeImap();
|
|
@@ -181,12 +207,21 @@ export class ImapSmtpAdapter {
|
|
|
181
207
|
: undefined;
|
|
182
208
|
since?.setUTCHours(0, 0, 0, 0);
|
|
183
209
|
let scannedRanges = 0;
|
|
210
|
+
let budgetStartedAt;
|
|
211
|
+
const checkNewMail = this.newMailChecker(parseSyncCursor(this.syncRun?.changeCursor ?? input.baselineCursor)
|
|
212
|
+
.folders, input.signal);
|
|
184
213
|
while (folderIndex < folders.length &&
|
|
185
214
|
messages.length < limit &&
|
|
186
215
|
scannedRanges < 10) {
|
|
187
216
|
scannedRanges += 1;
|
|
188
217
|
throwIfAborted(input.signal);
|
|
189
218
|
const folder = folders[folderIndex];
|
|
219
|
+
const selectedFolderIndex = folderIndex;
|
|
220
|
+
// Top pages share this call's budget from the first top page onward.
|
|
221
|
+
// Continuation pages keep their throughput and existing push checks.
|
|
222
|
+
const topPage = upperUid === undefined && legacyOffset === undefined;
|
|
223
|
+
if (topPage)
|
|
224
|
+
budgetStartedAt ??= Date.now();
|
|
190
225
|
const mailbox = await (await this.imap()).mailboxOpen(folder, {
|
|
191
226
|
readOnly: true,
|
|
192
227
|
});
|
|
@@ -244,7 +279,16 @@ export class ImapSmtpAdapter {
|
|
|
244
279
|
upperUid = start - 1;
|
|
245
280
|
}
|
|
246
281
|
}
|
|
247
|
-
|
|
282
|
+
let yielded = false;
|
|
283
|
+
const fetched = await this.fetchMessages(folder, String(mailbox.uidValidity), selected, input.signal, async () => {
|
|
284
|
+
yielded = await checkNewMail();
|
|
285
|
+
if (!yielded && topPage && Date.now() - budgetStartedAt >= 2_000) {
|
|
286
|
+
this.yieldReasons.add('budget');
|
|
287
|
+
yielded = true;
|
|
288
|
+
}
|
|
289
|
+
return yielded;
|
|
290
|
+
});
|
|
291
|
+
for (const copy of fetched) {
|
|
248
292
|
const { receivedAt } = copy.message;
|
|
249
293
|
if (input.receivedAfter &&
|
|
250
294
|
receivedAt &&
|
|
@@ -253,10 +297,16 @@ export class ImapSmtpAdapter {
|
|
|
253
297
|
}
|
|
254
298
|
messages.push(copy);
|
|
255
299
|
}
|
|
300
|
+
if (yielded) {
|
|
301
|
+
folderIndex = selectedFolderIndex;
|
|
302
|
+
upperUid = fetched.at(-1).tuple.uid - 1;
|
|
303
|
+
}
|
|
256
304
|
if (upperUid !== undefined && upperUid < 1) {
|
|
257
305
|
folderIndex += 1;
|
|
258
306
|
upperUid = undefined;
|
|
259
307
|
}
|
|
308
|
+
if (yielded)
|
|
309
|
+
break;
|
|
260
310
|
}
|
|
261
311
|
const nextCursor = folderIndex < folders.length
|
|
262
312
|
? encodeHistoryCursor({ folderIndex, upperUid })
|
|
@@ -295,7 +345,12 @@ export class ImapSmtpAdapter {
|
|
|
295
345
|
};
|
|
296
346
|
}
|
|
297
347
|
const mailboxes = this.selectableMailboxes(await this.listMailboxes());
|
|
298
|
-
|
|
348
|
+
// Unvisited folders must retain their high-water marks when a completed
|
|
349
|
+
// folder's new copies are committed before the next folder is inspected.
|
|
350
|
+
const nextFolders = Object.fromEntries(mailboxes.flatMap((mailbox) => {
|
|
351
|
+
const old = previous.folders[mailbox.path];
|
|
352
|
+
return old ? [[mailbox.path, old]] : [];
|
|
353
|
+
}));
|
|
299
354
|
const messages = [];
|
|
300
355
|
let hasMore = false;
|
|
301
356
|
const limit = Math.max(1, input.limit);
|
|
@@ -344,19 +399,31 @@ export class ImapSmtpAdapter {
|
|
|
344
399
|
uidValidity: current.uidValidity,
|
|
345
400
|
uidNext: nextUid,
|
|
346
401
|
};
|
|
402
|
+
if (messages.length > 0) {
|
|
403
|
+
hasMore ||= mailbox !== mailboxes.at(-1);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
347
406
|
}
|
|
348
407
|
let verify;
|
|
349
408
|
if (this.locations) {
|
|
350
409
|
if (hasMore || messages.length > 0) {
|
|
351
|
-
//
|
|
352
|
-
verify = previous.verify && {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
410
|
+
// Commit new copies first, retaining the last complete verification pass.
|
|
411
|
+
verify = previous.verify && { ...previous.verify };
|
|
412
|
+
hasMore = true;
|
|
413
|
+
}
|
|
414
|
+
else if (this.syncRun?.mode === 'initial' &&
|
|
415
|
+
!this.syncRun.historyComplete) {
|
|
416
|
+
// Catch up between history pages without spending verification attempts.
|
|
417
|
+
verify = previous.verify;
|
|
418
|
+
}
|
|
419
|
+
else if (previous.verify?.rechecks?.length) {
|
|
420
|
+
// Recheck only committed identities, without mixing partial observations
|
|
421
|
+
// into a verification pass. The cursor saves progress after each folder.
|
|
422
|
+
verify = await this.advanceExcludedRecheck(previous.verify, nextFolders, input.signal);
|
|
356
423
|
hasMore = true;
|
|
357
424
|
}
|
|
358
425
|
else {
|
|
359
|
-
const result = await this.verifyFolders(mailboxes, nextFolders, previous.verify, input.signal);
|
|
426
|
+
const result = await this.verifyFolders(mailboxes, nextFolders, previous.verify, input.signal, previous.receivedAfter ?? this.syncRun?.policy.receivedAfter);
|
|
360
427
|
if (result === 'generation-changed')
|
|
361
428
|
return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed during verification.', 'provider', false);
|
|
362
429
|
messages.push(...this.verificationCopies);
|
|
@@ -364,6 +431,8 @@ export class ImapSmtpAdapter {
|
|
|
364
431
|
verify = result.verify;
|
|
365
432
|
}
|
|
366
433
|
}
|
|
434
|
+
if (verify?.excluded && messages.length)
|
|
435
|
+
verify = this.enqueueExcludedRechecks(verify, messages);
|
|
367
436
|
return {
|
|
368
437
|
ok: true,
|
|
369
438
|
value: {
|
|
@@ -372,6 +441,7 @@ export class ImapSmtpAdapter {
|
|
|
372
441
|
deletedProviderMessageIds: [],
|
|
373
442
|
nextCursor: syncCursor({
|
|
374
443
|
version: 1,
|
|
444
|
+
receivedAfter: previous.receivedAfter ?? this.syncRun?.policy.receivedAfter,
|
|
375
445
|
folders: nextFolders,
|
|
376
446
|
...(verify ? { verify } : {}),
|
|
377
447
|
}),
|
|
@@ -899,6 +969,8 @@ export class ImapSmtpAdapter {
|
|
|
899
969
|
}
|
|
900
970
|
}
|
|
901
971
|
async close() {
|
|
972
|
+
this.stopCommandObservation?.();
|
|
973
|
+
this.stopCommandObservation = undefined;
|
|
902
974
|
this.smtpTransport.close();
|
|
903
975
|
const client = this.imapClient;
|
|
904
976
|
this.imapClient = undefined;
|
|
@@ -931,6 +1003,8 @@ export class ImapSmtpAdapter {
|
|
|
931
1003
|
}
|
|
932
1004
|
}
|
|
933
1005
|
closeImap() {
|
|
1006
|
+
this.stopCommandObservation?.();
|
|
1007
|
+
this.stopCommandObservation = undefined;
|
|
934
1008
|
const client = this.imapClient;
|
|
935
1009
|
this.imapClient = undefined;
|
|
936
1010
|
if (client) {
|
|
@@ -957,8 +1031,20 @@ export class ImapSmtpAdapter {
|
|
|
957
1031
|
}
|
|
958
1032
|
}
|
|
959
1033
|
this.imapClient = client;
|
|
1034
|
+
if (this.syncRun) {
|
|
1035
|
+
this.stopCommandObservation = observeImapCommands(client, this.commandMetrics.record);
|
|
1036
|
+
this.commandMetrics.instrumented ||=
|
|
1037
|
+
this.stopCommandObservation !== undefined;
|
|
1038
|
+
}
|
|
960
1039
|
return client;
|
|
961
1040
|
}
|
|
1041
|
+
syncDiagnostics() {
|
|
1042
|
+
return {
|
|
1043
|
+
imapCommands: this.commandMetrics.snapshot(),
|
|
1044
|
+
yielded: this.yieldReasons.size > 0,
|
|
1045
|
+
yieldReasons: [...this.yieldReasons],
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
962
1048
|
async listMailboxes() {
|
|
963
1049
|
const client = await this.imap();
|
|
964
1050
|
const mailboxes = await client.list({
|
|
@@ -1059,7 +1145,7 @@ export class ImapSmtpAdapter {
|
|
|
1059
1145
|
kind: 'folder',
|
|
1060
1146
|
};
|
|
1061
1147
|
}
|
|
1062
|
-
async fetchMessages(folder, uidValidity, uids, signal) {
|
|
1148
|
+
async fetchMessages(folder, uidValidity, uids, signal, shouldYield) {
|
|
1063
1149
|
if (uids.length === 0)
|
|
1064
1150
|
return [];
|
|
1065
1151
|
const observed = this.locations
|
|
@@ -1075,6 +1161,10 @@ export class ImapSmtpAdapter {
|
|
|
1075
1161
|
metadata.push(message);
|
|
1076
1162
|
}
|
|
1077
1163
|
const messages = [];
|
|
1164
|
+
if (shouldYield && Array.isArray(uids)) {
|
|
1165
|
+
const order = new Map(uids.map((uid, index) => [uid, index]));
|
|
1166
|
+
metadata.sort((a, b) => order.get(a.uid) - order.get(b.uid));
|
|
1167
|
+
}
|
|
1078
1168
|
for (const message of metadata) {
|
|
1079
1169
|
throwIfAborted(signal);
|
|
1080
1170
|
const normalized = await this.normalizeMessage(folder, uidValidity, message, signal);
|
|
@@ -1089,9 +1179,54 @@ export class ImapSmtpAdapter {
|
|
|
1089
1179
|
structure: message.bodyStructure,
|
|
1090
1180
|
size: message.size,
|
|
1091
1181
|
});
|
|
1182
|
+
if (await shouldYield?.())
|
|
1183
|
+
break;
|
|
1092
1184
|
}
|
|
1093
1185
|
return messages;
|
|
1094
1186
|
}
|
|
1187
|
+
/** Check only between completed commands; issuing commands inside FETCH deadlocks. */
|
|
1188
|
+
newMailChecker(folders, signal) {
|
|
1189
|
+
let lastUidCheck = Date.now();
|
|
1190
|
+
let lastPushCheck = Date.now();
|
|
1191
|
+
const check = async () => {
|
|
1192
|
+
throwIfAborted(signal);
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
if (this.locations && this.syncRun && now - lastPushCheck >= 1_000) {
|
|
1195
|
+
lastPushCheck = now;
|
|
1196
|
+
const requestedAt = await this.locations.getPushRequestedAt();
|
|
1197
|
+
if (requestedAt &&
|
|
1198
|
+
Date.parse(requestedAt) > Date.parse(this.syncRun.updatedAt)) {
|
|
1199
|
+
this.yieldReasons.add('push');
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (now - lastUidCheck < 4_000)
|
|
1204
|
+
return false;
|
|
1205
|
+
lastUidCheck = now;
|
|
1206
|
+
const client = await this.imap();
|
|
1207
|
+
if (client.capabilities.has('LIST-STATUS')) {
|
|
1208
|
+
const changed = this.selectableMailboxes(await this.listMailboxes()).some((box) => box.status?.uidNext !== undefined &&
|
|
1209
|
+
box.status.uidNext !== folders[box.path]?.uidNext);
|
|
1210
|
+
if (changed)
|
|
1211
|
+
this.yieldReasons.add('uidnext');
|
|
1212
|
+
return changed;
|
|
1213
|
+
}
|
|
1214
|
+
if (!folders.INBOX)
|
|
1215
|
+
return false;
|
|
1216
|
+
const status = await client.status('INBOX', { uidNext: true });
|
|
1217
|
+
if (!status)
|
|
1218
|
+
throw new Error('The IMAP UIDNEXT check was incomplete.');
|
|
1219
|
+
const changed = status.uidNext !== folders.INBOX.uidNext;
|
|
1220
|
+
if (changed)
|
|
1221
|
+
this.yieldReasons.add('uidnext');
|
|
1222
|
+
return changed;
|
|
1223
|
+
};
|
|
1224
|
+
return Object.assign(check, {
|
|
1225
|
+
uidChecked: () => {
|
|
1226
|
+
lastUidCheck = Date.now();
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1095
1230
|
beginStep() {
|
|
1096
1231
|
this.copies = [];
|
|
1097
1232
|
this.changes = [];
|
|
@@ -1311,16 +1446,40 @@ export class ImapSmtpAdapter {
|
|
|
1311
1446
|
* One complete pass over every selectable folder. It reconciles stored
|
|
1312
1447
|
* copies, and two matching passes in a row authorize orphan cleanup.
|
|
1313
1448
|
*/
|
|
1314
|
-
async verifyFolders(mailboxes, folders, previous, signal) {
|
|
1449
|
+
async verifyFolders(mailboxes, folders, previous, signal, receivedAfter) {
|
|
1315
1450
|
const attempts = (previous?.attempts ?? 0) + 1;
|
|
1316
1451
|
const snapshot = {};
|
|
1317
1452
|
const present = new Map();
|
|
1318
1453
|
const observedCopies = new Map();
|
|
1319
|
-
const excluded =
|
|
1454
|
+
const excluded = {
|
|
1455
|
+
...previous?.excluded,
|
|
1456
|
+
};
|
|
1320
1457
|
const uncertainGenerations = new Set();
|
|
1321
1458
|
let stable = true;
|
|
1459
|
+
const checkNewMail = this.newMailChecker(folders, signal);
|
|
1460
|
+
const interrupted = () => {
|
|
1461
|
+
if (!this.yieldReasons.size)
|
|
1462
|
+
this.yieldReasons.add('uidnext');
|
|
1463
|
+
// Partial observations must never invalidate copies or publish flags.
|
|
1464
|
+
this.copies = [];
|
|
1465
|
+
this.changes = [];
|
|
1466
|
+
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS)
|
|
1467
|
+
this.verificationIncomplete = true;
|
|
1468
|
+
return {
|
|
1469
|
+
done: attempts >= MAX_IMAP_VERIFY_ATTEMPTS,
|
|
1470
|
+
verify: {
|
|
1471
|
+
attempts,
|
|
1472
|
+
folders: previous?.folders,
|
|
1473
|
+
excluded,
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
};
|
|
1477
|
+
const changed = (boxes) => boxes.some((box) => box.status?.uidNext !== undefined &&
|
|
1478
|
+
box.status.uidNext !== folders[box.path]?.uidNext);
|
|
1322
1479
|
const known = await this.locations.listKnownLocations();
|
|
1323
1480
|
for (const mailbox of mailboxes) {
|
|
1481
|
+
if (await checkNewMail())
|
|
1482
|
+
return interrupted();
|
|
1324
1483
|
throwIfAborted(signal);
|
|
1325
1484
|
const client = await this.imap();
|
|
1326
1485
|
const selected = await client.mailboxOpen(mailbox.path, {
|
|
@@ -1332,7 +1491,7 @@ export class ImapSmtpAdapter {
|
|
|
1332
1491
|
return 'generation-changed';
|
|
1333
1492
|
// New mail arrived after this page: import it before verifying again.
|
|
1334
1493
|
if (state.uidNext !== cursor.uidNext)
|
|
1335
|
-
|
|
1494
|
+
return interrupted();
|
|
1336
1495
|
let uids;
|
|
1337
1496
|
try {
|
|
1338
1497
|
uids = await client.search({ all: true }, { uid: true });
|
|
@@ -1343,7 +1502,7 @@ export class ImapSmtpAdapter {
|
|
|
1343
1502
|
throw error;
|
|
1344
1503
|
// The UID list exceeds the protocol budget: keep every copy and skip cleanup.
|
|
1345
1504
|
this.verificationIncomplete = true;
|
|
1346
|
-
return { done: true, verify: { attempts, excluded
|
|
1505
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1347
1506
|
}
|
|
1348
1507
|
if (!Array.isArray(uids))
|
|
1349
1508
|
// Retry the step: an incomplete SEARCH proves nothing about missing copies.
|
|
@@ -1355,7 +1514,7 @@ export class ImapSmtpAdapter {
|
|
|
1355
1514
|
if (Buffer.byteLength(JSON.stringify(snapshot)) >
|
|
1356
1515
|
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1357
1516
|
this.verificationIncomplete = true;
|
|
1358
|
-
return { done: true, verify: { attempts, excluded
|
|
1517
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1359
1518
|
}
|
|
1360
1519
|
const bound = new Set(known
|
|
1361
1520
|
.filter((location) => location.folder === mailbox.path &&
|
|
@@ -1368,6 +1527,8 @@ export class ImapSmtpAdapter {
|
|
|
1368
1527
|
location.source &&
|
|
1369
1528
|
present.get(mailbox.path).has(location.uid));
|
|
1370
1529
|
for (let offset = 0; offset < candidates.length; offset += 1000) {
|
|
1530
|
+
if (await checkNewMail())
|
|
1531
|
+
return interrupted();
|
|
1371
1532
|
throwIfAborted(signal);
|
|
1372
1533
|
const batch = candidates.slice(offset, offset + 1000);
|
|
1373
1534
|
const messages = await client.fetchAll(compressUids(batch.map((location) => location.uid)), {
|
|
@@ -1392,15 +1553,76 @@ export class ImapSmtpAdapter {
|
|
|
1392
1553
|
stable = false;
|
|
1393
1554
|
}
|
|
1394
1555
|
}
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
|
|
1556
|
+
const excludedUids = new Set(matchingUids(uids, excluded[mailbox.path]?.uidValidity === state.uidValidity
|
|
1557
|
+
? excluded[mailbox.path].uids
|
|
1558
|
+
: ''));
|
|
1559
|
+
const rememberExcluded = () => {
|
|
1560
|
+
if (!excludedUids.size) {
|
|
1561
|
+
delete excluded[mailbox.path];
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
excluded[mailbox.path] = {
|
|
1398
1565
|
uidValidity: state.uidValidity,
|
|
1399
|
-
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1566
|
+
uids: compressUids(excludedUids),
|
|
1567
|
+
};
|
|
1568
|
+
};
|
|
1569
|
+
const unbound = uids.filter((uid) => !bound.has(uid) && !excludedUids.has(uid));
|
|
1570
|
+
const knownUids = new Set(known
|
|
1571
|
+
.filter((location) => location.folder === mailbox.path &&
|
|
1572
|
+
location.uidValidity === state.uidValidity)
|
|
1573
|
+
.map((location) => location.uid));
|
|
1574
|
+
const toFetch = [];
|
|
1575
|
+
const bodyLimit = this.syncRun?.policy.batchSize ?? 100;
|
|
1576
|
+
// Screen large historical mailboxes without downloading old bodies. A copy
|
|
1577
|
+
// of an existing identity still needs full evidence, even with an old date.
|
|
1578
|
+
for (let offset = 0; offset < unbound.length; offset += 1000) {
|
|
1579
|
+
if (await checkNewMail()) {
|
|
1580
|
+
rememberExcluded();
|
|
1581
|
+
return interrupted();
|
|
1582
|
+
}
|
|
1583
|
+
const batch = unbound.slice(offset, offset + 1000);
|
|
1584
|
+
const metadata = await client.fetchAll(compressUids(batch), {
|
|
1585
|
+
uid: true,
|
|
1586
|
+
internalDate: true,
|
|
1587
|
+
emailId: true,
|
|
1588
|
+
headers: ['message-id'],
|
|
1589
|
+
}, { uid: true });
|
|
1590
|
+
const sources = await this.locations.findMessageSources(metadata.flatMap((message) => {
|
|
1591
|
+
const id = validNativeId(this.nativeSource(), message.emailId) ??
|
|
1592
|
+
validRfcMessageId(messageIdHeader(message.headers));
|
|
1593
|
+
return id ? [id] : [];
|
|
1594
|
+
}));
|
|
1595
|
+
for (const message of metadata) {
|
|
1596
|
+
const nativeId = validNativeId(this.nativeSource(), message.emailId);
|
|
1597
|
+
const id = nativeId ?? validRfcMessageId(messageIdHeader(message.headers));
|
|
1598
|
+
const identityReadable = nativeId ||
|
|
1599
|
+
(message.headers &&
|
|
1600
|
+
message.headers.byteLength <= MAX_HEADER_BYTES &&
|
|
1601
|
+
(message.headers.equals(Buffer.from('\r\n')) ||
|
|
1602
|
+
message.headers.subarray(-4).equals(Buffer.from('\r\n\r\n'))));
|
|
1603
|
+
const receivedAt = toIsoDate(message.internalDate);
|
|
1604
|
+
const observed = knownUids.has(message.uid);
|
|
1605
|
+
if (receivedAfter &&
|
|
1606
|
+
identityReadable &&
|
|
1607
|
+
receivedAt &&
|
|
1608
|
+
Date.parse(receivedAt) < Date.parse(receivedAfter) &&
|
|
1609
|
+
!observed &&
|
|
1610
|
+
(!id || !sources.has(id)))
|
|
1611
|
+
excludedUids.add(message.uid);
|
|
1612
|
+
else
|
|
1613
|
+
toFetch.push(message.uid);
|
|
1614
|
+
}
|
|
1615
|
+
rememberExcluded();
|
|
1616
|
+
// Bound the full-body work; remaining UIDs stay unbound for the next pass.
|
|
1617
|
+
if (toFetch.length >= bodyLimit)
|
|
1618
|
+
break;
|
|
1619
|
+
}
|
|
1620
|
+
rememberExcluded();
|
|
1621
|
+
if (toFetch.length) {
|
|
1622
|
+
if (await checkNewMail())
|
|
1623
|
+
return interrupted();
|
|
1624
|
+
let yielded = false;
|
|
1625
|
+
const fetched = await this.fetchMessages(mailbox.path, state.uidValidity, toFetch.slice(0, bodyLimit), signal, async () => (yielded = await checkNewMail()));
|
|
1404
1626
|
const sources = await this.locations.findMessageSources(fetched.flatMap((copy) => {
|
|
1405
1627
|
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1406
1628
|
copy.rfcMessageId;
|
|
@@ -1409,26 +1631,37 @@ export class ImapSmtpAdapter {
|
|
|
1409
1631
|
for (const copy of fetched) {
|
|
1410
1632
|
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1411
1633
|
copy.rfcMessageId;
|
|
1412
|
-
|
|
1634
|
+
if (receivedAfter &&
|
|
1413
1635
|
copy.message.receivedAt &&
|
|
1414
|
-
Date.parse(copy.message.receivedAt) <
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1636
|
+
Date.parse(copy.message.receivedAt) < Date.parse(receivedAfter) &&
|
|
1637
|
+
!copy.observed &&
|
|
1638
|
+
(!id || !sources.has(id)))
|
|
1639
|
+
excludedUids.add(copy.tuple.uid);
|
|
1418
1640
|
else
|
|
1419
1641
|
this.verificationCopies.push(copy);
|
|
1420
1642
|
}
|
|
1421
|
-
|
|
1643
|
+
rememberExcluded();
|
|
1644
|
+
if (yielded)
|
|
1645
|
+
return interrupted();
|
|
1422
1646
|
}
|
|
1647
|
+
if (unbound.some((uid) => !excludedUids.has(uid)))
|
|
1648
|
+
stable = false;
|
|
1423
1649
|
}
|
|
1424
1650
|
const after = this.selectableMailboxes(await this.listMailboxes());
|
|
1651
|
+
checkNewMail.uidChecked();
|
|
1652
|
+
if (changed(after))
|
|
1653
|
+
return interrupted();
|
|
1425
1654
|
if (after.length !== mailboxes.length ||
|
|
1426
1655
|
after.some((box) => !snapshot[box.path]))
|
|
1427
1656
|
stable = false;
|
|
1428
1657
|
for (const mailbox of after) {
|
|
1658
|
+
if (await checkNewMail())
|
|
1659
|
+
return interrupted();
|
|
1429
1660
|
throwIfAborted(signal);
|
|
1430
1661
|
const selected = await (await this.imap()).mailboxOpen(mailbox.path, { readOnly: true });
|
|
1431
1662
|
const state = await this.selectedFolderState(selected);
|
|
1663
|
+
if (state.uidNext !== folders[mailbox.path]?.uidNext)
|
|
1664
|
+
return interrupted();
|
|
1432
1665
|
const rechecked = await (await this.imap()).search({ all: true }, { uid: true });
|
|
1433
1666
|
if (!Array.isArray(rechecked))
|
|
1434
1667
|
throw new Error('The final IMAP SEARCH was incomplete.');
|
|
@@ -1441,6 +1674,8 @@ export class ImapSmtpAdapter {
|
|
|
1441
1674
|
uncertainGenerations.add(mailbox.path);
|
|
1442
1675
|
}
|
|
1443
1676
|
const finalRange = this.selectableMailboxes(await this.listMailboxes());
|
|
1677
|
+
if (changed(finalRange))
|
|
1678
|
+
return interrupted();
|
|
1444
1679
|
const finalPaths = new Set(finalRange.map((mailbox) => mailbox.path));
|
|
1445
1680
|
if (finalPaths.size !== mailboxes.length ||
|
|
1446
1681
|
mailboxes.some((mailbox) => !finalPaths.has(mailbox.path)))
|
|
@@ -1510,29 +1745,122 @@ export class ImapSmtpAdapter {
|
|
|
1510
1745
|
if (next.status !== 'valid' || next.status !== location.status)
|
|
1511
1746
|
this.changes.push({ id: location.id, observed: location, ...next });
|
|
1512
1747
|
}
|
|
1513
|
-
if (Buffer.byteLength(JSON.stringify({ snapshot, excluded
|
|
1748
|
+
if (Buffer.byteLength(JSON.stringify({ snapshot, excluded })) >
|
|
1514
1749
|
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1515
1750
|
this.verificationIncomplete = true;
|
|
1516
|
-
return { done: true, verify: { attempts } };
|
|
1751
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1517
1752
|
}
|
|
1518
1753
|
if (!stable) {
|
|
1519
1754
|
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1520
1755
|
this.verificationIncomplete = true;
|
|
1521
|
-
return { done: true, verify: { attempts, excluded
|
|
1756
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1522
1757
|
}
|
|
1523
|
-
return { done: false, verify: { attempts, excluded
|
|
1758
|
+
return { done: false, verify: { attempts, excluded } };
|
|
1524
1759
|
}
|
|
1525
|
-
if (previous?.folders &&
|
|
1760
|
+
if (previous?.folders &&
|
|
1761
|
+
sameSnapshot(previous.folders, snapshot) &&
|
|
1762
|
+
// A newly imported then expunged copy needs its own complete missing pass.
|
|
1763
|
+
!known.some((location) => location.uidValidity ===
|
|
1764
|
+
previous.folders?.[location.folder]?.uidValidity &&
|
|
1765
|
+
location.uid >= previous.folders[location.folder].uidNext &&
|
|
1766
|
+
!present.get(location.folder)?.has(location.uid))) {
|
|
1526
1767
|
this.cleanup = true;
|
|
1527
|
-
return { done: true };
|
|
1768
|
+
return { done: true, verify: { excluded } };
|
|
1528
1769
|
}
|
|
1529
1770
|
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1530
1771
|
this.verificationIncomplete = true;
|
|
1531
|
-
return { done: true, verify: { attempts, excluded
|
|
1772
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1532
1773
|
}
|
|
1533
1774
|
return {
|
|
1534
1775
|
done: false,
|
|
1535
|
-
verify: { attempts, folders: snapshot, excluded
|
|
1776
|
+
verify: { attempts, folders: snapshot, excluded },
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
enqueueExcludedRechecks(verify, copies) {
|
|
1780
|
+
const folders = Object.keys(verify.excluded ?? {}).filter((path) => verify.excluded[path].uids);
|
|
1781
|
+
if (!folders.length)
|
|
1782
|
+
return verify;
|
|
1783
|
+
const pending = [...(verify.rechecks ?? [])];
|
|
1784
|
+
const key = (identity) => identity.emailId !== undefined
|
|
1785
|
+
? 'native:' + identity.emailId
|
|
1786
|
+
: 'rfc:' + identity.rfcMessageId;
|
|
1787
|
+
const queued = new Set(pending.flatMap((batch) => batch.identities.map(key)));
|
|
1788
|
+
const identities = new Map();
|
|
1789
|
+
for (const copy of copies) {
|
|
1790
|
+
const source = this.nativeSource();
|
|
1791
|
+
const nativeId = validNativeId(source, copy.emailId ??
|
|
1792
|
+
(copy.observed?.source === source
|
|
1793
|
+
? copy.observed?.providerMessageId
|
|
1794
|
+
: undefined));
|
|
1795
|
+
const identity = nativeId
|
|
1796
|
+
? { emailId: nativeId }
|
|
1797
|
+
: copy.rfcMessageId
|
|
1798
|
+
? { rfcMessageId: copy.rfcMessageId }
|
|
1799
|
+
: undefined;
|
|
1800
|
+
if (identity && !queued.has(key(identity)))
|
|
1801
|
+
identities.set(key(identity), identity);
|
|
1802
|
+
}
|
|
1803
|
+
if (pending.reduce((total, batch) => total + batch.identities.length, 0) +
|
|
1804
|
+
identities.size >
|
|
1805
|
+
MAX_IMAP_RECHECK_IDENTITIES) {
|
|
1806
|
+
// Bounded cursor fallback: re-screen later, after the arrival is committed.
|
|
1807
|
+
return { attempts: 0 };
|
|
1808
|
+
}
|
|
1809
|
+
const added = [...identities.values()];
|
|
1810
|
+
for (let offset = 0; offset < added.length; offset += IMAP_RECHECK_BATCH_SIZE)
|
|
1811
|
+
pending.push({
|
|
1812
|
+
identities: added.slice(offset, offset + IMAP_RECHECK_BATCH_SIZE),
|
|
1813
|
+
folders,
|
|
1814
|
+
});
|
|
1815
|
+
return { ...verify, rechecks: pending.length ? pending : undefined };
|
|
1816
|
+
}
|
|
1817
|
+
async advanceExcludedRecheck(verify, folders, signal) {
|
|
1818
|
+
const excluded = { ...verify.excluded };
|
|
1819
|
+
const pending = [...(verify.rechecks ?? [])];
|
|
1820
|
+
while (pending.length) {
|
|
1821
|
+
throwIfAborted(signal);
|
|
1822
|
+
const batch = pending[0];
|
|
1823
|
+
const [path, ...remaining] = batch.folders;
|
|
1824
|
+
const advance = () => {
|
|
1825
|
+
if (remaining.length)
|
|
1826
|
+
pending[0] = { ...batch, folders: remaining };
|
|
1827
|
+
else
|
|
1828
|
+
pending.shift();
|
|
1829
|
+
};
|
|
1830
|
+
const range = excluded[path];
|
|
1831
|
+
if (!range?.uids || folders[path]?.uidValidity !== range.uidValidity) {
|
|
1832
|
+
delete excluded[path];
|
|
1833
|
+
advance();
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
const client = await this.imap();
|
|
1837
|
+
const selected = await client.mailboxOpen(path, { readOnly: true });
|
|
1838
|
+
if (String(selected.uidValidity) !== range.uidValidity) {
|
|
1839
|
+
delete excluded[path];
|
|
1840
|
+
advance();
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
const identities = batch.identities.map((identity) => identity.emailId !== undefined
|
|
1844
|
+
? { emailId: identity.emailId }
|
|
1845
|
+
: { header: { 'message-id': identity.rfcMessageId } });
|
|
1846
|
+
const found = await client.search({
|
|
1847
|
+
uid: range.uids,
|
|
1848
|
+
...(identities.length === 1 ? identities[0] : { or: identities }),
|
|
1849
|
+
}, { uid: true });
|
|
1850
|
+
if (!Array.isArray(found))
|
|
1851
|
+
throw new Error('The excluded-copy identity SEARCH was incomplete.');
|
|
1852
|
+
const uids = subtractUids(range.uids, found);
|
|
1853
|
+
if (uids)
|
|
1854
|
+
excluded[path] = { ...range, uids };
|
|
1855
|
+
else
|
|
1856
|
+
delete excluded[path];
|
|
1857
|
+
advance();
|
|
1858
|
+
break; // One folder per committed step; arrivals take priority on the next step.
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
...verify,
|
|
1862
|
+
excluded,
|
|
1863
|
+
rechecks: pending.length ? pending : undefined,
|
|
1536
1864
|
};
|
|
1537
1865
|
}
|
|
1538
1866
|
async normalizeMessage(folder, uidValidity, message, signal, maxBodyBytes = MAX_IMAP_BODY_BYTES) {
|
|
@@ -1581,7 +1909,7 @@ export class ImapSmtpAdapter {
|
|
|
1581
1909
|
...body,
|
|
1582
1910
|
size: message.size,
|
|
1583
1911
|
// Preserve the header value; mailparser can wrap malformed bare IDs in brackets.
|
|
1584
|
-
internetMessageId:
|
|
1912
|
+
internetMessageId: messageIdHeader(header),
|
|
1585
1913
|
providerFolderIds: [folder],
|
|
1586
1914
|
from,
|
|
1587
1915
|
to,
|
|
@@ -1629,8 +1957,23 @@ function sameSnapshot(left, right) {
|
|
|
1629
1957
|
const paths = Object.keys(right);
|
|
1630
1958
|
return (Object.keys(left).length === paths.length &&
|
|
1631
1959
|
paths.every((path) => left[path]?.uidValidity === right[path].uidValidity &&
|
|
1632
|
-
left[path].uidNext
|
|
1633
|
-
left[path].uids === right[path].uids));
|
|
1960
|
+
left[path].uidNext <= right[path].uidNext &&
|
|
1961
|
+
left[path].uids === uidsBelow(right[path].uids, left[path].uidNext)));
|
|
1962
|
+
}
|
|
1963
|
+
/** Clip compressed ranges without expanding potentially very large UID sets. */
|
|
1964
|
+
function uidsBelow(ranges, uidNext) {
|
|
1965
|
+
return ranges
|
|
1966
|
+
.split(',')
|
|
1967
|
+
.flatMap((range) => {
|
|
1968
|
+
if (!range)
|
|
1969
|
+
return [];
|
|
1970
|
+
const [start, end = start] = range.split(':').map(Number);
|
|
1971
|
+
if (start >= uidNext)
|
|
1972
|
+
return [];
|
|
1973
|
+
const last = Math.min(end, uidNext - 1);
|
|
1974
|
+
return [start === last ? String(start) : `${start}:${last}`];
|
|
1975
|
+
})
|
|
1976
|
+
.join(',');
|
|
1634
1977
|
}
|
|
1635
1978
|
function isResponseLimitError(error) {
|
|
1636
1979
|
return ['LiteralTooLarge', 'ResponseTooLarge', 'LineTooLarge'].includes(error?.code ?? '');
|
|
@@ -1714,4 +2057,9 @@ function copyMatches(location, message, currentSource) {
|
|
|
1714
2057
|
return (source === 'locator' &&
|
|
1715
2058
|
encodeCopyLocator(location, location.id) === location.providerMessageId);
|
|
1716
2059
|
}
|
|
2060
|
+
/** Preserve the raw header value, including malformed IDs, on both FETCH paths. */
|
|
2061
|
+
function messageIdHeader(header) {
|
|
2062
|
+
return (header &&
|
|
2063
|
+
/^message-id:[\t ]*([^\r\n]*(?:\r\n[\t ][^\r\n]*)*)\r\n/im.exec(header.toString('utf8'))?.[1]);
|
|
2064
|
+
}
|
|
1717
2065
|
//# sourceMappingURL=adapter.js.map
|