@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,363 +0,0 @@
1
- import type {
2
- IAccountConfigRepository,
3
- IAccountRepository,
4
- IAddressRepository,
5
- IMessageRepository,
6
- IThreadMessageRepository,
7
- ThreadMessageItem,
8
- } from "@remit/data-ports";
9
- import { MessageClassificationState } from "@remit/domain-enums";
10
- import type { StorageService } from "@remit/storage-service";
11
- import { parseMessageBody } from "./body-parse.js";
12
- import { extractSnippet } from "./body-sync.js";
13
- import { classifyParsedMessage } from "./classify-message.js";
14
- import { extractListId } from "./filters/list-id.js";
15
- import {
16
- type FolderPlacementLookup,
17
- resolveAccountFolderPlacements,
18
- } from "./folder-placement.js";
19
- import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
20
- import {
21
- denormalizeMessageCategory,
22
- hasDecidedCategory,
23
- } from "./message-category.js";
24
-
25
- const DEFAULT_BATCH_SIZE = 200;
26
-
27
- /**
28
- * Where the full-corpus pass left off: the identity of the account it was
29
- * working through, and the page cursor within that account. Resuming looks the
30
- * account up by id and re-opens it at its saved cursor, rather than re-scanning
31
- * the whole corpus from the top after an interruption. A checkpoint naming no
32
- * configured account restarts the pass.
33
- */
34
- export interface ClassificationBackfillCheckpoint {
35
- accountConfigId: string;
36
- continuationToken?: string;
37
- }
38
-
39
- /**
40
- * Persistence for {@link ClassificationBackfillCheckpoint}, injected so the
41
- * pass stays testable with an in-memory fake; the real entrypoint backs it
42
- * with a file. `clear()` runs once the whole corpus has been scanned, so a
43
- * later run starts fresh rather than reading a stale finished-run checkpoint.
44
- */
45
- export interface ClassificationBackfillCheckpointStore {
46
- load(): Promise<ClassificationBackfillCheckpoint | undefined>;
47
- save(checkpoint: ClassificationBackfillCheckpoint): Promise<void>;
48
- clear(): Promise<void>;
49
- }
50
-
51
- export interface ClassificationBackfillDeps {
52
- accountConfigService: Pick<IAccountConfigRepository, "listAll">;
53
- accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
54
- mailboxSpecialUseService: FolderPlacementLookup;
55
- /** Reads the sender's `Address.flags.category` override (issue #299). */
56
- addressService: Pick<IAddressRepository, "getAddress">;
57
- threadMessageService: Pick<
58
- IThreadMessageRepository,
59
- "listByAccount" | "findAllByMessageId" | "update"
60
- >;
61
- messageService: Pick<IMessageRepository, "get" | "update">;
62
- storageService: Pick<StorageService, "retrieve">;
63
- }
64
-
65
- export interface ClassificationBackfillLogger {
66
- info(obj: Record<string, unknown>, msg: string): void;
67
- error?(obj: Record<string, unknown>, msg: string): void;
68
- }
69
-
70
- export interface ClassificationBackfillTotals {
71
- scanned: number;
72
- alreadyExamined: number;
73
- alreadyCategorized: number;
74
- skippedNoBody: number;
75
- backfilled: number;
76
- failed: number;
77
- }
78
-
79
- export interface ClassificationBackfillResult
80
- extends ClassificationBackfillTotals {
81
- failedThreadMessageIds: string[];
82
- }
83
-
84
- export interface ClassificationBackfillProgress
85
- extends ClassificationBackfillTotals {
86
- accountConfigId: string;
87
- }
88
-
89
- export interface ClassificationBackfillOptions {
90
- /** Rows fetched per `listByAccount` page. */
91
- batchSize?: number;
92
- checkpointStore?: ClassificationBackfillCheckpointStore;
93
- logger?: ClassificationBackfillLogger;
94
- /** Called once per page, after that page's rows are settled. */
95
- onProgress?: (progress: ClassificationBackfillProgress) => void;
96
- }
97
-
98
- const emptyTotals = (): ClassificationBackfillTotals => ({
99
- scanned: 0,
100
- alreadyExamined: 0,
101
- alreadyCategorized: 0,
102
- skippedNoBody: 0,
103
- backfilled: 0,
104
- failed: 0,
105
- });
106
-
107
- /**
108
- * Read the stored raw source, classify it once, and record that the
109
- * classifier ran. Kept as its own function (rather than inline in a try/catch)
110
- * so the caller can contain a failure with `.then(fulfilled, rejected)`
111
- * instead of a block catch, which keeps the `try` around the one call whose
112
- * failure this contains.
113
- *
114
- * The ThreadMessage rows are denormalized BEFORE the Message update, for the
115
- * same reason the deleted in-sync backfill did it that way (issue #320): the
116
- * signal this pass selects by — `classificationState` — is written in the
117
- * Message update, so a failure between the two writes leaves both undone and
118
- * the rerun redoes them. Writing the Message first would strand the
119
- * denormalized rows at `uncategorized` forever — the row reads as examined and
120
- * the retry returns early.
121
- */
122
- const classifyAndApply = async (
123
- deps: ClassificationBackfillDeps,
124
- accountConfigId: string,
125
- row: ThreadMessageItem,
126
- bodyStorageKey: string,
127
- placement: FolderPlacement,
128
- ): Promise<void> => {
129
- const body = await deps.storageService.retrieve(bodyStorageKey);
130
- const parsed = await parseMessageBody(body);
131
- const classification = await classifyParsedMessage(
132
- deps.addressService,
133
- accountConfigId,
134
- parsed,
135
- placement,
136
- );
137
-
138
- // The same denormalized fields the body-store path writes (see
139
- // `applyPostStoreSteps`), not just the category: the snippet and `List-Id`
140
- // are derived from the same bytes already in hand, and the rows this pass
141
- // reaches never got them — the pass that stored the body is the pass that
142
- // classifies, and it never ran for this cohort.
143
- await denormalizeMessageCategory(
144
- { threadMessageService: deps.threadMessageService },
145
- accountConfigId,
146
- row.messageId,
147
- {
148
- category: classification.category,
149
- snippet: extractSnippet(parsed),
150
- listId: extractListId(parsed),
151
- },
152
- );
153
-
154
- await deps.messageService.update(row.messageId, {
155
- ...classification,
156
- classificationState: MessageClassificationState.Examined,
157
- });
158
- };
159
-
160
- /**
161
- * One-time, resumable pass that classifies the stored-body cohort the
162
- * classifier has never examined (issue #1197): rows whose bodies were synced
163
- * before the classifier reached them, which carry the `NotExamined` sentinel
164
- * on `Message.classificationState` and so stay `uncategorized` in the list —
165
- * the field is what selects the cohort, and nothing on the sync path selects
166
- * it. Read-only against the stored raw source: it never opens IMAP, and it
167
- * has no placement or filter side effects — the same bounds the in-sync
168
- * backfill #1173 removed had. Index-time moves and filter actions are
169
- * decisions that already ran (or were declined) when the body first landed;
170
- * re-running them would move mail the user has since filed by hand.
171
- *
172
- * A row is a candidate when its Message carries `NotExamined` — the named
173
- * pending state, never inferred from `category` or from `bodyStorageKey` —
174
- * and has a `bodyStorageKey`; a candidate without a stored body has nothing
175
- * local to read, and the ordinary sync path will classify it once the body
176
- * lands. A candidate whose category is already decided is marked `Examined`
177
- * without re-deriving anything: those rows were classified by a pass that
178
- * predates the field, and RFC 034 Decision 3.1's write-once category must not
179
- * be recomputed (#355). The classification itself is
180
- * {@link classifyParsedMessage} — the same rule table, and the same sender
181
- * override, the body-store path classifies with.
182
- *
183
- * Chunked by `listByAccount`'s existing keyset pagination, one account at a
184
- * time in account-id order, so an interrupted run and its resume walk the
185
- * same sequence. A failure reading, parsing, or classifying one message is
186
- * contained to that message — logged, counted, and the pass continues: one
187
- * unreadable object must not strand the rest of the corpus.
188
- */
189
- export const backfillClassifications = async (
190
- deps: ClassificationBackfillDeps,
191
- options: ClassificationBackfillOptions = {},
192
- ): Promise<ClassificationBackfillResult> => {
193
- const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
194
- const { logger, checkpointStore } = options;
195
-
196
- const accounts = [...(await deps.accountConfigService.listAll())].sort(
197
- (left, right) => left.accountConfigId.localeCompare(right.accountConfigId),
198
- );
199
- const startingCheckpoint = await checkpointStore?.load();
200
- const checkpointedIndex = startingCheckpoint
201
- ? accounts.findIndex(
202
- (account) =>
203
- account.accountConfigId === startingCheckpoint.accountConfigId,
204
- )
205
- : -1;
206
-
207
- if (startingCheckpoint && checkpointedIndex === -1) {
208
- logger?.info(
209
- { accountConfigId: startingCheckpoint.accountConfigId },
210
- "Classification backfill checkpoint names no configured account; restarting the pass",
211
- );
212
- }
213
-
214
- const startIndex = checkpointedIndex === -1 ? 0 : checkpointedIndex;
215
- const startContinuationToken =
216
- checkpointedIndex === -1
217
- ? undefined
218
- : startingCheckpoint?.continuationToken;
219
-
220
- const totals = emptyTotals();
221
- const failedThreadMessageIds: string[] = [];
222
- // Messages settled this run. More than one ThreadMessage row can index one
223
- // Message, and the candidate fact lives on the Message, so without this a
224
- // second row would re-read the same body and re-classify it within the
225
- // same page — idempotent in outcome, wasteful in storage reads.
226
- const examinedMessageIds = new Set<string>();
227
-
228
- for (
229
- let accountIndex = startIndex;
230
- accountIndex < accounts.length;
231
- accountIndex++
232
- ) {
233
- const account = accounts[accountIndex];
234
- let continuationToken: string | undefined =
235
- accountIndex === startIndex ? startContinuationToken : undefined;
236
- const mailAccounts = await deps.accountService.listAllByAccountConfig(
237
- account.accountConfigId,
238
- );
239
- const placements = await resolveAccountFolderPlacements(
240
- deps.mailboxSpecialUseService,
241
- mailAccounts.map((mailAccount) => mailAccount.accountId),
242
- );
243
-
244
- do {
245
- const page = await deps.threadMessageService.listByAccount(
246
- account.accountConfigId,
247
- { limit: batchSize, continuationToken },
248
- );
249
-
250
- totals.scanned += page.items.length;
251
-
252
- if (page.items.length > 0) {
253
- const messages = await deps.messageService.get(
254
- page.items.map((row) => row.messageId),
255
- );
256
- const messageByMessageId = new Map(
257
- messages.map((message) => [message.messageId, message]),
258
- );
259
-
260
- for (const row of page.items) {
261
- const message = messageByMessageId.get(row.messageId);
262
- if (!message?.bodyStorageKey) {
263
- totals.skippedNoBody++;
264
- continue;
265
- }
266
-
267
- if (
268
- message.classificationState !==
269
- MessageClassificationState.NotExamined ||
270
- examinedMessageIds.has(row.messageId)
271
- ) {
272
- totals.alreadyExamined++;
273
- continue;
274
- }
275
-
276
- // Classified by a pass that predates the field: record that it
277
- // ran, and touch nothing else — the write-once category is not
278
- // recomputed (#355), and the derived fields that pass wrote are
279
- // already on the row.
280
- if (hasDecidedCategory(message.category)) {
281
- const outcome = await deps.messageService
282
- .update(row.messageId, {
283
- classificationState: MessageClassificationState.Examined,
284
- })
285
- .then(
286
- () => null,
287
- (error: unknown) => error,
288
- );
289
-
290
- if (outcome !== null) {
291
- totals.failed++;
292
- failedThreadMessageIds.push(row.threadMessageId);
293
- logger?.error?.(
294
- {
295
- threadMessageId: row.threadMessageId,
296
- messageId: row.messageId,
297
- error:
298
- outcome instanceof Error
299
- ? outcome.message
300
- : String(outcome),
301
- },
302
- "Classification backfill failed to record an already-categorized message; leaving it for a later pass",
303
- );
304
- continue;
305
- }
306
-
307
- examinedMessageIds.add(row.messageId);
308
- totals.alreadyCategorized++;
309
- continue;
310
- }
311
-
312
- const outcome = await classifyAndApply(
313
- deps,
314
- account.accountConfigId,
315
- row,
316
- message.bodyStorageKey,
317
- placements.get(row.mailboxId) ?? "other",
318
- ).then(
319
- () => null,
320
- (error: unknown) => error,
321
- );
322
-
323
- if (outcome !== null) {
324
- totals.failed++;
325
- failedThreadMessageIds.push(row.threadMessageId);
326
- logger?.error?.(
327
- {
328
- threadMessageId: row.threadMessageId,
329
- messageId: row.messageId,
330
- error:
331
- outcome instanceof Error ? outcome.message : String(outcome),
332
- },
333
- "Classification backfill failed for a message; leaving it for a later pass",
334
- );
335
- continue;
336
- }
337
-
338
- examinedMessageIds.add(row.messageId);
339
- totals.backfilled++;
340
- }
341
- }
342
-
343
- continuationToken = page.continuationToken;
344
- await checkpointStore?.save({
345
- accountConfigId: account.accountConfigId,
346
- continuationToken,
347
- });
348
-
349
- logger?.info(
350
- { accountConfigId: account.accountConfigId, ...totals },
351
- "Classification backfill progress",
352
- );
353
- options.onProgress?.({
354
- accountConfigId: account.accountConfigId,
355
- ...totals,
356
- });
357
- } while (continuationToken);
358
- }
359
-
360
- await checkpointStore?.clear();
361
-
362
- return { ...totals, failedThreadMessageIds };
363
- };
@@ -1,205 +0,0 @@
1
- /**
2
- * Tests for `classifyParsedMessage` (issue #1197): the one classification
3
- * implementation shared by the body-store path and the classification
4
- * backfill, so the two can never classify the same bytes differently. Pins
5
- * the `Address.flags.category` override contract (issue #299, RFC 039
6
- * Decision 3) on its own, rather than only through body-sync's public
7
- * surface: the override wins outright, a genuinely-absent Address is "no
8
- * override", and any other read failure propagates rather than silently
9
- * classifying by headers when the user asked for something else.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
14
- import type { AddressItem, IAddressRepository } from "@remit/data-ports";
15
- import { NotFoundError } from "@remit/data-ports/errors";
16
- import { DisplayNameCorrespondence } from "@remit/domain-enums";
17
- import { parseMessageBody } from "./body-parse.js";
18
- import {
19
- classifyParsedMessage,
20
- extractPrimaryFromEmail,
21
- } from "./classify-message.js";
22
-
23
- const PLAIN_EML = Buffer.from(
24
- [
25
- "From: Alice <alice@example.com>",
26
- "To: me@example.com",
27
- "Subject: Hi",
28
- "Content-Type: text/plain",
29
- "",
30
- "hi",
31
- ].join("\r\n"),
32
- );
33
-
34
- const LIST_EML = Buffer.from(
35
- [
36
- "From: Alice <alice@example.com>",
37
- "To: me@example.com",
38
- "Subject: Newsletter",
39
- "List-Id: <news.example.com>",
40
- "List-Unsubscribe: <https://example.com/unsub>",
41
- "Content-Type: text/plain",
42
- "",
43
- "this week in news",
44
- ].join("\r\n"),
45
- );
46
-
47
- const IMPERSONATION_EML = Buffer.from(
48
- [
49
- "From: InfoMedics <billing@serviceupdatebank.atlassian.net>",
50
- "To: me@example.com",
51
- "Subject: Openstaande factuur",
52
- "DKIM-Signature: v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
53
- "Content-Type: text/html",
54
- "",
55
- '<a href="https://betaal-vordering.example.org/pay">Betaal uw factuur</a>',
56
- ].join("\r\n"),
57
- );
58
-
59
- const addressService = (
60
- behavior: (accountConfigId: string, addressId: string) => unknown,
61
- ): Pick<IAddressRepository, "getAddress"> => ({
62
- getAddress: (async (accountConfigId: string, addressId: string) =>
63
- behavior(
64
- accountConfigId,
65
- addressId,
66
- )) as unknown as IAddressRepository["getAddress"],
67
- });
68
-
69
- const withFlags =
70
- (flags: AddressItem["flags"]): (() => AddressItem) =>
71
- () =>
72
- ({ flags }) as AddressItem;
73
-
74
- describe("classifyParsedMessage", () => {
75
- it("answers from the header rule table when the sender has no override", async () => {
76
- const parsed = await parseMessageBody(LIST_EML);
77
-
78
- const classification = await classifyParsedMessage(
79
- addressService(async () => {
80
- throw new NotFoundError("no Address row");
81
- }),
82
- "acc-1",
83
- parsed,
84
- "inbox",
85
- );
86
-
87
- assert.equal(classification.category, "newsletter");
88
- assert.equal(classification.hasListUnsubscribe, true);
89
- });
90
-
91
- it("substitutes the sender's flags.category override outright", async () => {
92
- const parsed = await parseMessageBody(PLAIN_EML);
93
-
94
- const classification = await classifyParsedMessage(
95
- addressService(withFlags({ category: { value: "marketing", setAt: 1 } })),
96
- "acc-1",
97
- parsed,
98
- "inbox",
99
- );
100
-
101
- // The header table alone would answer `personal`; the override wins
102
- // without blending (RFC 039 Decision 3).
103
- assert.equal(classification.category, "marketing");
104
- });
105
-
106
- it("propagates an Address read failure rather than classifying by headers", async () => {
107
- const parsed = await parseMessageBody(PLAIN_EML);
108
-
109
- await assert.rejects(
110
- classifyParsedMessage(
111
- addressService(async () => {
112
- throw new Error("throttled");
113
- }),
114
- "acc-1",
115
- parsed,
116
- "inbox",
117
- ),
118
- /throttled/,
119
- );
120
- });
121
-
122
- it("reads no Address at all when the message has no From address", async () => {
123
- const parsed = await parseMessageBody(
124
- Buffer.from(
125
- [
126
- "To: me@example.com",
127
- "Subject: Hi",
128
- "Content-Type: text/plain",
129
- "",
130
- "hi",
131
- ].join("\r\n"),
132
- ),
133
- );
134
- let reads = 0;
135
-
136
- const classification = await classifyParsedMessage(
137
- addressService(async () => {
138
- reads++;
139
- throw new NotFoundError("no Address row");
140
- }),
141
- "acc-1",
142
- parsed,
143
- "inbox",
144
- );
145
-
146
- assert.equal(reads, 0);
147
- assert.equal(classification.category, "personal");
148
- });
149
- });
150
-
151
- describe("classifyParsedMessage sender mismatch", () => {
152
- const noAddress = addressService(async () => {
153
- throw new NotFoundError("no Address row");
154
- });
155
-
156
- it("compares the sender of mail in Junk the provider rated clean", async () => {
157
- const parsed = await parseMessageBody(IMPERSONATION_EML);
158
-
159
- const classification = await classifyParsedMessage(
160
- noAddress,
161
- "acc-1",
162
- parsed,
163
- "junk",
164
- );
165
-
166
- assert.equal(
167
- classification.authenticity?.displayNameCorrespondence,
168
- DisplayNameCorrespondence.Unrelated,
169
- );
170
- assert.deepEqual(classification.authenticity?.offDomainLinkDomains, [
171
- "example.org",
172
- ]);
173
- });
174
-
175
- it("compares nothing for the same mail outside Junk without a provider verdict", async () => {
176
- const parsed = await parseMessageBody(IMPERSONATION_EML);
177
-
178
- const classification = await classifyParsedMessage(
179
- noAddress,
180
- "acc-1",
181
- parsed,
182
- "inbox",
183
- );
184
-
185
- assert.equal(
186
- classification.authenticity?.displayNameCorrespondence,
187
- undefined,
188
- );
189
- assert.equal(classification.authenticity?.offDomainLinkDomains, undefined);
190
- });
191
- });
192
-
193
- describe("extractPrimaryFromEmail", () => {
194
- it("reads the first From address", async () => {
195
- const parsed = await parseMessageBody(PLAIN_EML);
196
- assert.equal(extractPrimaryFromEmail(parsed), "alice@example.com");
197
- });
198
-
199
- it("answers null when there is no From address to read", async () => {
200
- const parsed = await parseMessageBody(
201
- Buffer.from(["To: me@example.com", "", "hi"].join("\r\n")),
202
- );
203
- assert.equal(extractPrimaryFromEmail(parsed), null);
204
- });
205
- });
@@ -1,116 +0,0 @@
1
- import type {
2
- IAddressRepository,
3
- ThreadMessageItem,
4
- UpdateMessageInput,
5
- } from "@remit/data-ports";
6
- import { NotFoundError } from "@remit/data-ports/errors";
7
- import { deriveAddressId } from "@remit/data-ports/id";
8
- import type { ParsedMail } from "mailparser";
9
- import {
10
- classifyByHeaders,
11
- extractAuthenticity,
12
- extractAuthResult,
13
- extractHasListUnsubscribe,
14
- extractProviderSpam,
15
- } from "./heuristics/classifyByHeaders.js";
16
- import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
17
- import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
18
-
19
- type ThreadMessageCategory = ThreadMessageItem["category"];
20
-
21
- /**
22
- * The From address the classifier reads, when there is one to read. Shared by
23
- * every path that classifies or counts a sender (body-sync's classification,
24
- * inbound counter and placement signals, and the classification backfill) so
25
- * they all read the same address off the same bytes.
26
- */
27
- export const extractPrimaryFromEmail = (parsed: ParsedMail): string | null => {
28
- const from = parsed.from;
29
- if (!from || !from.value || from.value.length === 0) return null;
30
- const address = from.value[0]?.address;
31
- if (!address) return null;
32
- return address.toLowerCase();
33
- };
34
-
35
- /**
36
- * The one-sided half of `Address.flags.category` (issue #299, RFC 039
37
- * Decision 3): a real value here overrides `classifyByHeaders` outright.
38
- * Only a genuinely-absent Address is "no override" — any other failure
39
- * (throttle, infra) propagates, because this feeds the write-once
40
- * `Message.category` at the moment it is decided, where a masked infra
41
- * failure would silently classify by headers when the user asked for
42
- * something else.
43
- */
44
- const resolveCategoryOverride = async (
45
- addressService: Pick<IAddressRepository, "getAddress">,
46
- accountConfigId: string,
47
- fromEmail: string,
48
- ): Promise<ThreadMessageCategory | undefined> => {
49
- try {
50
- const addressId = deriveAddressId(accountConfigId, fromEmail);
51
- const address = await addressService.getAddress(accountConfigId, addressId);
52
- return address.flags?.category?.value;
53
- } catch (err) {
54
- if (!(err instanceof NotFoundError)) throw err;
55
- return undefined;
56
- }
57
- };
58
-
59
- /**
60
- * Header classification, with the sender's `Address.flags.category` override
61
- * (issue #299, RFC 039 Decision 3) substituted for the header-derived
62
- * category when one is set. Returns the subset of the Message update that
63
- * carries the derived fields; the caller folds it into a single UpdateItem
64
- * alongside `bodyStorageKey` (body-sync) or `classificationState` (the
65
- * classification backfill, issue #1197). Optional signals are omitted when
66
- * absent so we never overwrite an existing value with `undefined`.
67
- *
68
- * The override wins outright rather than blending with the heuristic — RFC
69
- * 039 Decision 3 treats a direct reclassification as final, the same as
70
- * `flags.blocked`/`vip` already override placement. Every caller gates on
71
- * `hasDecidedCategory` before this result reaches a write, so a message that
72
- * already carries a real category is never re-touched regardless of what
73
- * this returns.
74
- */
75
- export const classifyParsedMessage = async (
76
- addressService: Pick<IAddressRepository, "getAddress">,
77
- accountConfigId: string,
78
- parsed: ParsedMail,
79
- placement: FolderPlacement,
80
- ): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> => {
81
- const headerCategory = classifyByHeaders(parsed);
82
- const authenticity = extractAuthenticity(parsed);
83
- const authResult = extractAuthResult(parsed);
84
- const providerSpam = extractProviderSpam(parsed);
85
- const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
86
-
87
- // A passing SPF/DKIM/DMARC check proves the sending domain, not the
88
- // identity the message claims — on a shared-tenant host the verified
89
- // subdomain belongs to whoever signed up. These two comparisons say
90
- // whether the claim holds, and run only over mail the provider already
91
- // called spam or that sits in the Junk folder.
92
- const senderMismatch =
93
- authenticity === null
94
- ? {}
95
- : extractSenderMismatch(parsed, {
96
- fromDomain: authenticity.fromDomain,
97
- providerSpamClassified: providerSpam?.classified === true,
98
- placement,
99
- bulkSender: hasListUnsubscribe,
100
- });
101
-
102
- const fromEmail = extractPrimaryFromEmail(parsed);
103
- const categoryOverride = fromEmail
104
- ? await resolveCategoryOverride(addressService, accountConfigId, fromEmail)
105
- : undefined;
106
-
107
- return {
108
- category: categoryOverride ?? headerCategory,
109
- hasListUnsubscribe,
110
- ...(authenticity !== null
111
- ? { authenticity: { ...authenticity, ...senderMismatch } }
112
- : {}),
113
- ...(authResult !== null ? { authResult } : {}),
114
- ...(providerSpam !== null ? { providerSpam } : {}),
115
- };
116
- };