@nocobase/app-plugin-mail 1.0.0-beta.13 → 1.0.0-beta.14

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 (55) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +8 -0
  3. package/dist/database/migrations/.manifest.json +4 -0
  4. package/dist/database/migrations/202610250001_mail_imap_message_locations.d.ts +4 -0
  5. package/dist/database/migrations/202610250001_mail_imap_message_locations.d.ts.map +1 -0
  6. package/dist/database/migrations/202610250001_mail_imap_message_locations.js +297 -0
  7. package/dist/database/migrations/202610250001_mail_imap_message_locations.js.map +1 -0
  8. package/dist/server/adapters/imap-smtp/adapter.d.ts +60 -3
  9. package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
  10. package/dist/server/adapters/imap-smtp/adapter.js +774 -83
  11. package/dist/server/adapters/imap-smtp/adapter.js.map +1 -1
  12. package/dist/server/adapters/imap-smtp/constants.d.ts +6 -0
  13. package/dist/server/adapters/imap-smtp/constants.d.ts.map +1 -1
  14. package/dist/server/adapters/imap-smtp/constants.js +6 -0
  15. package/dist/server/adapters/imap-smtp/constants.js.map +1 -1
  16. package/dist/server/adapters/imap-smtp/content.d.ts +11 -0
  17. package/dist/server/adapters/imap-smtp/content.d.ts.map +1 -1
  18. package/dist/server/adapters/imap-smtp/content.js +82 -3
  19. package/dist/server/adapters/imap-smtp/content.js.map +1 -1
  20. package/dist/server/adapters/imap-smtp/definition.d.ts +7 -0
  21. package/dist/server/adapters/imap-smtp/definition.d.ts.map +1 -1
  22. package/dist/server/adapters/imap-smtp/definition.js +19 -11
  23. package/dist/server/adapters/imap-smtp/definition.js.map +1 -1
  24. package/dist/server/adapters/imap-smtp/locators.d.ts +7 -0
  25. package/dist/server/adapters/imap-smtp/locators.d.ts.map +1 -1
  26. package/dist/server/adapters/imap-smtp/locators.js +53 -3
  27. package/dist/server/adapters/imap-smtp/locators.js.map +1 -1
  28. package/dist/server/adapters/imap-smtp/types.d.ts +87 -0
  29. package/dist/server/adapters/imap-smtp/types.d.ts.map +1 -1
  30. package/dist/server/operations/message-mutations.d.ts.map +1 -1
  31. package/dist/server/operations/message-mutations.js +57 -5
  32. package/dist/server/operations/message-mutations.js.map +1 -1
  33. package/dist/server/operations/sync-mailbox.d.ts.map +1 -1
  34. package/dist/server/operations/sync-mailbox.js +26 -10
  35. package/dist/server/operations/sync-mailbox.js.map +1 -1
  36. package/dist/server/providers/mail-builtins.d.ts.map +1 -1
  37. package/dist/server/providers/mail-builtins.js +6 -2
  38. package/dist/server/providers/mail-builtins.js.map +1 -1
  39. package/dist/server/store/attachments.d.ts.map +1 -1
  40. package/dist/server/store/attachments.js +3 -0
  41. package/dist/server/store/attachments.js.map +1 -1
  42. package/dist/server/store/message-writes.d.ts +24 -1
  43. package/dist/server/store/message-writes.d.ts.map +1 -1
  44. package/dist/server/store/message-writes.js +357 -1
  45. package/dist/server/store/message-writes.js.map +1 -1
  46. package/dist/server/store/messages.d.ts.map +1 -1
  47. package/dist/server/store/messages.js +11 -3
  48. package/dist/server/store/messages.js.map +1 -1
  49. package/dist/server/store/rows.d.ts +20 -0
  50. package/dist/server/store/rows.d.ts.map +1 -1
  51. package/dist/server/store/sync.d.ts +7 -1
  52. package/dist/server/store/sync.d.ts.map +1 -1
  53. package/dist/server/store/sync.js +333 -7
  54. package/dist/server/store/sync.js.map +1 -1
  55. package/package.json +14 -13
@@ -2,10 +2,10 @@ import MailComposer from 'nodemailer/lib/mail-composer/index.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { simpleParser } from 'mailparser';
4
4
  import { Readable, addAbortSignal } from 'node:stream';
5
- import { attachmentParts, bodyParts, readBody, metadataQuery, attachmentMetadata, } from './content.js';
6
- import { IMAP_SMTP_CAPABILITIES, MAX_IMAP_BODY_BYTES, MAX_IMAP_DETAIL_BODY_BYTES, } from './constants.js';
5
+ import { attachmentParts, bodyParts, readBody, readRawSource, rawComparable, metadataQuery, attachmentMetadata, MAX_HEADER_BYTES, } from './content.js';
6
+ 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';
7
7
  import { createImapClient, createSmtpTransport } from './connection.js';
8
- import { encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor, parseMessageLocator, parseSyncCursor, rangeFor, requireAttachmentLocator, requireMessageLocator, syncCursor, } from './locators.js';
8
+ import { compressUids, encodeCopyLocator, encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor, parseSyncCursor, rangeFor, requireAttachmentLocator, requireMessageLocator, syncCursor, } from './locators.js';
9
9
  import { classifyFolderType, folderTypeFromPath } from './folders.js';
