@nocobase/app-plugin-mail 1.0.0-beta.23 → 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 +31 -0
- package/dist/server/adapters/imap-smtp/adapter.d.ts +16 -1
- package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/adapter.js +503 -82
- 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-pool.d.ts +19 -0
- package/dist/server/adapters/imap-smtp/connection-pool.d.ts.map +1 -0
- package/dist/server/adapters/imap-smtp/connection-pool.js +91 -0
- package/dist/server/adapters/imap-smtp/connection-pool.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/definition.d.ts +2 -1
- package/dist/server/adapters/imap-smtp/definition.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/definition.js +3 -2
- package/dist/server/adapters/imap-smtp/definition.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-builtins.d.ts +2 -0
- package/dist/server/providers/mail-builtins.d.ts.map +1 -1
- package/dist/server/providers/mail-builtins.js +6 -1
- package/dist/server/providers/mail-builtins.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 +9 -10
|
@@ -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)
|
|
@@ -24,10 +25,14 @@ export class ImapSmtpAdapter {
|
|
|
24
25
|
locations;
|
|
25
26
|
protocol;
|
|
26
27
|
operation;
|
|
28
|
+
pool;
|
|
27
29
|
identity;
|
|
28
30
|
capabilities = IMAP_SMTP_CAPABILITIES;
|
|
29
31
|
folderTypes = new Map();
|
|
30
32
|
imapClient;
|
|
33
|
+
commandMetrics = new ImapCommandMetrics();
|
|
34
|
+
stopCommandObservation;
|
|
35
|
+
yieldReasons = new Set();
|
|
31
36
|
smtpTransport;
|
|
32
37
|
// Evidence and byte budget of the current step only; never reused across steps.
|
|
33
38
|
copies = [];
|
|
@@ -43,7 +48,7 @@ export class ImapSmtpAdapter {
|
|
|
43
48
|
smtp: createSmtpTransport,
|
|
44
49
|
expungeUid,
|
|
45
50
|
transferUid,
|
|
46
|
-
}), operation) {
|
|
51
|
+
}), operation, pool) {
|
|
47
52
|
this.context = context;
|
|
48
53
|
this.config = config;
|
|
49
54
|
this.account = account;
|
|
@@ -51,6 +56,7 @@ export class ImapSmtpAdapter {
|
|
|
51
56
|
this.locations = locations;
|
|
52
57
|
this.protocol = protocol;
|
|
53
58
|
this.operation = operation;
|
|
59
|
+
this.pool = pool;
|
|
54
60
|
if (locations && locations.accountId !== account.id)
|
|
55
61
|
throw new Error('The IMAP persistence belongs to another account.');
|
|
56
62
|
if (operation && operation.accountId !== account.id)
|
|
@@ -80,13 +86,13 @@ export class ImapSmtpAdapter {
|
|
|
80
86
|
throw new Error('An IMAP message operation cannot be reused for synchronization.');
|
|
81
87
|
if (run.accountId !== this.account.id)
|
|
82
88
|
throw new Error('The IMAP sync account changed.');
|
|
83
|
-
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, this.locations, run, this.protocol);
|
|
89
|
+
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, this.locations, run, this.protocol, undefined, this.pool);
|
|
84
90
|
}
|
|
85
91
|
async forMessage(messageId, providerMessageId, operation, move) {
|
|
86
92
|
if (!this.locations)
|
|
87
93
|
throw new Error('IMAP location persistence is not configured.');
|
|
88
94
|
const bound = await this.locations.bindMessage(messageId, providerMessageId, operation, move);
|
|
89
|
-
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, bound.persistence, undefined, this.protocol, bound.snapshot);
|
|
95
|
+
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, bound.persistence, undefined, this.protocol, bound.snapshot, this.pool);
|
|
90
96
|
}
|
|
91
97
|
/** True when this instance records message copies in the account's location table. */
|
|
92
98
|
get persistsLocations() {
|
|
@@ -107,6 +113,9 @@ export class ImapSmtpAdapter {
|
|
|
107
113
|
input.run.leaseToken !== this.syncRun.leaseToken))
|
|
108
114
|
throw new Error('The IMAP sync operation snapshot changed.');
|
|
109
115
|
const incomplete = this.verificationIncomplete;
|
|
116
|
+
const changeCursor = incomplete && input.changeCursor
|
|
117
|
+
? parseSyncCursor(input.changeCursor)
|
|
118
|
+
: undefined;
|
|
110
119
|
const evidence = {
|
|
111
120
|
copies: this.copies.filter((copy) => copy.flagsOnly || ids.has(copy.providerMessageId)),
|
|
112
121
|
changes: this.changes,
|
|
@@ -114,7 +123,19 @@ export class ImapSmtpAdapter {
|
|
|
114
123
|
};
|
|
115
124
|
this.beginStep();
|
|
116
125
|
return this.locations.commitSyncStep(incomplete
|
|
117
|
-
? {
|
|
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
|
+
}
|
|
118
139
|
: input, evidence);
|
|
119
140
|
}
|
|
120
141
|
async listFolders(input) {
|
|
@@ -130,6 +151,7 @@ export class ImapSmtpAdapter {
|
|
|
130
151
|
};
|
|
131
152
|
}
|
|
132
153
|
catch (error) {
|
|
154
|
+
this.closeImap();
|
|
133
155
|
return { ok: false, error: classifyError(error, 'IMAP_LIST_FOLDERS') };
|
|
134
156
|
}
|
|
135
157
|
}
|
|
@@ -142,9 +164,17 @@ export class ImapSmtpAdapter {
|
|
|
142
164
|
throwIfAborted(signal);
|
|
143
165
|
folders[mailbox.path] = await this.folderState(mailbox.path, mailbox);
|
|
144
166
|
}
|
|
145
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
value: syncCursor({
|
|
170
|
+
version: 1,
|
|
171
|
+
folders,
|
|
172
|
+
receivedAfter: this.syncRun?.policy.receivedAfter,
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
146
175
|
}
|
|
147
176
|
catch (error) {
|
|
177
|
+
this.closeImap();
|
|
148
178
|
return {
|
|
149
179
|
ok: false,
|
|
150
180
|
error: classifyError(error, 'IMAP_SYNC_CURSOR'),
|
|
@@ -177,12 +207,21 @@ export class ImapSmtpAdapter {
|
|
|
177
207
|
: undefined;
|
|
178
208
|
since?.setUTCHours(0, 0, 0, 0);
|
|
179
209
|
let scannedRanges = 0;
|
|
210
|
+
let budgetStartedAt;
|
|
211
|
+
const checkNewMail = this.newMailChecker(parseSyncCursor(this.syncRun?.changeCursor ?? input.baselineCursor)
|
|
212
|
+
.folders, input.signal);
|
|
180
213
|
while (folderIndex < folders.length &&
|
|
181
214
|
messages.length < limit &&
|
|
182
215
|
scannedRanges < 10) {
|
|
183
216
|
scannedRanges += 1;
|
|
184
217
|
throwIfAborted(input.signal);
|
|
185
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();
|
|
186
225
|
const mailbox = await (await this.imap()).mailboxOpen(folder, {
|
|
187
226
|
readOnly: true,
|
|
188
227
|
});
|
|
@@ -240,7 +279,16 @@ export class ImapSmtpAdapter {
|
|
|
240
279
|
upperUid = start - 1;
|
|
241
280
|
}
|
|
242
281
|
}
|
|
243
|
-
|
|
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) {
|
|
244
292
|
const { receivedAt } = copy.message;
|
|
245
293
|
if (input.receivedAfter &&
|
|
246
294
|
receivedAt &&
|
|
@@ -249,10 +297,16 @@ export class ImapSmtpAdapter {
|
|
|
249
297
|
}
|
|
250
298
|
messages.push(copy);
|
|
251
299
|
}
|
|
300
|
+
if (yielded) {
|
|
301
|
+
folderIndex = selectedFolderIndex;
|
|
302
|
+
upperUid = fetched.at(-1).tuple.uid - 1;
|
|
303
|
+
}
|
|
252
304
|
if (upperUid !== undefined && upperUid < 1) {
|
|
253
305
|
folderIndex += 1;
|
|
254
306
|
upperUid = undefined;
|
|
255
307
|
}
|
|
308
|
+
if (yielded)
|
|
309
|
+
break;
|
|
256
310
|
}
|
|
257
311
|
const nextCursor = folderIndex < folders.length
|
|
258
312
|
? encodeHistoryCursor({ folderIndex, upperUid })
|
|
@@ -267,6 +321,7 @@ export class ImapSmtpAdapter {
|
|
|
267
321
|
};
|
|
268
322
|
}
|
|
269
323
|
catch (error) {
|
|
324
|
+
this.closeImap();
|
|
270
325
|
return { ok: false, error: classifyError(error, 'IMAP_LIST_MESSAGES') };
|
|
271
326
|
}
|
|
272
327
|
}
|
|
@@ -290,7 +345,12 @@ export class ImapSmtpAdapter {
|
|
|
290
345
|
};
|
|
291
346
|
}
|
|
292
347
|
const mailboxes = this.selectableMailboxes(await this.listMailboxes());
|
|
293
|
-
|
|
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
|
+
}));
|
|
294
354
|
const messages = [];
|
|
295
355
|
let hasMore = false;
|
|
296
356
|
const limit = Math.max(1, input.limit);
|
|
@@ -339,19 +399,31 @@ export class ImapSmtpAdapter {
|
|
|
339
399
|
uidValidity: current.uidValidity,
|
|
340
400
|
uidNext: nextUid,
|
|
341
401
|
};
|
|
402
|
+
if (messages.length > 0) {
|
|
403
|
+
hasMore ||= mailbox !== mailboxes.at(-1);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
342
406
|
}
|
|
343
407
|
let verify;
|
|
344
408
|
if (this.locations) {
|
|
345
409
|
if (hasMore || messages.length > 0) {
|
|
346
|
-
//
|
|
347
|
-
verify = previous.verify && {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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);
|
|
351
423
|
hasMore = true;
|
|
352
424
|
}
|
|
353
425
|
else {
|
|
354
|
-
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);
|
|
355
427
|
if (result === 'generation-changed')
|
|
356
428
|
return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed during verification.', 'provider', false);
|
|
357
429
|
messages.push(...this.verificationCopies);
|
|
@@ -359,6 +431,8 @@ export class ImapSmtpAdapter {
|
|
|
359
431
|
verify = result.verify;
|
|
360
432
|
}
|
|
361
433
|
}
|
|
434
|
+
if (verify?.excluded && messages.length)
|
|
435
|
+
verify = this.enqueueExcludedRechecks(verify, messages);
|
|
362
436
|
return {
|
|
363
437
|
ok: true,
|
|
364
438
|
value: {
|
|
@@ -367,6 +441,7 @@ export class ImapSmtpAdapter {
|
|
|
367
441
|
deletedProviderMessageIds: [],
|
|
368
442
|
nextCursor: syncCursor({
|
|
369
443
|
version: 1,
|
|
444
|
+
receivedAfter: previous.receivedAfter ?? this.syncRun?.policy.receivedAfter,
|
|
370
445
|
folders: nextFolders,
|
|
371
446
|
...(verify ? { verify } : {}),
|
|
372
447
|
}),
|
|
@@ -375,6 +450,7 @@ export class ImapSmtpAdapter {
|
|
|
375
450
|
};
|
|
376
451
|
}
|
|
377
452
|
catch (error) {
|
|
453
|
+
this.closeImap();
|
|
378
454
|
return { ok: false, error: classifyError(error, 'IMAP_LIST_CHANGES') };
|
|
379
455
|
}
|
|
380
456
|
}
|
|
@@ -413,6 +489,7 @@ export class ImapSmtpAdapter {
|
|
|
413
489
|
};
|
|
414
490
|
}
|
|
415
491
|
catch (error) {
|
|
492
|
+
this.closeImap();
|
|
416
493
|
return { ok: false, error: classifyError(error, 'IMAP_GET_MESSAGE') };
|
|
417
494
|
}
|
|
418
495
|
}
|
|
@@ -479,7 +556,10 @@ export class ImapSmtpAdapter {
|
|
|
479
556
|
if (!download.content)
|
|
480
557
|
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'IMAP attachment was not found.', 'provider', false);
|
|
481
558
|
download.content.once('end', release);
|
|
482
|
-
download.content.once('error',
|
|
559
|
+
download.content.once('error', () => {
|
|
560
|
+
this.closeImap();
|
|
561
|
+
release?.();
|
|
562
|
+
});
|
|
483
563
|
download.content.once('close', release);
|
|
484
564
|
streamed = true;
|
|
485
565
|
if (signal)
|
|
@@ -503,6 +583,7 @@ export class ImapSmtpAdapter {
|
|
|
503
583
|
};
|
|
504
584
|
}
|
|
505
585
|
catch (error) {
|
|
586
|
+
this.closeImap();
|
|
506
587
|
return { ok: false, error: classifyError(error, 'IMAP_GET_ATTACHMENT') };
|
|
507
588
|
}
|
|
508
589
|
finally {
|
|
@@ -599,6 +680,7 @@ export class ImapSmtpAdapter {
|
|
|
599
680
|
}
|
|
600
681
|
}
|
|
601
682
|
catch (error) {
|
|
683
|
+
this.closeImap();
|
|
602
684
|
// SMTP already accepted the message; never report this as a send failure.
|
|
603
685
|
sentCopyError = {
|
|
604
686
|
...classifyError(error, 'IMAP_SAVE_SENT_COPY'),
|
|
@@ -616,6 +698,7 @@ export class ImapSmtpAdapter {
|
|
|
616
698
|
};
|
|
617
699
|
}
|
|
618
700
|
catch (error) {
|
|
701
|
+
this.closeImap();
|
|
619
702
|
const classified = classifyError(error, 'SMTP_SEND');
|
|
620
703
|
return {
|
|
621
704
|
status: submissionStarted &&
|
|
@@ -777,6 +860,7 @@ export class ImapSmtpAdapter {
|
|
|
777
860
|
return { ok: true, value: { providerMessageId: id } };
|
|
778
861
|
}
|
|
779
862
|
catch (error) {
|
|
863
|
+
this.closeImap();
|
|
780
864
|
if (error.imapCopyRejected &&
|
|
781
865
|
!state?.destination)
|
|
782
866
|
remoteStarted = false;
|
|
@@ -880,21 +964,27 @@ export class ImapSmtpAdapter {
|
|
|
880
964
|
return { ok: true, value: undefined };
|
|
881
965
|
}
|
|
882
966
|
catch (error) {
|
|
967
|
+
this.closeImap();
|
|
883
968
|
return { ok: false, error: classifyError(error, 'IMAP_DELETE_MESSAGE') };
|
|
884
969
|
}
|
|
885
970
|
}
|
|
886
971
|
async close() {
|
|
972
|
+
this.stopCommandObservation?.();
|
|
973
|
+
this.stopCommandObservation = undefined;
|
|
887
974
|
this.smtpTransport.close();
|
|
888
|
-
|
|
975
|
+
const client = this.imapClient;
|
|
976
|
+
this.imapClient = undefined;
|
|
977
|
+
if (!client)
|
|
889
978
|
return;
|
|
890
|
-
|
|
891
|
-
await this.
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
979
|
+
if (this.pool)
|
|
980
|
+
await this.pool.release(client);
|
|
981
|
+
else {
|
|
982
|
+
try {
|
|
983
|
+
await client.logout();
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
client.close();
|
|
987
|
+
}
|
|
898
988
|
}
|
|
899
989
|
}
|
|
900
990
|
async updateFlags(providerMessageId, flag, enabled, signal) {
|
|
@@ -908,22 +998,53 @@ export class ImapSmtpAdapter {
|
|
|
908
998
|
return { ok: true, value: undefined };
|
|
909
999
|
}
|
|
910
1000
|
catch (error) {
|
|
1001
|
+
this.closeImap();
|
|
911
1002
|
return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
|
|
912
1003
|
}
|
|
913
1004
|
}
|
|
914
1005
|
closeImap() {
|
|
1006
|
+
this.stopCommandObservation?.();
|
|
1007
|
+
this.stopCommandObservation = undefined;
|
|
915
1008
|
const client = this.imapClient;
|
|
916
1009
|
this.imapClient = undefined;
|
|
917
|
-
client
|
|
1010
|
+
if (client) {
|
|
1011
|
+
if (this.pool)
|
|
1012
|
+
this.pool.destroy(client);
|
|
1013
|
+
else
|
|
1014
|
+
client.close();
|
|
1015
|
+
}
|
|
918
1016
|
}
|
|
919
1017
|
async imap() {
|
|
920
1018
|
if (this.imapClient)
|
|
921
1019
|
return this.imapClient;
|
|
922
|
-
const client = this.
|
|
923
|
-
|
|
1020
|
+
const client = this.pool
|
|
1021
|
+
? await this.pool.acquire(this.account.id, this.config.imap, this.credential)
|
|
1022
|
+
: this.protocol.imap(this.config.imap, this.credential);
|
|
1023
|
+
if (!this.pool) {
|
|
1024
|
+
try {
|
|
1025
|
+
await client.connect();
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
this.closeImap();
|
|
1029
|
+
client.close();
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
924
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
|
+
}
|
|
925
1039
|
return client;
|
|
926
1040
|
}
|
|
1041
|
+
syncDiagnostics() {
|
|
1042
|
+
return {
|
|
1043
|
+
imapCommands: this.commandMetrics.snapshot(),
|
|
1044
|
+
yielded: this.yieldReasons.size > 0,
|
|
1045
|
+
yieldReasons: [...this.yieldReasons],
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
927
1048
|
async listMailboxes() {
|
|
928
1049
|
const client = await this.imap();
|
|
929
1050
|
const mailboxes = await client.list({
|
|
@@ -991,6 +1112,7 @@ export class ImapSmtpAdapter {
|
|
|
991
1112
|
return selectedState;
|
|
992
1113
|
}
|
|
993
1114
|
catch (error) {
|
|
1115
|
+
this.closeImap();
|
|
994
1116
|
if (error?.code !== 'IMAP_INVALID_UIDVALIDITY') {
|
|
995
1117
|
throw error;
|
|
996
1118
|
}
|
|
@@ -1023,7 +1145,7 @@ export class ImapSmtpAdapter {
|
|
|
1023
1145
|
kind: 'folder',
|
|
1024
1146
|
};
|
|
1025
1147
|
}
|
|
1026
|
-
async fetchMessages(folder, uidValidity, uids, signal) {
|
|
1148
|
+
async fetchMessages(folder, uidValidity, uids, signal, shouldYield) {
|
|
1027
1149
|
if (uids.length === 0)
|
|
1028
1150
|
return [];
|
|
1029
1151
|
const observed = this.locations
|
|
@@ -1039,6 +1161,10 @@ export class ImapSmtpAdapter {
|
|
|
1039
1161
|
metadata.push(message);
|
|
1040
1162
|
}
|
|
1041
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
|
+
}
|
|
1042
1168
|
for (const message of metadata) {
|
|
1043
1169
|
throwIfAborted(signal);
|
|
1044
1170
|
const normalized = await this.normalizeMessage(folder, uidValidity, message, signal);
|
|
@@ -1053,9 +1179,54 @@ export class ImapSmtpAdapter {
|
|
|
1053
1179
|
structure: message.bodyStructure,
|
|
1054
1180
|
size: message.size,
|
|
1055
1181
|
});
|
|
1182
|
+
if (await shouldYield?.())
|
|
1183
|
+
break;
|
|
1056
1184
|
}
|
|
1057
1185
|
return messages;
|
|
1058
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
|
+
}
|
|
1059
1230
|
beginStep() {
|
|
1060
1231
|
this.copies = [];
|
|
1061
1232
|
this.changes = [];
|
|
@@ -1204,6 +1375,7 @@ export class ImapSmtpAdapter {
|
|
|
1204
1375
|
: undefined;
|
|
1205
1376
|
}
|
|
1206
1377
|
catch (error) {
|
|
1378
|
+
this.closeImap();
|
|
1207
1379
|
const classified = classifyError(error, 'IMAP_READ_SOURCE');
|
|
1208
1380
|
if (classified.retryable || classified.category === 'authentication')
|
|
1209
1381
|
throw error;
|
|
@@ -1224,29 +1396,12 @@ export class ImapSmtpAdapter {
|
|
|
1224
1396
|
if (String(mailbox.uidValidity) !== location.uidValidity)
|
|
1225
1397
|
return undefined;
|
|
1226
1398
|
const message = await client.fetchOne(location.uid, { ...query, uid: true, envelope: true }, { uid: true });
|
|
1227
|
-
|
|
1228
|
-
return undefined;
|
|
1229
|
-
const source = location.source ?? 'locator';
|
|
1230
|
-
if (source === 'objectid' || source === 'x-gm-msgid') {
|
|
1231
|
-
const currentSource = this.nativeSource();
|
|
1232
|
-
const nativeId = validNativeId(currentSource, message.emailId);
|
|
1233
|
-
// A UID cannot identify another message within the same UIDVALIDITY.
|
|
1234
|
-
// Missing native metadata preserves this previously verified tuple.
|
|
1235
|
-
return !nativeId ||
|
|
1236
|
-
(currentSource === source && nativeId === location.providerMessageId)
|
|
1237
|
-
? message
|
|
1238
|
-
: undefined;
|
|
1239
|
-
}
|
|
1240
|
-
if (source === 'rfc-message-id')
|
|
1241
|
-
return message.envelope?.messageId === location.providerMessageId
|
|
1242
|
-
? message
|
|
1243
|
-
: undefined;
|
|
1244
|
-
return source === 'locator' &&
|
|
1245
|
-
encodeCopyLocator(location, location.id) === location.providerMessageId
|
|
1399
|
+
return message && copyMatches(location, message, this.nativeSource())
|
|
1246
1400
|
? message
|
|
1247
1401
|
: undefined;
|
|
1248
1402
|
}
|
|
1249
1403
|
catch (error) {
|
|
1404
|
+
this.closeImap();
|
|
1250
1405
|
const classified = classifyError(error, 'IMAP_OPEN_COPY');
|
|
1251
1406
|
if (classified.retryable || classified.category === 'authentication')
|
|
1252
1407
|
throw error;
|
|
@@ -1283,6 +1438,7 @@ export class ImapSmtpAdapter {
|
|
|
1283
1438
|
return { ok: true, value: undefined };
|
|
1284
1439
|
}
|
|
1285
1440
|
catch (error) {
|
|
1441
|
+
this.closeImap();
|
|
1286
1442
|
return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
|
|
1287
1443
|
}
|
|
1288
1444
|
}
|
|
@@ -1290,15 +1446,40 @@ export class ImapSmtpAdapter {
|
|
|
1290
1446
|
* One complete pass over every selectable folder. It reconciles stored
|
|
1291
1447
|
* copies, and two matching passes in a row authorize orphan cleanup.
|
|
1292
1448
|
*/
|
|
1293
|
-
async verifyFolders(mailboxes, folders, previous, signal) {
|
|
1449
|
+
async verifyFolders(mailboxes, folders, previous, signal, receivedAfter) {
|
|
1294
1450
|
const attempts = (previous?.attempts ?? 0) + 1;
|
|
1295
1451
|
const snapshot = {};
|
|
1296
1452
|
const present = new Map();
|
|
1297
|
-
const
|
|
1453
|
+
const observedCopies = new Map();
|
|
1454
|
+
const excluded = {
|
|
1455
|
+
...previous?.excluded,
|
|
1456
|
+
};
|
|
1298
1457
|
const uncertainGenerations = new Set();
|
|
1299
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);
|
|
1300
1479
|
const known = await this.locations.listKnownLocations();
|
|
1301
1480
|
for (const mailbox of mailboxes) {
|
|
1481
|
+
if (await checkNewMail())
|
|
1482
|
+
return interrupted();
|
|
1302
1483
|
throwIfAborted(signal);
|
|
1303
1484
|
const client = await this.imap();
|
|
1304
1485
|
const selected = await client.mailboxOpen(mailbox.path, {
|
|
@@ -1310,18 +1491,18 @@ export class ImapSmtpAdapter {
|
|
|
1310
1491
|
return 'generation-changed';
|
|
1311
1492
|
// New mail arrived after this page: import it before verifying again.
|
|
1312
1493
|
if (state.uidNext !== cursor.uidNext)
|
|
1313
|
-
|
|
1494
|
+
return interrupted();
|
|
1314
1495
|
let uids;
|
|
1315
1496
|
try {
|
|
1316
1497
|
uids = await client.search({ all: true }, { uid: true });
|
|
1317
1498
|
}
|
|
1318
1499
|
catch (error) {
|
|
1500
|
+
this.closeImap();
|
|
1319
1501
|
if (!isResponseLimitError(error))
|
|
1320
1502
|
throw error;
|
|
1321
1503
|
// The UID list exceeds the protocol budget: keep every copy and skip cleanup.
|
|
1322
|
-
this.closeImap();
|
|
1323
1504
|
this.verificationIncomplete = true;
|
|
1324
|
-
return { done: true, verify: { attempts, excluded
|
|
1505
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1325
1506
|
}
|
|
1326
1507
|
if (!Array.isArray(uids))
|
|
1327
1508
|
// Retry the step: an incomplete SEARCH proves nothing about missing copies.
|
|
@@ -1333,22 +1514,115 @@ export class ImapSmtpAdapter {
|
|
|
1333
1514
|
if (Buffer.byteLength(JSON.stringify(snapshot)) >
|
|
1334
1515
|
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1335
1516
|
this.verificationIncomplete = true;
|
|
1336
|
-
return { done: true, verify: { attempts, excluded
|
|
1517
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1337
1518
|
}
|
|
1338
1519
|
const bound = new Set(known
|
|
1339
1520
|
.filter((location) => location.folder === mailbox.path &&
|
|
1340
1521
|
location.uidValidity === state.uidValidity &&
|
|
1341
1522
|
location.status === 'valid')
|
|
1342
1523
|
.map((location) => location.uid));
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
|
|
1524
|
+
const candidates = known.filter((location) => location.folder === mailbox.path &&
|
|
1525
|
+
location.uidValidity === state.uidValidity &&
|
|
1526
|
+
location.status === 'valid' &&
|
|
1527
|
+
location.source &&
|
|
1528
|
+
present.get(mailbox.path).has(location.uid));
|
|
1529
|
+
for (let offset = 0; offset < candidates.length; offset += 1000) {
|
|
1530
|
+
if (await checkNewMail())
|
|
1531
|
+
return interrupted();
|
|
1532
|
+
throwIfAborted(signal);
|
|
1533
|
+
const batch = candidates.slice(offset, offset + 1000);
|
|
1534
|
+
const messages = await client.fetchAll(compressUids(batch.map((location) => location.uid)), {
|
|
1535
|
+
uid: true,
|
|
1536
|
+
flags: true,
|
|
1537
|
+
...(batch.some((location) => location.source === 'objectid' ||
|
|
1538
|
+
location.source === 'x-gm-msgid')
|
|
1539
|
+
? { emailId: true }
|
|
1540
|
+
: {}),
|
|
1541
|
+
...(batch.some((location) => location.source === 'rfc-message-id')
|
|
1542
|
+
? { envelope: true }
|
|
1543
|
+
: {}),
|
|
1544
|
+
}, { uid: true });
|
|
1545
|
+
const byUid = new Map(messages.map((message) => [message.uid, message]));
|
|
1546
|
+
for (const location of batch) {
|
|
1547
|
+
throwIfAborted(signal);
|
|
1548
|
+
const message = byUid.get(location.uid);
|
|
1549
|
+
if (message?.flags &&
|
|
1550
|
+
copyMatches(location, message, this.nativeSource()))
|
|
1551
|
+
observedCopies.set(location.id, message);
|
|
1552
|
+
else
|
|
1553
|
+
stable = false;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
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] = {
|
|
1346
1565
|
uidValidity: state.uidValidity,
|
|
1347
|
-
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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()));
|
|
1352
1626
|
const sources = await this.locations.findMessageSources(fetched.flatMap((copy) => {
|
|
1353
1627
|
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1354
1628
|
copy.rfcMessageId;
|
|
@@ -1357,25 +1631,37 @@ export class ImapSmtpAdapter {
|
|
|
1357
1631
|
for (const copy of fetched) {
|
|
1358
1632
|
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1359
1633
|
copy.rfcMessageId;
|
|
1360
|
-
|
|
1634
|
+
if (receivedAfter &&
|
|
1361
1635
|
copy.message.receivedAt &&
|
|
1362
|
-
Date.parse(copy.message.receivedAt) <
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1636
|
+
Date.parse(copy.message.receivedAt) < Date.parse(receivedAfter) &&
|
|
1637
|
+
!copy.observed &&
|
|
1638
|
+
(!id || !sources.has(id)))
|
|
1639
|
+
excludedUids.add(copy.tuple.uid);
|
|
1366
1640
|
else
|
|
1367
1641
|
this.verificationCopies.push(copy);
|
|
1368
1642
|
}
|
|
1369
|
-
|
|
1643
|
+
rememberExcluded();
|
|
1644
|
+
if (yielded)
|
|
1645
|
+
return interrupted();
|
|
1370
1646
|
}
|
|
1647
|
+
if (unbound.some((uid) => !excludedUids.has(uid)))
|
|
1648
|
+
stable = false;
|
|
1371
1649
|
}
|
|
1372
1650
|
const after = this.selectableMailboxes(await this.listMailboxes());
|
|
1651
|
+
checkNewMail.uidChecked();
|
|
1652
|
+
if (changed(after))
|
|
1653
|
+
return interrupted();
|
|
1373
1654
|
if (after.length !== mailboxes.length ||
|
|
1374
1655
|
after.some((box) => !snapshot[box.path]))
|
|
1375
1656
|
stable = false;
|
|
1376
1657
|
for (const mailbox of after) {
|
|
1658
|
+
if (await checkNewMail())
|
|
1659
|
+
return interrupted();
|
|
1660
|
+
throwIfAborted(signal);
|
|
1377
1661
|
const selected = await (await this.imap()).mailboxOpen(mailbox.path, { readOnly: true });
|
|
1378
1662
|
const state = await this.selectedFolderState(selected);
|
|
1663
|
+
if (state.uidNext !== folders[mailbox.path]?.uidNext)
|
|
1664
|
+
return interrupted();
|
|
1379
1665
|
const rechecked = await (await this.imap()).search({ all: true }, { uid: true });
|
|
1380
1666
|
if (!Array.isArray(rechecked))
|
|
1381
1667
|
throw new Error('The final IMAP SEARCH was incomplete.');
|
|
@@ -1388,11 +1674,14 @@ export class ImapSmtpAdapter {
|
|
|
1388
1674
|
uncertainGenerations.add(mailbox.path);
|
|
1389
1675
|
}
|
|
1390
1676
|
const finalRange = this.selectableMailboxes(await this.listMailboxes());
|
|
1677
|
+
if (changed(finalRange))
|
|
1678
|
+
return interrupted();
|
|
1391
1679
|
const finalPaths = new Set(finalRange.map((mailbox) => mailbox.path));
|
|
1392
1680
|
if (finalPaths.size !== mailboxes.length ||
|
|
1393
1681
|
mailboxes.some((mailbox) => !finalPaths.has(mailbox.path)))
|
|
1394
1682
|
stable = false;
|
|
1395
1683
|
for (const mailbox of finalRange) {
|
|
1684
|
+
throwIfAborted(signal);
|
|
1396
1685
|
const generation = mailbox.status?.uidValidity;
|
|
1397
1686
|
const uidNext = mailbox.status?.uidNext;
|
|
1398
1687
|
if (generation !== undefined &&
|
|
@@ -1404,6 +1693,7 @@ export class ImapSmtpAdapter {
|
|
|
1404
1693
|
stable = false;
|
|
1405
1694
|
}
|
|
1406
1695
|
for (const location of known) {
|
|
1696
|
+
throwIfAborted(signal);
|
|
1407
1697
|
if (this.verificationCopies.some((copy) => tupleKey(copy.tuple) === tupleKey(location)))
|
|
1408
1698
|
continue;
|
|
1409
1699
|
const uids = present.get(location.folder);
|
|
@@ -1421,9 +1711,11 @@ export class ImapSmtpAdapter {
|
|
|
1421
1711
|
location.source &&
|
|
1422
1712
|
generation === location.uidValidity &&
|
|
1423
1713
|
uids?.has(location.uid)) {
|
|
1424
|
-
const observed =
|
|
1425
|
-
if (!observed?.flags)
|
|
1426
|
-
|
|
1714
|
+
const observed = observedCopies.get(location.id);
|
|
1715
|
+
if (!observed?.flags) {
|
|
1716
|
+
stable = false;
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1427
1719
|
this.copies.push({
|
|
1428
1720
|
flagsOnly: true,
|
|
1429
1721
|
locationId: location.id,
|
|
@@ -1453,29 +1745,122 @@ export class ImapSmtpAdapter {
|
|
|
1453
1745
|
if (next.status !== 'valid' || next.status !== location.status)
|
|
1454
1746
|
this.changes.push({ id: location.id, observed: location, ...next });
|
|
1455
1747
|
}
|
|
1456
|
-
if (Buffer.byteLength(JSON.stringify({ snapshot, excluded
|
|
1748
|
+
if (Buffer.byteLength(JSON.stringify({ snapshot, excluded })) >
|
|
1457
1749
|
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1458
1750
|
this.verificationIncomplete = true;
|
|
1459
|
-
return { done: true, verify: { attempts } };
|
|
1751
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1460
1752
|
}
|
|
1461
1753
|
if (!stable) {
|
|
1462
1754
|
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1463
1755
|
this.verificationIncomplete = true;
|
|
1464
|
-
return { done: true, verify: { attempts, excluded
|
|
1756
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1465
1757
|
}
|
|
1466
|
-
return { done: false, verify: { attempts, excluded
|
|
1758
|
+
return { done: false, verify: { attempts, excluded } };
|
|
1467
1759
|
}
|
|
1468
|
-
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))) {
|
|
1469
1767
|
this.cleanup = true;
|
|
1470
|
-
return { done: true };
|
|
1768
|
+
return { done: true, verify: { excluded } };
|
|
1471
1769
|
}
|
|
1472
1770
|
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1473
1771
|
this.verificationIncomplete = true;
|
|
1474
|
-
return { done: true, verify: { attempts, excluded
|
|
1772
|
+
return { done: true, verify: { attempts, excluded } };
|
|
1475
1773
|
}
|
|
1476
1774
|
return {
|
|
1477
1775
|
done: false,
|
|
1478
|
-
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,
|
|
1479
1864
|
};
|
|
1480
1865
|
}
|
|
1481
1866
|
async normalizeMessage(folder, uidValidity, message, signal, maxBodyBytes = MAX_IMAP_BODY_BYTES) {
|
|
@@ -1524,7 +1909,7 @@ export class ImapSmtpAdapter {
|
|
|
1524
1909
|
...body,
|
|
1525
1910
|
size: message.size,
|
|
1526
1911
|
// Preserve the header value; mailparser can wrap malformed bare IDs in brackets.
|
|
1527
|
-
internetMessageId:
|
|
1912
|
+
internetMessageId: messageIdHeader(header),
|
|
1528
1913
|
providerFolderIds: [folder],
|
|
1529
1914
|
from,
|
|
1530
1915
|
to,
|
|
@@ -1572,8 +1957,23 @@ function sameSnapshot(left, right) {
|
|
|
1572
1957
|
const paths = Object.keys(right);
|
|
1573
1958
|
return (Object.keys(left).length === paths.length &&
|
|
1574
1959
|
paths.every((path) => left[path]?.uidValidity === right[path].uidValidity &&
|
|
1575
|
-
left[path].uidNext
|
|
1576
|
-
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(',');
|
|
1577
1977
|
}
|
|
1578
1978
|
function isResponseLimitError(error) {
|
|
1579
1979
|
return ['LiteralTooLarge', 'ResponseTooLarge', 'LineTooLarge'].includes(error?.code ?? '');
|
|
@@ -1641,4 +2041,25 @@ async function transferUid(client, uid, folder, native) {
|
|
|
1641
2041
|
parse(result.response);
|
|
1642
2042
|
return invalid ? undefined : mapping;
|
|
1643
2043
|
}
|
|
2044
|
+
/** Compare identity metadata without reopening the selected mailbox. */
|
|
2045
|
+
function copyMatches(location, message, currentSource) {
|
|
2046
|
+
if (message.uid !== location.uid)
|
|
2047
|
+
return false;
|
|
2048
|
+
const source = location.source ?? 'locator';
|
|
2049
|
+
if (source === 'objectid' || source === 'x-gm-msgid') {
|
|
2050
|
+
const nativeId = validNativeId(currentSource, message.emailId);
|
|
2051
|
+
// Missing native metadata preserves this previously verified tuple.
|
|
2052
|
+
return (!nativeId ||
|
|
2053
|
+
(currentSource === source && nativeId === location.providerMessageId));
|
|
2054
|
+
}
|
|
2055
|
+
if (source === 'rfc-message-id')
|
|
2056
|
+
return message.envelope?.messageId === location.providerMessageId;
|
|
2057
|
+
return (source === 'locator' &&
|
|
2058
|
+
encodeCopyLocator(location, location.id) === location.providerMessageId);
|
|
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
|
+
}
|
|
1644
2065
|
//# sourceMappingURL=adapter.js.map
|