@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/server/adapters/imap-smtp/adapter.d.ts +16 -1
  3. package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
  4. package/dist/server/adapters/imap-smtp/adapter.js +503 -82
  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-pool.d.ts +19 -0
  11. package/dist/server/adapters/imap-smtp/connection-pool.d.ts.map +1 -0
  12. package/dist/server/adapters/imap-smtp/connection-pool.js +91 -0
  13. package/dist/server/adapters/imap-smtp/connection-pool.js.map +1 -0
  14. package/dist/server/adapters/imap-smtp/connection.d.ts +1 -1
  15. package/dist/server/adapters/imap-smtp/connection.d.ts.map +1 -1
  16. package/dist/server/adapters/imap-smtp/connection.js +4 -2
  17. package/dist/server/adapters/imap-smtp/connection.js.map +1 -1
  18. package/dist/server/adapters/imap-smtp/constants.d.ts +2 -0
  19. package/dist/server/adapters/imap-smtp/constants.d.ts.map +1 -1
  20. package/dist/server/adapters/imap-smtp/constants.js +2 -0
  21. package/dist/server/adapters/imap-smtp/constants.js.map +1 -1
  22. package/dist/server/adapters/imap-smtp/definition.d.ts +2 -1
  23. package/dist/server/adapters/imap-smtp/definition.d.ts.map +1 -1
  24. package/dist/server/adapters/imap-smtp/definition.js +3 -2
  25. package/dist/server/adapters/imap-smtp/definition.js.map +1 -1
  26. package/dist/server/adapters/imap-smtp/locators.d.ts +4 -0
  27. package/dist/server/adapters/imap-smtp/locators.d.ts.map +1 -1
  28. package/dist/server/adapters/imap-smtp/locators.js +120 -6
  29. package/dist/server/adapters/imap-smtp/locators.js.map +1 -1
  30. package/dist/server/adapters/imap-smtp/types.d.ts +18 -2
  31. package/dist/server/adapters/imap-smtp/types.d.ts.map +1 -1
  32. package/dist/server/operations/sync-mailbox.d.ts.map +1 -1
  33. package/dist/server/operations/sync-mailbox.js +19 -1
  34. package/dist/server/operations/sync-mailbox.js.map +1 -1
  35. package/dist/server/providers/mail-builtins.d.ts +2 -0
  36. package/dist/server/providers/mail-builtins.d.ts.map +1 -1
  37. package/dist/server/providers/mail-builtins.js +6 -1
  38. package/dist/server/providers/mail-builtins.js.map +1 -1
  39. package/dist/server/providers/mail-core.d.ts.map +1 -1
  40. package/dist/server/providers/mail-core.js +1 -0
  41. package/dist/server/providers/mail-core.js.map +1 -1
  42. package/dist/server/runtime/imap-idle.d.ts +29 -0
  43. package/dist/server/runtime/imap-idle.d.ts.map +1 -0
  44. package/dist/server/runtime/imap-idle.js +164 -0
  45. package/dist/server/runtime/imap-idle.js.map +1 -0
  46. package/dist/server/runtime/push-timing.d.ts +9 -0
  47. package/dist/server/runtime/push-timing.d.ts.map +1 -0
  48. package/dist/server/runtime/push-timing.js +3 -0
  49. package/dist/server/runtime/push-timing.js.map +1 -0
  50. package/dist/server/runtime.d.ts +5 -0
  51. package/dist/server/runtime.d.ts.map +1 -1
  52. package/dist/server/runtime.js +56 -6
  53. package/dist/server/runtime.js.map +1 -1
  54. package/dist/server/services/accounts.d.ts.map +1 -1
  55. package/dist/server/services/accounts.js +5 -0
  56. package/dist/server/services/accounts.js.map +1 -1
  57. package/dist/server/services/authorization.d.ts +1 -1
  58. package/dist/server/services/authorization.d.ts.map +1 -1
  59. package/dist/server/services/authorization.js +1 -0
  60. package/dist/server/services/authorization.js.map +1 -1
  61. package/dist/server/services/dependencies.d.ts +1 -0
  62. package/dist/server/services/dependencies.d.ts.map +1 -1
  63. package/dist/server/store/sync.d.ts.map +1 -1
  64. package/dist/server/store/sync.js +29 -6
  65. package/dist/server/store/sync.js.map +1 -1
  66. 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 { 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)
@@ -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
- ? { ...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
+ }
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 { 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
+ };
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
- 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) {
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
- 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
+ }));
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
- // New copies are committed first; verification restarts afterwards.
347
- verify = previous.verify && {
348
- attempts: previous.verify.attempts,
349
- excluded: previous.verify.excluded,
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', release);
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
- if (!this.imapClient)
975
+ const client = this.imapClient;
976
+ this.imapClient = undefined;
977
+ if (!client)
889
978
  return;
890
- try {
891
- await this.imapClient.logout();
892
- }
893
- catch {
894
- this.imapClient.close();
895
- }
896
- finally {
897
- this.imapClient = undefined;
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?.close();
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.protocol.imap(this.config.imap, this.credential);
923
- await client.connect();
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
- if (!message || message.uid !== location.uid)
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 excluded = new Set(previous?.excluded ?? []);
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
- stable = false;
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: [...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: [...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 unbound = uids.filter((uid) => !bound.has(uid) &&
1344
- !excluded.has(tupleKey({
1345
- folder: mailbox.path,
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
- uid,
1348
- })));
1349
- if (unbound.length) {
1350
- // Reconcile copies below the scan high-water mark, including old-date moves.
1351
- 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()));
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
- const beforeImportDate = this.syncRun?.policy.receivedAfter &&
1634
+ if (receivedAfter &&
1361
1635
  copy.message.receivedAt &&
1362
- Date.parse(copy.message.receivedAt) <
1363
- Date.parse(this.syncRun.policy.receivedAfter);
1364
- if (beforeImportDate && !copy.observed && (!id || !sources.has(id)))
1365
- 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);
1366
1640
  else
1367
1641
  this.verificationCopies.push(copy);
1368
1642
  }
1369
- stable = false;
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 = await this.openCopy(location, { flags: true });
1425
- if (!observed?.flags)
1426
- throw new Error('The IMAP copy identity or flags changed during verification.');
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: [...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: [...excluded] } };
1756
+ return { done: true, verify: { attempts, excluded } };
1465
1757
  }
1466
- return { done: false, verify: { attempts, excluded: [...excluded] } };
1758
+ return { done: false, verify: { attempts, excluded } };
1467
1759
  }
1468
- 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))) {
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: [...excluded] } };
1772
+ return { done: true, verify: { attempts, excluded } };
1475
1773
  }
1476
1774
  return {
1477
1775
  done: false,
1478
- 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,
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: /^message-id:[\t ]*([^\r\n]*(?:\r\n[\t ][^\r\n]*)*)\r\n/im.exec(header.toString('utf8'))?.[1],
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 === right[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