@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,1202 @@
1
+ import { PassThrough, type Readable } from "node:stream";
2
+ import { inspect } from "node:util";
3
+ import type {
4
+ IAddressRepository,
5
+ IEnvelopeRepository,
6
+ IMailboxSpecialUseRepository,
7
+ IMessageRepository,
8
+ IThreadMessageRepository,
9
+ UpdateMessageInput,
10
+ } from "@remit/data-ports";
11
+ import { NotFoundError } from "@remit/data-ports/errors";
12
+ import { deriveAddressId } from "@remit/data-ports/id";
13
+ import { isBulkSender } from "@remit/data-ports/wellknown";
14
+ import {
15
+ MailboxSpecialUse,
16
+ PlacementAction,
17
+ PlacementConfidence,
18
+ SenderTrust,
19
+ } from "@remit/domain-enums";
20
+ import {
21
+ isStorageNotFoundError,
22
+ type ParsedBody,
23
+ type StorageService,
24
+ } from "@remit/storage-service";
25
+ import { type ParsedMail, simpleParser } from "mailparser";
26
+ import pMap from "p-map";
27
+ import { mapBodyPartsToContent } from "./body-part-mapper.js";
28
+ import type { FilterMessage } from "./filters/match.js";
29
+ import {
30
+ type FilterConfig,
31
+ type FilterDecision,
32
+ FilterPipeline,
33
+ } from "./filters/pipeline.js";
34
+ import {
35
+ classifyByHeaders,
36
+ extractAuthenticity,
37
+ extractAuthResult,
38
+ extractHasListUnsubscribe,
39
+ extractProviderSpam,
40
+ } from "./heuristics/classifyByHeaders.js";
41
+ import {
42
+ classifyPlacement,
43
+ type FolderPlacement,
44
+ } from "./heuristics/classifyPlacement.js";
45
+ import type { PlacementMoveService } from "./placement-move.js";
46
+ import { extractSnippetFromEmail } from "./snippet.js";
47
+ import { type IImapConnection, MailConnectionError } from "./types.js";
48
+
49
+ const BODY_PART_STORE_CONCURRENCY = 4;
50
+
51
+ type MessagePlacementVerdict = NonNullable<
52
+ UpdateMessageInput["placementVerdict"]
53
+ >;
54
+
55
+ /**
56
+ * Outcome of {@link BodySyncService.resolvePlacement}: the audit `verdict` to
57
+ * persist on the Message (present whenever Remit decided to act, i.e. action
58
+ * != leave), and the IMAP `move` to enqueue (present only for a confident,
59
+ * actionable verdict). An unsure verdict carries a `verdict` but no `move`.
60
+ */
61
+ interface PlacementOutcome {
62
+ verdict?: MessagePlacementVerdict;
63
+ move?: { destinationMailboxId: string; destinationPath: string };
64
+ }
65
+
66
+ /**
67
+ * Whether the body-sync hot path defers per-part S3 objects. Each MIME leaf is
68
+ * its own PutObject, so during bulk sync these per-part writes dominate S3 write
69
+ * count and cost. With deferral ON a synced message writes only `body.eml` +
70
+ * `parsed.json.gz` (2 writes); the per-part objects are materialized lazily on
71
+ * the first `contentUrl` read via {@link BodySyncService.ensureBodyPartsStored}.
72
+ *
73
+ * Default ON (cost savings). Set `DEFER_BODY_PARTS=false`/`0`/`off` to restore
74
+ * the original eager per-part writes — a safe, reversible escape hatch.
75
+ */
76
+ export const isBodyPartDeferralEnabled = (): boolean => {
77
+ const raw = process.env.DEFER_BODY_PARTS;
78
+ if (raw === undefined) return true;
79
+ const normalized = raw.trim().toLowerCase();
80
+ return normalized !== "false" && normalized !== "0" && normalized !== "off";
81
+ };
82
+
83
+ /**
84
+ * Sentinel object written under a message's parts prefix once lazy
85
+ * materialization has stored EVERY leaf. The read path checks it first with a
86
+ * single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
87
+ * and the per-leaf HEADs on every warm re-open.
88
+ *
89
+ * Deliberately an S3-only marker (no Message-row flag): a DDB write would
90
+ * re-fire the redundant vector upsert that #607 removed. A leading dot keeps it
91
+ * out of any real IMAP section path (those are dot-separated digits).
92
+ */
93
+ const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
94
+
95
+ /**
96
+ * A mid-stream socket drop during the ranged body fetch surfaces as a typed
97
+ * `MailConnectionError` (the connection layer classifies imapflow's
98
+ * `EConnectionClosed`/`NoConnection`). Detect by type/code, never by message
99
+ * text — the underlying library strings vary.
100
+ */
101
+ const isConnectionDrop = (error: unknown): boolean => {
102
+ if (error instanceof MailConnectionError) {
103
+ return error.kind === "network";
104
+ }
105
+ const code = (error as { code?: string }).code;
106
+ return code === "EConnectionClosed" || code === "NoConnection";
107
+ };
108
+
109
+ export const extractPrimaryFromEmail = (parsed: ParsedMail): string | null => {
110
+ const from = parsed.from;
111
+ if (!from || !from.value || from.value.length === 0) return null;
112
+ const address = from.value[0]?.address;
113
+ if (!address) return null;
114
+ return address.toLowerCase();
115
+ };
116
+
117
+ /**
118
+ * Project a parsed message onto the fields a filter matches against (RFC 034) —
119
+ * the literal-clause targets plus the text a semantic anchor embeds. Kept
120
+ * separate from `ParsedMail` so {@link FilterPipeline} stays parser-agnostic.
121
+ */
122
+ const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
123
+ from: extractPrimaryFromEmail(parsed) ?? "",
124
+ fromName: parsed.from?.value?.[0]?.name ?? "",
125
+ subject: parsed.subject ?? "",
126
+ text: parsed.text ?? "",
127
+ });
128
+
129
+ export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
130
+ text: parsed.text ?? null,
131
+ html: typeof parsed.html === "string" ? parsed.html : null,
132
+ attachments: (parsed.attachments ?? []).map((a) => ({
133
+ filename: a.filename ?? null,
134
+ contentType: a.contentType,
135
+ contentDisposition: a.contentDisposition ?? null,
136
+ contentId: a.contentId ?? null,
137
+ size: a.size,
138
+ })),
139
+ });
140
+
141
+ export interface BodySyncLogger {
142
+ info(obj: Record<string, unknown>, msg: string): void;
143
+ debug?(obj: Record<string, unknown>, msg: string): void;
144
+ warn?(obj: Record<string, unknown>, msg: string): void;
145
+ error?(obj: Record<string, unknown>, msg: string): void;
146
+ }
147
+
148
+ const noopLogger: BodySyncLogger = {
149
+ info: () => {},
150
+ };
151
+
152
+ export interface SyncBodiesResult {
153
+ syncedCount: number;
154
+ syncedMessageIds: string[];
155
+ skippedCount: number;
156
+ failedCount: number;
157
+ failedMessageIds: string[];
158
+ }
159
+
160
+ export interface FetchBodyResult {
161
+ text: string | null;
162
+ html: string | null;
163
+ storedAt: string;
164
+ }
165
+
166
+ export interface StoreBodyPartContentsResult {
167
+ stored: number;
168
+ }
169
+
170
+ export type ConnectionGetter = () => Promise<IImapConnection>;
171
+
172
+ export interface PlacementConfig {
173
+ mailboxSpecialUseService: IMailboxSpecialUseRepository;
174
+ /**
175
+ * Local-first mover for a confident placement verdict (issue #1271). Same
176
+ * `moveMessage(accountConfigId, messageId, destinationMailboxId, accountId)`
177
+ * signature as the general-purpose `MessageMoveService` it replaced here,
178
+ * but backed by a pending-move marker + its own reconciler queue instead of
179
+ * the general MESSAGE_MOVE event (see `placement-move.ts`).
180
+ */
181
+ placementMoveService: PlacementMoveService;
182
+ }
183
+
184
+ export class BodySyncService {
185
+ private log: BodySyncLogger;
186
+ private readonly filterPipeline?: FilterPipeline;
187
+
188
+ constructor(
189
+ private messageService: IMessageRepository,
190
+ private storageService: StorageService,
191
+ private threadMessageService: IThreadMessageRepository,
192
+ private addressService: IAddressRepository,
193
+ private envelopeService: IEnvelopeRepository,
194
+ logger?: BodySyncLogger,
195
+ private readonly placementConfig?: PlacementConfig,
196
+ private readonly filterConfig?: FilterConfig,
197
+ ) {
198
+ this.log = logger ?? noopLogger;
199
+ this.filterPipeline = filterConfig
200
+ ? new FilterPipeline(filterConfig, this.log)
201
+ : undefined;
202
+ }
203
+
204
+ /**
205
+ * Sync message bodies for a batch of messages.
206
+ *
207
+ * Fail-fast on connection errors: if the IMAP connection is lost,
208
+ * immediately stop processing and return all remaining messages as failed
209
+ * so they can be requeued for later retry.
210
+ *
211
+ * @param messageIds - The message IDs to sync bodies for
212
+ * @param accountId - The account ID (for storage path)
213
+ * @param accountConfigId - The account config ID (for thread updates)
214
+ * @param mailboxPath - The IMAP mailbox path
215
+ * @param getConnection - Function to get a (lazy) IMAP connection
216
+ * @param force - Bypass the "already stored" skip guard and re-fetch every
217
+ * message in this batch even though `bodyStorageKey` is already set. Set
218
+ * only by the read-miss re-arm cue (a `/content` read found the storage
219
+ * object missing despite the DB row saying otherwise); bulk metadata-sync
220
+ * never sets this, so it keeps skipping on `bodyStorageKey` with no
221
+ * existence check.
222
+ */
223
+ async syncBodies(
224
+ messageIds: string[],
225
+ accountId: string,
226
+ accountConfigId: string,
227
+ mailboxPath: string,
228
+ getConnection: ConnectionGetter,
229
+ force = false,
230
+ ): Promise<SyncBodiesResult> {
231
+ const syncedMessageIds: string[] = [];
232
+ let skippedCount = 0;
233
+
234
+ // Resolve every message up front so we can issue ONE ranged FETCH for the
235
+ // whole batch (the desktop-client pattern) instead of a SELECT + download
236
+ // per message. Messages whose body is already stored are skipped here and
237
+ // never hit the wire — UNLESS `force` is set, in which case every message
238
+ // is re-fetched regardless of `bodyStorageKey` (the read-miss cue already
239
+ // confirmed the stored object is gone). `pending` maps each UID to its
240
+ // messageId so we can match FETCH rows back and re-enqueue any UID the
241
+ // server never returns.
242
+ const pending = new Map<number, string>();
243
+ for (const messageId of messageIds) {
244
+ const message = await this.messageService.get(messageId);
245
+ if (message.bodyStorageKey && !force) {
246
+ this.log.debug?.({ messageId }, "Body already stored, skipping");
247
+ skippedCount++;
248
+ continue;
249
+ }
250
+ pending.set(message.uid, messageId);
251
+ }
252
+
253
+ if (pending.size === 0) {
254
+ return this.buildResult(syncedMessageIds, skippedCount, []);
255
+ }
256
+
257
+ const connection = await getConnection();
258
+ // Single SELECT for the whole batch. openBox is idempotent, so a warm
259
+ // connection already on this mailbox skips the SELECT entirely.
260
+ await connection.openBox(mailboxPath);
261
+
262
+ let connectionLost = false;
263
+ try {
264
+ for await (const { uid, source } of connection.fetchMessageBodies([
265
+ ...pending.keys(),
266
+ ])) {
267
+ const messageId = pending.get(uid);
268
+ if (!messageId) {
269
+ // A UID we didn't ask for — drain the stream so the connection
270
+ // stays usable, then ignore it.
271
+ source.resume();
272
+ continue;
273
+ }
274
+ try {
275
+ await this.storeStreamedBody(
276
+ messageId,
277
+ accountId,
278
+ accountConfigId,
279
+ source,
280
+ );
281
+ } catch (error) {
282
+ // A per-message store failure (e.g. the parsed-body S3 write) is a
283
+ // real fault, but it must not silently mark the message synced —
284
+ // that would leave parsedBody null and the message unindexed. Leave
285
+ // the UID in `pending` so it lands in failedMessageIds and the SQS
286
+ // batch requeues just this message. A dropped connection is handled
287
+ // by the outer catch (fail-fast on the whole remaining batch).
288
+ if (isConnectionDrop(error)) throw error;
289
+ this.log.error?.(
290
+ {
291
+ messageId,
292
+ errorName: (error as { name?: string }).name,
293
+ error: (error as Error).message,
294
+ },
295
+ "Body store failed for message; leaving for requeue",
296
+ );
297
+ // Drain the stream so the connection stays usable for the next UID.
298
+ source.resume();
299
+ continue;
300
+ }
301
+ pending.delete(uid);
302
+ syncedMessageIds.push(messageId);
303
+ }
304
+ } catch (error) {
305
+ this.log.error?.(
306
+ { error: (error as Error).message },
307
+ "Body fetch stream failed",
308
+ );
309
+ // Fail-fast: a dropped connection mid-stream leaves every not-yet-yielded
310
+ // UID in `pending`; they fall through to failedMessageIds and re-enqueue.
311
+ // Any other error is a real fault — let it crash.
312
+ if (!isConnectionDrop(error)) {
313
+ throw error;
314
+ }
315
+ connectionLost = true;
316
+ this.log.info?.(
317
+ { remainingCount: pending.size },
318
+ "Connection lost, aborting batch",
319
+ );
320
+ }
321
+
322
+ // Anything still pending was never yielded (mid-stream drop or a UID the
323
+ // server silently omitted) — re-enqueue it.
324
+ const failedMessageIds = [...pending.values()];
325
+
326
+ this.log.info(
327
+ {
328
+ synced: syncedMessageIds.length,
329
+ skipped: skippedCount,
330
+ failed: failedMessageIds.length,
331
+ total: messageIds.length,
332
+ aborted: connectionLost,
333
+ },
334
+ "Body sync complete",
335
+ );
336
+
337
+ return this.buildResult(syncedMessageIds, skippedCount, failedMessageIds);
338
+ }
339
+
340
+ private buildResult(
341
+ syncedMessageIds: string[],
342
+ skippedCount: number,
343
+ failedMessageIds: string[],
344
+ ): SyncBodiesResult {
345
+ return {
346
+ syncedCount: syncedMessageIds.length,
347
+ syncedMessageIds,
348
+ skippedCount,
349
+ failedCount: failedMessageIds.length,
350
+ failedMessageIds,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Stream one message body straight to storage while teeing the bytes into a
356
+ * buffer for the parse-dependent steps (snippet, classification, parsed-body
357
+ * cache, per-part objects). The S3 upload never sees a whole-body concat —
358
+ * the storage service streams it — but mailparser still needs the full bytes,
359
+ * so we collect them in parallel. Later issues move parsing off the hot path.
360
+ */
361
+ private async storeStreamedBody(
362
+ messageId: string,
363
+ accountId: string,
364
+ accountConfigId: string,
365
+ source: Readable,
366
+ ): Promise<void> {
367
+ const toStorage = new PassThrough();
368
+ const chunks: Buffer[] = [];
369
+
370
+ // Tee the source: bytes flow to storage as a stream (no whole-body concat
371
+ // on the upload path) while we also collect them for the parse-dependent
372
+ // steps below, which still need the full body for mailparser. The store
373
+ // and the tee are awaited together so neither rejection is orphaned.
374
+ const tee = new Promise<void>((resolve, reject) => {
375
+ source.on("data", (chunk: Buffer) => {
376
+ chunks.push(chunk);
377
+ toStorage.write(chunk);
378
+ });
379
+ source.on("end", () => {
380
+ toStorage.end();
381
+ resolve();
382
+ });
383
+ source.on("error", (err) => {
384
+ toStorage.destroy(err);
385
+ reject(err);
386
+ });
387
+ });
388
+
389
+ const [ref] = await Promise.all([
390
+ this.storageService.storeMessageBodyStream({
391
+ accountConfigId,
392
+ accountId,
393
+ messageId,
394
+ content: toStorage,
395
+ }),
396
+ tee,
397
+ ]);
398
+
399
+ const body = Buffer.concat(chunks);
400
+
401
+ await this.applyPostStoreSteps(
402
+ messageId,
403
+ accountId,
404
+ accountConfigId,
405
+ body,
406
+ {
407
+ uri: ref.uri,
408
+ },
409
+ );
410
+ }
411
+
412
+ /**
413
+ * The steps every synced body must go through regardless of which path
414
+ * fetched it first — the sync path ({@link storeStreamedBody}) and the
415
+ * read-path backfill ({@link fetchAndGetBody}) both call this instead of
416
+ * duplicating (and drifting on) their own subset. Before issue #1271,
417
+ * `fetchAndGetBody` skipped classification/placement entirely — a message
418
+ * materialized by a read got different treatment than one synced in bulk.
419
+ *
420
+ * Order matters for two independent reasons, both load-bearing:
421
+ * 1. The parsed-body cache must be durable before `bodyStorageKey` (the
422
+ * skip-guard signal) — see the comment on the write below.
423
+ * 2. A placement move — local mailboxId change, pending marker, IMAP-push
424
+ * enqueue (`PlacementMoveService.moveMessage`, issue #1271) — must
425
+ * complete, or fully fail, BEFORE `bodyStorageKey` is written. That
426
+ * failure is never swallowed: if it throws, `bodyStorageKey` is not yet
427
+ * durable, so a retry reprocesses the message from scratch (marker
428
+ * included) instead of the skip-guard stranding a `movedByRemit` flag
429
+ * with no marker behind it — the defect this issue fixes.
430
+ *
431
+ * Returns the parsed mail so callers that already need it (both do) don't
432
+ * pay for mailparser twice.
433
+ */
434
+ private async applyPostStoreSteps(
435
+ messageId: string,
436
+ accountId: string,
437
+ accountConfigId: string,
438
+ body: Buffer,
439
+ bodyRef: { uri: string },
440
+ ): Promise<ParsedMail> {
441
+ // Snippet + thread update; reuses the parsed mail for the steps below.
442
+ // This is a ThreadMessage write — a different entity — so it does NOT
443
+ // trigger the Message-filtered stream bridge and stays separate.
444
+ const parsed = await this.updateSnippets(messageId, accountConfigId, body);
445
+
446
+ // Compute the header classification once. The derived fields are folded
447
+ // into the single Message update below — they are NOT written here.
448
+ const classification = this.classifyMessage(parsed);
449
+
450
+ // Decide the placement move from the in-memory classification before the
451
+ // write, so its `movedByRemit` flag and audit verdict join the same
452
+ // UpdateItem. The verdict is recorded whenever Remit decided to act
453
+ // (action != leave); the move is enqueued only for a confident,
454
+ // actionable verdict.
455
+ const resolved = await this.resolvePlacement(
456
+ messageId,
457
+ accountId,
458
+ accountConfigId,
459
+ parsed,
460
+ classification,
461
+ );
462
+
463
+ // Index-time filter pass (RFC 034). Isolated the same way placement is —
464
+ // FilterPipeline.evaluate swallows any read/decision failure and returns
465
+ // no actions, so a schema-drifted filter query never fails the body store.
466
+ // The DECISION is computed here; the applies below are unswallowed.
467
+ const filterDecision = await this.evaluateFilters(
468
+ accountConfigId,
469
+ messageId,
470
+ parsed,
471
+ );
472
+
473
+ // Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
474
+ // guard in syncBodies treats a stored bodyStorageKey as "fully synced",
475
+ // so if we wrote it first and the parsed-cache write then failed, the
476
+ // requeued retry would skip the message — leaving parsedBody null and the
477
+ // search-index upsert bodyless forever. A parsed-cache failure here throws
478
+ // and propagates before bodyStorageKey is written, so the requeued message
479
+ // genuinely re-attempts the parsed write and gets indexed.
480
+ await this.storeParsedBodyCache(
481
+ accountConfigId,
482
+ accountId,
483
+ messageId,
484
+ parsed,
485
+ );
486
+
487
+ // Per-part S3 objects dominate S3 writes during bulk sync. When deferral
488
+ // is enabled we skip them here; they are materialized lazily on the first
489
+ // `contentUrl` read (see ensureBodyPartsStored), keeping bulk sync at 2
490
+ // writes/message (body.eml + parsed.json.gz).
491
+ if (!isBodyPartDeferralEnabled()) {
492
+ await this.storeBodyPartContents(
493
+ accountConfigId,
494
+ accountId,
495
+ messageId,
496
+ parsed,
497
+ );
498
+ }
499
+
500
+ // Local-first move (issue #1271): local mailboxId change + pending marker +
501
+ // IMAP-push enqueue via PlacementMoveService, UNSWALLOWED — a throw here
502
+ // propagates before bodyStorageKey is written (see the method doc above),
503
+ // so a genuine SQS/DDB write failure requeues the message rather than being
504
+ // absorbed. A matched filter's move is exclusive and outranks the
505
+ // classifier's placement move (RFC 034 Decision 3.1) — an explicit user
506
+ // rule wins the single mailbox a message occupies — so at most one move is
507
+ // enqueued. A non-confident/leave verdict with no filter move is a no-op.
508
+ if (filterDecision.move) {
509
+ await this.filterConfig?.placementMoveService.moveMessage(
510
+ accountConfigId,
511
+ messageId,
512
+ filterDecision.move.destinationMailboxId,
513
+ accountId,
514
+ );
515
+ } else if (resolved.move) {
516
+ await this.placementConfig?.placementMoveService.moveMessage(
517
+ accountConfigId,
518
+ messageId,
519
+ resolved.move.destinationMailboxId,
520
+ accountId,
521
+ );
522
+ }
523
+
524
+ // Additive label actions (RFC 034 Decision 3.1): every matching filter's
525
+ // label applies. The deterministic MessageLabel upsert is idempotent, so a
526
+ // requeue after a later failure re-applies safely; UNSWALLOWED, so a DDB
527
+ // write failure propagates before bodyStorageKey is written.
528
+ for (const label of filterDecision.labels) {
529
+ await this.filterConfig?.messageLabelService.apply({
530
+ messageId,
531
+ labelId: label.labelId,
532
+ accountConfigId,
533
+ appliedByFilterId: label.filterId,
534
+ });
535
+ }
536
+
537
+ const moved = Boolean(resolved.move || filterDecision.move);
538
+
539
+ // ONE Message UpdateItem per synced message: bodyStorageKey + every
540
+ // classification/derived field + the move flag + the audit verdict. Each
541
+ // extra Message mutation emits a DDB stream record that fans out to a
542
+ // redundant S3-Vectors upsert, so we collapse them into a single write.
543
+ // The flag is folded in only when a move WAS applied (by the classifier or
544
+ // a filter); the verdict is folded in whenever Remit decided to act.
545
+ // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
546
+ // once the parsed cache AND the move (when any) are.
547
+ const update: UpdateMessageInput = {
548
+ bodyStorageKey: bodyRef.uri,
549
+ ...classification,
550
+ ...(moved ? { movedByRemit: true } : {}),
551
+ ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
552
+ };
553
+ await this.messageService.update(messageId, update);
554
+ this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
555
+
556
+ // From-Address engagement counter (Address entity, not Message).
557
+ await this.incrementInboundCount(
558
+ messageId,
559
+ accountConfigId,
560
+ parsed,
561
+ classification,
562
+ );
563
+
564
+ return parsed;
565
+ }
566
+
567
+ /**
568
+ * Fetch a single message body, store it, and return the parsed content.
569
+ *
570
+ * If the body is already stored, retrieves it from storage.
571
+ * Otherwise fetches from IMAP, stores it, and returns the parsed content.
572
+ *
573
+ * @param messageId - The message ID to fetch
574
+ * @param accountId - The account ID (for storage path)
575
+ * @param accountConfigId - The account config ID (for thread updates)
576
+ * @param mailboxPath - The IMAP mailbox path
577
+ * @param getConnection - Function to get a (lazy) IMAP connection
578
+ * @returns Parsed text and HTML content
579
+ */
580
+ async fetchAndGetBody(
581
+ messageId: string,
582
+ accountId: string,
583
+ accountConfigId: string,
584
+ mailboxPath: string,
585
+ getConnection: ConnectionGetter,
586
+ ): Promise<FetchBodyResult> {
587
+ const message = await this.messageService.get(messageId);
588
+
589
+ let body: Buffer;
590
+ let needsStore = false;
591
+
592
+ if (message.bodyStorageKey) {
593
+ // Body already stored, try to retrieve from storage
594
+ this.log.debug?.({ messageId }, "Retrieving body from storage");
595
+ try {
596
+ body = await this.storageService.retrieve(message.bodyStorageKey);
597
+ } catch (err) {
598
+ // Only a genuinely-missing object (NoSuchKey) is a safe IMAP
599
+ // fallback — that's the cross-environment / never-stored case.
600
+ // A permission/infra error (AccessDenied, throttle) must NOT be
601
+ // masked as a missing object: let it crash so it's observable.
602
+ if (!isStorageNotFoundError(err)) {
603
+ this.log.error?.(
604
+ {
605
+ messageId,
606
+ storageKey: message.bodyStorageKey,
607
+ errorName: (err as { name?: string }).name,
608
+ errorCode: (err as { Code?: string }).Code,
609
+ error: inspect(err),
610
+ },
611
+ "Body storage retrieval failed (non-NoSuchKey); not falling back to IMAP",
612
+ );
613
+ throw err;
614
+ }
615
+ this.log.debug?.(
616
+ { messageId, error: (err as Error).message },
617
+ "Body object missing (NoSuchKey), falling back to IMAP",
618
+ );
619
+ needsStore = true;
620
+ body = await this.fetchFromImap(
621
+ message.uid,
622
+ mailboxPath,
623
+ getConnection,
624
+ );
625
+ }
626
+ } else {
627
+ // Fetch from IMAP and store
628
+ needsStore = true;
629
+ body = await this.fetchFromImap(message.uid, mailboxPath, getConnection);
630
+ }
631
+
632
+ let parsed: ParsedMail;
633
+
634
+ if (needsStore) {
635
+ const ref = await this.storageService.storeMessageBody({
636
+ accountConfigId,
637
+ accountId,
638
+ messageId,
639
+ content: body,
640
+ });
641
+
642
+ // Same shared step the sync path (storeStreamedBody) runs — issue
643
+ // #1271 unified the two body paths so classification/placement no
644
+ // longer depends on which one fetched the body first.
645
+ parsed = await this.applyPostStoreSteps(
646
+ messageId,
647
+ accountId,
648
+ accountConfigId,
649
+ body,
650
+ { uri: ref.uri },
651
+ );
652
+ } else {
653
+ parsed = await simpleParser(body);
654
+ }
655
+
656
+ return {
657
+ text: parsed.text ?? null,
658
+ html: typeof parsed.html === "string" ? parsed.html : null,
659
+ storedAt: message.bodyStorageKey ?? "newly-stored",
660
+ };
661
+ }
662
+
663
+ private async fetchFromImap(
664
+ uid: number,
665
+ mailboxPath: string,
666
+ getConnection: ConnectionGetter,
667
+ ): Promise<Buffer> {
668
+ this.log.debug?.({ uid }, "Fetching body from IMAP");
669
+ const connection = await getConnection();
670
+ await connection.openBox(mailboxPath);
671
+ return connection.fetchMessageBody(uid);
672
+ }
673
+
674
+ /**
675
+ * Pure header classification. Returns the subset of the Message update that
676
+ * carries the derived fields; the caller folds it into a single UpdateItem
677
+ * alongside `bodyStorageKey`. Optional signals are omitted when absent so we
678
+ * never overwrite an existing value with `undefined`.
679
+ */
680
+ private classifyMessage(parsed: ParsedMail): UpdateMessageInput {
681
+ const category = classifyByHeaders(parsed);
682
+ const authenticity = extractAuthenticity(parsed);
683
+ const authResult = extractAuthResult(parsed);
684
+ const providerSpam = extractProviderSpam(parsed);
685
+ const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
686
+ return {
687
+ category,
688
+ hasListUnsubscribe,
689
+ ...(authenticity !== null ? { authenticity } : {}),
690
+ ...(authResult !== null ? { authResult } : {}),
691
+ ...(providerSpam !== null ? { providerSpam } : {}),
692
+ };
693
+ }
694
+
695
+ private async incrementInboundCount(
696
+ messageId: string,
697
+ accountConfigId: string,
698
+ parsed: ParsedMail,
699
+ classification: UpdateMessageInput,
700
+ ): Promise<void> {
701
+ const fromEmail = extractPrimaryFromEmail(parsed);
702
+ if (!fromEmail) {
703
+ this.log.debug?.(
704
+ { messageId },
705
+ "No From address; skipping inbound counter",
706
+ );
707
+ return;
708
+ }
709
+
710
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
711
+ const bulk = isBulkSender(
712
+ classification.category,
713
+ classification.hasListUnsubscribe ?? false,
714
+ );
715
+ await this.addressService.incrementInboundCount(
716
+ accountConfigId,
717
+ addressId,
718
+ Date.now(),
719
+ bulk,
720
+ );
721
+ }
722
+
723
+ private async deriveSenderTrust(
724
+ accountConfigId: string,
725
+ fromEmail: string,
726
+ ): Promise<(typeof SenderTrust)[keyof typeof SenderTrust]> {
727
+ try {
728
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
729
+ const address = await this.addressService.getAddress(
730
+ accountConfigId,
731
+ addressId,
732
+ );
733
+ if (address.flags?.vip?.value === true) return SenderTrust.Vip;
734
+ if (address.flags?.wellknown?.value === true)
735
+ return SenderTrust.Wellknown;
736
+ } catch (err) {
737
+ // A genuinely-absent address means "unknown trust". Any other failure
738
+ // (AccessDenied, throttle, infra) must NOT be silently downgraded to
739
+ // Unknown — let it crash so the rescue decision isn't made on bad data.
740
+ if (!(err instanceof NotFoundError)) throw err;
741
+ }
742
+ return SenderTrust.Unknown;
743
+ }
744
+
745
+ /**
746
+ * Evaluate the account's active filters against a synced message (RFC 034),
747
+ * BEFORE the single Message update — so a filter's `movedByRemit` flag joins
748
+ * that same UpdateItem. Returns the actions to apply (labels + at most one
749
+ * move); the caller applies them. A no-op returning no actions when no
750
+ * {@link FilterConfig} is wired.
751
+ *
752
+ * Isolation lives in {@link FilterPipeline.evaluate}: this read/decision phase
753
+ * never fails the body store (the #1246 placement precedent). The applies of
754
+ * the returned decision are the caller's and are deliberately unswallowed.
755
+ */
756
+ private async evaluateFilters(
757
+ accountConfigId: string,
758
+ messageId: string,
759
+ parsed: ParsedMail,
760
+ ): Promise<FilterDecision> {
761
+ if (!this.filterPipeline) return { labels: [] };
762
+ return this.filterPipeline.evaluate(
763
+ accountConfigId,
764
+ messageId,
765
+ toFilterMessage(parsed),
766
+ );
767
+ }
768
+
769
+ /**
770
+ * Resolve the placement decision for a message, BEFORE the single Message
771
+ * update — so both the `movedByRemit` flag and the audit verdict join that
772
+ * one UpdateItem instead of a second mutation that would fan out another
773
+ * redundant S3-Vectors upsert. Drives both directions (junk → inbox rescue,
774
+ * inbox → junk demote) off the pure {@link classifyPlacement} verdict.
775
+ *
776
+ * Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
777
+ * decided to act (action != leave), confident and unsure alike, so the
778
+ * distribution is queryable on the message; and a `move` to enqueue only for
779
+ * a confident verdict.
780
+ *
781
+ * Always logs a structured verdict line for confident, actionable verdicts so
782
+ * the real distribution is observable on a live mailbox.
783
+ *
784
+ * The verdict reads the just-computed classification rather than a persisted
785
+ * row, since those fields are not written until the single update. Placement
786
+ * is auxiliary to the body store — the primary artifact (body.eml) is already
787
+ * durable by the time this runs — so a failure here (e.g. a placement-repo
788
+ * query erroring on a schema-drifted DB) is caught and logged loudly with an
789
+ * alertable field instead of failing the surrounding message store. The empty
790
+ * {@link PlacementOutcome} means "no action", whether Remit genuinely decided
791
+ * to leave the message alone or placement itself failed; the alert log is
792
+ * what distinguishes the latter.
793
+ */
794
+ private async resolvePlacement(
795
+ messageId: string,
796
+ accountId: string,
797
+ accountConfigId: string,
798
+ parsed: ParsedMail,
799
+ classification: UpdateMessageInput,
800
+ ): Promise<PlacementOutcome> {
801
+ if (!this.placementConfig) return {};
802
+ return this.computePlacement(
803
+ this.placementConfig,
804
+ messageId,
805
+ accountId,
806
+ accountConfigId,
807
+ parsed,
808
+ classification,
809
+ ).catch((error: unknown) => {
810
+ this.log.error?.(
811
+ {
812
+ alert: "body_sync_placement_failed",
813
+ messageId,
814
+ accountId,
815
+ accountConfigId,
816
+ errorName: (error as { name?: string })?.name,
817
+ error: inspect(error),
818
+ },
819
+ "Placement resolution failed; body already stored, continuing without placement (best-effort, non-fatal)",
820
+ );
821
+ return {};
822
+ });
823
+ }
824
+
825
+ private async computePlacement(
826
+ placementConfig: PlacementConfig,
827
+ messageId: string,
828
+ accountId: string,
829
+ accountConfigId: string,
830
+ parsed: ParsedMail,
831
+ classification: UpdateMessageInput,
832
+ ): Promise<PlacementOutcome> {
833
+ const { mailboxSpecialUseService } = placementConfig;
834
+
835
+ const message = await this.messageService.get(messageId);
836
+ const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
837
+ accountId,
838
+ MailboxSpecialUse.Junk,
839
+ );
840
+ const inboxMailbox =
841
+ await mailboxSpecialUseService.findInboxMailbox(accountId);
842
+
843
+ const placement: FolderPlacement =
844
+ junkMailbox && message.mailboxId === junkMailbox.mailboxId
845
+ ? "junk"
846
+ : inboxMailbox && message.mailboxId === inboxMailbox.mailboxId
847
+ ? "inbox"
848
+ : "other";
849
+
850
+ const fromEmail = extractPrimaryFromEmail(parsed);
851
+ const senderTrust = fromEmail
852
+ ? await this.deriveSenderTrust(accountConfigId, fromEmail)
853
+ : SenderTrust.Unknown;
854
+
855
+ // The verdict needs the classification signals (providerSpam,
856
+ // authResult, authenticity) that this body-sync pass just derived; the
857
+ // stored row does not carry them yet, so overlay them onto the message.
858
+ const candidate = { ...message, ...classification };
859
+ const verdict = classifyPlacement(candidate, placement, senderTrust);
860
+
861
+ // A `leave` verdict carries no audit record and no move.
862
+ if (verdict.action === "leave") {
863
+ return {};
864
+ }
865
+
866
+ // Audit verdict — recorded for every actionable verdict (both
867
+ // confidences), so the distribution is queryable on the message.
868
+ // Independent of whether a move is enqueued.
869
+ const audit: MessagePlacementVerdict = {
870
+ action:
871
+ verdict.action === "move-to-inbox"
872
+ ? PlacementAction.MoveToInbox
873
+ : PlacementAction.MoveToJunk,
874
+ confidence:
875
+ verdict.confidence === "confident"
876
+ ? PlacementConfidence.Confident
877
+ : PlacementConfidence.Unsure,
878
+ fromPlacement: placement,
879
+ reasons: verdict.reasons,
880
+ dryRun: false,
881
+ decidedAt: Date.now(),
882
+ };
883
+
884
+ // Only a confident verdict moves mail. An unsure verdict is recorded
885
+ // but never enqueues a move.
886
+ if (verdict.confidence !== "confident") {
887
+ return { verdict: audit };
888
+ }
889
+
890
+ const target =
891
+ verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
892
+ if (!target) return { verdict: audit };
893
+
894
+ // Structured verdict line — emitted for confident, actionable verdicts
895
+ // so the real verdict distribution is observable on a live mailbox.
896
+ this.log.info(
897
+ {
898
+ messageId,
899
+ accountId,
900
+ placement,
901
+ action: verdict.action,
902
+ confidence: verdict.confidence,
903
+ reasons: verdict.reasons,
904
+ destinationMailboxId: target.mailboxId,
905
+ },
906
+ "Placement verdict",
907
+ );
908
+
909
+ return {
910
+ verdict: audit,
911
+ move: {
912
+ destinationMailboxId: target.mailboxId,
913
+ destinationPath: target.fullPath,
914
+ },
915
+ };
916
+ }
917
+
918
+ // The old `enqueuePlacementMove` (best-effort, catch-and-log) lived here.
919
+ // Issue #1271: it ran AFTER `bodyStorageKey` was already durable, so a
920
+ // failure was swallowed to avoid stranding the message behind the
921
+ // body-sync skip guard — but that meant `movedByRemit` could be true with
922
+ // no record the move ever reached IMAP. Replaced by the unswallowed call
923
+ // inside `applyPostStoreSteps`, sequenced BEFORE `bodyStorageKey` is
924
+ // written, backed by `PlacementMoveService`'s pending-move marker.
925
+
926
+ /**
927
+ * Persist one S3 object per non-multipart leaf so the SPA can resolve
928
+ * `BodyPartResponse.contentUrl` (#298). Keys follow the layout
929
+ * `accounts/{accountConfigId}/{accountId}/messages/{messageId}/parts/{partPath}`
930
+ * so they line up with the URL shape `derive/contentUrl.ts` emits.
931
+ *
932
+ * The mapper is total (#395 PR B): every leaf gets a `BodyPartContentPair`,
933
+ * possibly with a zero-byte content for leaves that have no source bytes
934
+ * (genuinely empty parts, or pathological inputs the positional fallback
935
+ * couldn't pair). No try/catch is needed; the only failure surface here is
936
+ * an S3 write itself, which `pMap` surfaces directly.
937
+ *
938
+ * If `listBodyParts` returns an empty list (e.g. a legacy message synced
939
+ * before #133 populated BodyPart rows), this is a no-op.
940
+ */
941
+ private async storeBodyPartContents(
942
+ accountConfigId: string,
943
+ accountId: string,
944
+ messageId: string,
945
+ parsed: ParsedMail,
946
+ options?: { skipExisting?: boolean },
947
+ ): Promise<StoreBodyPartContentsResult> {
948
+ const bodyParts = await this.envelopeService.listBodyParts(messageId);
949
+ if (bodyParts.length === 0) {
950
+ this.log.debug?.(
951
+ { messageId },
952
+ "No BodyPart rows; skipping per-part storage",
953
+ );
954
+ return { stored: 0 };
955
+ }
956
+
957
+ const log = this.log;
958
+ const pairs = mapBodyPartsToContent(bodyParts, parsed, {
959
+ messageId,
960
+ logger:
961
+ log.warn || log.debug
962
+ ? {
963
+ warn: (obj, msg) => {
964
+ log.warn?.(obj, msg);
965
+ },
966
+ debug: (obj, msg) => {
967
+ log.debug?.(obj, msg);
968
+ },
969
+ }
970
+ : undefined,
971
+ });
972
+
973
+ if (pairs.length === 0) {
974
+ return { stored: 0 };
975
+ }
976
+
977
+ const bodyPartIdByPath = new Map(
978
+ bodyParts.map((bp) => [bp.partPath, bp.bodyPartId]),
979
+ );
980
+
981
+ let stored = 0;
982
+ const textContents: { bodyPartId: string; content: string }[] = [];
983
+ await pMap(
984
+ pairs,
985
+ async (entry) => {
986
+ if (
987
+ options?.skipExisting &&
988
+ (await this.storageService.bodyPartExists(
989
+ accountConfigId,
990
+ accountId,
991
+ messageId,
992
+ entry.partPath,
993
+ ))
994
+ ) {
995
+ return;
996
+ }
997
+ await this.storageService.storeBodyPart({
998
+ accountConfigId,
999
+ accountId,
1000
+ messageId,
1001
+ partPath: entry.partPath,
1002
+ content: entry.content,
1003
+ contentType: entry.contentType,
1004
+ });
1005
+ stored++;
1006
+
1007
+ if (
1008
+ entry.contentType.toLowerCase().startsWith("text/") &&
1009
+ entry.content.length > 0
1010
+ ) {
1011
+ const bodyPartId = bodyPartIdByPath.get(entry.partPath);
1012
+ if (bodyPartId) {
1013
+ textContents.push({
1014
+ bodyPartId,
1015
+ content: entry.content.toString("utf8"),
1016
+ });
1017
+ }
1018
+ }
1019
+ },
1020
+ { concurrency: BODY_PART_STORE_CONCURRENCY },
1021
+ );
1022
+
1023
+ if (textContents.length > 0) {
1024
+ await this.envelopeService.upsertBodyPartContents(
1025
+ messageId,
1026
+ textContents,
1027
+ );
1028
+ }
1029
+
1030
+ this.log.info(
1031
+ { messageId, partCount: pairs.length, stored },
1032
+ "Body parts stored",
1033
+ );
1034
+
1035
+ return { stored };
1036
+ }
1037
+
1038
+ /**
1039
+ * Lazily materialize the per-part S3 objects for an already-synced message
1040
+ * whose parts were deferred during bulk sync (DEFER_BODY_PARTS).
1041
+ *
1042
+ * Called from the read path (the API `describeMessage` handler) before the
1043
+ * SPA fetches any `contentUrl`: the part bytes are served directly from S3 by
1044
+ * CloudFront with no Lambda in the request path, so a missing object would
1045
+ * surface to the SPA as a hard "body-missing" failure. Generating the parts
1046
+ * here — re-parsing the stored `body.eml` with the same `mapBodyPartsToContent`
1047
+ * logic body-sync uses — guarantees every `contentUrl` resolves, while bulk
1048
+ * sync stays at 2 writes/message.
1049
+ *
1050
+ * Idempotent: skips any leaf already on S3, so repeat reads cost only HEAD
1051
+ * checks, and a re-read after a partial failure fills the gaps. Requires the
1052
+ * message body to be stored (`bodyStorageKey`); callers gate on that.
1053
+ *
1054
+ * Warm-open fast path: a single HEAD on the `.materialized` sentinel
1055
+ * short-circuits the whole pass — no body.eml GET, no MIME re-parse, no
1056
+ * per-leaf HEADs. The sentinel is written only after every leaf is confirmed
1057
+ * stored, so its presence guarantees all `contentUrl`s resolve.
1058
+ */
1059
+ async ensureBodyPartsStored(
1060
+ accountConfigId: string,
1061
+ accountId: string,
1062
+ messageId: string,
1063
+ bodyStorageKey: string,
1064
+ ): Promise<StoreBodyPartContentsResult> {
1065
+ if (
1066
+ await this.storageService.bodyPartExists(
1067
+ accountConfigId,
1068
+ accountId,
1069
+ messageId,
1070
+ MATERIALIZED_SENTINEL_PART_PATH,
1071
+ )
1072
+ ) {
1073
+ this.log.debug?.(
1074
+ { messageId },
1075
+ "Body parts already materialized (sentinel hit); skipping",
1076
+ );
1077
+ return { stored: 0 };
1078
+ }
1079
+
1080
+ const body = await this.storageService.retrieve(bodyStorageKey);
1081
+ const parsed = await simpleParser(body);
1082
+ const result = await this.storeBodyPartContents(
1083
+ accountConfigId,
1084
+ accountId,
1085
+ messageId,
1086
+ parsed,
1087
+ { skipExisting: true },
1088
+ );
1089
+
1090
+ // Reaching here means storeBodyPartContents resolved — `pMap` rejects on
1091
+ // any single failed write, so every leaf is now durably on S3. Drop the
1092
+ // sentinel so subsequent opens take the HEAD-only fast path above.
1093
+ await this.storageService.storeBodyPart({
1094
+ accountConfigId,
1095
+ accountId,
1096
+ messageId,
1097
+ partPath: MATERIALIZED_SENTINEL_PART_PATH,
1098
+ content: Buffer.alloc(0),
1099
+ });
1100
+
1101
+ return result;
1102
+ }
1103
+
1104
+ /**
1105
+ * Persist the pre-parsed body cache. A failure here MUST fail the
1106
+ * surrounding body-sync: the parsed-body object is what the search-index
1107
+ * pipeline reads, so swallowing a write failure leaves the message marked
1108
+ * synced while `parsedBody` is null and it never gets indexed — a silent
1109
+ * search gap. Propagate so the message lands in failedMessageIds and the
1110
+ * SQS batch requeues it.
1111
+ */
1112
+ private async storeParsedBodyCache(
1113
+ accountConfigId: string,
1114
+ accountId: string,
1115
+ messageId: string,
1116
+ parsed: ParsedMail,
1117
+ ): Promise<void> {
1118
+ const parsedBody = toParsedBody(parsed);
1119
+ try {
1120
+ await this.storageService.storeParsedBody({
1121
+ accountConfigId,
1122
+ accountId,
1123
+ messageId,
1124
+ parsed: parsedBody,
1125
+ });
1126
+ this.log.debug?.({ messageId }, "Parsed body cache stored");
1127
+ } catch (err: unknown) {
1128
+ this.log.error?.(
1129
+ {
1130
+ messageId,
1131
+ errorName: (err as { name?: string }).name,
1132
+ errorCode: (err as { Code?: string }).Code,
1133
+ error: inspect(err),
1134
+ },
1135
+ "Failed to store parsed body cache; failing sync to requeue",
1136
+ );
1137
+ throw err;
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * Extract snippet and header category from the body and denormalize both
1143
+ * onto the ThreadMessage. `category` mirrors the Message: created as
1144
+ * `uncategorized` at metadata-sync and set to the classified value here, so
1145
+ * the list/search read path carries it without a per-row Message fetch.
1146
+ * Returns the parsed mail so callers can reuse it (e.g., to write the
1147
+ * parsed-body cache) without paying for mailparser twice.
1148
+ */
1149
+ private async updateSnippets(
1150
+ messageId: string,
1151
+ accountConfigId: string,
1152
+ body: Buffer,
1153
+ ): Promise<ParsedMail> {
1154
+ // Parse the email body
1155
+ const parsed = await simpleParser(body);
1156
+
1157
+ // Extract snippet from text or HTML content
1158
+ const snippet = extractSnippetFromEmail(
1159
+ parsed.text,
1160
+ typeof parsed.html === "string" ? parsed.html : undefined,
1161
+ 256,
1162
+ );
1163
+
1164
+ const category = classifyByHeaders(parsed);
1165
+
1166
+ // Get the ThreadMessage by messageId (efficient GSI lookup). The write is
1167
+ // keyed on messageId, so it does not depend on the RFC822 Message-ID
1168
+ // header — a headerless message still gets its category/snippet
1169
+ // denormalized, matching the unconditional Message.category write.
1170
+ const threadMessage = await this.threadMessageService.getByMessageId(
1171
+ accountConfigId,
1172
+ messageId,
1173
+ );
1174
+
1175
+ // Update ThreadMessage snippet + denormalized category.
1176
+ // Pass the full composite set so that if a future key-attribute addition
1177
+ // touches lsi3/lsi4/lsi5/gsi2 sort keys, the index rows remain consistent.
1178
+ // The threadMessage was fetched just above, so the values are already in scope.
1179
+ await this.threadMessageService.update(
1180
+ accountConfigId,
1181
+ threadMessage.threadMessageId,
1182
+ { category, ...(snippet ? { snippet } : {}) },
1183
+ {
1184
+ composites: {
1185
+ sentDate: threadMessage.sentDate,
1186
+ mailboxId: threadMessage.mailboxId,
1187
+ isRead: threadMessage.isRead,
1188
+ isDeleted: threadMessage.isDeleted,
1189
+ hasStars: threadMessage.hasStars,
1190
+ hasAttachment: threadMessage.hasAttachment,
1191
+ },
1192
+ },
1193
+ );
1194
+
1195
+ this.log.debug?.(
1196
+ { messageId, category, snippetLength: snippet?.length ?? 0 },
1197
+ "ThreadMessage snippet + category updated",
1198
+ );
1199
+
1200
+ return parsed;
1201
+ }
1202
+ }