@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/server/adapters/imap-smtp/adapter.d.ts +13 -0
  3. package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
  4. package/dist/server/adapters/imap-smtp/adapter.js +391 -43
  5. package/dist/server/adapters/imap-smtp/adapter.js.map +1 -1
  6. package/dist/server/adapters/imap-smtp/command-metrics.d.ts +24 -0
  7. package/dist/server/adapters/imap-smtp/command-metrics.d.ts.map +1 -0
  8. package/dist/server/adapters/imap-smtp/command-metrics.js +116 -0
  9. package/dist/server/adapters/imap-smtp/command-metrics.js.map +1 -0
  10. package/dist/server/adapters/imap-smtp/connection.d.ts +1 -1
  11. package/dist/server/adapters/imap-smtp/connection.d.ts.map +1 -1
  12. package/dist/server/adapters/imap-smtp/connection.js +4 -2
  13. package/dist/server/adapters/imap-smtp/connection.js.map +1 -1
  14. package/dist/server/adapters/imap-smtp/constants.d.ts +2 -0
  15. package/dist/server/adapters/imap-smtp/constants.d.ts.map +1 -1
  16. package/dist/server/adapters/imap-smtp/constants.js +2 -0
  17. package/dist/server/adapters/imap-smtp/constants.js.map +1 -1
  18. package/dist/server/adapters/imap-smtp/locators.d.ts +4 -0
  19. package/dist/server/adapters/imap-smtp/locators.d.ts.map +1 -1
  20. package/dist/server/adapters/imap-smtp/locators.js +120 -6
  21. package/dist/server/adapters/imap-smtp/locators.js.map +1 -1
  22. package/dist/server/adapters/imap-smtp/types.d.ts +18 -2
  23. package/dist/server/adapters/imap-smtp/types.d.ts.map +1 -1
  24. package/dist/server/operations/sync-mailbox.d.ts.map +1 -1
  25. package/dist/server/operations/sync-mailbox.js +19 -1
  26. package/dist/server/operations/sync-mailbox.js.map +1 -1
  27. package/dist/server/providers/mail-core.d.ts.map +1 -1
  28. package/dist/server/providers/mail-core.js +1 -0
  29. package/dist/server/providers/mail-core.js.map +1 -1
  30. package/dist/server/runtime/imap-idle.d.ts +29 -0
  31. package/dist/server/runtime/imap-idle.d.ts.map +1 -0
  32. package/dist/server/runtime/imap-idle.js +164 -0
  33. package/dist/server/runtime/imap-idle.js.map +1 -0
  34. package/dist/server/runtime/push-timing.d.ts +9 -0
  35. package/dist/server/runtime/push-timing.d.ts.map +1 -0
  36. package/dist/server/runtime/push-timing.js +3 -0
  37. package/dist/server/runtime/push-timing.js.map +1 -0
  38. package/dist/server/runtime.d.ts +5 -0
  39. package/dist/server/runtime.d.ts.map +1 -1
  40. package/dist/server/runtime.js +56 -6
  41. package/dist/server/runtime.js.map +1 -1
  42. package/dist/server/services/accounts.d.ts.map +1 -1
  43. package/dist/server/services/accounts.js +5 -0
  44. package/dist/server/services/accounts.js.map +1 -1
  45. package/dist/server/services/authorization.d.ts +1 -1
  46. package/dist/server/services/authorization.d.ts.map +1 -1
  47. package/dist/server/services/authorization.js +1 -0
  48. package/dist/server/services/authorization.js.map +1 -1
  49. package/dist/server/services/dependencies.d.ts +1 -0
  50. package/dist/server/services/dependencies.d.ts.map +1 -1
  51. package/dist/server/store/sync.d.ts.map +1 -1
  52. package/dist/server/store/sync.js +29 -6
  53. package/dist/server/store/sync.js.map +1 -1
  54. 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 { compressUids, encodeCopyLocator, encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor, parseSyncCursor, rangeFor, requireAttachmentLocator, requireMessageLocator, syncCursor, } from './locators.js';
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
- ? { ...input, status: 'partial', createNextTask: false }
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 { ok: true, value: syncCursor({ version: 1, folders }) };
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
- for (const copy of await this.fetchMessages(folder, String(mailbox.uidValidity), selected, input.signal)) {
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
- const nextFolders = {};
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
- // New copies are committed first; verification restarts afterwards.
352
- verify = previous.verify && {
353
- attempts: previous.verify.attempts,
354
- excluded: previous.verify.excluded,
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 = new Set(previous?.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
- stable = false;
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: [...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: [...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 unbound = uids.filter((uid) => !bound.has(uid) &&
1396
- !excluded.has(tupleKey({
1397
- folder: mailbox.path,
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
- uid,
1400
- })));
1401
- if (unbound.length) {
1402
- // Reconcile copies below the scan high-water mark, including old-date moves.
1403
- const fetched = await this.fetchMessages(mailbox.path, state.uidValidity, unbound.slice(0, this.syncRun?.policy.batchSize ?? 100), signal);
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
- const beforeImportDate = this.syncRun?.policy.receivedAfter &&
1634
+ if (receivedAfter &&
1413
1635
  copy.message.receivedAt &&
1414
- Date.parse(copy.message.receivedAt) <
1415
- Date.parse(this.syncRun.policy.receivedAfter);
1416
- if (beforeImportDate && !copy.observed && (!id || !sources.has(id)))
1417
- excluded.add(tupleKey(copy.tuple));
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
- stable = false;
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: [...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: [...excluded] } };
1756
+ return { done: true, verify: { attempts, excluded } };
1522
1757
  }
1523
- return { done: false, verify: { attempts, excluded: [...excluded] } };
1758
+ return { done: false, verify: { attempts, excluded } };
1524
1759
  }
1525
- if (previous?.folders && sameSnapshot(previous.folders, snapshot)) {
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: [...excluded] } };
1772
+ return { done: true, verify: { attempts, excluded } };
1532
1773
  }
1533
1774
  return {
1534
1775
  done: false,
1535
- verify: { attempts, folders: snapshot, excluded: [...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: /^message-id:[\t ]*([^\r\n]*(?:\r\n[\t ][^\r\n]*)*)\r\n/im.exec(header.toString('utf8'))?.[1],
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 === right[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