@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,39 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, describe, it } from "node:test";
3
- import {
4
- _resetMessageEmbedderForTest,
5
- getMessageEmbedder,
6
- } from "./message-embedder.js";
7
-
8
- describe("getMessageEmbedder", () => {
9
- afterEach(() => {
10
- _resetMessageEmbedderForTest();
11
- });
12
-
13
- it("adapts the batch embedding service into a single-text message embedder", async () => {
14
- const vector = await getMessageEmbedder().embed("invoice from stripe");
15
-
16
- assert.ok(Array.isArray(vector));
17
- assert.ok(vector.length > 0);
18
- assert.ok(vector.every((value) => typeof value === "number"));
19
- });
20
-
21
- it("embeds the same text deterministically", async () => {
22
- const embedder = getMessageEmbedder();
23
- const a = await embedder.embed("your monthly receipt");
24
- const b = await embedder.embed("your monthly receipt");
25
-
26
- assert.deepEqual(a, b);
27
- });
28
-
29
- it("memoizes the embedder across calls", () => {
30
- assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
31
- });
32
-
33
- it("rebuilds after a reset", () => {
34
- const first = getMessageEmbedder();
35
- _resetMessageEmbedderForTest();
36
-
37
- assert.notStrictEqual(getMessageEmbedder(), first);
38
- });
39
- });
@@ -1,33 +0,0 @@
1
- import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
2
- import type { MessageEmbedder } from "./pipeline.js";
3
-
4
- /**
5
- * Adapt the batch {@link EmbeddingService} the rest of the stack composes from
6
- * env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
7
- * incoming message becomes one message-level vector to compare against a filter's
8
- * persisted anchor (RFC 034 Decision 2.1/2.3).
9
- *
10
- * The embedder is selected by the same `SEARCH_EMBEDDING_*` env the search-index
11
- * worker reads, so every filter pass embeds under the identical model the anchors
12
- * were built with. The instance is memoized across warm invocations so a local
13
- * Transformers.js model loads once per container rather than per event.
14
- */
15
- let cached: MessageEmbedder | undefined;
16
-
17
- export const getMessageEmbedder = (): MessageEmbedder => {
18
- if (cached) return cached;
19
- const service = buildEmbeddingServiceFromEnv();
20
- cached = {
21
- embed: async (text: string): Promise<number[]> => {
22
- const [vector] = await service.embed([text]);
23
- return vector;
24
- },
25
- embeddingId: service.embeddingId,
26
- };
27
- return cached;
28
- };
29
-
30
- /** Reset the singleton — test use only. */
31
- export const _resetMessageEmbedderForTest = (): void => {
32
- cached = undefined;
33
- };
@@ -1,330 +0,0 @@
1
- /**
2
- * The index-time filter pass over an anchorless literal filter — the shape the
3
- * organize widen creates on a deployment without the vector pipeline: `From`
4
- * clauses combined with `Or`, `hasAnchor: false`. This is what keeps a standing
5
- * "move all mail from these senders" filter working on future mail with no
6
- * vectors, so it is pinned directly: a matching sender resolves the move, a
7
- * non-matching one resolves nothing, and the embedder is never touched.
8
- */
9
-
10
- import assert from "node:assert/strict";
11
- import { describe, it } from "node:test";
12
- import type {
13
- FilterItem,
14
- IFilterAnchorRepository,
15
- IFilterRepository,
16
- IMessageLabelRepository,
17
- } from "@remit/data-ports";
18
- import {
19
- FilterClauseField,
20
- FilterMatchOperator,
21
- FilterState,
22
- } from "@remit/domain-enums";
23
- import type { PlacementMoveService } from "../placement-move.js";
24
- import { NO_ACTION } from "./match.js";
25
- import { type FilterConfig, FilterPipeline } from "./pipeline.js";
26
-
27
- const senderFilter = (destinationMailboxId: string): FilterItem =>
28
- ({
29
- filterId: "flt-senders",
30
- accountConfigId: "cfg-1",
31
- name: "npm barrage",
32
- scope: "Standing",
33
- state: FilterState.Active,
34
- hasAnchor: false,
35
- ruleChangedAt: 1,
36
- matchOperator: FilterMatchOperator.Or,
37
- literalClauses: [
38
- { field: FilterClauseField.From, value: "npm@github.com" },
39
- { field: FilterClauseField.From, value: "notifications@github.com" },
40
- ],
41
- actionLabelId: NO_ACTION,
42
- actionMailboxId: destinationMailboxId,
43
- createdAt: 1,
44
- updatedAt: 1,
45
- }) as unknown as FilterItem;
46
-
47
- const buildPipeline = (...filters: FilterItem[]) => {
48
- const state = { embedCalls: 0 };
49
- const config: FilterConfig = {
50
- filterService: {
51
- listByAccountAndState: async () => filters,
52
- refreshExpiry: async (f: FilterItem) => f,
53
- } as unknown as IFilterRepository,
54
- filterAnchorService: {
55
- get: async () => undefined,
56
- } as unknown as IFilterAnchorRepository,
57
- messageLabelService: {} as unknown as IMessageLabelRepository,
58
- placementMoveService: {} as unknown as PlacementMoveService,
59
- embedder: {
60
- embed: async () => {
61
- state.embedCalls += 1;
62
- return [];
63
- },
64
- embeddingId: "test-model@0",
65
- },
66
- };
67
- return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
68
- };
69
-
70
- describe("FilterPipeline — anchorless From/Or filter at index time", () => {
71
- it("resolves the move for a matching sender and never embeds", async () => {
72
- const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
73
- const decision = await pipeline.evaluate("cfg-1", "m-1", {
74
- from: "npm@github.com",
75
- fromName: "npm",
76
- subject: "A new version of left-pad is available",
77
- text: "body",
78
- listId: "",
79
- });
80
-
81
- assert.deepEqual(decision.move, {
82
- destinationMailboxId: "mbx-archive",
83
- filterId: "flt-senders",
84
- });
85
- // A literal-only filter is decided by its clauses alone — the embedder is
86
- // never constructed or called.
87
- assert.equal(state.embedCalls, 0);
88
- });
89
-
90
- it("resolves nothing for a sender none of the clauses match", async () => {
91
- const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
92
- const decision = await pipeline.evaluate("cfg-1", "m-2", {
93
- from: "hello@stripe.com",
94
- fromName: "Stripe",
95
- subject: "Your receipt",
96
- text: "body",
97
- listId: "",
98
- });
99
-
100
- assert.equal(decision.move, undefined);
101
- assert.deepEqual(decision.labels, []);
102
- assert.equal(state.embedCalls, 0);
103
- });
104
- });
105
-
106
- /**
107
- * Arbitration survives exactly here: two standing filters colliding over the
108
- * same message at the same trigger. A move is exclusive, so the pass resolves
109
- * one destination — the more-recently-changed action wins (reader #497 leaves
110
- * this untouched while removing arbitration from the user-initiated apply).
111
- */
112
- describe("FilterPipeline — two standing filters colliding on one message", () => {
113
- const rivalFilter = (): FilterItem =>
114
- ({
115
- ...senderFilter("mbx-projects"),
116
- filterId: "flt-rival",
117
- name: "github to projects",
118
- actionChangedAt: 2,
119
- }) as unknown as FilterItem;
120
-
121
- it("resolves a single destination from the more-recently-changed action", async () => {
122
- const older = {
123
- ...senderFilter("mbx-archive"),
124
- actionChangedAt: 1,
125
- } as unknown as FilterItem;
126
- const { pipeline } = buildPipeline(older, rivalFilter());
127
- const decision = await pipeline.evaluate("cfg-1", "m-3", {
128
- from: "npm@github.com",
129
- fromName: "npm",
130
- subject: "A new version of left-pad is available",
131
- text: "body",
132
- listId: "",
133
- });
134
-
135
- assert.deepEqual(decision.move, {
136
- destinationMailboxId: "mbx-projects",
137
- filterId: "flt-rival",
138
- });
139
- });
140
- });
141
-
142
- describe("FilterPipeline — anchor lazy re-embed on model drift (reader #295)", () => {
143
- const anchoredFilter = (): FilterItem =>
144
- ({
145
- filterId: "flt-semantic",
146
- accountConfigId: "cfg-1",
147
- name: "receipts",
148
- scope: "Standing",
149
- state: FilterState.Active,
150
- hasAnchor: true,
151
- ruleChangedAt: 1,
152
- matchOperator: FilterMatchOperator.And,
153
- literalClauses: [],
154
- actionLabelId: "lbl-1",
155
- actionMailboxId: NO_ACTION,
156
- createdAt: 1,
157
- updatedAt: 1,
158
- }) as unknown as FilterItem;
159
-
160
- const message = {
161
- from: "billing@stripe.com",
162
- fromName: "Stripe",
163
- subject: "Your receipt",
164
- text: "Thanks for your payment",
165
- listId: "",
166
- };
167
-
168
- it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
169
- const putCalls: Array<Record<string, unknown>> = [];
170
- let embedCalls = 0;
171
- const config: FilterConfig = {
172
- filterService: {
173
- listByAccountAndState: async () => [anchoredFilter()],
174
- refreshExpiry: async (f: FilterItem) => f,
175
- } as unknown as IFilterRepository,
176
- filterAnchorService: {
177
- get: async () => ({
178
- accountConfigId: "cfg-1",
179
- filterId: "flt-semantic",
180
- // Stale-model space — orthogonal to what the current model
181
- // produces, so a raw comparison against it would not match.
182
- anchorEmbedding: [0, 1, 0],
183
- anchorEmbeddingId: "old-model@3",
184
- anchorSourceText: "your receipt is ready",
185
- anchorMessageId: "msg-anchor",
186
- }),
187
- put: async (input: Record<string, unknown>) => {
188
- putCalls.push(input);
189
- return { ...input, createdAt: 1, updatedAt: 2 };
190
- },
191
- } as unknown as IFilterAnchorRepository,
192
- messageLabelService: {} as unknown as IMessageLabelRepository,
193
- placementMoveService: {} as unknown as PlacementMoveService,
194
- embedder: {
195
- embed: async () => {
196
- embedCalls += 1;
197
- return [1, 0, 0];
198
- },
199
- embeddingId: "new-model@3",
200
- },
201
- };
202
-
203
- const decision = await new FilterPipeline(config, {
204
- info: () => {},
205
- }).evaluate("cfg-1", "m-1", message);
206
-
207
- assert.deepEqual(
208
- decision.labels,
209
- [{ labelId: "lbl-1", filterId: "flt-semantic" }],
210
- "the anchor matches once re-embedded into the current model's space",
211
- );
212
- assert.equal(
213
- putCalls.length,
214
- 1,
215
- "the refreshed anchor is written back exactly once",
216
- );
217
- assert.deepEqual(putCalls[0], {
218
- accountConfigId: "cfg-1",
219
- filterId: "flt-semantic",
220
- anchorEmbedding: [1, 0, 0],
221
- anchorEmbeddingId: "new-model@3",
222
- anchorSourceText: "your receipt is ready",
223
- anchorMessageId: "msg-anchor",
224
- });
225
- assert.equal(
226
- embedCalls,
227
- 2,
228
- "one embed re-embeds the anchor's source text, one embeds the candidate message",
229
- );
230
- });
231
-
232
- it("never re-embeds when the anchor's embeddingId is already current", async () => {
233
- let putCalls = 0;
234
- let embedCalls = 0;
235
- const config: FilterConfig = {
236
- filterService: {
237
- listByAccountAndState: async () => [anchoredFilter()],
238
- refreshExpiry: async (f: FilterItem) => f,
239
- } as unknown as IFilterRepository,
240
- filterAnchorService: {
241
- get: async () => ({
242
- anchorEmbedding: [1, 0, 0],
243
- anchorEmbeddingId: "current-model@3",
244
- anchorSourceText: "your receipt is ready",
245
- anchorMessageId: "msg-anchor",
246
- }),
247
- put: async () => {
248
- putCalls += 1;
249
- throw new Error("must not be called");
250
- },
251
- } as unknown as IFilterAnchorRepository,
252
- messageLabelService: {} as unknown as IMessageLabelRepository,
253
- placementMoveService: {} as unknown as PlacementMoveService,
254
- embedder: {
255
- embed: async () => {
256
- embedCalls += 1;
257
- return [1, 0, 0];
258
- },
259
- embeddingId: "current-model@3",
260
- },
261
- };
262
-
263
- const decision = await new FilterPipeline(config, {
264
- info: () => {},
265
- }).evaluate("cfg-1", "m-1", message);
266
-
267
- assert.deepEqual(decision.labels, [
268
- { labelId: "lbl-1", filterId: "flt-semantic" },
269
- ]);
270
- assert.equal(putCalls, 0, "a current anchor is never rewritten");
271
- assert.equal(
272
- embedCalls,
273
- 1,
274
- "only the candidate message is embedded — no wasted re-embed call",
275
- );
276
- });
277
-
278
- it("degrades a re-embed failure to skipping just this filter, not the whole pass", async () => {
279
- const literalFilter: FilterItem = {
280
- filterId: "flt-literal",
281
- accountConfigId: "cfg-1",
282
- name: "stripe receipts",
283
- scope: "Standing",
284
- state: FilterState.Active,
285
- hasAnchor: false,
286
- ruleChangedAt: 1,
287
- matchOperator: FilterMatchOperator.Or,
288
- literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
289
- actionLabelId: "lbl-literal",
290
- actionMailboxId: NO_ACTION,
291
- createdAt: 1,
292
- updatedAt: 1,
293
- } as unknown as FilterItem;
294
-
295
- const config: FilterConfig = {
296
- filterService: {
297
- listByAccountAndState: async () => [anchoredFilter(), literalFilter],
298
- refreshExpiry: async (f: FilterItem) => f,
299
- } as unknown as IFilterRepository,
300
- filterAnchorService: {
301
- get: async () => ({
302
- anchorEmbedding: [0, 1, 0],
303
- anchorEmbeddingId: "old-model@3",
304
- anchorSourceText: "your receipt is ready",
305
- anchorMessageId: "msg-anchor",
306
- }),
307
- put: async () => {
308
- throw new Error("SQLITE_BUSY");
309
- },
310
- } as unknown as IFilterAnchorRepository,
311
- messageLabelService: {} as unknown as IMessageLabelRepository,
312
- placementMoveService: {} as unknown as PlacementMoveService,
313
- embedder: {
314
- embed: async () => [1, 0, 0],
315
- embeddingId: "new-model@3",
316
- },
317
- };
318
-
319
- const decision = await new FilterPipeline(config, {
320
- info: () => {},
321
- error: () => {},
322
- }).evaluate("cfg-1", "m-1", message);
323
-
324
- assert.deepEqual(
325
- decision.labels,
326
- [{ labelId: "lbl-literal", filterId: "flt-literal" }],
327
- "the anchored filter's re-embed failure is isolated — the literal filter still applies",
328
- );
329
- });
330
- });
@@ -1,284 +0,0 @@
1
- import { inspect } from "node:util";
2
- import type {
3
- FilterItem,
4
- IFilterAnchorRepository,
5
- IFilterRepository,
6
- IMessageLabelRepository,
7
- } from "@remit/data-ports";
8
- import { FilterState } from "@remit/domain-enums";
9
- import {
10
- isEmbeddingCapabilityAbsence,
11
- isEmbeddingCapabilityUnavailable,
12
- recordEmbeddingCapabilityAbsence,
13
- } from "@remit/search-service/capability";
14
- import type { PlacementMoveService } from "../placement-move.js";
15
- import {
16
- type AnchorEmbedder,
17
- refreshAnchorForEmbedder,
18
- } from "./anchor-drift.js";
19
- import {
20
- buildMatchText,
21
- cosineSimilarity,
22
- DEFAULT_SEMANTIC_MATCH_THRESHOLD,
23
- type FilterMessage,
24
- literalClausesMatch,
25
- NO_ACTION,
26
- selectMoveWinner,
27
- } from "./match.js";
28
-
29
- /**
30
- * Turns the candidate message's text into a single message-level vector to
31
- * compare against a filter's persisted `anchorEmbedding`. The anchor side is
32
- * embedded here only to repair a drifted anchor in place (see
33
- * {@link refreshAnchorForEmbedder}); the match itself reads `FilterAnchor` as
34
- * a fixed fact (RFC 034 Decision 2.1/2.3), and the incoming message is
35
- * embedded once per message, only when a semantic filter is actually in play.
36
- */
37
- export type MessageEmbedder = AnchorEmbedder;
38
-
39
- export interface FilterLogger {
40
- info(obj: Record<string, unknown>, msg: string): void;
41
- debug?(obj: Record<string, unknown>, msg: string): void;
42
- warn?(obj: Record<string, unknown>, msg: string): void;
43
- error?(obj: Record<string, unknown>, msg: string): void;
44
- }
45
-
46
- /**
47
- * Dependencies for the index-time filter pass (RFC 034). Optional on
48
- * {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
49
- * without it simply runs no filter matching.
50
- *
51
- * `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
52
- * without it, so they are skipped when it is absent, but literal-clause filters
53
- * always run. The `FilterAnchor` write path (a separate ticket) and a wired
54
- * embedder are what light the semantic half up.
55
- */
56
- export interface FilterConfig {
57
- filterService: IFilterRepository;
58
- filterAnchorService: IFilterAnchorRepository;
59
- messageLabelService: IMessageLabelRepository;
60
- placementMoveService: PlacementMoveService;
61
- embedder?: MessageEmbedder;
62
- similarityThreshold?: number;
63
- }
64
-
65
- /**
66
- * What the matched filters resolve to for one message: every matching filter's
67
- * label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
68
- * most-recently-changed matching filter, Decision 3.2).
69
- */
70
- export interface FilterDecision {
71
- labels: Array<{ labelId: string; filterId: string }>;
72
- move?: { destinationMailboxId: string; filterId: string };
73
- }
74
-
75
- const EMPTY_DECISION: FilterDecision = { labels: [] };
76
-
77
- /**
78
- * Evaluates a synced message against the account's active filters and resolves
79
- * the actions to apply (RFC 034). Reads only — listing filters, refreshing lazy
80
- * expiry, reading anchors, and embedding the message; the caller applies the
81
- * resulting {@link FilterDecision}.
82
- *
83
- * `evaluate` isolates every failure in this read/decision phase from body sync,
84
- * matching the placement-resolution precedent (#1246): a schema-drifted query or
85
- * a bad anchor must never fail the surrounding message store, since the body is
86
- * already durable by the time this runs. The apply of the decision — label
87
- * writes and the move enqueue — is the caller's responsibility and is *not*
88
- * swallowed there, so a genuine DDB/SQS write failure still propagates and
89
- * requeues the message.
90
- */
91
- export class FilterPipeline {
92
- constructor(
93
- private readonly config: FilterConfig,
94
- private readonly log: FilterLogger,
95
- ) {}
96
-
97
- async evaluate(
98
- accountConfigId: string,
99
- messageId: string,
100
- msg: FilterMessage,
101
- ): Promise<FilterDecision> {
102
- return this.match(accountConfigId, msg).catch((error: unknown) => {
103
- this.log.error?.(
104
- {
105
- alert: "body_sync_filter_match_failed",
106
- messageId,
107
- accountConfigId,
108
- errorName: (error as { name?: string })?.name,
109
- error: inspect(error),
110
- },
111
- "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
112
- );
113
- return EMPTY_DECISION;
114
- });
115
- }
116
-
117
- private async match(
118
- accountConfigId: string,
119
- msg: FilterMessage,
120
- ): Promise<FilterDecision> {
121
- const active = await this.config.filterService.listByAccountAndState(
122
- accountConfigId,
123
- FilterState.Active,
124
- );
125
- if (active.length === 0) return EMPTY_DECISION;
126
-
127
- // The message is embedded lazily and at most once: `undefined` means not
128
- // yet computed, `null` means computed-but-unavailable (no embedder). The
129
- // embed only ever fires when a filter survives the literal pre-filter and
130
- // carries an anchor — a purely-literal account never pays for it.
131
- let messageEmbedding: number[] | null | undefined;
132
- const embed = async (): Promise<number[] | null> => {
133
- if (messageEmbedding !== undefined) return messageEmbedding;
134
- const embedder = this.config.embedder;
135
- if (!embedder) {
136
- messageEmbedding = null;
137
- return null;
138
- }
139
- messageEmbedding = await embedder
140
- .embed(buildMatchText(msg))
141
- .catch((error: unknown) => {
142
- // This deployment carries no embedding pipeline at all — the
143
- // container images ship without `@huggingface/transformers`. That is
144
- // the same absence `SEARCH_EMBEDDING_PROVIDER=off` states, so it takes
145
- // the same designed skip rather than an error-level
146
- // `filter_anchor_match_failed` per semantic filter per message. The
147
- // absence is remembered process-wide, so this is the only embed that
148
- // pays for it: `buildFilterConfig` wires no embedder afterwards.
149
- if (!isEmbeddingCapabilityAbsence(error)) throw error;
150
- if (!isEmbeddingCapabilityUnavailable()) {
151
- this.log.warn?.(
152
- {
153
- accountConfigId,
154
- error: inspect(error),
155
- },
156
- "No embedding pipeline in this deployment; semantic filters are skipped (literal filters still run)",
157
- );
158
- }
159
- recordEmbeddingCapabilityAbsence(error);
160
- return null;
161
- });
162
- return messageEmbedding;
163
- };
164
-
165
- const matched: FilterItem[] = [];
166
- for (const filter of active) {
167
- // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
168
- // its expiresAt patches it to Expired on this read and drops it from
169
- // evaluation, independent of whether the TTL delete has run.
170
- const usable = await this.config.filterService.refreshExpiry(filter);
171
- if (usable.state !== FilterState.Active) continue;
172
- // A bad or stale anchor vector (a dimension mismatch under a changed
173
- // embedding model) throws from cosineSimilarity. Isolate it to this
174
- // filter: it is skipped and loudly logged, and every other filter on
175
- // the message still evaluates and applies. This sits inside the
176
- // evaluate-level catch — a whole-phase failure still degrades to no
177
- // filter actions — but stops one poisoned anchor from doing so.
178
- const isMatch = await this.filterMatches(
179
- accountConfigId,
180
- usable,
181
- msg,
182
- embed,
183
- ).catch((error: unknown) => {
184
- this.log.error?.(
185
- {
186
- alert: "filter_anchor_match_failed",
187
- filterId: usable.filterId,
188
- accountConfigId,
189
- errorName: (error as { name?: string })?.name,
190
- error: inspect(error),
191
- },
192
- "Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
193
- );
194
- return "skip" as const;
195
- });
196
- if (isMatch === "skip") continue;
197
- if (isMatch) matched.push(usable);
198
- }
199
- if (matched.length === 0) return EMPTY_DECISION;
200
-
201
- const labels: FilterDecision["labels"] = [];
202
- const seenLabels = new Set<string>();
203
- for (const filter of matched) {
204
- if (filter.actionLabelId === NO_ACTION) continue;
205
- if (seenLabels.has(filter.actionLabelId)) continue;
206
- seenLabels.add(filter.actionLabelId);
207
- labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
208
- }
209
-
210
- const winner = selectMoveWinner(
211
- matched.filter((filter) => filter.actionMailboxId !== NO_ACTION),
212
- );
213
-
214
- return {
215
- labels,
216
- ...(winner
217
- ? {
218
- move: {
219
- destinationMailboxId: winner.actionMailboxId,
220
- filterId: winner.filterId,
221
- },
222
- }
223
- : {}),
224
- };
225
- }
226
-
227
- private async filterMatches(
228
- accountConfigId: string,
229
- filter: FilterItem,
230
- msg: FilterMessage,
231
- embed: () => Promise<number[] | null>,
232
- ): Promise<boolean> {
233
- if (
234
- !literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
235
- ) {
236
- return false;
237
- }
238
-
239
- if (!filter.hasAnchor) {
240
- // A filter with neither clauses nor an anchor has no predicate and
241
- // matches nothing; a literal-only filter matched iff it had clauses.
242
- return filter.literalClauses.length > 0;
243
- }
244
-
245
- // One deterministic point read per semantic candidate that survived the
246
- // literal pre-filter — never a scan, never the anchor message itself
247
- // (RFC 034 Decision 2.3).
248
- let anchor = await this.config.filterAnchorService.get(
249
- accountConfigId,
250
- filter.filterId,
251
- );
252
- if (!anchor) {
253
- this.log.warn?.(
254
- { filterId: filter.filterId, accountConfigId },
255
- "Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
256
- );
257
- return false;
258
- }
259
-
260
- // A re-embed failure here propagates to the per-filter catch in
261
- // `match()`, which logs and skips this filter for this one evaluation
262
- // exactly as an unrecoverable stale anchor does today.
263
- anchor = await refreshAnchorForEmbedder(
264
- {
265
- anchorRepository: this.config.filterAnchorService,
266
- embedder: this.config.embedder,
267
- },
268
- anchor,
269
- );
270
-
271
- const vector = await embed();
272
- if (!vector) {
273
- this.log.debug?.(
274
- { filterId: filter.filterId },
275
- "No embedder configured; skipping semantic filter",
276
- );
277
- return false;
278
- }
279
-
280
- const threshold =
281
- this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
282
- return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
283
- }
284
- }