@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,1021 @@
1
+ import type {
2
+ BodyPartUpsertInput,
3
+ IAddressRepository,
4
+ IEnvelopeRepository,
5
+ IMailboxRepository,
6
+ IMessageRepository,
7
+ IThreadMessageRepository,
8
+ IUnitOfWork,
9
+ MailboxItem,
10
+ } from "@remit/data-ports";
11
+ import {
12
+ deriveAddressId,
13
+ deriveBodyPartId,
14
+ deriveEnvelopeAddressId,
15
+ deriveEnvelopeId,
16
+ deriveMessageIdFromSource,
17
+ deriveThreadId,
18
+ isValidMessageId,
19
+ } from "@remit/data-ports/id";
20
+ import { AddressRole, MailboxCursorState } from "@remit/domain-enums";
21
+ import pMap from "p-map";
22
+ import type { ManagedConnectionFactory } from "./connection-factory.js";
23
+ import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
24
+ import {
25
+ type CursorRebuildRow,
26
+ type CursorRebuildSnapshot,
27
+ matchCursorRebuild,
28
+ } from "./mailbox-cursor-rebuild.js";
29
+ import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
30
+ import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
31
+ import { reconcileStaleMessage } from "./stale-message-reconcile.js";
32
+ import type {
33
+ ImapAddress,
34
+ ImapBodyStructure,
35
+ ImapEnvelope,
36
+ ImapMessage,
37
+ } from "./types.js";
38
+
39
+ const MESSAGE_SAVE_CONCURRENCY = 10;
40
+
41
+ // Some IMAP servers (e.g. Hostnet) emit these literal placeholders in the
42
+ // ENVELOPE when they cannot parse a From header, instead of leaving the
43
+ // address parts empty.
44
+ const HOSTNET_MISSING_MAILBOX = "missing_mailbox";
45
+ const HOSTNET_MISSING_DOMAIN = "missing_domain";
46
+
47
+ /**
48
+ * A From address is only usable when it looks like a real mailbox: both parts
49
+ * present, neither is a known "could not parse" sentinel, and the host carries
50
+ * an actual domain (at least one dot). Detect this structurally so a fabricated
51
+ * string like `missing_mailbox@missing_domain` is never persisted as a sender.
52
+ */
53
+ export const isParseableEmailAddress = (
54
+ address: ImapAddress | undefined,
55
+ ): boolean => {
56
+ if (!address) return false;
57
+ const mailbox = address.mailbox?.trim();
58
+ const host = address.host?.trim();
59
+ if (!mailbox || !host) return false;
60
+ if (mailbox === HOSTNET_MISSING_MAILBOX || host === HOSTNET_MISSING_DOMAIN) {
61
+ return false;
62
+ }
63
+ return host.includes(".");
64
+ };
65
+
66
+ /**
67
+ * Parse an external `Date:` header into an epoch-millisecond integer.
68
+ *
69
+ * The IMAP envelope `date` is a raw RFC 2822 header copied verbatim from the
70
+ * message. It can be missing, malformed, or in a format `Date` cannot parse —
71
+ * `new Date(raw).getTime()` then yields `NaN`. `NaN` is not a valid integer
72
+ * and ElectroDB rejects it, which previously threw on the envelope upsert and
73
+ * (because the batch aborted on the first rejection) stalled the whole mailbox.
74
+ *
75
+ * When the header is unparseable we fall back to `fallbackMs` — the IMAP
76
+ * server's own INTERNALDATE receive time, always a valid integer. The raw
77
+ * header is preserved separately in `dateRaw`, so nothing is lost.
78
+ */
79
+ export const parseHeaderDate = (
80
+ raw: string | undefined,
81
+ fallbackMs: number,
82
+ ): { value: number; usedFallback: boolean } => {
83
+ if (raw !== undefined && raw !== "") {
84
+ const parsed = new Date(raw).getTime();
85
+ if (Number.isFinite(parsed)) {
86
+ return { value: parsed, usedFallback: false };
87
+ }
88
+ }
89
+ return { value: fallbackMs, usedFallback: true };
90
+ };
91
+
92
+ /**
93
+ * @deprecated Use ManagedConnectionFactory instead
94
+ */
95
+ export type ImapConnectionFactory = () => {
96
+ connect(): Promise<void>;
97
+ disconnect(): Promise<void>;
98
+ };
99
+
100
+ export interface SyncLogger {
101
+ info(obj: Record<string, unknown>, msg: string): void;
102
+ warn(obj: Record<string, unknown>, msg: string): void;
103
+ }
104
+
105
+ const noopLogger: SyncLogger = {
106
+ info: () => {},
107
+ warn: () => {},
108
+ };
109
+
110
+ export interface SyncedMessage {
111
+ messageId: string;
112
+ uid: number;
113
+ }
114
+
115
+ /**
116
+ * Per-message save outcome. `owned` is true when the row was created by this
117
+ * sync or already belongs to the current mailbox; false for a residual
118
+ * cross-mailbox collision whose stored row points at a different mailbox.
119
+ */
120
+ interface SaveMessageResult extends SyncedMessage {
121
+ owned: boolean;
122
+ }
123
+
124
+ /**
125
+ * Wrapper outcome for a single message in the batch. A `failed` outcome means
126
+ * the save threw (and was caught) — its UID must NOT advance the watermark, so
127
+ * the message is re-fetched and retried on the next cycle. `null` means the
128
+ * message carried no envelope and was intentionally skipped (nothing to retry).
129
+ */
130
+ type BatchOutcome =
131
+ | { kind: "saved"; uid: number; result: SaveMessageResult | null }
132
+ | { kind: "failed"; uid: number };
133
+
134
+ export interface SyncMessagesResult {
135
+ syncedCount: number;
136
+ syncedMessageIds: string[];
137
+ syncedMessages: SyncedMessage[];
138
+ hasMore: boolean;
139
+ remainingCount: number;
140
+ }
141
+
142
+ export class MessageSyncService {
143
+ private log: SyncLogger;
144
+ private unitOfWork: IUnitOfWork;
145
+
146
+ constructor(
147
+ private connectionFactory: ManagedConnectionFactory,
148
+ private mailboxService: IMailboxRepository,
149
+ messageService: IMessageRepository,
150
+ envelopeService: IEnvelopeRepository,
151
+ addressService: IAddressRepository,
152
+ private threadMessageService: IThreadMessageRepository,
153
+ logger?: SyncLogger,
154
+ unitOfWork?: IUnitOfWork,
155
+ ) {
156
+ this.log = logger ?? noopLogger;
157
+ this.unitOfWork =
158
+ unitOfWork ??
159
+ new PassThroughUnitOfWork({
160
+ message: messageService,
161
+ envelope: envelopeService,
162
+ address: addressService,
163
+ threadMessage: threadMessageService,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Sync ONE batch of messages for a mailbox using newest-first strategy.
169
+ *
170
+ * Uses dual-watermark tracking:
171
+ * - highWaterMarkUid: highest UID ever seen (detects new messages)
172
+ * - lastSyncUid: lowest UID processed (tracks backfill progress)
173
+ *
174
+ * Returns hasMore=true if there are more messages to sync. The caller
175
+ * should re-enqueue another sync event to continue processing.
176
+ *
177
+ * @param mailboxId - The database mailbox ID
178
+ * @param accountId - The account ID (scopes message/thread identity)
179
+ * @param accountConfigId - The account config ID (used for address linking)
180
+ * @param batchSize - Number of messages to process per batch
181
+ */
182
+ async syncMessages(
183
+ mailboxId: string,
184
+ accountId: string,
185
+ accountConfigId: string,
186
+ batchSize = 50,
187
+ ): Promise<SyncMessagesResult> {
188
+ const mailbox = await this.mailboxService.get(accountId, mailboxId);
189
+ const mailboxPath = mailbox.fullPath;
190
+
191
+ // A mailbox whose cursor is already invalid (or a rebuild that crashed
192
+ // mid-way, #1272) never falls through to the normal watermark-based sync
193
+ // below — stored UIDs on that axis are not trustworthy. The rebuild is a
194
+ // variant of sync, not a special wipe path, so it runs here under the
195
+ // same mailbox lock the caller already holds.
196
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
197
+ return this.rebuildCursor(mailbox, accountId, accountConfigId, mailboxId);
198
+ }
199
+
200
+ const lastSyncUid = mailbox.lastSyncUid || 0;
201
+ const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
202
+
203
+ const { box, unseenCount, deletedCount, uids } = await this.fetchUidsToSync(
204
+ mailboxPath,
205
+ lastSyncUid,
206
+ highWaterMarkUid,
207
+ );
208
+
209
+ // Detection: the served UIDVALIDITY may have changed since it was last
210
+ // stored, even though this mailbox was `normal` a moment ago. Trip the
211
+ // cursor and pause — the watermarks just used to filter `uids` may
212
+ // already be meaningless on the new axis, so nothing below may be acted
213
+ // on this round (epic #1281 invariants 3 and 5).
214
+ const cursorCheck = await guardMailboxCursor(
215
+ { mailboxService: this.mailboxService },
216
+ accountId,
217
+ mailbox,
218
+ box.uidvalidity,
219
+ );
220
+ if (!cursorCheck.ok) {
221
+ this.log.warn(
222
+ { mailboxId, mailboxPath, cursorState: cursorCheck.state },
223
+ "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round",
224
+ );
225
+ return {
226
+ syncedCount: 0,
227
+ syncedMessageIds: [],
228
+ syncedMessages: [],
229
+ hasMore: false,
230
+ remainingCount: 0,
231
+ };
232
+ }
233
+
234
+ if (uids.length === 0) {
235
+ // Still update counts even if no new messages to sync
236
+ await this.mailboxService.update(accountId, mailboxId, {
237
+ lastMessageSyncAt: Date.now(),
238
+ uidValidity: box.uidvalidity,
239
+ messageCount: box.messageCount,
240
+ unseenCount,
241
+ deletedCount,
242
+ });
243
+
244
+ this.log.info(
245
+ {
246
+ mailboxId,
247
+ mailboxPath,
248
+ total: 0,
249
+ messageCount: box.messageCount,
250
+ unseenCount,
251
+ },
252
+ "No new messages to sync",
253
+ );
254
+ return {
255
+ syncedCount: 0,
256
+ syncedMessageIds: [],
257
+ syncedMessages: [],
258
+ hasMore: false,
259
+ remainingCount: 0,
260
+ };
261
+ }
262
+
263
+ const totalBatches = Math.ceil(uids.length / batchSize);
264
+ this.log.info(
265
+ { mailboxId, mailboxPath, total: uids.length, batches: totalBatches },
266
+ "Starting message sync batch (newest first)",
267
+ );
268
+
269
+ // Process only the first batch
270
+ const batchUids = uids.slice(0, batchSize);
271
+ const messages = await this.fetchMessageBatch(batchUids);
272
+
273
+ // Process messages in parallel with concurrency limit. `stopOnError` stays
274
+ // at its default — but each message is saved through `trySaveMessage`,
275
+ // which catches its own error and reports a `failed` outcome instead of
276
+ // rejecting. So one bad message can no longer abort the whole batch (the
277
+ // poison pill that previously froze the mailbox, #817).
278
+ const outcomes = await pMap(
279
+ messages,
280
+ (msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
281
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
282
+ );
283
+
284
+ // Body-sync set: only rows created or owned by THIS mailbox. A residual
285
+ // cross-mailbox collision (same deterministic messageId already owned by a
286
+ // different mailbox) must not push a foreign-owned messageId into
287
+ // syncedMessageIds, or body-sync would FETCH against the wrong mailbox's UID.
288
+ const ownedResults = outcomes.flatMap((o) =>
289
+ o.kind === "saved" && o.result !== null && o.result.owned
290
+ ? [o.result]
291
+ : [],
292
+ );
293
+ const syncedMessages: SyncedMessage[] = ownedResults.map(
294
+ ({ messageId, uid }) => ({ messageId, uid }),
295
+ );
296
+ const syncedMessageIds = syncedMessages.map((m) => m.messageId);
297
+
298
+ // UIDs whose save threw. They must stay inside the next cycle's fetch
299
+ // window, so the watermark may not advance past them (no silent loss).
300
+ const failedUids = new Set(
301
+ outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
302
+ );
303
+ if (failedUids.size > 0) {
304
+ this.log.warn(
305
+ { mailboxId, mailboxPath, failedUids: [...failedUids] },
306
+ "Some messages failed to save; holding watermark below them for retry",
307
+ );
308
+ }
309
+
310
+ // Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
311
+ // independent of ownership. `fetchUidsToSync` reselects work purely by UID
312
+ // vs watermark (there is no per-UID processed set), so a foreign-owned UID
313
+ // that did not advance the watermark would be re-fetched every cycle
314
+ // forever. The same Message-ID legitimately appears in several of one
315
+ // account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
316
+ // conflicts are routine; excluding them from body-sync is correct, stalling
317
+ // forward sync is not.
318
+ //
319
+ // Failures are different: the watermark range [batchMin, batchMax] jumps
320
+ // over any interior UID, so a failed UID inside the range would be lost.
321
+ // We therefore advance the forward watermark only past the top contiguous
322
+ // run of successes, and the backfill watermark only past the bottom
323
+ // contiguous run — clamping at the first failure from each end so every
324
+ // failed UID stays selectable next cycle.
325
+ const ascendingUids = [...batchUids].sort((a, b) => a - b);
326
+
327
+ // Top contiguous run of successes → the highest UID safe to mark "seen".
328
+ let forwardMax = highWaterMarkUid;
329
+ for (let i = ascendingUids.length - 1; i >= 0; i--) {
330
+ const uid = ascendingUids[i];
331
+ if (failedUids.has(uid)) break;
332
+ forwardMax = Math.max(forwardMax, uid);
333
+ }
334
+ const newHighWaterMark = forwardMax;
335
+
336
+ // Bottom contiguous run of successes → the lowest UID safe to backfill
337
+ // past. The first (lowest) UID that succeeded defines it; if the very
338
+ // lowest UID failed there is nothing safe to backfill past.
339
+ const backfillMin: number | undefined = failedUids.has(ascendingUids[0])
340
+ ? undefined
341
+ : ascendingUids[0];
342
+
343
+ // Update lastSyncUid only for backfill UIDs (below current lastSyncUid or
344
+ // fresh sync). When the lowest UID failed there is nothing safe to backfill
345
+ // past, so leave lastSyncUid untouched.
346
+ const newLastSyncUid =
347
+ backfillMin !== undefined &&
348
+ (lastSyncUid === 0 || backfillMin < lastSyncUid)
349
+ ? backfillMin
350
+ : lastSyncUid;
351
+
352
+ await this.mailboxService.update(accountId, mailboxId, {
353
+ lastSyncUid: newLastSyncUid,
354
+ highWaterMarkUid: newHighWaterMark,
355
+ lastMessageSyncAt: Date.now(),
356
+ uidValidity: box.uidvalidity,
357
+ messageCount: box.messageCount,
358
+ unseenCount,
359
+ deletedCount,
360
+ });
361
+
362
+ const remainingCount = uids.length - batchUids.length;
363
+ const hasMore = remainingCount > 0;
364
+
365
+ this.log.info(
366
+ {
367
+ batch: 1,
368
+ totalBatches,
369
+ batchSize: messages.length,
370
+ synced: syncedMessageIds.length,
371
+ total: uids.length,
372
+ remaining: remainingCount,
373
+ hasMore,
374
+ highWaterMarkUid: newHighWaterMark,
375
+ lastSyncUid: newLastSyncUid,
376
+ },
377
+ "Batch complete",
378
+ );
379
+
380
+ return {
381
+ syncedCount: syncedMessageIds.length,
382
+ syncedMessageIds,
383
+ syncedMessages,
384
+ hasMore,
385
+ remainingCount,
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Re-key a mailbox's stored UIDs against a new UIDVALIDITY axis (#1272).
391
+ *
392
+ * One envelope-level pass (UID + Message-ID + INTERNALDATE, no body
393
+ * fetches — {@link matchCursorRebuild}) matched against the rows already
394
+ * stored for this mailbox:
395
+ * - Match → rewrite the row's UID mapping in place; bodies and threads are
396
+ * untouched.
397
+ * - Server message with no row → normal new-message sync (the same
398
+ * `trySaveMessage` pipeline the regular batch sync uses).
399
+ * - Row with no counterpart → expunged; reconcile via {@link
400
+ * reconcileStaleMessage} (#1283 — the exact same "gone upstream" outcome
401
+ * as a body-sync retry exhaustion finding a stale row).
402
+ *
403
+ * Idempotent by construction: re-entering mid-rebuild (crash recovery —
404
+ * the mailbox was left `rebuilding`) simply redoes the same match/rewrite
405
+ * pass, which converges on the same result. `cursorState` is stamped
406
+ * `rebuilding` before any write and only cleared to `normal` after the
407
+ * watermarks are rebuilt, so a crash anywhere in between leaves the
408
+ * mailbox paused rather than falling back to the stale axis.
409
+ */
410
+ private async rebuildCursor(
411
+ mailbox: MailboxItem,
412
+ accountId: string,
413
+ accountConfigId: string,
414
+ mailboxId: string,
415
+ ): Promise<SyncMessagesResult> {
416
+ const mailboxPath = mailbox.fullPath;
417
+
418
+ await this.mailboxService.update(accountId, mailboxId, {
419
+ cursorState: MailboxCursorState.rebuilding,
420
+ });
421
+
422
+ const connection = this.connectionFactory.getConnection();
423
+ const box = await connection.openBox(mailboxPath);
424
+ const allUids = await connection.search(["ALL"]);
425
+ const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
426
+ const serverSnapshots: CursorRebuildSnapshot[] = snapshots.map((s) => ({
427
+ uid: s.uid,
428
+ messageId: s.messageId,
429
+ internalDate: s.internalDate.getTime(),
430
+ }));
431
+
432
+ const existingRows = await this.listExistingCursorRows(
433
+ accountConfigId,
434
+ mailboxId,
435
+ );
436
+
437
+ const { matched, newUids, staleMessageIds } = matchCursorRebuild(
438
+ serverSnapshots,
439
+ existingRows,
440
+ );
441
+
442
+ // Bounded concurrency (mirrors MESSAGE_SAVE_CONCURRENCY below) — a
443
+ // sequential loop over a large mailbox's full match set risked the
444
+ // Lambda timeout on its own, independent of the fetch-size question
445
+ // (#1272 review, non-blocking finding).
446
+ await pMap(
447
+ matched,
448
+ async ({ messageId, newUid, threadMessage }) => {
449
+ await this.unitOfWork.transaction((repos) =>
450
+ repos.message.updateUid(messageId, newUid, mailboxId),
451
+ );
452
+ // Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
453
+ // a normal move keeps both in sync (see buildThreadMessageMoveUpdate
454
+ // in message-move.ts), and the rebuild must too, or a resumed
455
+ // rebuild re-emits a no-op rewrite forever (listExistingCursorRows
456
+ // reads uid from ThreadMessage) and any reader of the list
457
+ // projection sees a stale UID.
458
+ if (threadMessage) {
459
+ await this.unitOfWork.transaction((repos) =>
460
+ repos.threadMessage.update(
461
+ threadMessage.accountConfigId,
462
+ threadMessage.threadMessageId,
463
+ { uid: newUid },
464
+ {
465
+ composites: {
466
+ sentDate: threadMessage.sentDate,
467
+ mailboxId: threadMessage.mailboxId,
468
+ isRead: threadMessage.isRead,
469
+ isDeleted: threadMessage.isDeleted,
470
+ hasStars: threadMessage.hasStars,
471
+ hasAttachment: threadMessage.hasAttachment,
472
+ },
473
+ },
474
+ ),
475
+ );
476
+ }
477
+ },
478
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
479
+ );
480
+
481
+ await pMap(
482
+ staleMessageIds,
483
+ (messageId) =>
484
+ this.unitOfWork.transaction((repos) =>
485
+ reconcileStaleMessage(
486
+ {
487
+ messageService: repos.message,
488
+ threadMessageService: repos.threadMessage,
489
+ },
490
+ accountConfigId,
491
+ messageId,
492
+ ),
493
+ ),
494
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
495
+ );
496
+
497
+ const newMessages =
498
+ newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
499
+ const outcomes = await pMap(
500
+ newMessages,
501
+ (msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
502
+ { concurrency: MESSAGE_SAVE_CONCURRENCY },
503
+ );
504
+ const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
505
+ o.kind === "saved" && o.result !== null && o.result.owned
506
+ ? [{ messageId: o.result.messageId, uid: o.result.uid }]
507
+ : [],
508
+ );
509
+
510
+ const serverUids = serverSnapshots.map((s) => s.uid);
511
+ const status = await connection.getMailboxStatus(mailboxPath);
512
+
513
+ await this.mailboxService.update(accountId, mailboxId, {
514
+ cursorState: MailboxCursorState.normal,
515
+ uidValidity: box.uidvalidity,
516
+ highWaterMarkUid: serverUids.length > 0 ? Math.max(...serverUids) : 0,
517
+ lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
518
+ highestModseq: status.highestModseq,
519
+ lastMessageSyncAt: Date.now(),
520
+ messageCount: status.messages,
521
+ unseenCount: status.unseen,
522
+ deletedCount: status.deletedCount,
523
+ });
524
+
525
+ this.log.info(
526
+ {
527
+ mailboxId,
528
+ mailboxPath,
529
+ matched: matched.length,
530
+ newMessages: syncedMessages.length,
531
+ stale: staleMessageIds.length,
532
+ },
533
+ "Mailbox cursor rebuild complete; returned to normal",
534
+ );
535
+
536
+ return {
537
+ syncedCount: syncedMessages.length,
538
+ syncedMessageIds: syncedMessages.map((m) => m.messageId),
539
+ syncedMessages,
540
+ hasMore: false,
541
+ remainingCount: 0,
542
+ };
543
+ }
544
+
545
+ /**
546
+ * Page through every ThreadMessage row for this mailbox, projecting just
547
+ * the fields {@link matchCursorRebuild} needs. ThreadMessage (not
548
+ * Message) is the read source: it already denormalizes `messageIdHeader`
549
+ * and `internalDate` alongside `uid`, so this needs no per-row Envelope
550
+ * lookup.
551
+ */
552
+ private async listExistingCursorRows(
553
+ accountConfigId: string,
554
+ mailboxId: string,
555
+ ): Promise<CursorRebuildRow[]> {
556
+ const rows: CursorRebuildRow[] = [];
557
+ let continuationToken: string | undefined;
558
+
559
+ do {
560
+ const result = await this.threadMessageService.listByMailbox(
561
+ accountConfigId,
562
+ mailboxId,
563
+ {
564
+ continuationToken,
565
+ attributes: [
566
+ "messageId",
567
+ "messageIdHeader",
568
+ "internalDate",
569
+ "uid",
570
+ "accountConfigId",
571
+ "threadMessageId",
572
+ "sentDate",
573
+ "mailboxId",
574
+ "isRead",
575
+ "isDeleted",
576
+ "hasStars",
577
+ "hasAttachment",
578
+ ],
579
+ },
580
+ );
581
+ for (const row of result.items) {
582
+ rows.push({
583
+ messageId: row.messageId,
584
+ messageIdHeader: row.messageIdHeader ?? "",
585
+ internalDate: row.internalDate,
586
+ uid: row.uid,
587
+ // Carried so a match can also rewrite ThreadMessage.uid (a normal
588
+ // move keeps both in sync — the rebuild must too, or #1271's
589
+ // push-time UID resolution can read a stale projection).
590
+ threadMessage: {
591
+ accountConfigId: row.accountConfigId,
592
+ threadMessageId: row.threadMessageId,
593
+ sentDate: row.sentDate,
594
+ mailboxId: row.mailboxId,
595
+ isRead: row.isRead,
596
+ isDeleted: row.isDeleted,
597
+ hasStars: row.hasStars,
598
+ hasAttachment: row.hasAttachment,
599
+ },
600
+ });
601
+ }
602
+ continuationToken = result.continuationToken;
603
+ } while (continuationToken);
604
+
605
+ return rows;
606
+ }
607
+
608
+ /**
609
+ * Fetch UIDs to sync using dual-watermark strategy.
610
+ *
611
+ * Returns UIDs sorted descending (newest first):
612
+ * 1. New messages: UIDs > highWaterMarkUid
613
+ * 2. Backfill: UIDs < lastSyncUid (if lastSyncUid > 1)
614
+ */
615
+ private async fetchUidsToSync(
616
+ mailboxPath: string,
617
+ lastSyncUid: number,
618
+ highWaterMarkUid: number,
619
+ ): Promise<{
620
+ box: { uidvalidity: number; uidnext: number; messageCount: number };
621
+ unseenCount: number;
622
+ deletedCount: number;
623
+ uids: number[];
624
+ }> {
625
+ const connection = this.connectionFactory.getConnection();
626
+ const box = await connection.openBox(mailboxPath);
627
+
628
+ // Get mailbox status including unseen count
629
+ const status = await connection.getMailboxStatus(mailboxPath);
630
+
631
+ const allUids = await connection.search(["ALL"]);
632
+
633
+ // New messages: UIDs greater than what we've seen
634
+ const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
635
+
636
+ // Backfill: UIDs below our lowest synced point (if sync started)
637
+ const backfillUids =
638
+ lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
639
+
640
+ // Fresh sync: if no watermarks, sync everything
641
+ const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
642
+ const uidsToSync = isFreshSync ? allUids : [...newUids, ...backfillUids];
643
+
644
+ // Sort descending (newest first)
645
+ uidsToSync.sort((a, b) => b - a);
646
+
647
+ return {
648
+ box: {
649
+ uidvalidity: box.uidvalidity,
650
+ uidnext: box.uidnext,
651
+ messageCount: status.messages,
652
+ },
653
+ unseenCount: status.unseen,
654
+ deletedCount: status.deletedCount,
655
+ uids: uidsToSync,
656
+ };
657
+ }
658
+
659
+ /**
660
+ * Fetch a batch of messages using the managed connection.
661
+ * Assumes mailbox is already open from fetchUidsToSync.
662
+ */
663
+ private async fetchMessageBatch(uids: number[]): Promise<ImapMessage[]> {
664
+ const connection = this.connectionFactory.getConnection();
665
+ return await connection.fetchMessages(uids);
666
+ }
667
+
668
+ /**
669
+ * Save a single message without ever rejecting. Any error is caught and
670
+ * reported as a `failed` outcome so it cannot abort the surrounding `pMap`
671
+ * batch — the failed UID is held back from the watermark and retried next
672
+ * cycle. This is the guardrail that stops a single unsaveable message from
673
+ * permanently freezing the mailbox (#817).
674
+ */
675
+ private async trySaveMessage(
676
+ mailboxId: string,
677
+ accountId: string,
678
+ accountConfigId: string,
679
+ msg: ImapMessage,
680
+ ): Promise<BatchOutcome> {
681
+ return this.saveMessage(mailboxId, accountId, accountConfigId, msg)
682
+ .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
683
+ .catch((error): BatchOutcome => {
684
+ this.log.warn(
685
+ {
686
+ mailboxId,
687
+ uid: msg.uid,
688
+ messageId: msg.envelope?.messageId,
689
+ error: error instanceof Error ? error.message : String(error),
690
+ },
691
+ "Failed to save message; will retry on next sync",
692
+ );
693
+ return { kind: "failed", uid: msg.uid };
694
+ });
695
+ }
696
+
697
+ private async saveMessage(
698
+ mailboxId: string,
699
+ accountId: string,
700
+ accountConfigId: string,
701
+ msg: ImapMessage,
702
+ ): Promise<SaveMessageResult | null> {
703
+ if (!msg.envelope) return null;
704
+
705
+ // Store envelope to preserve narrowing in closures
706
+ const envelope = msg.envelope;
707
+
708
+ const messageId = deriveMessageIdFromSource(accountId, {
709
+ messageId: envelope.messageId,
710
+ uid: msg.uid,
711
+ mailboxId,
712
+ date: envelope.date,
713
+ subject: envelope.subject,
714
+ fromMailbox: envelope.from?.[0]?.mailbox,
715
+ fromHost: envelope.from?.[0]?.host,
716
+ });
717
+ const envelopeId = deriveEnvelopeId(messageId);
718
+ const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
719
+
720
+ const internalDateMs = msg.internalDate.getTime();
721
+ const { value: sentDate, usedFallback: dateFellBack } = parseHeaderDate(
722
+ envelope.date,
723
+ internalDateMs,
724
+ );
725
+ if (dateFellBack) {
726
+ this.log.warn(
727
+ {
728
+ mailboxId,
729
+ messageId,
730
+ uid: msg.uid,
731
+ dateRaw: envelope.date,
732
+ },
733
+ "Unparseable Date header; fell back to IMAP internalDate",
734
+ );
735
+ }
736
+
737
+ // Prepare all address save operations
738
+ const addressOps: Array<{
739
+ addresses: ImapAddress[] | undefined;
740
+ role: (typeof AddressRole)[keyof typeof AddressRole];
741
+ }> = [
742
+ { addresses: envelope.from, role: AddressRole.From },
743
+ { addresses: envelope.sender, role: AddressRole.Sender },
744
+ { addresses: envelope.replyTo, role: AddressRole.ReplyTo },
745
+ { addresses: envelope.to, role: AddressRole.To },
746
+ { addresses: envelope.cc, role: AddressRole.Cc },
747
+ { addresses: envelope.bcc, role: AddressRole.Bcc },
748
+ ];
749
+
750
+ const bodyParts = buildBodyPartUpserts(msg.bodyStructure);
751
+ const hasAttachment = bodyParts.some(
752
+ (p) => !p.isMultipart && p.disposition === "attachment",
753
+ );
754
+
755
+ // Ownership of this messageId by the current mailbox: the row was created
756
+ // by this call, or an existing row already belongs to this mailbox. A
757
+ // conflict whose stored row points at a different mailbox is foreign-owned
758
+ // and must not feed this mailbox's watermark / body-sync (#634).
759
+ let owned = false;
760
+
761
+ // One unit of work for the whole message: on Postgres these repos are
762
+ // transaction-bound, so the Envelope, addresses, Message, BodyParts and
763
+ // ThreadMessage — and the transactional-outbox rows the Message write
764
+ // appends — all commit together. A throw anywhere rolls the whole set
765
+ // back, so a failed save never strands a Message without its Envelope
766
+ // (#1072). Writes run in sequence: a single transaction serialises on one
767
+ // connection, and it lets the Envelope land before the Message, with the
768
+ // ThreadMessage written last so the list path never anchors on a
769
+ // ThreadMessage whose Message does not yet exist (#1209).
770
+ await this.unitOfWork.transaction(async (repos) => {
771
+ await repos.envelope.upsertEnvelope({
772
+ envelopeId,
773
+ messageId,
774
+ dateValue: sentDate,
775
+ dateRaw: envelope.date,
776
+ subject: envelope.subject,
777
+ messageIdValue: envelope.messageId,
778
+ });
779
+
780
+ for (const { addresses, role } of addressOps) {
781
+ await this.saveAddresses(
782
+ repos.address,
783
+ messageId,
784
+ accountConfigId,
785
+ addresses,
786
+ role,
787
+ );
788
+ }
789
+
790
+ // IMAP returns BODYSTRUCTURE in the same FETCH that returns the
791
+ // envelope, so persisting BodyParts here is "free" — no extra round-trip.
792
+ if (bodyParts.length > 0) {
793
+ await repos.envelope.upsertBodyParts(messageId, bodyParts);
794
+ }
795
+
796
+ const { item, created } = await repos.message.upsertWithStatus({
797
+ messageId,
798
+ mailboxId,
799
+ uid: msg.uid,
800
+ sequenceNumber: msg.seq,
801
+ rfc822Size: msg.size ?? 0,
802
+ internalDate: msg.internalDate.getTime(),
803
+ envelopeId,
804
+ rootBodyPartId,
805
+ });
806
+ owned = created || item.mailboxId === mailboxId;
807
+
808
+ await this.createThreadForMessage(
809
+ repos.threadMessage,
810
+ messageId,
811
+ mailboxId,
812
+ accountId,
813
+ accountConfigId,
814
+ msg.uid,
815
+ msg.internalDate.getTime(),
816
+ sentDate,
817
+ envelope,
818
+ msg.flags,
819
+ msg.references,
820
+ hasAttachment,
821
+ );
822
+ });
823
+
824
+ return { messageId, uid: msg.uid, owned };
825
+ }
826
+
827
+ private async saveAddresses(
828
+ addressService: IAddressRepository,
829
+ messageId: string,
830
+ accountConfigId: string,
831
+ addresses: ImapAddress[] | undefined,
832
+ role: (typeof AddressRole)[keyof typeof AddressRole],
833
+ ) {
834
+ if (!addresses) return;
835
+
836
+ // Pre-compute address data with order indices, filtering valid addresses
837
+ const addressData: Array<{
838
+ localPart: string;
839
+ domain: string;
840
+ displayName: string;
841
+ order: number;
842
+ }> = [];
843
+
844
+ for (let i = 0; i < addresses.length; i++) {
845
+ const addr = addresses[i];
846
+ if (!isParseableEmailAddress(addr)) continue;
847
+ addressData.push({
848
+ localPart: addr.mailbox,
849
+ domain: addr.host,
850
+ displayName: addr.name || "",
851
+ order: i,
852
+ });
853
+ }
854
+
855
+ for (const { localPart, domain, displayName, order } of addressData) {
856
+ const normalizedEmail = `${localPart}@${domain}`.toLowerCase();
857
+ const normalizedCompound = `${displayName.toLowerCase()} ${normalizedEmail}`;
858
+
859
+ const addressId = deriveAddressId(accountConfigId, normalizedEmail);
860
+
861
+ const envelopeAddressId = deriveEnvelopeAddressId(messageId, role, order);
862
+
863
+ await addressService.upsertAddress({
864
+ addressId,
865
+ accountConfigId,
866
+ localPart,
867
+ domain,
868
+ normalizedEmail,
869
+ normalizedCompound,
870
+ displayName,
871
+ });
872
+
873
+ await addressService.upsertEnvelopeAddress({
874
+ envelopeAddressId,
875
+ messageId,
876
+ addressId,
877
+ displayName,
878
+ normalizedEmail,
879
+ addressRole: role,
880
+ addressOrder: order,
881
+ });
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Create or update Thread and ThreadMessage for a synced message.
887
+ *
888
+ * Thread ID derivation (RFC 2822 compliant):
889
+ * 1. If References header exists, use the FIRST entry as thread root
890
+ * (References format: <root> <parent1> ... <direct-parent>)
891
+ * 2. Fall back to In-Reply-To if no References
892
+ * 3. Fall back to Message-ID (this message is a thread root)
893
+ *
894
+ * This ensures proper threading even when messages arrive out of order.
895
+ */
896
+ private async createThreadForMessage(
897
+ threadMessageService: IThreadMessageRepository,
898
+ messageId: string,
899
+ mailboxId: string,
900
+ accountId: string,
901
+ accountConfigId: string,
902
+ uid: number,
903
+ internalDate: number,
904
+ sentDate: number,
905
+ envelope: ImapEnvelope,
906
+ flags: string[],
907
+ references?: string[],
908
+ hasAttachment = false,
909
+ ): Promise<void> {
910
+ // Determine the thread root Message-ID
911
+ let rootMessageIdHeader: string;
912
+
913
+ if (references && references.length > 0) {
914
+ // References header exists - first entry is the thread root (RFC 2822)
915
+ rootMessageIdHeader = references[0];
916
+ } else if (envelope.inReplyTo) {
917
+ // No References, but has In-Reply-To - use as thread root
918
+ // (This is a reply to a single message, which becomes the root)
919
+ rootMessageIdHeader = envelope.inReplyTo;
920
+ } else if (isValidMessageId(envelope.messageId)) {
921
+ // No References, no In-Reply-To - this message is a thread root
922
+ rootMessageIdHeader = envelope.messageId;
923
+ } else {
924
+ // No usable header (missing, empty, or a "<>" delivery-failure
925
+ // placeholder). Fall back to the always-present internal messageId so
926
+ // this message becomes a standalone thread-of-one. Distinct headerless
927
+ // messages keep distinct ids, so they never collide into one bogus
928
+ // thread, and every persisted Message gets exactly one ThreadMessage.
929
+ rootMessageIdHeader = messageId;
930
+ }
931
+
932
+ // Derive threadId from the root Message-ID (deterministic)
933
+ const threadId = deriveThreadId(accountId, rootMessageIdHeader);
934
+
935
+ // Check if message is read based on IMAP flags
936
+ const isRead = flags.includes("\\Seen");
937
+
938
+ // Extract sender info. When the server could not parse the From address,
939
+ // omit fromEmail rather than persist a fabricated string — a display name
940
+ // may still be present and useful, so keep it.
941
+ const fromAddr = envelope.from?.[0];
942
+ const fromEmail = isParseableEmailAddress(fromAddr)
943
+ ? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
944
+ : undefined;
945
+ const fromName = fromAddr?.name;
946
+
947
+ // Calculate reference order (position in the thread chain)
948
+ // references.length gives the position since References = [root, parent1, parent2, ...]
949
+ const referenceOrder = references?.length ?? (envelope.inReplyTo ? 1 : 0);
950
+
951
+ // Create ThreadMessage linking message to thread
952
+ await threadMessageService
953
+ .create({
954
+ threadId,
955
+ messageId,
956
+ accountConfigId,
957
+ mailboxId,
958
+ uid,
959
+ messageIdHeader: envelope.messageId,
960
+ inReplyTo: envelope.inReplyTo,
961
+ referenceOrder,
962
+ fromEmail,
963
+ fromName,
964
+ subject: envelope.subject,
965
+ internalDate,
966
+ sentDate,
967
+ isRead,
968
+ isDeleted: false,
969
+ hasAttachment,
970
+ hasStars: false,
971
+ })
972
+ .catch((error: unknown) => {
973
+ // Ignore conflict errors (idempotent create)
974
+ if (
975
+ (error as { name?: string })?.name === "CreateFailedConflictError"
976
+ ) {
977
+ return;
978
+ }
979
+ throw error;
980
+ });
981
+ }
982
+ }
983
+
984
+ /**
985
+ * Translate the IMAP BODYSTRUCTURE for a single message into a list of
986
+ * `BodyPartUpsertInput`s ready for `EnvelopeService.upsertBodyParts`.
987
+ * Returns an empty list when the server didn't return BODYSTRUCTURE
988
+ * (some unusual messages, or older test fixtures).
989
+ */
990
+ const buildBodyPartUpserts = (
991
+ bodyStructure: ImapBodyStructure | undefined,
992
+ ): BodyPartUpsertInput[] => {
993
+ if (!bodyStructure) return [];
994
+ return walkMimeStructure(bodyStructure).map((part) => ({
995
+ partPath: part.partPath,
996
+ parentPartPath: part.parentPartPath,
997
+ mediaType: part.mediaType,
998
+ mediaSubtype: part.mediaSubtype,
999
+ transferEncoding: part.transferEncoding,
1000
+ sizeOctets: part.sizeOctets,
1001
+ isMultipart: part.isMultipart,
1002
+ parameters: part.parameters,
1003
+ ...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
1004
+ ...(part.contentDescription !== undefined
1005
+ ? { contentDescription: part.contentDescription }
1006
+ : {}),
1007
+ ...(part.lineCount !== undefined ? { lineCount: part.lineCount } : {}),
1008
+ ...(part.md5Hash !== undefined ? { md5Hash: part.md5Hash } : {}),
1009
+ ...(part.disposition !== undefined
1010
+ ? { disposition: part.disposition }
1011
+ : {}),
1012
+ ...(part.dispositionFilename !== undefined
1013
+ ? { dispositionFilename: part.dispositionFilename }
1014
+ : {}),
1015
+ ...(part.language !== undefined ? { language: part.language } : {}),
1016
+ ...(part.location !== undefined ? { location: part.location } : {}),
1017
+ ...(part.multipartSubtype !== undefined
1018
+ ? { multipartSubtype: part.multipartSubtype }
1019
+ : {}),
1020
+ }));
1021
+ };