@remit/mailbox-service 0.0.111 → 0.0.112

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 (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -3,9 +3,7 @@ import { describe, it } from "node:test";
3
3
  import type { FilterItem } from "@remit/data-ports";
4
4
  import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
5
5
  import {
6
- buildMatchText,
7
6
  clauseMatches,
8
- cosineSimilarity,
9
7
  type FilterMessage,
10
8
  literalClausesMatch,
11
9
  literalClauseTerms,
@@ -197,7 +195,7 @@ describe("clauseMatches", () => {
197
195
  });
198
196
 
199
197
  describe("literalClausesMatch", () => {
200
- it("passes vacuously with no clauses (a purely-semantic filter)", () => {
198
+ it("passes vacuously with no clauses", () => {
201
199
  assert.equal(
202
200
  literalClausesMatch([], FilterMatchOperator.And, message()),
203
201
  true,
@@ -243,27 +241,6 @@ describe("literalClausesMatch", () => {
243
241
  });
244
242
  });
245
243
 
246
- describe("cosineSimilarity", () => {
247
- it("scores identical vectors as 1", () => {
248
- assert.equal(cosineSimilarity([1, 2, 3], [1, 2, 3]), 1);
249
- });
250
-
251
- it("scores orthogonal vectors as 0", () => {
252
- assert.equal(cosineSimilarity([1, 0], [0, 1]), 0);
253
- });
254
-
255
- it("scores a zero vector as 0 rather than dividing by zero", () => {
256
- assert.equal(cosineSimilarity([0, 0], [1, 1]), 0);
257
- });
258
-
259
- it("throws on a dimension mismatch instead of scoring incomparable vectors", () => {
260
- assert.throws(
261
- () => cosineSimilarity([1, 2], [1, 2, 3]),
262
- /dimension mismatch/,
263
- );
264
- });
265
- });
266
-
267
244
  describe("selectMoveWinner", () => {
268
245
  const filter = (
269
246
  filterId: string,
@@ -311,20 +288,6 @@ describe("selectMoveWinner", () => {
311
288
  });
312
289
  });
313
290
 
314
- describe("buildMatchText", () => {
315
- it("joins subject and body and bounds the length", () => {
316
- const text = buildMatchText(message({ subject: "hello", text: "world" }));
317
- assert.equal(text, "hello\nworld");
318
- });
319
-
320
- it("caps at 512 characters", () => {
321
- const text = buildMatchText(
322
- message({ subject: "", text: "x".repeat(1000) }),
323
- );
324
- assert.equal(text.length, 512);
325
- });
326
- });
327
-
328
291
  describe("literalClauseTerms (reader #459)", () => {
329
292
  it("narrows From and Subject onto the columns that carry them", () => {
330
293
  assert.deepEqual(
@@ -16,25 +16,9 @@ type FilterClause = FilterItem["literalClauses"][number];
16
16
  */
17
17
  export const NO_ACTION = FILTER_NO_ACTION;
18
18
 
19
- /**
20
- * Default cosine cut-off for a semantic-anchor match (RFC 031 "the semantic
21
- * anchor evaluates as a kNN threshold against the new message's embedding").
22
- * Tunable per pipeline via {@link FilterConfig.similarityThreshold}.
23
- */
24
- export const DEFAULT_SEMANTIC_MATCH_THRESHOLD = 0.75;
25
-
26
- /**
27
- * Bound on the text embedded for a semantic match — the same 512-char budget
28
- * `buildTextPreview` applies to chunk vectors and `FilterAnchor.anchorSourceText`
29
- * persists, so the candidate side of the comparison is derived on the same
30
- * footing as the anchor side.
31
- */
32
- const MATCH_TEXT_LIMIT = 512;
33
-
34
19
  /**
35
20
  * The message fields a filter evaluates against — the literal-clause targets
36
- * (from / subject / body) plus the text embedded for a semantic anchor. A plain
37
- * value object so the matcher stays independent of the `ParsedMail` shape and is
21
+ * (from / subject / body / list). A plain value object so the matcher stays independent of the `ParsedMail` shape and is
38
22
  * trivially constructed in a test.
39
23
  */
40
24
  export interface FilterMessage {
@@ -112,8 +96,8 @@ export const clauseMatches = (
112
96
 
113
97
  /**
114
98
  * Whether the literal clauses match under their combine operator. Empty clauses
115
- * carry no literal constraint and pass vacuously — a purely-semantic filter (no
116
- * clauses, only an anchor) is gated by its anchor alone, not by this.
99
+ * carry no literal constraint and pass vacuously; a caller that needs a
100
+ * predicate checks for clauses itself.
117
101
  */
118
102
  export const literalClausesMatch = (
119
103
  clauses: readonly FilterClause[],
@@ -207,31 +191,6 @@ export const literalClauseTerms = (
207
191
  return { terms, operator: "or" };
208
192
  };
209
193
 
210
- /**
211
- * Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
212
- * rather than silently scoring incomparable vectors — a mismatch means a stale
213
- * anchor embedded under a different model (`anchorEmbeddingId`), which is a
214
- * migration concern, not a match to guess at.
215
- */
216
- export const cosineSimilarity = (
217
- a: readonly number[],
218
- b: readonly number[],
219
- ): number => {
220
- if (a.length !== b.length) {
221
- throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
222
- }
223
- let dot = 0;
224
- let normA = 0;
225
- let normB = 0;
226
- for (let i = 0; i < a.length; i++) {
227
- dot += a[i] * b[i];
228
- normA += a[i] * a[i];
229
- normB += b[i] * b[i];
230
- }
231
- if (normA === 0 || normB === 0) return 0;
232
- return dot / (Math.sqrt(normA) * Math.sqrt(normB));
233
- };
234
-
235
194
  /**
236
195
  * The move a message ends in when several filters matched: the filter whose
237
196
  * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
@@ -264,11 +223,3 @@ export const selectMoveWinner = (
264
223
  }
265
224
  return winner;
266
225
  };
267
-
268
- /**
269
- * The candidate text embedded once for a semantic match — subject then body,
270
- * bounded to the anchor's `anchorSourceText` budget so both sides of the cosine
271
- * comparison are derived from comparable inputs.
272
- */
273
- export const buildMatchText = (msg: FilterMessage): string =>
274
- `${msg.subject}\n${msg.text}`.slice(0, MATCH_TEXT_LIMIT);
@@ -1,330 +1,199 @@
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
1
  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";
2
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
3
+ import type { AccountItem, CreateFilterInput } from "@remit/data-ports";
18
4
  import {
5
+ AccountAuthType,
6
+ ConnectionState,
19
7
  FilterClauseField,
20
8
  FilterMatchOperator,
21
- FilterState,
9
+ FilterScope,
22
10
  } 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";
11
+ import { PlacementMoveService } from "../placement-move.js";
12
+ import {
13
+ openStore,
14
+ type Store,
15
+ seedAccount,
16
+ seedMailbox,
17
+ } from "../test-helpers/test-store.js";
18
+ import type { FilterMessage } from "./match.js";
19
+ import { FilterPipeline } from "./pipeline.js";
20
+
21
+ const ACCOUNT = "cfg-1";
22
+
23
+ let store: Store;
24
+ let account: AccountItem;
25
+ let archive: string;
26
+ let projects: string;
27
+
28
+ beforeEach(async () => {
29
+ store = await openStore();
30
+ account = await seedAccount(store, { accountConfigId: ACCOUNT });
31
+ archive = (await seedMailbox(store, account, "Archive")).mailboxId;
32
+ projects = (await seedMailbox(store, account, "Projects")).mailboxId;
33
+ });
34
+
35
+ afterEach(async () => {
36
+ mock.timers.reset();
37
+ await store.close();
38
+ });
26
39
 
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,
40
+ const fromGithub: FilterMessage = {
41
+ from: "npm@github.com",
42
+ fromName: "npm",
43
+ subject: "A new version of left-pad is available",
44
+ text: "body",
45
+ listId: "",
46
+ };
47
+
48
+ const receipt: FilterMessage = {
49
+ from: "billing@stripe.com",
50
+ fromName: "Stripe",
51
+ subject: "Your receipt",
52
+ text: "Thanks for your payment",
53
+ listId: "",
54
+ };
55
+
56
+ const senders = (actionMailboxId: string) =>
57
+ store.filter.create({
58
+ accountConfigId: ACCOUNT,
59
+ name: "rule",
60
+ scope: FilterScope.Standing,
36
61
  matchOperator: FilterMatchOperator.Or,
37
62
  literalClauses: [
38
63
  { field: FilterClauseField.From, value: "npm@github.com" },
39
64
  { field: FilterClauseField.From, value: "notifications@github.com" },
40
65
  ],
41
- actionLabelId: NO_ACTION,
42
- actionMailboxId: destinationMailboxId,
43
- createdAt: 1,
44
- updatedAt: 1,
45
- }) as unknown as FilterItem;
66
+ actionMailboxId,
67
+ });
46
68
 
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",
69
+ const pipeline = () =>
70
+ new FilterPipeline(
71
+ {
72
+ filterService: store.filter,
73
+ messageLabelService: store.messageLabel,
74
+ mailboxService: store.mailbox,
75
+ placementMoveService: new PlacementMoveService({
76
+ messageService: store.message,
77
+ threadMessageService: store.threadMessage,
78
+ markerService: store.placementMove,
79
+ addressService: store.address,
80
+ mailboxSpecialUseService: store.mailboxSpecialUse,
81
+ mailboxService: store.mailbox,
82
+ sqsQueueUrl: "https://sqs.test/message-mgmt",
83
+ }),
65
84
  },
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
- });
85
+ { info: () => {}, error: () => {} },
86
+ );
87
+
88
+ describe("FilterPipeline over literal filters", () => {
89
+ it("resolves the move for a matching sender", async () => {
90
+ const filter = await senders(archive);
91
+
92
+ const decision = await pipeline().evaluate(
93
+ ACCOUNT,
94
+ account.accountId,
95
+ "m-1",
96
+ fromGithub,
97
+ );
80
98
 
81
99
  assert.deepEqual(decision.move, {
82
- destinationMailboxId: "mbx-archive",
83
- filterId: "flt-senders",
100
+ destinationMailboxId: archive,
101
+ filterId: filter.filterId,
84
102
  });
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
103
  });
89
104
 
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
- });
105
+ it("resolves nothing for a sender no clause names", async () => {
106
+ await senders(archive);
107
+
108
+ const decision = await pipeline().evaluate(
109
+ ACCOUNT,
110
+ account.accountId,
111
+ "m-2",
112
+ {
113
+ ...fromGithub,
114
+ from: "hello@stripe.com",
115
+ },
116
+ );
99
117
 
100
118
  assert.equal(decision.move, undefined);
101
119
  assert.deepEqual(decision.labels, []);
102
- assert.equal(state.embedCalls, 0);
103
120
  });
104
- });
105
121
 
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
- });
122
+ it("gives a contested message to the more recently changed action", async () => {
123
+ mock.timers.enable({ apis: ["Date"], now: 1_000_000 });
124
+ await senders(archive);
125
+ mock.timers.setTime(9_000_000);
126
+ const rival = await senders(projects);
127
+
128
+ const decision = await pipeline().evaluate(
129
+ ACCOUNT,
130
+ account.accountId,
131
+ "m-3",
132
+ fromGithub,
133
+ );
134
134
 
135
135
  assert.deepEqual(decision.move, {
136
- destinationMailboxId: "mbx-projects",
137
- filterId: "flt-rival",
136
+ destinationMailboxId: projects,
137
+ filterId: rival.filterId,
138
138
  });
139
139
  });
140
140
  });
141
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",
142
+ describe("FilterPipeline scopes a filter to its folder's account", () => {
143
+ it("leaves another account's mail alone, as if the filter had not matched", async () => {
144
+ const sibling = await store.account.create({
145
+ accountConfigId: ACCOUNT,
146
+ username: "me-too@example.com",
147
+ email: "me-too@example.com",
148
+ authType: AccountAuthType.Password,
149
+ passwordHash: '{"ciphertext":"x"}',
150
+ imapHost: "imap.example.com",
151
+ imapPort: 993,
152
+ imapTls: true,
153
+ imapStartTls: false,
154
+ isActive: true,
155
+ connectionState: ConnectionState.Authenticated,
156
+ });
157
+ const travel = await seedMailbox(store, sibling, "Travel");
158
+ const filter = await senders(travel.mailboxId);
159
+
160
+ const mine = await pipeline().evaluate(
161
+ ACCOUNT,
162
+ account.accountId,
163
+ "m-1",
164
+ fromGithub,
211
165
  );
212
- assert.equal(
213
- putCalls.length,
214
- 1,
215
- "the refreshed anchor is written back exactly once",
166
+ const theirs = await pipeline().evaluate(
167
+ ACCOUNT,
168
+ sibling.accountId,
169
+ "m-2",
170
+ fromGithub,
216
171
  );
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",
172
+
173
+ assert.deepEqual(mine, { labels: [] });
174
+ assert.deepEqual(theirs.move, {
175
+ destinationMailboxId: travel.mailboxId,
176
+ filterId: filter.filterId,
224
177
  });
225
178
  assert.equal(
226
- embedCalls,
227
- 2,
228
- "one embed re-embeds the anchor's source text, one embeds the candidate message",
179
+ (await store.filter.get(ACCOUNT, filter.filterId)).state,
180
+ "Active",
229
181
  );
230
182
  });
231
183
 
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);
184
+ it("turns the filter off when no account holds its folder any more", async () => {
185
+ const filter = await senders("mbx-gone");
266
186
 
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",
187
+ const decision = await pipeline().evaluate(
188
+ ACCOUNT,
189
+ account.accountId,
190
+ "m-1",
191
+ fromGithub,
275
192
  );
276
- });
277
193
 
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
- );
194
+ assert.deepEqual(decision, { labels: [] });
195
+ const stored = await store.filter.get(ACCOUNT, filter.filterId);
196
+ assert.equal(stored.state, "Disabled");
197
+ assert.equal(stored.disabledReason, "FolderMissing");
329
198
  });
330
199
  });