@remit/mailbox-service 0.0.111 → 0.0.113

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 (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -0,0 +1,1204 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { inspect } from "node:util";
3
+ import { provisionDefaultCalendar, recordCalendarSuggestion, } from "@remit/calendar-service";
4
+ import { isSenderMuted, } from "@remit/data-ports";
5
+ import { MessageGoneFromServerError, MessagePlacementUnsettledError, NotFoundError, } from "@remit/data-ports/errors";
6
+ import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
7
+ import { isEncapsulatedMessage } from "@remit/data-ports/message-parts";
8
+ import { isBulkSender } from "@remit/data-ports/wellknown";
9
+ import { FilterState, MessageClassificationState, PlacementAction, PlacementConfidence, QuarantineFailureStage, SenderTrust, SyncWarningStage, } from "@remit/domain-enums";
10
+ import { isStorageNotFoundError, } from "@remit/storage-service";
11
+ import { simpleParser } from "mailparser";
12
+ import pMap from "p-map";
13
+ import { BodyParseError, parseMessageBody } from "./body-parse.js";
14
+ import { mapMessagePartsToContent, parsePartSource, } from "./body-part-mapper.js";
15
+ import { calendarParts } from "./calendar-parts.js";
16
+ import { classifyParsedMessage, extractPrimaryFromEmail, } from "./classify-message.js";
17
+ import { extractListId } from "./filters/list-id.js";
18
+ import { FilterPipeline, } from "./filters/pipeline.js";
19
+ import { placementOf, resolveFolderRoleMailboxes } from "./folder-placement.js";
20
+ import { classifyPlacement, resolveSenderPlacement, } from "./heuristics/classifyPlacement.js";
21
+ import { denormalizeMessageCategory, hasDecidedCategory, } from "./message-category.js";
22
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
23
+ import { isPlacementUnsettled, uidMailboxIdOf } from "./placement-settled.js";
24
+ import { shapeFromMessageData } from "./quarantine.js";
25
+ import { extractSearchText, extractSnippetFromEmail } from "./snippet.js";
26
+ import { MailConnectionError } from "./types.js";
27
+ const BODY_PART_STORE_CONCURRENCY = 4;
28
+ /**
29
+ * Whether the body-sync hot path defers per-part S3 objects. Each MIME leaf is
30
+ * its own PutObject, so during bulk sync these per-part writes dominate S3 write
31
+ * count and cost. With deferral ON a synced message writes only `body.eml` +
32
+ * `parsed.json.gz` (2 writes); the per-part objects are materialized lazily on
33
+ * the first `contentUrl` read via {@link BodySyncService.ensureBodyPartsStored}.
34
+ *
35
+ * Default ON (cost savings). Set `DEFER_BODY_PARTS=false`/`0`/`off` to restore
36
+ * the original eager per-part writes — a safe, reversible escape hatch.
37
+ */
38
+ export const isBodyPartDeferralEnabled = () => {
39
+ const raw = process.env.DEFER_BODY_PARTS;
40
+ if (raw === undefined)
41
+ return true;
42
+ const normalized = raw.trim().toLowerCase();
43
+ return normalized !== "false" && normalized !== "0" && normalized !== "off";
44
+ };
45
+ /**
46
+ * Sentinel object written under a message's parts prefix once lazy
47
+ * materialization has stored EVERY leaf. The read path checks it first with a
48
+ * single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
49
+ * and the per-leaf HEADs on every warm re-open.
50
+ *
51
+ * Deliberately an S3-only marker (no Message-row flag). A leading dot keeps it
52
+ * out of any real IMAP section path (those are dot-separated digits).
53
+ */
54
+ const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
55
+ /**
56
+ * The sentinel for a message carrying a forwarded message. Those parts were
57
+ * once stored under a layout in which the forwarded message's root overwrote
58
+ * its own row, so a `.materialized` written then vouches for objects at the
59
+ * wrong paths. Its own name makes every such message materialize once more,
60
+ * overwriting what is there; no other message is touched.
61
+ */
62
+ const MATERIALIZED_SENTINEL_V2_PART_PATH = ".materialized.2";
63
+ /**
64
+ * A mid-stream socket drop during the ranged body fetch surfaces as a typed
65
+ * `MailConnectionError` (the connection layer classifies imapflow's
66
+ * `EConnectionClosed`/`NoConnection`). Detect by type/code, never by message
67
+ * text — the underlying library strings vary.
68
+ */
69
+ const isConnectionDrop = (error) => {
70
+ if (error instanceof MailConnectionError) {
71
+ return error.kind === "network";
72
+ }
73
+ const code = error.code;
74
+ return code === "EConnectionClosed" || code === "NoConnection";
75
+ };
76
+ /**
77
+ * Project a parsed message onto the fields a filter matches against (RFC 034) —
78
+ * the literal-clause targets. Kept
79
+ * separate from `ParsedMail` so {@link FilterPipeline} stays parser-agnostic.
80
+ */
81
+ const toFilterMessage = (parsed) => ({
82
+ from: extractPrimaryFromEmail(parsed) ?? "",
83
+ fromName: parsed.from?.value?.[0]?.name ?? "",
84
+ subject: parsed.subject ?? "",
85
+ text: parsed.text ?? "",
86
+ listId: extractListId(parsed),
87
+ });
88
+ const SNIPPET_LENGTH = 256;
89
+ /**
90
+ * The snippet the list row shows, from whichever body part carries text.
91
+ * Exported for the classification backfill (issue #1197), which denormalizes
92
+ * the same fields the body-store path does, from the same bytes.
93
+ */
94
+ export const extractSnippet = (parsed) => extractSnippetFromEmail(parsed.text, typeof parsed.html === "string" ? parsed.html : undefined, SNIPPET_LENGTH);
95
+ /**
96
+ * Issue #499: whether a completed body-sync pass has already stored this
97
+ * message's body. `bodyStorageKey` is written last, once every derivation of
98
+ * that pass has run, so its presence means that pass finished.
99
+ *
100
+ * Key presence, and nothing more. What the classifier decided is
101
+ * `Message.category`; whether it ran at all is `Message.classificationState`.
102
+ * Neither is inferred from here — the field this reads is the storage fact.
103
+ *
104
+ * The re-fetch paths that re-enter with the key already set (`syncBodies(...,
105
+ * force: true)` and `fetchAndGetBody`'s `NoSuchKey` fallback) are re-stores
106
+ * that skip the derivations entirely (issue #1011), so this guard is what
107
+ * keeps a first store that lands on an already-stored row from re-deciding
108
+ * anything.
109
+ */
110
+ const hasStoredBody = (bodyStorageKey) => Boolean(bodyStorageKey);
111
+ /**
112
+ * Issue #398: `flags.autoArchive` is a filing preference relative to the Inbox.
113
+ * A `leave` verdict reaches it for two unrelated reasons and only one of them
114
+ * means "nothing confident to say":
115
+ *
116
+ * - `already-moved-by-remit` is {@link classifyPlacement}'s guard against
117
+ * re-deciding a settled placement, and auto-archive is a re-decision. It
118
+ * fires on messages Remit itself placed, or that a user moved back
119
+ * afterwards.
120
+ * - A message already in Junk gets an unsure `leave` whatever the sender's
121
+ * `blocked` flag says, since the demote branch only fires outside Junk.
122
+ * Moving mail out of Junk is a rescue, which this file reserves for a
123
+ * confident signal plus sender trust.
124
+ */
125
+ const autoArchiveApplies = (verdict, placement) => placement !== "junk" && !verdict.reasons.includes("already-moved-by-remit");
126
+ export const toParsedBody = (parsed) => ({
127
+ text: parsed.text ?? null,
128
+ html: typeof parsed.html === "string" ? parsed.html : null,
129
+ attachments: (parsed.attachments ?? []).map((a) => ({
130
+ filename: a.filename ?? null,
131
+ contentType: a.contentType,
132
+ contentDisposition: a.contentDisposition ?? null,
133
+ contentId: a.contentId ?? null,
134
+ size: a.size,
135
+ })),
136
+ });
137
+ const noopLogger = {
138
+ info: () => { },
139
+ };
140
+ export class BodySyncService {
141
+ messageService;
142
+ storageService;
143
+ threadMessageService;
144
+ addressService;
145
+ envelopeService;
146
+ syncWarningService;
147
+ placementConfig;
148
+ filterConfig;
149
+ quarantineConfig;
150
+ unsubscribeConfig;
151
+ calendarConfig;
152
+ log;
153
+ filterPipeline;
154
+ constructor(messageService, storageService, threadMessageService, addressService, envelopeService, syncWarningService, logger, placementConfig, filterConfig, quarantineConfig, unsubscribeConfig, calendarConfig) {
155
+ this.messageService = messageService;
156
+ this.storageService = storageService;
157
+ this.threadMessageService = threadMessageService;
158
+ this.addressService = addressService;
159
+ this.envelopeService = envelopeService;
160
+ this.syncWarningService = syncWarningService;
161
+ this.placementConfig = placementConfig;
162
+ this.filterConfig = filterConfig;
163
+ this.quarantineConfig = quarantineConfig;
164
+ this.unsubscribeConfig = unsubscribeConfig;
165
+ this.calendarConfig = calendarConfig;
166
+ this.log = logger ?? noopLogger;
167
+ this.filterPipeline = filterConfig
168
+ ? new FilterPipeline(filterConfig, this.log)
169
+ : undefined;
170
+ }
171
+ async syncBodies(messageIds, accountId, accountConfigId, folder, getConnection, force = false) {
172
+ const syncedMessageIds = [];
173
+ const syncedUids = [];
174
+ let skippedCount = 0;
175
+ const location = {
176
+ accountId,
177
+ accountConfigId,
178
+ mailboxPath: folder.fullPath,
179
+ };
180
+ // Resolve every message up front so we can issue ONE ranged FETCH for the
181
+ // whole batch (the desktop-client pattern) instead of a SELECT + download
182
+ // per message. Messages whose body is already stored are skipped here and
183
+ // never hit the wire — UNLESS `force` is set, in which case every message
184
+ // is re-fetched regardless of `bodyStorageKey` (the read-miss cue already
185
+ // confirmed the stored object is gone). `pending` maps each UID to its
186
+ // messageId so we can match FETCH rows back and re-enqueue any UID the
187
+ // server never returns.
188
+ const pending = new Map();
189
+ // Message IDs in this batch whose row already carried `bodyStorageKey`
190
+ // when `force` re-added them to `pending`: a forced re-fetch of an
191
+ // already-stored body is a re-store (issue #1011) — bytes only, no
192
+ // decision re-run. A message with no stored body inside a forced batch
193
+ // is still a first store and takes the full decision pass.
194
+ const reStoredMessageIds = new Set();
195
+ // One read per round, not per message (issue #72). The list is small by
196
+ // design and almost always empty, so a lookup per message would put a
197
+ // query on the hot path for a state that is nearly never set.
198
+ const quarantined = await this.quarantineConfig?.quarantineService.load(accountConfigId);
199
+ const rows = new Map((await this.messageService.get(messageIds)).map((row) => [
200
+ row.messageId,
201
+ row,
202
+ ]));
203
+ for (const messageId of messageIds) {
204
+ const message = rows.get(messageId);
205
+ // A row deleted since the batch was cut, or moved out of this folder
206
+ // for good, has no uid here; its new uid would fetch another message.
207
+ if (!message || uidMailboxIdOf(message) !== folder.mailboxId) {
208
+ skippedCount++;
209
+ continue;
210
+ }
211
+ // A quarantined message has no stored body, so nothing else here would
212
+ // stop it being fetched and re-parsed on every redelivery, forever.
213
+ if (this.quarantineConfig &&
214
+ quarantined?.has(this.quarantineConfig.mailboxId, this.quarantineConfig.uidValidity, message.uid)) {
215
+ this.log.debug?.({ messageId, uid: message.uid }, "Message is quarantined, skipping");
216
+ skippedCount++;
217
+ continue;
218
+ }
219
+ // The body is stored, so there is nothing to fetch (issue #331). This
220
+ // used to re-read those bytes from storage to re-derive a category,
221
+ // keyed on `category === uncategorized` — which is also the classifier's
222
+ // own "nothing to say", so a message it declined cost one storage read
223
+ // and two writes on every pass, forever, for the same answer. What the
224
+ // classifier did is now recorded as `classificationState`, so a sync
225
+ // pass never has to guess it from the row, and the cohort it has not
226
+ // reached is selectable by a backfill instead of re-derived here.
227
+ if (hasStoredBody(message.bodyStorageKey) && !force) {
228
+ this.log.debug?.({ messageId }, "Body already stored, skipping");
229
+ skippedCount++;
230
+ continue;
231
+ }
232
+ if (message.bodyStorageKey)
233
+ reStoredMessageIds.add(messageId);
234
+ pending.set(message.uid, messageId);
235
+ }
236
+ if (pending.size === 0) {
237
+ return this.buildResult(syncedMessageIds, skippedCount, []);
238
+ }
239
+ const connection = await getConnection();
240
+ // Single SELECT for the whole batch. openBox is idempotent, so a warm
241
+ // connection already on this mailbox skips the SELECT entirely.
242
+ await connection.openBox(folder.fullPath);
243
+ let connectionLost = false;
244
+ try {
245
+ for await (const { uid, source } of connection.fetchMessageBodies([
246
+ ...pending.keys(),
247
+ ])) {
248
+ const messageId = pending.get(uid);
249
+ if (!messageId) {
250
+ // A UID we didn't ask for — drain the stream so the connection
251
+ // stays usable, then ignore it.
252
+ source.resume();
253
+ continue;
254
+ }
255
+ try {
256
+ await this.storeStreamedBody(messageId, accountId, accountConfigId, source, folder.placement, reStoredMessageIds.has(messageId));
257
+ }
258
+ catch (error) {
259
+ // The one error in this frame that is the message's own fault. It
260
+ // reaches here from the parse call and nowhere else, so the S3, DB
261
+ // and queue work this same frame wraps cannot be mistaken for it.
262
+ // The row is durable before the UID leaves `pending`, so the
263
+ // message is never let go of unrecorded.
264
+ if (error instanceof BodyParseError && this.quarantineConfig) {
265
+ await this.recordQuarantine(this.quarantineConfig, messageId, location, error);
266
+ pending.delete(uid);
267
+ skippedCount++;
268
+ source.resume();
269
+ continue;
270
+ }
271
+ // Anything else is our own storage, database or queue failing. It
272
+ // aborts the batch, which redelivers whole; what stored skips.
273
+ throw error;
274
+ }
275
+ pending.delete(uid);
276
+ syncedMessageIds.push(messageId);
277
+ syncedUids.push(uid);
278
+ }
279
+ }
280
+ catch (error) {
281
+ this.log.error?.({ error: error.message }, "Body fetch stream failed");
282
+ // Fail-fast: a dropped connection mid-stream leaves every not-yet-yielded
283
+ // UID in `pending`; they fall through to failedMessageIds and re-enqueue.
284
+ // Any other error is a real fault — let it crash.
285
+ if (!isConnectionDrop(error)) {
286
+ throw error;
287
+ }
288
+ connectionLost = true;
289
+ this.log.info?.({ remainingCount: pending.size }, "Connection lost, aborting batch");
290
+ }
291
+ // Anything still pending was never yielded (mid-stream drop or a UID the
292
+ // server silently omitted) — re-enqueue it.
293
+ const failedMessageIds = [...pending.values()];
294
+ await this.syncWarningService.settle({
295
+ accountConfigId,
296
+ accountId,
297
+ mailboxId: folder.mailboxId,
298
+ uidValidity: folder.uidValidity,
299
+ }, SyncWarningStage.Body, {
300
+ failedUids: [...pending.keys()],
301
+ syncedUids,
302
+ serverUids: { kind: "Unlisted" },
303
+ });
304
+ this.log.info({
305
+ synced: syncedMessageIds.length,
306
+ skipped: skippedCount,
307
+ failed: failedMessageIds.length,
308
+ total: messageIds.length,
309
+ aborted: connectionLost,
310
+ }, "Body sync complete");
311
+ return this.buildResult(syncedMessageIds, skippedCount, failedMessageIds);
312
+ }
313
+ /**
314
+ * Set a message aside because its body will not parse (issue #72). The
315
+ * fingerprint comes from the BodyPart rows metadata sync already wrote: the
316
+ * body path streams raw bytes and has no FETCH result to read.
317
+ */
318
+ async recordQuarantine(config, messageId, location, error) {
319
+ const message = await this.messageService.get(messageId);
320
+ const messageData = await this.envelopeService.getMessageData(messageId);
321
+ const threadMessage = await this.threadMessageService.findByMessageId(location.accountConfigId, messageId);
322
+ await config.quarantineService.record({
323
+ accountId: location.accountId,
324
+ accountConfigId: location.accountConfigId,
325
+ mailboxId: config.mailboxId,
326
+ mailboxPath: location.mailboxPath,
327
+ uidValidity: config.uidValidity,
328
+ attempts: config.attempts,
329
+ }, message.uid, {
330
+ stage: QuarantineFailureStage.BodyParse,
331
+ code: error.failureCode,
332
+ message: error.message,
333
+ }, shapeFromMessageData(message, messageData, threadMessage?.messageIdHeader));
334
+ }
335
+ buildResult(syncedMessageIds, skippedCount, failedMessageIds) {
336
+ return {
337
+ syncedCount: syncedMessageIds.length,
338
+ syncedMessageIds,
339
+ skippedCount,
340
+ failedCount: failedMessageIds.length,
341
+ failedMessageIds,
342
+ };
343
+ }
344
+ /**
345
+ * Stream one message body straight to storage while teeing the bytes into a
346
+ * buffer for the parse-dependent steps (snippet, classification, parsed-body
347
+ * cache, per-part objects). The S3 upload never sees a whole-body concat —
348
+ * the storage service streams it — but mailparser still needs the full bytes,
349
+ * so we collect them in parallel. Later issues move parsing off the hot path.
350
+ *
351
+ * `isReStore` marks a re-fetch of a message whose row already carried
352
+ * `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
353
+ * post-store steps then re-materialize the body artifacts without
354
+ * re-running any decision (issue #1011).
355
+ */
356
+ async storeStreamedBody(messageId, accountId, accountConfigId, source, placement, isReStore) {
357
+ const toStorage = new PassThrough();
358
+ const chunks = [];
359
+ // Tee the source: bytes flow to storage as a stream (no whole-body concat
360
+ // on the upload path) while we also collect them for the parse-dependent
361
+ // steps below, which still need the full body for mailparser. The store
362
+ // and the tee are awaited together so neither rejection is orphaned.
363
+ const tee = new Promise((resolve, reject) => {
364
+ source.on("data", (chunk) => {
365
+ chunks.push(chunk);
366
+ toStorage.write(chunk);
367
+ });
368
+ source.on("end", () => {
369
+ toStorage.end();
370
+ resolve();
371
+ });
372
+ source.on("error", (err) => {
373
+ toStorage.destroy(err);
374
+ reject(err);
375
+ });
376
+ });
377
+ const [ref] = await Promise.all([
378
+ this.storageService.storeMessageBodyStream({
379
+ accountConfigId,
380
+ accountId,
381
+ messageId,
382
+ content: toStorage,
383
+ }),
384
+ tee,
385
+ ]);
386
+ const body = Buffer.concat(chunks);
387
+ await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, {
388
+ uri: ref.uri,
389
+ }, placement, isReStore);
390
+ }
391
+ /**
392
+ * The steps every synced body must go through regardless of which path
393
+ * fetched it first — the sync path ({@link storeStreamedBody}) and the
394
+ * read-path backfill ({@link fetchAndGetBody}) both call this instead of
395
+ * duplicating (and drifting on) their own subset. Before issue #1271,
396
+ * `fetchAndGetBody` skipped classification/placement entirely — a message
397
+ * materialized by a read got different treatment than one synced in bulk.
398
+ *
399
+ * Order matters for two independent reasons, both load-bearing:
400
+ * 1. The parsed-body cache must be durable before `bodyStorageKey` (the
401
+ * skip-guard signal) — see the comment on the write below.
402
+ * 2. A placement move — local mailboxId change, pending marker, IMAP-push
403
+ * enqueue (`PlacementMoveService.moveMessage`, issue #1271) — must
404
+ * complete, or fully fail, BEFORE `bodyStorageKey` is written. That
405
+ * failure is never swallowed: if it throws, `bodyStorageKey` is not yet
406
+ * durable, so a retry reprocesses the message from scratch (marker
407
+ * included) instead of the skip-guard stranding a `movedByRemit` flag
408
+ * with no marker behind it — the defect this issue fixes.
409
+ *
410
+ * Returns the parsed mail so callers that already need it (both do) don't
411
+ * pay for mailparser twice.
412
+ */
413
+ async applyPostStoreSteps(messageId, accountId, accountConfigId, body, bodyRef, placement,
414
+ /**
415
+ * Whether this call is a re-store of an already-classified message
416
+ * rather than a first store. The two re-entrant paths (`force: true`
417
+ * in `syncBodies`, and `fetchAndGetBody`'s `NoSuchKey` IMAP fallback) are
418
+ * both body-only re-fetches: a body re-sync fetches and stores bytes and
419
+ * runs no decision logic (issue #1011). When `isReStore` is true the
420
+ * placement/filter/category machinery is skipped entirely and the
421
+ * message's existing category is carried forward unchanged.
422
+ */
423
+ isReStore) {
424
+ // The one operation on this path that can fail because of how the message
425
+ // is built. Its own try block lives in `parseMessageBody`, so this frame —
426
+ // which also wraps the S3 body write, the parsed-body cache, the placement
427
+ // move, the label writes and the counter update — can tell the two apart
428
+ // and quarantine only this one (issue #72). The ThreadMessage write these
429
+ // feed happens later, alongside the Message write (see below), once the
430
+ // write-once category is decided.
431
+ const parsed = await parseMessageBody(body);
432
+ await this.messageService.setSearchText(messageId, extractSearchText(parsed));
433
+ // Body-only re-fetch (issue #1011): the decision pass already ran when
434
+ // this message's body first landed, so a re-store re-materializes only
435
+ // the body-derived artifacts — parsed cache, body parts, the storage
436
+ // key — and re-decides nothing: no classification, placement, filters,
437
+ // labels, read state, and no second engagement-counter increment.
438
+ // Skipping the decisions here is what lets the write-once fields stay
439
+ // safe without per-derivation re-entrancy guards.
440
+ if (isReStore) {
441
+ await this.messageService.syncSearch([messageId]);
442
+ // Same ordering rule as the first-store path below: the parsed-body
443
+ // cache must be durable before `bodyStorageKey` — the skip-guard
444
+ // signal — is written, so a failure here requeues the message and
445
+ // the retry genuinely re-attempts the artifact writes.
446
+ await this.storeParsedBodyCache(accountConfigId, accountId, messageId, parsed);
447
+ if (!isBodyPartDeferralEnabled()) {
448
+ await this.storeBodyPartContents(accountConfigId, accountId, messageId, await this.envelopeService.listBodyParts(messageId), body);
449
+ }
450
+ await this.messageService.update(messageId, {
451
+ bodyStorageKey: bodyRef.uri,
452
+ });
453
+ this.log.info({ messageId, storageKey: bodyRef.uri }, "Body re-stored without re-running decisions");
454
+ return parsed;
455
+ }
456
+ const snippet = extractSnippet(parsed);
457
+ const listId = extractListId(parsed);
458
+ // Compute the header classification once, folding in the sender's
459
+ // `flags.category` override when one applies (issue #299). The derived
460
+ // fields are folded into the single Message update below — they are NOT
461
+ // written here.
462
+ const classification = await this.classifyMessage(accountConfigId, parsed, placement);
463
+ // Decide the placement move from the in-memory classification before the
464
+ // write, so its `movedByRemit` flag and audit verdict join the same
465
+ // UpdateItem. The verdict is recorded whenever Remit decided to act
466
+ // (action != leave); the move is enqueued only for a confident,
467
+ // actionable verdict.
468
+ const resolved = await this.resolvePlacement(messageId, accountId, accountConfigId, parsed, classification);
469
+ // Index-time filter pass (RFC 034). Isolated the same way placement is —
470
+ // FilterPipeline.evaluate swallows any read/decision failure and returns
471
+ // no actions, so a schema-drifted filter query never fails the body store.
472
+ // The DECISION is computed here; the applies below are unswallowed.
473
+ const filterDecision = await this.evaluateFilters(accountConfigId, accountId, messageId, parsed);
474
+ // Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
475
+ // guard in syncBodies treats a stored bodyStorageKey as "fully synced",
476
+ // so if we wrote it first and the parsed-cache write then failed, the
477
+ // requeued retry would skip the message — leaving parsedBody null forever.
478
+ // A parsed-cache failure here throws and propagates before bodyStorageKey
479
+ // is written, so the requeued message genuinely re-attempts the parsed
480
+ // write.
481
+ await this.storeParsedBodyCache(accountConfigId, accountId, messageId, parsed);
482
+ // Per-part S3 objects dominate S3 writes during bulk sync. When deferral
483
+ // is enabled we skip them here; they are materialized lazily on the first
484
+ // `contentUrl` read (see ensureBodyPartsStored), keeping bulk sync at 2
485
+ // writes/message (body.eml + parsed.json.gz).
486
+ if (!isBodyPartDeferralEnabled()) {
487
+ await this.storeBodyPartContents(accountConfigId, accountId, messageId, await this.envelopeService.listBodyParts(messageId), body);
488
+ }
489
+ // Local-first move (issue #1271): local mailboxId change + pending marker +
490
+ // IMAP-push enqueue via PlacementMoveService, UNSWALLOWED — a throw here
491
+ // propagates before bodyStorageKey is written (see the method doc above),
492
+ // so a genuine SQS/DDB write failure requeues the message rather than being
493
+ // absorbed. A matched filter's move is exclusive and outranks the
494
+ // classifier's placement move (RFC 034 Decision 3.1) — an explicit user
495
+ // rule wins the single mailbox a message occupies — so at most one move is
496
+ // enqueued. A non-confident/leave verdict with no filter move is a no-op.
497
+ let filterMove;
498
+ let filterMoved = false;
499
+ if (filterDecision.move) {
500
+ const destinationMailboxId = filterDecision.move.destinationMailboxId;
501
+ // Capture the source mailbox BEFORE the move rewrites mailboxId, so the
502
+ // auto-moved badge can name where the filter took the message from and
503
+ // offer an undo back to it (issue #223). Snapshotted into the marker
504
+ // rather than read from `originalMailboxId` at derive time, which a
505
+ // later user move would overwrite — the filter's source must survive an
506
+ // undo to stay derivable. A redelivery after a failed enqueue finds
507
+ // the move already applied locally, so the source is the folder the
508
+ // uid still names, not the one the row now points at.
509
+ const sourceMailboxId = uidMailboxIdOf(await this.messageService.get(messageId));
510
+ const filed = await this.filterConfig?.placementMoveService.moveMessage(accountConfigId, messageId, destinationMailboxId, accountId);
511
+ // A filter matches by content, not location: a message delivered
512
+ // straight into the destination (a server-side rule) or already filed
513
+ // there matches but was not moved by Remit. `moveMessage` no-ops in that
514
+ // case; record neither the marker nor `movedByRemit`, so the badge never
515
+ // claims a move that did not happen and its Undo is never a no-op.
516
+ if (sourceMailboxId !== destinationMailboxId && filed !== "refused") {
517
+ filterMoved = true;
518
+ filterMove = {
519
+ filterId: filterDecision.move.filterId,
520
+ sourceMailboxId,
521
+ destinationMailboxId,
522
+ decidedAt: Date.now(),
523
+ };
524
+ }
525
+ }
526
+ else if (resolved.move) {
527
+ await this.placementConfig?.placementMoveService.moveMessage(accountConfigId, messageId, resolved.move.destinationMailboxId, accountId);
528
+ }
529
+ // Additive label actions (RFC 034 Decision 3.1): every matching filter's
530
+ // label applies. The deterministic MessageLabel upsert is idempotent, so a
531
+ // requeue after a later failure re-applies safely; UNSWALLOWED, so a DDB
532
+ // write failure propagates before bodyStorageKey is written.
533
+ for (const label of filterDecision.labels) {
534
+ await this.filterConfig?.messageLabelService.apply({
535
+ messageId,
536
+ labelId: label.labelId,
537
+ accountConfigId,
538
+ appliedByFilterId: label.filterId,
539
+ });
540
+ }
541
+ // Read once for the two write-once guards below — the auto-read decision
542
+ // and the category carry-forward. Taken here rather than earlier because
543
+ // nothing between this point and the Message update writes either field;
544
+ // the moves above touch `mailboxId` only.
545
+ const existingMessage = await this.messageService.get(messageId);
546
+ // `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
547
+ // reusing the same FlagQueueService.markAsRead round-trip a manual
548
+ // mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
549
+ // the message is already \Seen), so a failure here safely re-fires on the
550
+ // next attempt rather than being lost behind the bodyStorageKey skip guard.
551
+ await this.applyUnsubscribedAutoRead(messageId, accountId, accountConfigId, parsed, existingMessage.bodyStorageKey);
552
+ await this.deriveCalendarSuggestions(messageId, accountConfigId, parsed, existingMessage.bodyStorageKey);
553
+ const moved = Boolean(resolved.move || filterMoved);
554
+ // ONE Message UpdateItem per synced message: bodyStorageKey + every
555
+ // classification/derived field + the move flag + the audit verdict,
556
+ // collapsed into a single write.
557
+ // The flag is folded in only when a move WAS applied (by the classifier or
558
+ // a filter); the verdict is folded in whenever Remit decided to act.
559
+ // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
560
+ // once the parsed cache AND the move (when any) are.
561
+ //
562
+ // `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
563
+ // GSI sort key depends on it never churning). The re-entrant re-store
564
+ // paths (issue #1011) return before this write; the carry-forward keeps
565
+ // the field write-once even when a first store lands on a row that
566
+ // already carries a real category — a previously-decided category wins
567
+ // over the just-recomputed one. It likewise protects a `flags.category`
568
+ // override (issue #299): recomputing would let a *later* override silently
569
+ // rewrite a category already decided, which is exactly the churn RFC
570
+ // 030's GSI-safety argument forbids.
571
+ //
572
+ // `resolved.placementDecidedAt` (issue #383) is only ever present on
573
+ // the pass that first decided it: the re-store paths skip placement
574
+ // entirely (issue #1011), so `computePlacement` is reached only on a
575
+ // first store.
576
+ const finalCategory = hasDecidedCategory(existingMessage.category)
577
+ ? existingMessage.category
578
+ : classification.category;
579
+ // Thread-list denormalization, using the same write-once category as the
580
+ // Message update below — the two rows must never disagree on category.
581
+ await denormalizeMessageCategory({ threadMessageService: this.threadMessageService }, accountConfigId, messageId, { category: finalCategory, snippet, listId });
582
+ // `classificationState` records that the classifier ran, in the same
583
+ // UpdateItem as the answer it gave (issue #331). Without it `uncategorized`
584
+ // carries two meanings at once — not reached yet, and reached with nothing
585
+ // to say — and only one of them should ever be looked at again.
586
+ const update = {
587
+ bodyStorageKey: bodyRef.uri,
588
+ ...classification,
589
+ category: finalCategory,
590
+ classificationState: MessageClassificationState.Examined,
591
+ ...(moved ? { movedByRemit: true } : {}),
592
+ ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
593
+ ...(filterMove ? { filterMove } : {}),
594
+ ...(resolved.placementDecidedAt
595
+ ? { placementDecidedAt: resolved.placementDecidedAt }
596
+ : {}),
597
+ };
598
+ await this.messageService.update(messageId, update);
599
+ this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
600
+ // From-Address engagement counter (Address entity, not Message).
601
+ await this.incrementInboundCount(messageId, accountConfigId, parsed, classification);
602
+ return parsed;
603
+ }
604
+ /**
605
+ * Fetch a single message body, store it, and return the parsed content.
606
+ *
607
+ * If the body is already stored, retrieves it from storage.
608
+ * Otherwise fetches from IMAP, stores it, and returns the parsed content.
609
+ *
610
+ * @param messageId - The message ID to fetch
611
+ * @param accountId - The account ID (for storage path)
612
+ * @param accountConfigId - The account config ID (for thread updates)
613
+ * @param folder - The IMAP mailbox path and its folder placement
614
+ * @param getConnection - Function to get a (lazy) IMAP connection
615
+ * @returns Parsed text and HTML content
616
+ */
617
+ async fetchAndGetBody(messageId, accountId, accountConfigId, folder, getConnection) {
618
+ const message = await this.messageService.get(messageId);
619
+ let body;
620
+ let needsStore = false;
621
+ if (message.bodyStorageKey) {
622
+ // Body already stored, try to retrieve from storage
623
+ this.log.debug?.({ messageId }, "Retrieving body from storage");
624
+ try {
625
+ body = await this.storageService.retrieve(message.bodyStorageKey);
626
+ }
627
+ catch (err) {
628
+ // Only a genuinely-missing object (NoSuchKey) is a safe IMAP
629
+ // fallback — that's the cross-environment / never-stored case.
630
+ // A permission/infra error (AccessDenied, throttle) must NOT be
631
+ // masked as a missing object: let it crash so it's observable.
632
+ if (!isStorageNotFoundError(err)) {
633
+ this.log.error?.({
634
+ messageId,
635
+ storageKey: message.bodyStorageKey,
636
+ errorName: err.name,
637
+ errorCode: err.Code,
638
+ error: inspect(err),
639
+ }, "Body storage retrieval failed (non-NoSuchKey); not falling back to IMAP");
640
+ throw err;
641
+ }
642
+ this.log.debug?.({ messageId, error: err.message }, "Body object missing (NoSuchKey), falling back to IMAP");
643
+ needsStore = true;
644
+ body = await this.fetchFromImap(message, accountId, accountConfigId, folder.fullPath, getConnection);
645
+ }
646
+ }
647
+ else {
648
+ // Fetch from IMAP and store
649
+ needsStore = true;
650
+ body = await this.fetchFromImap(message, accountId, accountConfigId, folder.fullPath, getConnection);
651
+ }
652
+ let parsed;
653
+ if (needsStore) {
654
+ const ref = await this.storageService.storeMessageBody({
655
+ accountConfigId,
656
+ accountId,
657
+ messageId,
658
+ content: body,
659
+ });
660
+ // Same shared step the sync path (storeStreamedBody) runs — issue
661
+ // #1271 unified the two body paths so classification/placement no
662
+ // longer depends on which one fetched the body first. A store from
663
+ // here is a re-store exactly when the row already claimed a stored
664
+ // body — the NoSuchKey fallback above; a row with no bodyStorageKey is
665
+ // a genuine first store and takes the full decision pass.
666
+ parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null);
667
+ }
668
+ else {
669
+ parsed = await simpleParser(body);
670
+ }
671
+ return {
672
+ text: parsed.text ?? null,
673
+ html: typeof parsed.html === "string" ? parsed.html : null,
674
+ storedAt: message.bodyStorageKey ?? "newly-stored",
675
+ };
676
+ }
677
+ async fetchFromImap(message, accountId, accountConfigId, mailboxPath, getConnection) {
678
+ const { uid } = message;
679
+ this.log.debug?.({ uid }, "Fetching body from IMAP");
680
+ const connection = await getConnection();
681
+ await connection.openBox(mailboxPath);
682
+ const body = await connection.fetchMessageBody(uid);
683
+ if (body)
684
+ return body;
685
+ if (!(await isMessageGoneFromOpenMailbox(connection, uid))) {
686
+ throw new Error(`IMAP FETCH returned no source for UID ${uid} in ${mailboxPath}, which the server still lists`);
687
+ }
688
+ return this.reconcileGoneMessage(message, accountId, mailboxPath);
689
+ }
690
+ /**
691
+ * The server no longer holds the message at the row's UID. A row with our
692
+ * own move or delete in flight belongs to that mutation (409). A settled row
693
+ * is removed with its thread entries, but only while its placement still
694
+ * matches what was read (imap-mutations R3); losing that predicate is a 409
695
+ * like any other lost placement transition.
696
+ */
697
+ async reconcileGoneMessage(message, accountId, mailboxPath) {
698
+ const { messageId, uid } = message;
699
+ if (isPlacementUnsettled(message)) {
700
+ throw new MessagePlacementUnsettledError(`Message ${messageId} is not at its UID while a move or delete is in flight`, accountId, messageId, "in_flight");
701
+ }
702
+ const reconciled = await this.messageService.deleteIfPlacement(messageId, {
703
+ status: message.status,
704
+ mailboxId: message.mailboxId,
705
+ uid,
706
+ });
707
+ if (!reconciled) {
708
+ throw new MessagePlacementUnsettledError(`Message ${messageId} changed placement while its absence from ${mailboxPath} was being reconciled`, accountId, messageId, "in_flight");
709
+ }
710
+ this.log.info({
711
+ metric: "message_gone_stale_row_reconciled",
712
+ messageId,
713
+ uid,
714
+ mailboxPath,
715
+ }, "Message no longer on the mail server (expunged or moved by another client); stale row reconciled");
716
+ throw new MessageGoneFromServerError(messageId);
717
+ }
718
+ /**
719
+ * Header classification, with the sender's `Address.flags.category`
720
+ * override (issue #299, RFC 039 Decision 3) substituted for the
721
+ * header-derived category when one is set. Shared with the classification
722
+ * backfill (issue #1197) as {@link classifyParsedMessage}, so the one-time
723
+ * pass and the body-store path can never classify the same bytes
724
+ * differently.
725
+ */
726
+ async classifyMessage(accountConfigId, parsed, placement) {
727
+ return classifyParsedMessage(this.addressService, accountConfigId, parsed, placement);
728
+ }
729
+ async incrementInboundCount(messageId, accountConfigId, parsed, classification) {
730
+ const fromEmail = extractPrimaryFromEmail(parsed);
731
+ if (!fromEmail) {
732
+ this.log.debug?.({ messageId }, "No From address; skipping inbound counter");
733
+ return;
734
+ }
735
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
736
+ const bulk = isBulkSender(classification.category, classification.hasListUnsubscribe ?? false);
737
+ await this.addressService.incrementInboundCount(accountConfigId, addressId, Date.now(), bulk);
738
+ }
739
+ /**
740
+ * The per-sender signals {@link computePlacement} needs, from ONE `Address`
741
+ * fetch (RFC 039 Decision 3/3a, issue #300): the trust reads exactly as
742
+ * `deriveSenderTrust` always did — `vip → wellknown → unknown`, untouched by
743
+ * `blocked` — plus `blocked`/`autoArchive` off the same row. `trustSetAt` is
744
+ * the `setAt` of whichever flag produced the trust value, needed by
745
+ * {@link resolveSenderPlacement}'s tie-break; it stays local to placement and
746
+ * never reaches `deriveSenderTrust`'s own contract (the trust badge).
747
+ */
748
+ async deriveSenderPlacementSignals(accountConfigId, fromEmail) {
749
+ const unknown = {
750
+ trust: SenderTrust.Unknown,
751
+ blocked: false,
752
+ neverSpam: false,
753
+ autoArchive: false,
754
+ };
755
+ try {
756
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
757
+ const address = await this.addressService.getAddress(accountConfigId, addressId);
758
+ const flags = address.flags;
759
+ if (flags?.vip?.value === true) {
760
+ return {
761
+ trust: SenderTrust.Vip,
762
+ trustSetAt: flags.vip.setAt,
763
+ blocked: flags.blocked?.value === true,
764
+ blockedSetAt: flags.blocked?.setAt,
765
+ neverSpam: flags.neverSpam?.value === true,
766
+ autoArchive: flags.autoArchive?.value === true,
767
+ };
768
+ }
769
+ if (flags?.wellknown?.value === true) {
770
+ return {
771
+ trust: SenderTrust.Wellknown,
772
+ trustSetAt: flags.wellknown.setAt,
773
+ blocked: flags.blocked?.value === true,
774
+ blockedSetAt: flags.blocked?.setAt,
775
+ neverSpam: flags.neverSpam?.value === true,
776
+ autoArchive: flags.autoArchive?.value === true,
777
+ };
778
+ }
779
+ return {
780
+ ...unknown,
781
+ blocked: flags?.blocked?.value === true,
782
+ blockedSetAt: flags?.blocked?.setAt,
783
+ neverSpam: flags?.neverSpam?.value === true,
784
+ autoArchive: flags?.autoArchive?.value === true,
785
+ };
786
+ }
787
+ catch (err) {
788
+ // A genuinely-absent address means "no signals". Any other failure
789
+ // (AccessDenied, throttle, infra) must NOT be silently downgraded — let
790
+ // it crash so the placement decision isn't made on bad data.
791
+ if (!(err instanceof NotFoundError))
792
+ throw err;
793
+ }
794
+ return unknown;
795
+ }
796
+ /**
797
+ * `flags.unsubscribed` (issue #302, RFC 039 Decision 3): "auto-mark-read
798
+ * until sender stops" — fires on every new message from that sender for as
799
+ * long as the flag stays set, per the flag's own doc comment; there is no
800
+ * separate expiry mechanism, and no caching of the decision beyond this
801
+ * per-message `Address` read. A no-op when body sync was built without an
802
+ * {@link UnsubscribeConfig} or the message carries no `From` address.
803
+ *
804
+ * Write-once per message (issue #499), like the `category` and placement
805
+ * derivations alongside it: read state is applied on the pass that first
806
+ * classifies a message and never re-decided. A user who marks such a message
807
+ * unread afterwards owns it — a re-entrant pass over an already-classified
808
+ * body would otherwise silently undo that.
809
+ */
810
+ async applyUnsubscribedAutoRead(messageId, accountId, accountConfigId, parsed, storedBodyKey) {
811
+ if (!this.unsubscribeConfig)
812
+ return;
813
+ if (hasStoredBody(storedBodyKey))
814
+ return;
815
+ const fromEmail = extractPrimaryFromEmail(parsed);
816
+ if (!fromEmail)
817
+ return;
818
+ const unsubscribed = await this.deriveSenderUnsubscribed(accountConfigId, fromEmail);
819
+ if (!unsubscribed)
820
+ return;
821
+ await this.unsubscribeConfig.flagQueueService.markAsRead(accountConfigId, messageId, accountId);
822
+ }
823
+ /**
824
+ * Offer what a message's `text/calendar` parts propose, as cards beside the
825
+ * message (issue #1033). Nothing here reaches a calendar: a suggestion is
826
+ * written `Pending` and waits for a person, and a `METHOD:CANCEL` is a card
827
+ * of its own rather than a withdrawal of an event the user added.
828
+ *
829
+ * Write-once per message, like the `category`, placement and read-state
830
+ * derivations alongside it (issues #499, #1011): a card is offered on the
831
+ * pass that first classifies a message — the same moment the account's
832
+ * filters run — and never again. The two re-entrant paths a forced body
833
+ * re-sync goes through (`fetchAndGetBody`'s `NoSuchKey` fallback,
834
+ * `syncBodies(..., force: true)`) reach this with `bodyStorageKey` already
835
+ * set, and re-offering there would resurrect a card the user dismissed.
836
+ *
837
+ * Isolated the same way placement and filters are: a card is auxiliary to
838
+ * storing the mail, which is already durable by the time this runs. A
839
+ * repository failure here is logged loudly with the messageId rather than
840
+ * failing a body store that otherwise succeeded.
841
+ */
842
+ async deriveCalendarSuggestions(messageId, accountConfigId, parsed, storedBodyKey) {
843
+ if (!this.calendarConfig)
844
+ return;
845
+ if (hasStoredBody(storedBodyKey))
846
+ return;
847
+ const parts = calendarParts(parsed);
848
+ if (parts.length === 0)
849
+ return;
850
+ const { calendarStore, calendarSuggestionService, filterService } = this.calendarConfig;
851
+ await Promise.resolve()
852
+ .then(async () => {
853
+ // `dismiss{muteSender:true}` wrote a standing rule naming this
854
+ // sender. Honouring it here is what makes that button do anything:
855
+ // the index-time filter pipeline skips a rule with no label and no
856
+ // move, so this is the only reader such a rule has.
857
+ const sender = extractPrimaryFromEmail(parsed);
858
+ if (sender) {
859
+ const active = await filterService.listByAccountAndState(accountConfigId, FilterState.Active);
860
+ if (isSenderMuted(active, sender)) {
861
+ this.log.debug?.({ messageId, sender }, "Sender is muted; offering no calendar suggestion");
862
+ return;
863
+ }
864
+ }
865
+ // Provisioned rather than looked up, through the one function every
866
+ // caller provisions with. A missing collection has no timezone, and
867
+ // a floating DTSTART read in UTC puts the meeting hours from where
868
+ // the organizer meant it. Idempotent: the id is derived, so a second
869
+ // first use returns the collection the first one made.
870
+ const collection = await provisionDefaultCalendar(calendarStore, accountConfigId);
871
+ for (const part of parts) {
872
+ const recorded = await recordCalendarSuggestion(calendarSuggestionService, {
873
+ accountConfigId,
874
+ messageId,
875
+ bodyPartId: deriveBodyPartId(messageId, part.partPath),
876
+ source: part.source,
877
+ icalData: part.icalData,
878
+ timezone: collection.timezone,
879
+ });
880
+ if (!recorded.ok) {
881
+ this.log.info({
882
+ messageId,
883
+ partPath: part.partPath,
884
+ code: recorded.error.code,
885
+ reason: recorded.error.message,
886
+ }, "Message carries iCalendar this cannot read; no suggestion offered");
887
+ }
888
+ }
889
+ })
890
+ .catch((error) => {
891
+ this.log.error?.({ messageId, accountConfigId, error: inspect(error) }, "Calendar suggestion derivation failed; body is stored without a card");
892
+ });
893
+ }
894
+ async deriveSenderUnsubscribed(accountConfigId, fromEmail) {
895
+ try {
896
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
897
+ const address = await this.addressService.getAddress(accountConfigId, addressId);
898
+ return address.flags?.unsubscribed?.value === true;
899
+ }
900
+ catch (err) {
901
+ // A genuinely-absent address means "not unsubscribed". Any other
902
+ // failure (AccessDenied, throttle, infra) must NOT be silently
903
+ // downgraded — let it crash so the read-state decision isn't made on
904
+ // bad data.
905
+ if (!(err instanceof NotFoundError))
906
+ throw err;
907
+ return false;
908
+ }
909
+ }
910
+ /**
911
+ * Evaluate the account's active filters against a synced message (RFC 034),
912
+ * BEFORE the single Message update — so a filter's `movedByRemit` flag joins
913
+ * that same UpdateItem. Returns the actions to apply (labels + at most one
914
+ * move); the caller applies them. A no-op returning no actions when no
915
+ * {@link FilterConfig} is wired.
916
+ *
917
+ * Isolation lives in {@link FilterPipeline.evaluate}: this read/decision phase
918
+ * never fails the body store (the #1246 placement precedent). The applies of
919
+ * the returned decision are the caller's and are deliberately unswallowed.
920
+ */
921
+ async evaluateFilters(accountConfigId, accountId, messageId, parsed) {
922
+ if (!this.filterPipeline)
923
+ return { labels: [] };
924
+ return this.filterPipeline.evaluate(accountConfigId, accountId, messageId, toFilterMessage(parsed));
925
+ }
926
+ /**
927
+ * Resolve the placement decision for a message, BEFORE the single Message
928
+ * update — so both the `movedByRemit` flag and the audit verdict join that
929
+ * one UpdateItem instead of a second mutation. Drives both directions (junk → inbox rescue,
930
+ * inbox → junk demote) off the pure {@link classifyPlacement} verdict.
931
+ *
932
+ * Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
933
+ * decided to act (action != leave), confident and unsure alike, so the
934
+ * distribution is queryable on the message; a `move` to enqueue only for a
935
+ * confident verdict; and `placementDecidedAt` whenever a verdict — including
936
+ * "leave" — was genuinely computed (issue #383), so a re-entrant call short-
937
+ * circuits instead of re-deciding a placement the user may have since
938
+ * overridden by hand.
939
+ *
940
+ * Always logs a structured verdict line for confident, actionable verdicts so
941
+ * the real distribution is observable on a live mailbox.
942
+ *
943
+ * The verdict reads the just-computed classification rather than a persisted
944
+ * row, since those fields are not written until the single update. Placement
945
+ * is auxiliary to the body store — the primary artifact (body.eml) is already
946
+ * durable by the time this runs — so a failure here (e.g. a placement-repo
947
+ * query erroring on a schema-drifted DB) is caught and logged loudly with an
948
+ * alertable field instead of failing the surrounding message store. The empty
949
+ * {@link PlacementOutcome} means "no action", whether Remit genuinely decided
950
+ * to leave the message alone or placement itself failed; the alert log is
951
+ * what distinguishes the latter, and neither case marks the message decided.
952
+ */
953
+ async resolvePlacement(messageId, accountId, accountConfigId, parsed, classification) {
954
+ if (!this.placementConfig)
955
+ return {};
956
+ return this.computePlacement(this.placementConfig, messageId, accountId, accountConfigId, parsed, classification).catch((error) => {
957
+ this.log.error?.({
958
+ alert: "body_sync_placement_failed",
959
+ messageId,
960
+ accountId,
961
+ accountConfigId,
962
+ errorName: error?.name,
963
+ error: inspect(error),
964
+ }, "Placement resolution failed; body already stored, continuing without placement (best-effort, non-fatal)");
965
+ return {};
966
+ });
967
+ }
968
+ async computePlacement(placementConfig, messageId, accountId, accountConfigId, parsed, classification) {
969
+ const { mailboxSpecialUseService } = placementConfig;
970
+ const message = await this.messageService.get(messageId);
971
+ // Issue #383 / #1011: placement still runs once per message (RFC 039
972
+ // Non-goals), but the once-per-message guarantee now comes from the
973
+ // caller rather than a guard here — the re-store paths
974
+ // (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
975
+ // true)`) skip the decision steps entirely, so only the pass that first
976
+ // stores a body ever reaches this method.
977
+ const roleMailboxes = await resolveFolderRoleMailboxes(mailboxSpecialUseService, accountId);
978
+ const { junkMailbox, inboxMailbox } = roleMailboxes;
979
+ const placement = placementOf(message.mailboxId, roleMailboxes);
980
+ const fromEmail = extractPrimaryFromEmail(parsed);
981
+ const signals = fromEmail
982
+ ? await this.deriveSenderPlacementSignals(accountConfigId, fromEmail)
983
+ : {
984
+ trust: SenderTrust.Unknown,
985
+ blocked: false,
986
+ neverSpam: false,
987
+ autoArchive: false,
988
+ };
989
+ const { senderTrust, senderOverride } = resolveSenderPlacement({ trust: signals.trust, setAt: signals.trustSetAt }, { blocked: signals.blocked, setAt: signals.blockedSetAt }, signals.neverSpam);
990
+ // The verdict needs the classification signals (providerSpam,
991
+ // authResult, authenticity) that this body-sync pass just derived; the
992
+ // stored row does not carry them yet, so overlay them onto the message.
993
+ const candidate = { ...message, ...classification };
994
+ const verdict = classifyPlacement(candidate, placement, senderTrust, senderOverride);
995
+ // A `leave` verdict — including "nothing confident to say" — carries no
996
+ // audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
997
+ // lower-priority filing preference: it only files a message away when
998
+ // `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
999
+ // junk/inbox verdict computed above. Still marked decided (issue #383):
1000
+ // "leave" is itself a verdict, not "not yet evaluated".
1001
+ if (verdict.action === "leave") {
1002
+ const outcome = autoArchiveApplies(verdict, placement)
1003
+ ? await this.resolveAutoArchive(mailboxSpecialUseService, message, accountId, signals.autoArchive)
1004
+ : {};
1005
+ return { ...outcome, placementDecidedAt: Date.now() };
1006
+ }
1007
+ // Audit verdict — recorded for every actionable verdict (both
1008
+ // confidences), so the distribution is queryable on the message.
1009
+ // Independent of whether a move is enqueued.
1010
+ const audit = {
1011
+ action: verdict.action === "move-to-inbox"
1012
+ ? PlacementAction.MoveToInbox
1013
+ : PlacementAction.MoveToJunk,
1014
+ confidence: verdict.confidence === "confident"
1015
+ ? PlacementConfidence.Confident
1016
+ : PlacementConfidence.Unsure,
1017
+ fromPlacement: placement,
1018
+ reasons: verdict.reasons,
1019
+ dryRun: false,
1020
+ decidedAt: Date.now(),
1021
+ };
1022
+ // Only a confident verdict moves mail. An unsure verdict is recorded
1023
+ // but never enqueues a move.
1024
+ if (verdict.confidence !== "confident") {
1025
+ return { verdict: audit, placementDecidedAt: audit.decidedAt };
1026
+ }
1027
+ const target = verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
1028
+ if (!target)
1029
+ return { verdict: audit, placementDecidedAt: audit.decidedAt };
1030
+ // Structured verdict line — emitted for confident, actionable verdicts
1031
+ // so the real verdict distribution is observable on a live mailbox.
1032
+ this.log.info({
1033
+ messageId,
1034
+ accountId,
1035
+ placement,
1036
+ action: verdict.action,
1037
+ confidence: verdict.confidence,
1038
+ reasons: verdict.reasons,
1039
+ destinationMailboxId: target.mailboxId,
1040
+ }, "Placement verdict");
1041
+ return {
1042
+ verdict: audit,
1043
+ move: {
1044
+ destinationMailboxId: target.mailboxId,
1045
+ destinationPath: target.fullPath,
1046
+ },
1047
+ placementDecidedAt: audit.decidedAt,
1048
+ };
1049
+ }
1050
+ /**
1051
+ * `flags.autoArchive` (issue #300, RFC 039 Decision 3): file a message
1052
+ * straight to Archive, skipping Inbox. Only reached from
1053
+ * {@link computePlacement} when {@link classifyPlacement} had no confident
1054
+ * junk/inbox verdict of its own — `blocked`/DKIM/DMARC always take priority
1055
+ * over this filing preference — and only for the `leave` verdicts
1056
+ * {@link autoArchiveApplies} admits.
1057
+ *
1058
+ * No {@link MessagePlacementVerdict} is recorded: `PlacementAction` (the
1059
+ * audit enum) has only `MoveToInbox`/`MoveToJunk` — issue #300 is scoped to
1060
+ * no TypeSpec change, so an archive move carries no audit verdict, same as
1061
+ * a matched filter's move. The move itself reuses the same
1062
+ * `placementMoveService.moveMessage` path as every other confident move.
1063
+ *
1064
+ * Idempotent the same way {@link classifyPlacement}'s own branches are: a
1065
+ * message already sitting in Archive is left alone, not moved again.
1066
+ */
1067
+ async resolveAutoArchive(mailboxSpecialUseService, message, accountId, autoArchive) {
1068
+ if (!autoArchive)
1069
+ return {};
1070
+ const archiveMailbox = await mailboxSpecialUseService.findArchiveMailbox(accountId);
1071
+ if (!archiveMailbox || message.mailboxId === archiveMailbox.mailboxId) {
1072
+ return {};
1073
+ }
1074
+ this.log.info({
1075
+ messageId: message.messageId,
1076
+ accountId,
1077
+ destinationMailboxId: archiveMailbox.mailboxId,
1078
+ }, "Auto-archive verdict");
1079
+ return {
1080
+ move: {
1081
+ destinationMailboxId: archiveMailbox.mailboxId,
1082
+ destinationPath: archiveMailbox.fullPath,
1083
+ },
1084
+ };
1085
+ }
1086
+ // The old `enqueuePlacementMove` (best-effort, catch-and-log) lived here.
1087
+ // Issue #1271: it ran AFTER `bodyStorageKey` was already durable, so a
1088
+ // failure was swallowed to avoid stranding the message behind the
1089
+ // body-sync skip guard — but that meant `movedByRemit` could be true with
1090
+ // no record the move ever reached IMAP. Replaced by the unswallowed call
1091
+ // inside `applyPostStoreSteps`, sequenced BEFORE `bodyStorageKey` is
1092
+ // written, backed by `PlacementMoveService`'s pending-move marker.
1093
+ /**
1094
+ * Persist one S3 object per non-multipart leaf so the SPA can resolve
1095
+ * `BodyPartResponse.contentUrl` (#298). Keys follow the layout
1096
+ * `accounts/{accountConfigId}/{accountId}/messages/{messageId}/parts/{partPath}`
1097
+ * so they line up with the URL shape `derive/contentUrl.ts` emits.
1098
+ *
1099
+ * The mapper is total (#395 PR B): every leaf gets a `BodyPartContentPair`,
1100
+ * possibly with a zero-byte content for leaves that have no source bytes
1101
+ * (genuinely empty parts, or pathological inputs the positional fallback
1102
+ * couldn't pair). No try/catch is needed; the only failure surface here is
1103
+ * an S3 write itself, which `pMap` surfaces directly.
1104
+ *
1105
+ * If `listBodyParts` returns an empty list (e.g. a legacy message synced
1106
+ * before #133 populated BodyPart rows), this is a no-op.
1107
+ */
1108
+ async storeBodyPartContents(accountConfigId, accountId, messageId, bodyParts, body, options) {
1109
+ if (bodyParts.length === 0) {
1110
+ this.log.debug?.({ messageId }, "No BodyPart rows; skipping per-part storage");
1111
+ return { stored: 0 };
1112
+ }
1113
+ const log = this.log;
1114
+ // A parse of its own: classification and the snippet, which search reads,
1115
+ // need an inline forwarded message's text, which `ignoreEmbedded` keeps out.
1116
+ const pairs = await mapMessagePartsToContent(bodyParts, await parsePartSource(body), {
1117
+ messageId,
1118
+ logger: log.warn || log.debug
1119
+ ? {
1120
+ warn: (obj, msg) => {
1121
+ log.warn?.(obj, msg);
1122
+ },
1123
+ debug: (obj, msg) => {
1124
+ log.debug?.(obj, msg);
1125
+ },
1126
+ }
1127
+ : undefined,
1128
+ });
1129
+ if (pairs.length === 0) {
1130
+ return { stored: 0 };
1131
+ }
1132
+ let stored = 0;
1133
+ await pMap(pairs, async (entry) => {
1134
+ if (options?.skipExisting &&
1135
+ (await this.storageService.bodyPartExists(accountConfigId, accountId, messageId, entry.partPath))) {
1136
+ return;
1137
+ }
1138
+ await this.storageService.storeBodyPart({
1139
+ accountConfigId,
1140
+ accountId,
1141
+ messageId,
1142
+ partPath: entry.partPath,
1143
+ content: entry.content,
1144
+ contentType: entry.contentType,
1145
+ });
1146
+ stored++;
1147
+ }, { concurrency: BODY_PART_STORE_CONCURRENCY });
1148
+ this.log.info({ messageId, partCount: pairs.length, stored }, "Body parts stored");
1149
+ return { stored };
1150
+ }
1151
+ /**
1152
+ * Lazily materialize the per-part S3 objects for an already-synced message
1153
+ * whose parts were deferred during bulk sync (DEFER_BODY_PARTS).
1154
+ *
1155
+ * Called from the read path (the API `describeMessage` handler) before the
1156
+ * SPA fetches any `contentUrl`: the part bytes are served directly from S3 by
1157
+ * CloudFront with no Lambda in the request path, so a missing object would
1158
+ * surface to the SPA as a hard "body-missing" failure. Generating the parts
1159
+ * here — re-parsing the stored `body.eml` with the same `mapBodyPartsToContent`
1160
+ * logic body-sync uses — guarantees every `contentUrl` resolves, while bulk
1161
+ * sync stays at 2 writes/message.
1162
+ *
1163
+ * Idempotent: skips any leaf already on S3, so repeat reads cost only HEAD
1164
+ * checks, and a re-read after a partial failure fills the gaps. Requires the
1165
+ * message body to be stored (`bodyStorageKey`); callers gate on that.
1166
+ *
1167
+ * Warm-open fast path: a single HEAD on the `.materialized` sentinel
1168
+ * short-circuits the whole pass — no body.eml GET, no MIME re-parse, no
1169
+ * per-leaf HEADs. The sentinel is written only after every leaf is confirmed
1170
+ * stored, so its presence guarantees all `contentUrl`s resolve.
1171
+ */
1172
+ async ensureBodyPartsStored(accountConfigId, accountId, messageId, bodyStorageKey) {
1173
+ const bodyParts = await this.envelopeService.listBodyParts(messageId);
1174
+ const carriesMessage = bodyParts.some(isEncapsulatedMessage);
1175
+ const sentinel = carriesMessage
1176
+ ? MATERIALIZED_SENTINEL_V2_PART_PATH
1177
+ : MATERIALIZED_SENTINEL_PART_PATH;
1178
+ if (await this.storageService.bodyPartExists(accountConfigId, accountId, messageId, sentinel)) {
1179
+ this.log.debug?.({ messageId }, "Body parts already materialized (sentinel hit); skipping");
1180
+ return { stored: 0 };
1181
+ }
1182
+ const body = await this.storageService.retrieve(bodyStorageKey);
1183
+ const result = await this.storeBodyPartContents(accountConfigId, accountId, messageId, bodyParts, body, { skipExisting: !carriesMessage });
1184
+ // Reaching here means storeBodyPartContents resolved — `pMap` rejects on
1185
+ // any single failed write, so every leaf is now durably on S3. Drop the
1186
+ // sentinel so subsequent opens take the HEAD-only fast path above.
1187
+ await this.storageService.storeBodyPart({
1188
+ accountConfigId,
1189
+ accountId,
1190
+ messageId,
1191
+ partPath: sentinel,
1192
+ content: Buffer.alloc(0),
1193
+ });
1194
+ return result;
1195
+ }
1196
+ async storeParsedBodyCache(accountConfigId, accountId, messageId, parsed) {
1197
+ await this.storageService.storeParsedBody({
1198
+ accountConfigId,
1199
+ accountId,
1200
+ messageId,
1201
+ parsed: toParsedBody(parsed),
1202
+ });
1203
+ }
1204
+ }