@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
@@ -1,605 +0,0 @@
1
- /**
2
- * Issue #1011: a forced body re-sync must not re-fire standing rules on an
3
- * already-seen message.
4
- *
5
- * The two re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` IMAP fallback, and
6
- * `syncBodies(..., force: true)` (the read-miss re-arm cue) — both re-fetch a
7
- * body whose row already carries `bodyStorageKey`. That is a body-only fetch:
8
- * the decision pass (placement, filters, classification) already ran when the
9
- * body first landed. Re-running it now would move mail the user has since filed
10
- * by hand, re-fire the engagement counter, re-apply labels, and rewrite the
11
- * write-once category.
12
- *
13
- * Each test below presets a message whose body was already stored and whose
14
- * headers WOULD trigger a confident placement demote or a standing filter move
15
- * if the decision pass ran fresh. A regression that drops the `isReStore` skip
16
- * shows up as an unwanted move, not as a passing test relying on the heuristics
17
- * happening to agree.
18
- */
19
-
20
- import assert from "node:assert/strict";
21
- import { Readable } from "node:stream";
22
- import { describe, it } from "node:test";
23
- import type {
24
- FilterItem,
25
- IAddressRepository,
26
- IEnvelopeRepository,
27
- IFilterAnchorRepository,
28
- IFilterRepository,
29
- IMailboxSpecialUseRepository,
30
- IMessageLabelRepository,
31
- IMessageRepository,
32
- IThreadMessageRepository,
33
- MessageItem,
34
- UpdateMessageInput,
35
- } from "@remit/data-ports";
36
- import { NotFoundError } from "@remit/data-ports/errors";
37
- import {
38
- FilterClauseField,
39
- FilterMatchOperator,
40
- FilterState,
41
- MessageCategory,
42
- } from "@remit/domain-enums";
43
- import type { StorageService } from "@remit/storage-service";
44
- import type { PlacementConfig } from "./body-sync.js";
45
- import { BodySyncService } from "./body-sync.js";
46
- import { NO_ACTION } from "./filters/match.js";
47
- import type { FilterConfig } from "./filters/pipeline.js";
48
- import type { PlacementMoveService } from "./placement-move.js";
49
- import type { IImapConnection } from "./types.js";
50
-
51
- const MAILBOXES = {
52
- inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
53
- junk: { mailboxId: "mb-junk", fullPath: "Junk" },
54
- travel: { mailboxId: "mb-travel", fullPath: "Travel" },
55
- };
56
-
57
- /**
58
- * Demote headers: DKIM domain mismatches From, dmarc=fail, untrusted sender.
59
- * `classifyPlacement`'s demote branch (inbox → junk, HIGH bar) confidently acts
60
- * on this set when the message currently sits in Inbox.
61
- */
62
- const DEMOTE_EML = Buffer.from(
63
- [
64
- "From: Support <support@evil-mimic.example>",
65
- "To: me@example.com",
66
- "Subject: Verify your account",
67
- "Authentication-Results: mx.example.com; dmarc=fail",
68
- "DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
69
- "X-Spam-Status: No, score=0.1",
70
- "Content-Type: text/plain",
71
- "",
72
- "body",
73
- ].join("\r\n"),
74
- );
75
-
76
- /**
77
- * A standing literal filter that matches subjects containing "invitation" and
78
- * moves them to `destinationMailboxId`. Used to assert that a re-store does not
79
- * re-fire the filter on an already-seen message.
80
- */
81
- const buildInvitationFilter = (destinationMailboxId: string): FilterItem =>
82
- ({
83
- filterId: "flt-invite",
84
- accountConfigId: "cfg-1",
85
- name: "Invitations",
86
- scope: "Standing",
87
- state: FilterState.Active,
88
- hasAnchor: false,
89
- ruleChangedAt: 1,
90
- matchOperator: FilterMatchOperator.And,
91
- literalClauses: [{ field: FilterClauseField.Subject, value: "invitation" }],
92
- actionLabelId: NO_ACTION,
93
- actionMailboxId: destinationMailboxId,
94
- createdAt: 1,
95
- updatedAt: 1,
96
- }) as unknown as FilterItem;
97
-
98
- const INVITATION_EML = Buffer.from(
99
- [
100
- "From: Someone <someone@example.com>",
101
- "To: me@example.com",
102
- "Subject: You have a new invitation to Travel",
103
- "Content-Type: text/plain",
104
- "",
105
- "details",
106
- ].join("\r\n"),
107
- );
108
-
109
- interface Harness {
110
- service: BodySyncService;
111
- message: MessageItem;
112
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
113
- moves: Array<{ messageId: string; destinationMailboxId: string }>;
114
- }
115
-
116
- const noSuchKeyError = () =>
117
- Object.assign(new Error("missing"), { name: "NoSuchKey" });
118
-
119
- /**
120
- * Build a BodySyncService harness with both placement and filter configs wired,
121
- * so we can assert that neither fires during a re-store.
122
- */
123
- const buildHarness = (
124
- message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
125
- retrieve: () => Promise<Buffer>,
126
- filter: FilterItem | null,
127
- ): Harness => {
128
- const messageUpdates: Array<{
129
- messageId: string;
130
- input: UpdateMessageInput;
131
- }> = [];
132
- const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
133
-
134
- const messageRow = {
135
- uid: 1,
136
- mailboxId: MAILBOXES.inbox.mailboxId,
137
- ...message,
138
- } as unknown as MessageItem;
139
-
140
- const messageService = {
141
- get: async (messageId: string) =>
142
- messageId === message.messageId ? messageRow : undefined,
143
- update: async (messageId: string, input: UpdateMessageInput) => {
144
- messageUpdates.push({ messageId, input });
145
- Object.assign(messageRow, input);
146
- },
147
- } as unknown as IMessageRepository;
148
-
149
- const threadMessageService = {
150
- findAllByMessageId: async () => [
151
- {
152
- threadMessageId: "tm-1",
153
- messageId: message.messageId,
154
- mailboxId: messageRow.mailboxId,
155
- sentDate: 1,
156
- isRead: false,
157
- isDeleted: false,
158
- hasStars: false,
159
- hasAttachment: false,
160
- },
161
- ],
162
- update: async () => {},
163
- } as unknown as IThreadMessageRepository;
164
-
165
- const storageService = {
166
- retrieve,
167
- storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
168
- storeMessageBodyStream: async () => ({
169
- uri: `s3://bodies/${message.messageId}`,
170
- }),
171
- storeParsedBody: async () => {},
172
- listBodyParts: async () => [],
173
- } as unknown as StorageService;
174
-
175
- const addressService = {
176
- getAddress: async () => {
177
- throw new NotFoundError("Address not found");
178
- },
179
- incrementInboundCount: async () => {},
180
- } as unknown as IAddressRepository;
181
-
182
- const envelopeService = {
183
- listBodyParts: async () => [],
184
- } as unknown as IEnvelopeRepository;
185
-
186
- const mailboxSpecialUseService = {
187
- findJunkMailbox: async () => MAILBOXES.junk,
188
- findArchiveMailbox: async () => null,
189
- findInboxMailbox: async () => MAILBOXES.inbox,
190
- } as unknown as IMailboxSpecialUseRepository;
191
-
192
- const placementMoveService = {
193
- moveMessage: async (
194
- _accountConfigId: string,
195
- messageId: string,
196
- destinationMailboxId: string,
197
- ) => {
198
- moves.push({ messageId, destinationMailboxId });
199
- messageRow.mailboxId = destinationMailboxId;
200
- },
201
- } as unknown as PlacementMoveService;
202
-
203
- const placementConfig: PlacementConfig = {
204
- mailboxSpecialUseService,
205
- placementMoveService,
206
- };
207
-
208
- const filterConfig: FilterConfig | undefined = filter
209
- ? {
210
- filterService: {
211
- listByAccountAndState: async () => [filter],
212
- refreshExpiry: async (f: FilterItem) => f,
213
- } as unknown as IFilterRepository,
214
- filterAnchorService: {
215
- get: async () => undefined,
216
- } as unknown as IFilterAnchorRepository,
217
- messageLabelService: {
218
- apply: async () => {},
219
- } as unknown as IMessageLabelRepository,
220
- placementMoveService,
221
- }
222
- : undefined;
223
-
224
- const service = new BodySyncService(
225
- messageService,
226
- storageService,
227
- threadMessageService,
228
- addressService,
229
- envelopeService,
230
- { info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
231
- placementConfig,
232
- filterConfig,
233
- );
234
-
235
- return { service, message: messageRow, messageUpdates, moves };
236
- };
237
-
238
- const noSuchKeyConn = (body: Buffer): IImapConnection =>
239
- ({
240
- openBox: async () => {},
241
- fetchMessageBody: async () => body,
242
- }) as unknown as IImapConnection;
243
-
244
- const streamBody = (body: Buffer): Readable =>
245
- Readable.from([body]) as unknown as Readable;
246
-
247
- describe("issue #1011: forced re-store does not re-fire standing rules", () => {
248
- it("syncBodies(force: true) does not re-evaluate placement on a stored message", async () => {
249
- const harness = buildHarness(
250
- {
251
- messageId: "m-1",
252
- mailboxId: MAILBOXES.inbox.mailboxId,
253
- bodyStorageKey: "s3://bodies/m-1",
254
- // Already classified and placed — category and placementDecidedAt
255
- // set by a prior, genuine first-store pass.
256
- category: MessageCategory.personal,
257
- placementDecidedAt: 500,
258
- },
259
- async () => {
260
- throw new Error("force path must not retrieve from storage");
261
- },
262
- null, // no filter config
263
- );
264
-
265
- const connection = {
266
- openBox: async () => {},
267
- async *fetchMessageBodies(uids: number[]) {
268
- for (const uid of uids) {
269
- yield { uid, source: streamBody(DEMOTE_EML) };
270
- }
271
- },
272
- } as unknown as IImapConnection;
273
-
274
- const result = await harness.service.syncBodies(
275
- ["m-1"],
276
- "acc-1",
277
- "cfg-1",
278
- { fullPath: "INBOX", placement: "inbox" },
279
- async () => connection,
280
- true,
281
- );
282
-
283
- assert.deepEqual(result.syncedMessageIds, ["m-1"], "message is synced");
284
-
285
- // No placement move — a hand-rescued message stays where the user put it.
286
- assert.deepEqual(
287
- harness.moves,
288
- [],
289
- "a forced re-store must not re-decide placement, even though headers would demote",
290
- );
291
- assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
292
-
293
- // The update writes only bodyStorageKey — no placement or category churn.
294
- const update = harness.messageUpdates[0];
295
- assert.equal(update?.input.movedByRemit, undefined);
296
- assert.equal(update?.input.placementVerdict, undefined);
297
- assert.equal(update?.input.placementDecidedAt, undefined);
298
- assert.equal(update?.input.category, undefined);
299
- assert.deepEqual(
300
- Object.keys(update?.input ?? {}).sort(),
301
- ["bodyStorageKey"],
302
- "re-store must write only bodyStorageKey",
303
- );
304
- });
305
-
306
- it("syncBodies(force: true) does not re-fire a standing filter on a stored message", async () => {
307
- const harness = buildHarness(
308
- {
309
- messageId: "m-1",
310
- mailboxId: MAILBOXES.travel.mailboxId,
311
- bodyStorageKey: "s3://bodies/m-1",
312
- category: MessageCategory.personal,
313
- },
314
- async () => {
315
- throw new Error("force path must not retrieve from storage");
316
- },
317
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
318
- );
319
-
320
- const connection = {
321
- openBox: async () => {},
322
- async *fetchMessageBodies(uids: number[]) {
323
- for (const uid of uids) {
324
- yield { uid, source: streamBody(INVITATION_EML) };
325
- }
326
- },
327
- } as unknown as IImapConnection;
328
-
329
- const result = await harness.service.syncBodies(
330
- ["m-1"],
331
- "acc-1",
332
- "cfg-1",
333
- { fullPath: "INBOX", placement: "inbox" },
334
- async () => connection,
335
- true,
336
- );
337
-
338
- assert.deepEqual(result.syncedMessageIds, ["m-1"]);
339
-
340
- // No filter move — the message already sits in its filter destination
341
- // (Travel) and a re-store must not re-fire the filter.
342
- assert.deepEqual(
343
- harness.moves,
344
- [],
345
- "a forced re-store must not re-fire standing filters",
346
- );
347
- // The update writes only bodyStorageKey — no filterMove marker.
348
- const update = harness.messageUpdates[0];
349
- assert.equal(update?.input.filterMove, undefined);
350
- assert.equal(update?.input.movedByRemit, undefined);
351
- assert.deepEqual(
352
- Object.keys(update?.input ?? {}).sort(),
353
- ["bodyStorageKey"],
354
- "re-store must write only bodyStorageKey",
355
- );
356
- });
357
-
358
- it("fetchAndGetBody NoSuchKey fallback does not re-fire placement or filters", async () => {
359
- const harness = buildHarness(
360
- {
361
- messageId: "m-1",
362
- mailboxId: MAILBOXES.inbox.mailboxId,
363
- bodyStorageKey: "s3://bodies/m-1",
364
- category: MessageCategory.personal,
365
- placementDecidedAt: 500,
366
- },
367
- async () => {
368
- throw noSuchKeyError();
369
- },
370
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
371
- );
372
-
373
- await harness.service.fetchAndGetBody(
374
- "m-1",
375
- "acc-1",
376
- "cfg-1",
377
- { fullPath: "INBOX", placement: "inbox" },
378
- async () => noSuchKeyConn(DEMOTE_EML),
379
- );
380
-
381
- assert.deepEqual(
382
- harness.moves,
383
- [],
384
- "the NoSuchKey fallback must not re-decide placement or re-fire filters",
385
- );
386
- assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
387
-
388
- const update = harness.messageUpdates[0];
389
- assert.equal(update?.input.movedByRemit, undefined);
390
- assert.equal(update?.input.placementVerdict, undefined);
391
- assert.equal(update?.input.filterMove, undefined);
392
- assert.deepEqual(
393
- Object.keys(update?.input ?? {}).sort(),
394
- ["bodyStorageKey"],
395
- "re-store must write only bodyStorageKey",
396
- );
397
- });
398
-
399
- it("a first-store via fetchAndGetBody (no bodyStorageKey) still runs placement and filters", async () => {
400
- // Regression guard: the re-store skip must not disable decisions on a
401
- // genuine first store that happens to arrive via the read path.
402
- const harness = buildHarness(
403
- {
404
- messageId: "m-1",
405
- mailboxId: MAILBOXES.inbox.mailboxId,
406
- // No bodyStorageKey — a genuine first store.
407
- },
408
- async () => {
409
- throw new Error("must not retrieve from storage on a first store");
410
- },
411
- buildInvitationFilter(MAILBOXES.travel.mailboxId),
412
- );
413
-
414
- await harness.service.fetchAndGetBody(
415
- "m-1",
416
- "acc-1",
417
- "cfg-1",
418
- { fullPath: "INBOX", placement: "inbox" },
419
- async () => noSuchKeyConn(INVITATION_EML),
420
- );
421
-
422
- // The filter fires — it is a first store, not a re-store.
423
- assert.deepEqual(
424
- harness.moves,
425
- [{ messageId: "m-1", destinationMailboxId: MAILBOXES.travel.mailboxId }],
426
- "a genuine first store must run the standing filter",
427
- );
428
- const update = harness.messageUpdates[0];
429
- assert.equal(update?.input.filterMove?.filterId, "flt-invite");
430
- assert.equal(update?.input.movedByRemit, true);
431
- });
432
-
433
- it("a forced batch re-stores a stored message (bytes only) but first-stores a never-stored one", async () => {
434
- // A forced batch may contain both never-stored messages (first store —
435
- // full decisions) and already-stored ones (re-store — bytes only).
436
- const storedMessage = {
437
- messageId: "m-stored",
438
- mailboxId: MAILBOXES.inbox.mailboxId,
439
- uid: 1,
440
- bodyStorageKey: "s3://bodies/m-stored",
441
- category: MessageCategory.personal,
442
- } as unknown as MessageItem;
443
- const newMessage = {
444
- messageId: "m-new",
445
- mailboxId: MAILBOXES.inbox.mailboxId,
446
- uid: 2,
447
- } as unknown as MessageItem;
448
-
449
- const messageUpdates: Array<{
450
- messageId: string;
451
- input: UpdateMessageInput;
452
- }> = [];
453
- const moves: Array<{ messageId: string; destinationMailboxId: string }> =
454
- [];
455
-
456
- const messageService = {
457
- get: async (messageId: string) =>
458
- messageId === "m-stored"
459
- ? storedMessage
460
- : messageId === "m-new"
461
- ? newMessage
462
- : undefined,
463
- update: async (id: string, input: UpdateMessageInput) => {
464
- messageUpdates.push({ messageId: id, input });
465
- const row = id === "m-stored" ? storedMessage : newMessage;
466
- Object.assign(row, input);
467
- },
468
- } as unknown as IMessageRepository;
469
-
470
- const threadMessageService = {
471
- findAllByMessageId: async (
472
- _accountConfigId: string,
473
- messageId: string,
474
- ) => [
475
- {
476
- threadMessageId: `tm-${messageId}`,
477
- messageId,
478
- mailboxId:
479
- messageId === "m-stored"
480
- ? storedMessage.mailboxId
481
- : newMessage.mailboxId,
482
- sentDate: 1,
483
- isRead: false,
484
- isDeleted: false,
485
- hasStars: false,
486
- hasAttachment: false,
487
- },
488
- ],
489
- update: async () => {},
490
- } as unknown as IThreadMessageRepository;
491
-
492
- const storageService = {
493
- retrieve: async () => {
494
- throw new Error("must not retrieve on force path");
495
- },
496
- storeMessageBodyStream: async () => ({
497
- uri: "s3://bodies/m",
498
- }),
499
- storeParsedBody: async () => {},
500
- listBodyParts: async () => [],
501
- } as unknown as StorageService;
502
-
503
- const addressService = {
504
- getAddress: async () => {
505
- throw new NotFoundError("not found");
506
- },
507
- incrementInboundCount: async () => {},
508
- } as unknown as IAddressRepository;
509
-
510
- const envelopeService = {
511
- listBodyParts: async () => [],
512
- } as unknown as IEnvelopeRepository;
513
-
514
- const mailboxSpecialUseService = {
515
- findJunkMailbox: async () => MAILBOXES.junk,
516
- findArchiveMailbox: async () => null,
517
- findInboxMailbox: async () => MAILBOXES.inbox,
518
- } as unknown as IMailboxSpecialUseRepository;
519
-
520
- const placementMoveService = {
521
- moveMessage: async (
522
- _accountConfigId: string,
523
- messageId: string,
524
- destinationMailboxId: string,
525
- ) => {
526
- moves.push({ messageId, destinationMailboxId });
527
- },
528
- } as unknown as PlacementMoveService;
529
-
530
- const filterConfig: FilterConfig = {
531
- filterService: {
532
- listByAccountAndState: async () => [
533
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
534
- ],
535
- refreshExpiry: async (f: FilterItem) => f,
536
- } as unknown as IFilterRepository,
537
- filterAnchorService: {
538
- get: async () => undefined,
539
- } as unknown as IFilterAnchorRepository,
540
- messageLabelService: {
541
- apply: async () => {},
542
- } as unknown as IMessageLabelRepository,
543
- placementMoveService,
544
- };
545
-
546
- const service = new BodySyncService(
547
- messageService,
548
- storageService,
549
- threadMessageService,
550
- addressService,
551
- envelopeService,
552
- { info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
553
- { mailboxSpecialUseService, placementMoveService },
554
- filterConfig,
555
- );
556
-
557
- // UID 1 is the stored message, UID 2 is new. fetchMessageBodies yields
558
- // in order; the stored one (DEMOTE_EML) would demote if placement ran,
559
- // the new one (INVITATION_EML) would trigger the filter.
560
- const connection = {
561
- openBox: async () => {},
562
- async *fetchMessageBodies() {
563
- yield { uid: 1, source: streamBody(DEMOTE_EML) };
564
- yield { uid: 2, source: streamBody(INVITATION_EML) };
565
- },
566
- } as unknown as IImapConnection;
567
-
568
- const result = await service.syncBodies(
569
- ["m-stored", "m-new"],
570
- "acc-1",
571
- "cfg-1",
572
- { fullPath: "INBOX", placement: "inbox" },
573
- async () => connection,
574
- true,
575
- );
576
-
577
- // Both messages were handled.
578
- assert.deepEqual(result.syncedMessageIds.sort(), ["m-new", "m-stored"]);
579
-
580
- // The stored message is a re-store: no placement move, no filter run.
581
- const storedUpdate = messageUpdates.find((u) => u.messageId === "m-stored");
582
- assert.equal(storedUpdate?.input.movedByRemit, undefined);
583
- assert.equal(storedUpdate?.input.filterMove, undefined);
584
- assert.deepEqual(
585
- Object.keys(storedUpdate?.input ?? {}).sort(),
586
- ["bodyStorageKey"],
587
- "stored message in a forced batch must re-store (bytes only)",
588
- );
589
-
590
- // The new message is a first-store: the filter fires.
591
- assert.deepEqual(
592
- moves,
593
- [
594
- {
595
- messageId: "m-new",
596
- destinationMailboxId: MAILBOXES.junk.mailboxId,
597
- },
598
- ],
599
- "only the never-stored message in the forced batch takes the decision pass",
600
- );
601
- const newUpdate = messageUpdates.find((u) => u.messageId === "m-new");
602
- assert.equal(newUpdate?.input.movedByRemit, true);
603
- assert.equal(newUpdate?.input.filterMove?.filterId, "flt-invite");
604
- });
605
- });