10
10
  function classifyConfiguredFolder(mailbox, sentFolder) {
11
11
  if (sentFolder && mailbox.path === sentFolder)
@@ -15,18 +15,104 @@ function classifyConfiguredFolder(mailbox, sentFolder) {
15
15
  import { addresses, envelopeAddress, envelopeAddresses, preview, references, toHeader, toIsoDate, } from './normalize.js';
16
16
  import { classifyError, failure, throwIfAborted } from './errors.js';
17
17
  export class ImapSmtpAdapter {
18
+ context;
18
19
  config;
20
+ account;
19
21
  credential;
22
+ locations;
23
+ protocol;
24
+ operation;
20
25
  identity;
21
26
  capabilities = IMAP_SMTP_CAPABILITIES;
22
27
  folderTypes = new Map();
23
28
  imapClient;
24
29
  smtpTransport;
25
- constructor(_context, config, account, credential) {
30
+ // Evidence and byte budget of the current step only; never reused across steps.
31
+ copies = [];
32
+ changes = [];
33
+ cleanup = false;
34
+ verificationIncomplete = false;
35
+ verificationCopies = [];
36
+ syncRun;
37
+ rawBudget = MAX_IMAP_RAW_COMPARE_BUDGET;
38
+ rawCache = new Map();
39
+ constructor(context, config, account, credential, locations, run, protocol = Object.freeze({
40
+ imap: createImapClient,
41
+ smtp: createSmtpTransport,
42
+ expungeUid,
43
+ }), operation) {
44
+ this.context = context;
26
45
  this.config = config;
46
+ this.account = account;
27
47
  this.credential = credential;
28
- this.identity = account.provider;
29
- this.smtpTransport = createSmtpTransport(config.smtp, credential);
48
+ this.locations = locations;
49
+ this.protocol = protocol;
50
+ this.operation = operation;
51
+ if (locations && locations.accountId !== account.id)
52
+ throw new Error('The IMAP persistence belongs to another account.');
53
+ if (operation && operation.accountId !== account.id)
54
+ throw new Error('The IMAP operation belongs to another account.');
55
+ this.operation = operation && Object.freeze({ ...operation });
56
+ this.config = Object.freeze({
57
+ ...config,
58
+ imap: Object.freeze({ ...config.imap }),
59
+ smtp: Object.freeze({ ...config.smtp }),
60
+ });
61
+ this.account = Object.freeze({
62
+ ...account,
63
+ provider: Object.freeze({ ...account.provider }),
64
+ });
65
+ this.credential = Object.freeze({ ...credential });
66
+ this.syncRun =
67
+ run &&
68
+ Object.freeze({ ...run, policy: Object.freeze({ ...run.policy }) });
69
+ this.protocol = Object.freeze({ ...protocol });
70
+ this.identity = this.account.provider;
71
+ this.smtpTransport = this.protocol.smtp(this.config.smtp, this.credential);
72
+ }
73
+ forSync(run) {
74
+ if (!this.locations)
75
+ throw new Error('IMAP location persistence is not configured.');
76
+ if (this.operation)
77
+ throw new Error('An IMAP message operation cannot be reused for synchronization.');
78
+ if (run.accountId !== this.account.id)
79
+ throw new Error('The IMAP sync account changed.');
80
+ return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, this.locations, run, this.protocol);
81
+ }
82
+ async forMessage(messageId, providerMessageId, operation) {
83
+ if (!this.locations)
84
+ throw new Error('IMAP location persistence is not configured.');
85
+ const bound = await this.locations.bindMessage(messageId, providerMessageId, operation);
86
+ return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, bound.persistence, undefined, this.protocol, bound.snapshot);
87
+ }
88
+ /** True when this instance records message copies in the account's location table. */
89
+ get persistsLocations() {
90
+ return this.locations !== undefined;
91
+ }
92
+ /** Commit a sync step together with the location evidence this instance gathered for it. */
93
+ async commitSyncStep(input) {
94
+ if (!this.locations)
95
+ throw new Error('IMAP location persistence is not configured.');
96
+ if (!this.syncRun)
97
+ throw new Error('An IMAP sync commit requires its immutable run snapshot.');
98
+ const ids = new Set(input.messages.map((message) => message.providerMessageId));
99
+ if (this.syncRun &&
100
+ (input.run.id !== this.syncRun.id ||
101
+ input.run.accountId !== this.syncRun.accountId ||
102
+ input.run.phase !== this.syncRun.phase ||
103
+ input.run.revision !== this.syncRun.revision ||
104
+ input.run.leaseToken !== this.syncRun.leaseToken))
105
+ throw new Error('The IMAP sync operation snapshot changed.');
106
+ const incomplete = this.verificationIncomplete;
107
+ const evidence = {
108
+ copies: this.copies.filter((copy) => copy.flagsOnly || ids.has(copy.providerMessageId)),
109
+ changes: this.changes,
110
+ cleanup: this.cleanup,
111
+ };
112
+ this.beginStep();
113
+ return this.locations.commitSyncStep(incomplete
114
+ ? { ...input, status: 'partial', createNextTask: false }
115
+ : input, evidence);
30
116
  }
31
117
  async listFolders(input) {
32
118
  try {
@@ -63,6 +149,7 @@ export class ImapSmtpAdapter {
63
149
  }
64
150
  }
65
151
  async listMessages(input) {
152
+ this.beginStep();
66
153
  try {
67
154
  throwIfAborted(input.signal);
68
155
  const folders = input.providerFolderIds?.length
@@ -150,13 +237,14 @@ export class ImapSmtpAdapter {
150
237
  upperUid = start - 1;
151
238
  }
152
239
  }
153
- for (const message of await this.fetchMessages(folder, String(mailbox.uidValidity), selected, input.signal)) {
240
+ for (const copy of await this.fetchMessages(folder, String(mailbox.uidValidity), selected, input.signal)) {
241
+ const { receivedAt } = copy.message;
154
242
  if (input.receivedAfter &&
155
- message.receivedAt &&
156
- Date.parse(message.receivedAt) < Date.parse(input.receivedAfter)) {
243
+ receivedAt &&
244
+ Date.parse(receivedAt) < Date.parse(input.receivedAfter)) {
157
245
  continue;
158
246
  }
159
- messages.push(message);
247
+ messages.push(copy);
160
248
  }
161
249
  if (upperUid !== undefined && upperUid < 1) {
162
250
  folderIndex += 1;
@@ -169,7 +257,7 @@ export class ImapSmtpAdapter {
169
257
  return {
170
258
  ok: true,
171
259
  value: {
172
- messages,
260
+ messages: await this.assignIdentities(messages, input.signal),
173
261
  nextCursor,
174
262
  syncCursor: input.cursor ? undefined : input.baselineCursor,
175
263
  },
@@ -180,6 +268,7 @@ export class ImapSmtpAdapter {
180
268
  }
181
269
  }
182
270
  async listChanges(input) {
271
+ this.beginStep();
183
272
  try {
184
273
  throwIfAborted(input.signal);
185
274
  let previous;
@@ -200,78 +289,40 @@ export class ImapSmtpAdapter {
200
289
  const mailboxes = this.selectableMailboxes(await this.listMailboxes());
201
290
  const nextFolders = {};
202
291
  const messages = [];
203
- const deletedProviderMessageIds = new Set();
204
- const knownByFolder = new Map();
205
- for (const providerMessageId of input.knownProviderMessageIds ?? []) {
206
- const locator = parseMessageLocator(providerMessageId);
207
- if (!locator)
208
- continue;
209
- const known = knownByFolder.get(locator.folder) ?? new Set();
210
- known.add(providerMessageId);
211
- knownByFolder.set(locator.folder, known);
212
- }
213
292
  let hasMore = false;
214
293
  const limit = Math.max(1, input.limit);
215
294
  for (const mailbox of mailboxes) {
216
295
  throwIfAborted(input.signal);
217
296
  const current = await this.folderState(mailbox.path, mailbox);
218
297
  const old = previous.folders[mailbox.path];
219
- if (old && old.uidValidity !== current.uidValidity) {
220
- return {
221
- ok: false,
222
- error: {
223
- code: 'IMAP_SYNC_CURSOR_INVALID',
224
- message: `The IMAP UIDVALIDITY changed for ${mailbox.path}.`,
225
- category: 'provider',
226
- retryable: false,
227
- },
228
- };
229
- }
230
- const start = old?.uidNext ?? 1;
298
+ const sameGeneration = old?.uidValidity === current.uidValidity;
299
+ const start = sameGeneration ? old.uidNext : 1;
231
300
  const canFetch = messages.length < limit;
232
301
  const end = canFetch
233
302
  ? Math.min(current.uidNext - 1, start + limit - messages.length - 1)
234
303
  : start - 1;
235
- let nextUid = old?.uidNext ?? 1;
236
- const knownIds = knownByFolder.get(mailbox.path);
237
- if (start <= end || knownIds?.size) {
304
+ let nextUid = sameGeneration ? old.uidNext : 1;
305
+ if (start <= end) {
238
306
  const client = await this.imap();
239
307
  const selected = await client.mailboxOpen(mailbox.path, {
240
308
  readOnly: true,
241
309
  });
242
310
  if (String(selected.uidValidity) !== current.uidValidity)
243
311
  return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed before fetching.', 'provider', false);
244
- if (knownIds?.size) {
245
- const result = await client.search({ all: true }, { uid: true });
246
- if (!Array.isArray(result))
247
- return failure('IMAP_SEARCH_FAILED', `Could not search ${mailbox.path} while reconciling deleted messages.`, 'provider', true);
248
- const presentIds = new Set(result.map((uid) => encodeMessageLocator({
249
- folder: mailbox.path,
250
- uidValidity: current.uidValidity,
251
- uid,
252
- })));
253
- for (const providerMessageId of knownIds) {
254
- if (!presentIds.has(providerMessageId)) {
255
- deletedProviderMessageIds.add(providerMessageId);
256
- }
257
- }
258
- }
259
- if (start <= end) {
260
- const fetched = await this.fetchMessages(mailbox.path, current.uidValidity, rangeFor(start, end), input.signal);
261
- messages.push(...fetched);
262
- // Complete the bounded UID range before advancing, regardless of response order or holes.
263
- nextUid = end + 1;
264
- if (fetched.length === 0 && nextUid < current.uidNext) {
265
- // Some servers assign very large, sparse UIDs. Jump to the next
266
- // existing message instead of scheduling every empty UID range.
267
- const remaining = await client.search({ uid: rangeFor(nextUid, current.uidNext - 1) }, { uid: true });
268
- const lowerBound = nextUid;
269
- nextUid = current.uidNext;
270
- if (Array.isArray(remaining)) {
271
- for (const uid of remaining) {
272
- if (uid >= lowerBound && uid < nextUid)
273
- nextUid = uid;
274
- }
312
+ const fetched = await this.fetchMessages(mailbox.path, current.uidValidity, rangeFor(start, end), input.signal);
313
+ messages.push(...fetched);
314
+ // Complete the bounded UID range before advancing, regardless of response order or holes.
315
+ nextUid = end + 1;
316
+ if (fetched.length === 0 && nextUid < current.uidNext) {
317
+ // Some servers assign very large, sparse UIDs. Jump to the next
318
+ // existing message instead of scheduling every empty UID range.
319
+ const remaining = await client.search({ uid: rangeFor(nextUid, current.uidNext - 1) }, { uid: true });
320
+ const lowerBound = nextUid;
321
+ nextUid = current.uidNext;
322
+ if (Array.isArray(remaining)) {
323
+ for (const uid of remaining) {
324
+ if (uid >= lowerBound && uid < nextUid)
325
+ nextUid = uid;
275
326
  }
276
327
  }
277
328
  }
@@ -286,12 +337,36 @@ export class ImapSmtpAdapter {
286
337
  uidNext: nextUid,
287
338
  };
288
339
  }
340
+ let verify;
341
+ if (this.locations) {
342
+ if (hasMore || messages.length > 0) {
343
+ // New copies are committed first; verification restarts afterwards.
344
+ verify = previous.verify && {
345
+ attempts: previous.verify.attempts,
346
+ excluded: previous.verify.excluded,
347
+ };
348
+ hasMore = true;
349
+ }
350
+ else {
351
+ const result = await this.verifyFolders(mailboxes, nextFolders, previous.verify, input.signal);
352
+ if (result === 'generation-changed')
353
+ return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed during verification.', 'provider', false);
354
+ messages.push(...this.verificationCopies);
355
+ hasMore = !result.done;
356
+ verify = result.verify;
357
+ }
358
+ }
289
359
  return {
290
360
  ok: true,
291
361
  value: {
292
- messages,
293
- deletedProviderMessageIds: [...deletedProviderMessageIds],
294
- nextCursor: syncCursor({ version: 1, folders: nextFolders }),
362
+ messages: await this.assignIdentities(messages, input.signal),
363
+ // Copies leave through location evidence; one missing UID never deletes a message.
364
+ deletedProviderMessageIds: [],
365
+ nextCursor: syncCursor({
366
+ version: 1,
367
+ folders: nextFolders,
368
+ ...(verify ? { verify } : {}),
369
+ }),
295
370
  hasMore,
296
371
  },
297
372
  };
@@ -303,6 +378,22 @@ export class ImapSmtpAdapter {
303
378
  async getMessage(providerMessageId, signal) {
304
379
  try {
305
380
  throwIfAborted(signal);
381
+ if (this.locations) {
382
+ // Try each verified copy; a stale copy is skipped, never reported as deleted.
383
+ for (const location of await this.locations.listValidLocations(providerMessageId)) {
384
+ const message = await this.openCopy(location, metadataQuery());
385
+ if (!message)
386
+ continue;
387
+ return {
388
+ ok: true,
389
+ value: {
390
+ ...(await this.normalizeMessage(location.folder, location.uidValidity, message, signal, MAX_IMAP_DETAIL_BODY_BYTES)),
391
+ providerMessageId,
392
+ },
393
+ };
394
+ }
395
+ return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
396
+ }
306
397
  const locator = requireMessageLocator(providerMessageId);
307
398
  const mailbox = await (await this.imap()).mailboxOpen(locator.folder, {
308
399
  readOnly: true,
@@ -322,24 +413,72 @@ export class ImapSmtpAdapter {
322
413
  return { ok: false, error: classifyError(error, 'IMAP_GET_MESSAGE') };
323
414
  }
324
415
  }
325
- async getAttachment(_providerMessageId, providerAttachmentId, signal) {
416
+ async getAttachment(providerMessageId, providerAttachmentId, signal) {
417
+ let release;
418
+ let streamed = false;
326
419
  try {
327
420
  throwIfAborted(signal);
328
421
  const locator = requireAttachmentLocator(providerAttachmentId);
329
- const selected = await (await this.imap()).mailboxOpen(locator.folder, { readOnly: true });
422
+ let storedCopy;
423
+ let expected;
424
+ if (this.locations) {
425
+ // The part index is only meaningful on the copy it was read from.
426
+ const [location] = await this.locations.findLocations([locator]);
427
+ if (location?.status !== 'valid' ||
428
+ location.providerMessageId !== providerMessageId)
429
+ return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP copy holding this attachment is no longer available.', 'provider', false);
430
+ storedCopy = location;
431
+ expected = await this.locations.findAttachment(location, providerAttachmentId);
432
+ if (!expected)
433
+ return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The stored attachment does not belong to this copy.', 'provider', false);
434
+ }
435
+ const lockedClient = await this.imap();
436
+ const lock = await lockedClient.getMailboxLock(locator.folder, {
437
+ readOnly: true,
438
+ });
439
+ let released = false;
440
+ release = () => {
441
+ if (!released) {
442
+ released = true;
443
+ lock.release();
444
+ }
445
+ };
446
+ const selected = lockedClient.mailbox;
447
+ if (!selected)
448
+ throw new Error('IMAP mailbox lock did not select the folder.');
330
449
  if (String(selected.uidValidity) !== locator.uidValidity)
331
450
  return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed before downloading.', 'provider', false);
332
451
  const client = await this.imap();
333
- const message = await client.fetchOne(locator.uid, { uid: true, bodyStructure: true }, { uid: true });
334
- const part = message && attachmentParts(message.bodyStructure)[locator.attachment];
452
+ const message = await client.fetchOne(locator.uid, { uid: true, bodyStructure: true, envelope: true }, { uid: true });
453
+ if (!message || message.uid !== locator.uid)
454
+ return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP attachment copy changed.', 'provider', false);
455
+ const part = attachmentParts(message.bodyStructure)[locator.attachment];
335
456
  if (!part)
336
457
  return failure('IMAP_ATTACHMENT_NOT_FOUND', 'IMAP attachment was not found.', 'provider', false);
458
+ if (expected) {
459
+ const actual = attachmentMetadata(locator.folder, locator.uidValidity, locator.uid, part, locator.attachment);
460
+ if (actual.fileName !== expected.fileName ||
461
+ actual.contentType !== expected.contentType ||
462
+ actual.size !== expected.size ||
463
+ actual.contentId !== expected.contentId ||
464
+ actual.inline !== expected.inline ||
465
+ ((storedCopy?.source === 'objectid' ||
466
+ storedCopy?.source === 'x-gm-msgid') &&
467
+ validNativeId(this.nativeSource(), message.emailId) !== undefined &&
468
+ (this.nativeSource() !== storedCopy.source ||
469
+ message.emailId !== storedCopy.providerMessageId)))
470
+ return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP attachment metadata or identity changed.', 'provider', false);
471
+ }
337
472
  const download = await client.download(locator.uid, part.part ?? '1', {
338
473
  uid: true,
339
474
  chunkSize: 64 * 1024,
340
475
  });
341
476
  if (!download.content)
342
477
  return failure('IMAP_ATTACHMENT_NOT_FOUND', 'IMAP attachment was not found.', 'provider', false);
478
+ download.content.once('end', release);
479
+ download.content.once('error', release);
480
+ download.content.once('close', release);
481
+ streamed = true;
343
482
  if (signal)
344
483
  addAbortSignal(signal, download.content);
345
484
  return {
@@ -363,6 +502,10 @@ export class ImapSmtpAdapter {
363
502
  catch (error) {
364
503
  return { ok: false, error: classifyError(error, 'IMAP_GET_ATTACHMENT') };
365
504
  }
505
+ finally {
506
+ if (!streamed)
507
+ release?.();
508
+ }
366
509
  }
367
510
  async sendMessage(input) {
368
511
  let submissionStarted = false;
@@ -483,21 +626,80 @@ export class ImapSmtpAdapter {
483
626
  }
484
627
  }
485
628
  async setRead(providerMessageId, read, signal) {
486
- return this.updateFlags(providerMessageId, '\\Seen', read, signal);
629
+ if ((this.locations && !this.operation) ||
630
+ (this.operation &&
631
+ (this.operation.operation !== 'flags' ||
632
+ this.operation.providerMessageId !== providerMessageId)))
633
+ return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
634
+ return this.locations
635
+ ? this.updateCopyFlags(providerMessageId, '\\Seen', read, signal)
636
+ : this.updateFlags(providerMessageId, '\\Seen', read, signal);
487
637
  }
488
638
  async setStarred(providerMessageId, starred, signal) {
489
- return this.updateFlags(providerMessageId, '\\Flagged', starred, signal);
639
+ if ((this.locations && !this.operation) ||
640
+ (this.operation &&
641
+ (this.operation.operation !== 'flags' ||
642
+ this.operation.providerMessageId !== providerMessageId)))
643
+ return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
644
+ return this.locations
645
+ ? this.updateCopyFlags(providerMessageId, '\\Flagged', starred, signal)
646
+ : this.updateFlags(providerMessageId, '\\Flagged', starred, signal);
490
647
  }
491
648
  async deleteMessage(providerMessageId, permanently, signal) {
649
+ if ((this.locations && !this.operation) ||
650
+ (this.operation &&
651
+ (this.operation.operation !== 'delete' ||
652
+ this.operation.providerMessageId !== providerMessageId)))
653
+ return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
492
654
  if (!permanently) {
493
655
  return failure('IMAP_SOFT_DELETE_UNSUPPORTED', 'This IMAP Provider cannot move messages to Trash. Delete them in your mail client instead.', 'configuration', false);
494
656
  }
495
657
  try {
496
658
  throwIfAborted(signal);
497
- const locator = requireMessageLocator(providerMessageId);
498
659
  const client = await this.imap();
499
- await client.mailboxOpen(locator.folder);
500
- await client.messageDelete(locator.uid, { uid: true });
660
+ // Without UIDPLUS, ImapFlow falls back to a mailbox-wide EXPUNGE.
661
+ if (!client.capabilities?.has('UIDPLUS'))
662
+ return failure('IMAP_UIDPLUS_REQUIRED', 'This IMAP server cannot expunge a single message (UIDPLUS is missing). Delete it in your mail client instead.', 'configuration', false);
663
+ if (!this.locations) {
664
+ const locator = requireMessageLocator(providerMessageId);
665
+ const selected = await client.mailboxOpen(locator.folder);
666
+ if (String(selected.uidValidity) !== locator.uidValidity)
667
+ return this.copyChanged();
668
+ const target = await client.fetchOne(locator.uid, { uid: true }, { uid: true });
669
+ if (!target || target.uid !== locator.uid)
670
+ return this.copyChanged();
671
+ if (!(await client.messageFlagsAdd(String(locator.uid), ['\\Deleted'], {
672
+ uid: true,
673
+ })))
674
+ throw new Error('The IMAP server did not set the deletion flag.');
675
+ if (!client.capabilities.has('UIDPLUS'))
676
+ throw new Error('UIDPLUS became unavailable before expunging.');
677
+ await this.protocol.expungeUid(client, locator.uid);
678
+ const remaining = await client.search({ uid: String(locator.uid) }, { uid: true });
679
+ if (!Array.isArray(remaining) || remaining.includes(locator.uid))
680
+ throw new Error('The IMAP server still lists the deleted message.');
681
+ return { ok: true, value: undefined };
682
+ }
683
+ const locations = await this.locations.listValidLocations(providerMessageId);
684
+ if (locations.length === 0)
685
+ return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
686
+ for (const location of locations) {
687
+ throwIfAborted(signal);
688
+ if (!(await this.openCopy(location, { uid: true }, false)))
689
+ return this.copyChanged();
690
+ const uid = String(location.uid);
691
+ if (!(await client.messageFlagsAdd(uid, ['\\Deleted'], { uid: true })))
692
+ throw new Error('The IMAP server did not set the deletion flag.');
693
+ if (!client.capabilities.has('UIDPLUS'))
694
+ throw new Error('UIDPLUS became unavailable before expunging.');
695
+ // Never use messageDelete(): its post-STORE capability check can choose global EXPUNGE.
696
+ await this.protocol.expungeUid(client, location.uid);
697
+ const remaining = await client.search({ uid }, { uid: true });
698
+ if (!Array.isArray(remaining) || remaining.includes(location.uid))
699
+ throw new Error('The IMAP server still lists the deleted message.');
700
+ // Earlier copies stay committed if a later copy fails.
701
+ await this.locations.commitDeleted(location);
702
+ }
501
703
  return { ok: true, value: undefined };
502
704
  }
503
705
  catch (error) {
@@ -532,10 +734,15 @@ export class ImapSmtpAdapter {
532
734
  return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
533
735
  }
534
736
  }
737
+ closeImap() {
738
+ const client = this.imapClient;
739
+ this.imapClient = undefined;
740
+ client?.close();
741
+ }
535
742
  async imap() {
536
743
  if (this.imapClient)
537
744
  return this.imapClient;
538
- const client = createImapClient(this.config.imap, this.credential);
745
+ const client = this.protocol.imap(this.config.imap, this.credential);
539
746
  await client.connect();
540
747
  this.imapClient = client;
541
748
  return client;
@@ -642,6 +849,9 @@ export class ImapSmtpAdapter {
642
849
  async fetchMessages(folder, uidValidity, uids, signal) {
643
850
  if (uids.length === 0)
644
851
  return [];
852
+ const observed = this.locations
853
+ ? await this.locations.listKnownLocations()
854
+ : [];
645
855
  const metadata = [];
646
856
  const client = await this.imap();
647
857
  // Finish FETCH before issuing more IMAP commands: nested downloads deadlock.
@@ -654,12 +864,450 @@ export class ImapSmtpAdapter {
654
864
  const messages = [];
655
865
  for (const message of metadata) {
656
866
  throwIfAborted(signal);
657
- messages.push(await this.normalizeMessage(folder, uidValidity, message, signal));
867
+ const normalized = await this.normalizeMessage(folder, uidValidity, message, signal);
868
+ messages.push({
869
+ message: normalized,
870
+ tuple: { folder, uidValidity, uid: message.uid },
871
+ observed: observed.find((location) => location.folder === folder &&
872
+ location.uidValidity === uidValidity &&
873
+ location.uid === message.uid),
874
+ emailId: message.emailId,
875
+ rfcMessageId: validRfcMessageId(normalized.internetMessageId),
876
+ structure: message.bodyStructure,
877
+ size: message.size,
878
+ });
879
+ }
880
+ return messages;
881
+ }
882
+ beginStep() {
883
+ this.copies = [];
884
+ this.changes = [];
885
+ this.cleanup = false;
886
+ this.verificationIncomplete = false;
887
+ this.verificationCopies.length = 0;
888
+ this.rawBudget = MAX_IMAP_RAW_COMPARE_BUDGET;
889
+ this.rawCache.clear();
890
+ }
891
+ /**
892
+ * Choose each copy's identity: its persisted location first, then the
893
+ * server's native ID, then an RFC Message-ID proven by identical MIME bytes,
894
+ * and otherwise the copy's own location.
895
+ */
896
+ async assignIdentities(fetched, signal) {
897
+ if (!this.locations)
898
+ return fetched.map((copy) => copy.message);
899
+ const stored = new Map((await this.locations.findLocations(fetched.map((copy) => copy.tuple))).map((location) => [tupleKey(location), location]));
900
+ const nativeSource = this.nativeSource();
901
+ const candidates = new Set();
902
+ for (const copy of fetched) {
903
+ if (stored.has(tupleKey(copy.tuple)))
904
+ continue;
905
+ const id = validNativeId(nativeSource, copy.emailId) ?? copy.rfcMessageId;
906
+ if (id)
907
+ candidates.add(id);
908
+ }
909
+ const sources = await this.locations.findMessageSources([...candidates]);
910
+ const claimed = new Map();
911
+ const messages = [];
912
+ for (const copy of fetched) {
913
+ throwIfAborted(signal);
914
+ const existing = stored.get(tupleKey(copy.tuple));
915
+ const locationId = existing?.id ?? randomUUID();
916
+ const nativeId = validNativeId(nativeSource, copy.emailId);
917
+ let identity;
918
+ if (existing) {
919
+ if (nativeId &&
920
+ (existing.source === 'objectid' ||
921
+ existing.source === 'x-gm-msgid') &&
922
+ (existing.source !== nativeSource ||
923
+ existing.providerMessageId !== nativeId)) {
924
+ throw Object.assign(new Error('The IMAP native identity conflicts with its stored copy.'), { code: 'IMAP_IDENTITY_CONFLICT' });
925
+ }
926
+ // A verified location keeps its identity even if a native field is missing now.
927
+ identity = {
928
+ id: existing.providerMessageId,
929
+ source: existing.source ?? 'locator',
930
+ };
931
+ }
932
+ else if (nativeId && nativeSource) {
933
+ if (sources.has(nativeId) && sources.get(nativeId) !== nativeSource) {
934
+ throw Object.assign(new Error('The IMAP identity source conflicts with a stored message.'), { code: 'IMAP_IDENTITY_CONFLICT' });
935
+ }
936
+ if (!sources.has(nativeId) || sources.get(nativeId) === nativeSource)
937
+ identity = { id: nativeId, source: nativeSource };
938
+ }
939
+ else if (copy.rfcMessageId) {
940
+ const id = copy.rfcMessageId;
941
+ if (sources.has(id) &&
942
+ sources.get(id) !== undefined &&
943
+ sources.get(id) !== 'rfc-message-id') {
944
+ throw Object.assign(new Error('The IMAP identity source conflicts with a stored message.'), { code: 'IMAP_IDENTITY_CONFLICT' });
945
+ }
946
+ const claimer = claimed.get(id);
947
+ if (claimer
948
+ ? await this.sameRawCopies(claimer, copy, signal)
949
+ : !sources.has(id) ||
950
+ (sources.get(id) === 'rfc-message-id' &&
951
+ (await this.matchesStoredCopy(id, copy, signal)))) {
952
+ identity = { id, source: 'rfc-message-id' };
953
+ if (!claimer)
954
+ claimed.set(id, copy);
955
+ }
956
+ }
957
+ identity ??= {
958
+ id: encodeCopyLocator(copy.tuple, locationId),
959
+ source: 'locator',
960
+ };
961
+ this.copies.push({
962
+ ...copy.tuple,
963
+ observed: copy.observed,
964
+ locationId,
965
+ providerMessageId: identity.id,
966
+ source: identity.source,
967
+ read: copy.message.read,
968
+ starred: copy.message.starred,
969
+ receivedAt: copy.message.receivedAt,
970
+ });
971
+ messages.push({ ...copy.message, providerMessageId: identity.id });
658
972
  }
659
973
  return messages;
660
974
  }
975
+ nativeSource() {
976
+ const capabilities = this.imapClient?.capabilities;
977
+ if (capabilities?.has('OBJECTID'))
978
+ return 'objectid';
979
+ if (capabilities?.has('X-GM-EXT-1'))
980
+ return 'x-gm-msgid';
981
+ return undefined;
982
+ }
983
+ /** Compare a new copy with a verified copy already stored under this RFC Message-ID. */
984
+ async matchesStoredCopy(id, copy, signal) {
985
+ for (const location of await this.locations.listValidLocations(id)) {
986
+ if (location.source !== 'rfc-message-id')
987
+ return false;
988
+ const stored = await this.openCopy(location, {
989
+ size: true,
990
+ bodyStructure: true,
991
+ });
992
+ if (!stored)
993
+ continue;
994
+ return this.sameRawCopies({
995
+ message: copy.message,
996
+ tuple: location,
997
+ structure: stored.bodyStructure,
998
+ size: stored.size,
999
+ }, copy, signal);
1000
+ }
1001
+ // A candidate that is gone, pending or unreadable cannot prove anything.
1002
+ return false;
1003
+ }
1004
+ async sameRawCopies(left, right, signal) {
1005
+ const first = await this.rawCopy(left, signal);
1006
+ const second = first && (await this.rawCopy(right, signal));
1007
+ return Boolean(first && second && first.equals(second));
1008
+ }
1009
+ /** Complete raw MIME of one copy within the per-message and per-step budgets. */
1010
+ async rawCopy(copy, signal) {
1011
+ const key = tupleKey(copy.tuple);
1012
+ if (this.rawCache.has(key))
1013
+ return this.rawCache.get(key);
1014
+ let raw;
1015
+ if (rawComparable(copy.structure, copy.size, MAX_IMAP_RAW_COMPARE_BYTES) &&
1016
+ copy.size + 1 <= this.rawBudget) {
1017
+ // The probe byte past the reported size is charged as well.
1018
+ this.rawBudget -= copy.size + 1;
1019
+ try {
1020
+ const client = await this.imap();
1021
+ const mailbox = await client.mailboxOpen(copy.tuple.folder, {
1022
+ readOnly: true,
1023
+ });
1024
+ raw =
1025
+ String(mailbox.uidValidity) === copy.tuple.uidValidity
1026
+ ? await readRawSource(client, copy.tuple.uid, copy.size, signal)
1027
+ : undefined;
1028
+ }
1029
+ catch (error) {
1030
+ const classified = classifyError(error, 'IMAP_READ_SOURCE');
1031
+ if (classified.retryable || classified.category === 'authentication')
1032
+ throw error;
1033
+ raw = undefined;
1034
+ }
1035
+ }
1036
+ this.rawCache.set(key, raw);
1037
+ return raw;
1038
+ }
1039
+ /**
1040
+ * Select a stored copy and confirm it is still the same message. Returns
1041
+ * undefined for a changed generation, a missing UID or another identity.
1042
+ */
1043
+ async openCopy(location, query, readOnly = true) {
1044
+ try {
1045
+ const client = await this.imap();
1046
+ const mailbox = await client.mailboxOpen(location.folder, { readOnly });
1047
+ if (String(mailbox.uidValidity) !== location.uidValidity)
1048
+ return undefined;
1049
+ const message = await client.fetchOne(location.uid, { ...query, uid: true, envelope: true }, { uid: true });
1050
+ if (!message || message.uid !== location.uid)
1051
+ return undefined;
1052
+ const source = location.source ?? 'locator';
1053
+ if (source === 'objectid' || source === 'x-gm-msgid') {
1054
+ const currentSource = this.nativeSource();
1055
+ const nativeId = validNativeId(currentSource, message.emailId);
1056
+ // A UID cannot identify another message within the same UIDVALIDITY.
1057
+ // Missing native metadata preserves this previously verified tuple.
1058
+ return !nativeId ||
1059
+ (currentSource === source && nativeId === location.providerMessageId)
1060
+ ? message
1061
+ : undefined;
1062
+ }
1063
+ if (source === 'rfc-message-id')
1064
+ return message.envelope?.messageId === location.providerMessageId
1065
+ ? message
1066
+ : undefined;
1067
+ return source === 'locator' &&
1068
+ encodeCopyLocator(location, location.id) === location.providerMessageId
1069
+ ? message
1070
+ : undefined;
1071
+ }
1072
+ catch (error) {
1073
+ const classified = classifyError(error, 'IMAP_OPEN_COPY');
1074
+ if (classified.retryable || classified.category === 'authentication')
1075
+ throw error;
1076
+ return undefined;
1077
+ }
1078
+ }
1079
+ copyChanged() {
1080
+ return failure('IMAP_MESSAGE_COPY_CHANGED', 'An IMAP copy of this message changed. Synchronize the mailbox and try again.', 'provider', false);
1081
+ }
1082
+ /** Change one flag on every verified copy, committing each confirmed copy on its own. */
1083
+ async updateCopyFlags(providerMessageId, flag, enabled, signal) {
1084
+ try {
1085
+ throwIfAborted(signal);
1086
+ const locations = await this.locations.listValidLocations(providerMessageId);
1087
+ if (locations.length === 0)
1088
+ return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
1089
+ const client = await this.imap();
1090
+ for (const location of locations) {
1091
+ throwIfAborted(signal);
1092
+ if (!(await this.openCopy(location, { uid: true }, false)))
1093
+ return this.copyChanged();
1094
+ const uid = String(location.uid);
1095
+ const method = enabled ? 'messageFlagsAdd' : 'messageFlagsRemove';
1096
+ if (!(await client[method](uid, [flag], { uid: true })))
1097
+ throw new Error('The IMAP server did not update the message flags.');
1098
+ const confirmed = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
1099
+ if (!confirmed ||
1100
+ confirmed.uid !== location.uid ||
1101
+ !confirmed.flags ||
1102
+ confirmed.flags.has(flag) !== enabled)
1103
+ throw new Error('The IMAP server did not confirm the message flags.');
1104
+ await this.locations.commitFlags(location, flag === '\\Seen' ? { read: enabled } : { starred: enabled });
1105
+ }
1106
+ return { ok: true, value: undefined };
1107
+ }
1108
+ catch (error) {
1109
+ return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
1110
+ }
1111
+ }
1112
+ /**
1113
+ * One complete pass over every selectable folder. It reconciles stored
1114
+ * copies, and two matching passes in a row authorize orphan cleanup.
1115
+ */
1116
+ async verifyFolders(mailboxes, folders, previous, signal) {
1117
+ const attempts = (previous?.attempts ?? 0) + 1;
1118
+ const snapshot = {};
1119
+ const present = new Map();
1120
+ const excluded = new Set(previous?.excluded ?? []);
1121
+ const uncertainGenerations = new Set();
1122
+ let stable = true;
1123
+ const known = await this.locations.listKnownLocations();
1124
+ for (const mailbox of mailboxes) {
1125
+ throwIfAborted(signal);
1126
+ const client = await this.imap();
1127
+ const selected = await client.mailboxOpen(mailbox.path, {
1128
+ readOnly: true,
1129
+ });
1130
+ const state = await this.selectedFolderState(selected);
1131
+ const cursor = folders[mailbox.path];
1132
+ if (state.uidValidity !== cursor.uidValidity)
1133
+ return 'generation-changed';
1134
+ // New mail arrived after this page: import it before verifying again.
1135
+ if (state.uidNext !== cursor.uidNext)
1136
+ stable = false;
1137
+ let uids;
1138
+ try {
1139
+ uids = await client.search({ all: true }, { uid: true });
1140
+ }
1141
+ catch (error) {
1142
+ if (!isResponseLimitError(error))
1143
+ throw error;
1144
+ // The UID list exceeds the protocol budget: keep every copy and skip cleanup.
1145
+ this.closeImap();
1146
+ this.verificationIncomplete = true;
1147
+ return { done: true, verify: { attempts, excluded: [...excluded] } };
1148
+ }
1149
+ if (!Array.isArray(uids))
1150
+ // Retry the step: an incomplete SEARCH proves nothing about missing copies.
1151
+ throw Object.assign(new Error(`Could not search ${mailbox.path} during verification.`), { code: 'IMAP_SEARCH_FAILED', serverResponseCode: 'UNAVAILABLE' });
1152
+ if (uids.some((uid) => !Number.isSafeInteger(uid) || uid < 1 || uid > 4294967295))
1153
+ throw new Error('Invalid UID in IMAP SEARCH.');
1154
+ present.set(mailbox.path, new Set(uids));
1155
+ snapshot[mailbox.path] = { ...state, uids: compressUids(uids) };
1156
+ if (Buffer.byteLength(JSON.stringify(snapshot)) >
1157
+ MAX_IMAP_VERIFY_STATE_BYTES) {
1158
+ this.verificationIncomplete = true;
1159
+ return { done: true, verify: { attempts, excluded: [...excluded] } };
1160
+ }
1161
+ const bound = new Set(known
1162
+ .filter((location) => location.folder === mailbox.path &&
1163
+ location.uidValidity === state.uidValidity &&
1164
+ location.status === 'valid')
1165
+ .map((location) => location.uid));
1166
+ const unbound = uids.filter((uid) => !bound.has(uid) &&
1167
+ !excluded.has(tupleKey({
1168
+ folder: mailbox.path,
1169
+ uidValidity: state.uidValidity,
1170
+ uid,
1171
+ })));
1172
+ if (unbound.length) {
1173
+ // Reconcile copies below the scan high-water mark, including old-date moves.
1174
+ const fetched = await this.fetchMessages(mailbox.path, state.uidValidity, unbound.slice(0, this.syncRun?.policy.batchSize ?? 100), signal);
1175
+ const sources = await this.locations.findMessageSources(fetched.flatMap((copy) => {
1176
+ const id = validNativeId(this.nativeSource(), copy.emailId) ??
1177
+ copy.rfcMessageId;
1178
+ return id ? [id] : [];
1179
+ }));
1180
+ for (const copy of fetched) {
1181
+ const id = validNativeId(this.nativeSource(), copy.emailId) ??
1182
+ copy.rfcMessageId;
1183
+ const beforeImportDate = this.syncRun?.policy.receivedAfter &&
1184
+ copy.message.receivedAt &&
1185
+ Date.parse(copy.message.receivedAt) <
1186
+ Date.parse(this.syncRun.policy.receivedAfter);
1187
+ if (beforeImportDate && !copy.observed && (!id || !sources.has(id)))
1188
+ excluded.add(tupleKey(copy.tuple));
1189
+ else
1190
+ this.verificationCopies.push(copy);
1191
+ }
1192
+ stable = false;
1193
+ }
1194
+ }
1195
+ const after = this.selectableMailboxes(await this.listMailboxes());
1196
+ if (after.length !== mailboxes.length ||
1197
+ after.some((box) => !snapshot[box.path]))
1198
+ stable = false;
1199
+ for (const mailbox of after) {
1200
+ const selected = await (await this.imap()).mailboxOpen(mailbox.path, { readOnly: true });
1201
+ const state = await this.selectedFolderState(selected);
1202
+ const rechecked = await (await this.imap()).search({ all: true }, { uid: true });
1203
+ if (!Array.isArray(rechecked))
1204
+ throw new Error('The final IMAP SEARCH was incomplete.');
1205
+ if (compressUids(rechecked) !== snapshot[mailbox.path]?.uids)
1206
+ stable = false;
1207
+ if (state.uidValidity !== snapshot[mailbox.path]?.uidValidity ||
1208
+ state.uidNext !== snapshot[mailbox.path]?.uidNext)
1209
+ stable = false;
1210
+ if (state.uidValidity !== snapshot[mailbox.path]?.uidValidity)
1211
+ uncertainGenerations.add(mailbox.path);
1212
+ }
1213
+ const finalRange = this.selectableMailboxes(await this.listMailboxes());
1214
+ const finalPaths = new Set(finalRange.map((mailbox) => mailbox.path));
1215
+ if (finalPaths.size !== mailboxes.length ||
1216
+ mailboxes.some((mailbox) => !finalPaths.has(mailbox.path)))
1217
+ stable = false;
1218
+ for (const mailbox of finalRange) {
1219
+ const generation = mailbox.status?.uidValidity;
1220
+ const uidNext = mailbox.status?.uidNext;
1221
+ if (generation !== undefined &&
1222
+ String(generation) !== snapshot[mailbox.path]?.uidValidity) {
1223
+ uncertainGenerations.add(mailbox.path);
1224
+ stable = false;
1225
+ }
1226
+ if (uidNext !== undefined && uidNext !== snapshot[mailbox.path]?.uidNext)
1227
+ stable = false;
1228
+ }
1229
+ for (const location of known) {
1230
+ if (this.verificationCopies.some((copy) => tupleKey(copy.tuple) === tupleKey(location)))
1231
+ continue;
1232
+ const uids = present.get(location.folder);
1233
+ const generation = snapshot[location.folder]?.uidValidity;
1234
+ const replaced = generation !== location.uidValidity &&
1235
+ (location.source === 'objectid' || location.source === 'x-gm-msgid') &&
1236
+ known.some((copy) => copy.messageId === location.messageId &&
1237
+ copy.source === location.source &&
1238
+ copy.providerMessageId === location.providerMessageId &&
1239
+ copy.status === 'valid' &&
1240
+ snapshot[copy.folder]?.uidValidity === copy.uidValidity &&
1241
+ present.get(copy.folder)?.has(copy.uid));
1242
+ if (stable &&
1243
+ location.status === 'valid' &&
1244
+ location.source &&
1245
+ generation === location.uidValidity &&
1246
+ uids?.has(location.uid)) {
1247
+ const observed = await this.openCopy(location, { flags: true });
1248
+ if (!observed?.flags)
1249
+ throw new Error('The IMAP copy identity or flags changed during verification.');
1250
+ this.copies.push({
1251
+ flagsOnly: true,
1252
+ locationId: location.id,
1253
+ observed: location,
1254
+ folder: location.folder,
1255
+ uidValidity: location.uidValidity,
1256
+ uid: location.uid,
1257
+ providerMessageId: location.providerMessageId,
1258
+ source: location.source,
1259
+ read: observed.flags.has('\\Seen'),
1260
+ starred: observed.flags.has('\\Flagged'),
1261
+ });
1262
+ }
1263
+ const next = !finalPaths.has(location.folder)
1264
+ ? { status: 'pending', reason: 'folder-unavailable' }
1265
+ : uncertainGenerations.has(location.folder)
1266
+ ? { status: 'pending', reason: 'generation-changed' }
1267
+ : replaced && stable
1268
+ ? { status: 'invalid', reason: 'generation-replaced' }
1269
+ : !uids
1270
+ ? { status: 'pending', reason: 'folder-unavailable' }
1271
+ : generation !== location.uidValidity
1272
+ ? { status: 'pending', reason: 'generation-changed' }
1273
+ : uids.has(location.uid)
1274
+ ? { status: 'valid' }
1275
+ : { status: 'invalid', reason: 'uid-missing' };
1276
+ if (next.status !== 'valid' || next.status !== location.status)
1277
+ this.changes.push({ id: location.id, observed: location, ...next });
1278
+ }
1279
+ if (Buffer.byteLength(JSON.stringify({ snapshot, excluded: [...excluded] })) >
1280
+ MAX_IMAP_VERIFY_STATE_BYTES) {
1281
+ this.verificationIncomplete = true;
1282
+ return { done: true, verify: { attempts } };
1283
+ }
1284
+ if (!stable) {
1285
+ if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
1286
+ this.verificationIncomplete = true;
1287
+ return { done: true, verify: { attempts, excluded: [...excluded] } };
1288
+ }
1289
+ return { done: false, verify: { attempts, excluded: [...excluded] } };
1290
+ }
1291
+ if (previous?.folders && sameSnapshot(previous.folders, snapshot)) {
1292
+ this.cleanup = true;
1293
+ return { done: true };
1294
+ }
1295
+ if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
1296
+ this.verificationIncomplete = true;
1297
+ return { done: true, verify: { attempts, excluded: [...excluded] } };
1298
+ }
1299
+ return {
1300
+ done: false,
1301
+ verify: { attempts, folders: snapshot, excluded: [...excluded] },
1302
+ };
1303
+ }
661
1304
  async normalizeMessage(folder, uidValidity, message, signal, maxBodyBytes = MAX_IMAP_BODY_BYTES) {
662
- const header = message.headers?.subarray(0, 64 * 1024);
1305
+ const header = message.headers;
1306
+ if (!header ||
1307
+ header.byteLength > MAX_HEADER_BYTES ||
1308
+ !header.subarray(-4).equals(Buffer.from('\r\n\r\n'))) {
1309
+ throw Object.assign(new Error('The complete IMAP header could not be read within the header budget.'), { code: 'IMAP_HEADER_INCOMPLETE' });
1310
+ }
663
1311
  const parsed = header
664
1312
  ? await simpleParser(Buffer.concat([header, Buffer.from('\r\n\r\n')]), {
665
1313
  skipHtmlToText: true,
@@ -698,7 +1346,8 @@ export class ImapSmtpAdapter {
698
1346
  providerMessageId,
699
1347
  ...body,
700
1348
  size: message.size,
701
- internetMessageId: parsed?.messageId ?? message.envelope?.messageId,
1349
+ // Preserve the header value; mailparser can wrap malformed bare IDs in brackets.
1350
+ internetMessageId: /^message-id:[\t ]*([^\r\n]*(?:\r\n[\t ][^\r\n]*)*)\r\n/im.exec(header.toString('utf8'))?.[1],
702
1351
  providerFolderIds: [folder],
703
1352
  from,
704
1353
  to,
@@ -718,4 +1367,46 @@ export class ImapSmtpAdapter {
718
1367
  };
719
1368
  }
720
1369
  }
1370
+ function tupleKey(tuple) {
1371
+ return JSON.stringify([tuple.folder, tuple.uidValidity, tuple.uid]);
1372
+ }
1373
+ const MAX_UINT64 = 2n ** 64n - 1n;
1374
+ /** The server's own object ID, never ImapFlow's synthesized message.id. */
1375
+ function validNativeId(source, value) {
1376
+ if (!value)
1377
+ return undefined;
1378
+ if (source === 'objectid')
1379
+ return /^[A-Za-z0-9_-]{1,255}$/.test(value) ? value : undefined;
1380
+ if (source === 'x-gm-msgid')
1381
+ // Keep the decimal string: a JavaScript number would lose uint64 precision.
1382
+ return /^(0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= MAX_UINT64
1383
+ ? value
1384
+ : undefined;
1385
+ return undefined;
1386
+ }
1387
+ function validRfcMessageId(value) {
1388
+ return value &&
1389
+ value.length <= MAX_IMAP_PROVIDER_ID_LENGTH &&
1390
+ /^<[\x21-\x3b\x3d\x3f-\x7e]+@[\x21-\x3b\x3d\x3f-\x7e]+>$/.test(value)
1391
+ ? value
1392
+ : undefined;
1393
+ }
1394
+ function sameSnapshot(left, right) {
1395
+ const paths = Object.keys(right);
1396
+ return (Object.keys(left).length === paths.length &&
1397
+ paths.every((path) => left[path]?.uidValidity === right[path].uidValidity &&
1398
+ left[path].uidNext === right[path].uidNext &&
1399
+ left[path].uids === right[path].uids));
1400
+ }
1401
+ function isResponseLimitError(error) {
1402
+ return ['LiteralTooLarge', 'ResponseTooLarge', 'LineTooLarge'].includes(error?.code ?? '');
1403
+ }
1404
+ /** ImapFlow 2.2.10's command primitive keeps the verb explicitly scoped to a UID. */
1405
+ async function expungeUid(client, uid) {
1406
+ const connection = client;
1407
+ const response = await connection.exec('UID EXPUNGE', [
1408
+ { type: 'SEQUENCE', value: String(uid) },
1409
+ ]);
1410
+ response.next();
1411
+ }
721
1412
  //# sourceMappingURL=adapter.js.map