@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
@@ -0,0 +1,313 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ FilterDisabledReason,
5
+ FilterScope,
6
+ FilterState,
7
+ } from "@remit/domain-enums";
8
+ import { resolveFilterUpdate } from "./filter-operations.js";
9
+ import type { Refusable } from "./refusal.js";
10
+
11
+ const NOW = Date.parse("2026-09-25T12:00:00Z");
12
+
13
+ const standing = {
14
+ scope: FilterScope.Standing,
15
+ expiresAt: undefined,
16
+ state: FilterState.Active,
17
+ disabledReason: FilterDisabledReason.None,
18
+ actionMailboxId: "mbx-invoices",
19
+ };
20
+
21
+ const awaiting = {
22
+ ...standing,
23
+ state: FilterState.Disabled,
24
+ disabledReason: FilterDisabledReason.AwaitingFolder,
25
+ actionMailboxId: "None",
26
+ };
27
+
28
+ const acceptedValue = <T>(outcome: Refusable<T>): T => {
29
+ if (!outcome.ok) assert.fail(`refused: ${outcome.refusal.message}`);
30
+ return outcome.value;
31
+ };
32
+
33
+ const refusedWith = <T>(outcome: Refusable<T>, message: RegExp): void => {
34
+ assert.equal(outcome.ok, false, "expected a refusal");
35
+ if (outcome.ok) return;
36
+ assert.equal(outcome.refusal.kind, "Invalid");
37
+ assert.match(outcome.refusal.message, message);
38
+ };
39
+
40
+ describe("resolveFilterUpdate: state (#1103)", () => {
41
+ it("records UserDisabled when the user turns a filter off", () => {
42
+ assert.deepEqual(
43
+ resolveFilterUpdate(standing, { state: FilterState.Disabled }, NOW),
44
+ {
45
+ ok: true,
46
+ value: {
47
+ state: FilterState.Disabled,
48
+ disabledReason: FilterDisabledReason.UserDisabled,
49
+ },
50
+ },
51
+ );
52
+ });
53
+
54
+ for (const reason of [
55
+ FilterDisabledReason.UserDisabled,
56
+ FilterDisabledReason.AwaitingFolder,
57
+ FilterDisabledReason.FolderCreateFailed,
58
+ FilterDisabledReason.FolderMissing,
59
+ ]) {
60
+ it(`clears ${reason} when the user turns the filter back on`, () => {
61
+ assert.deepEqual(
62
+ resolveFilterUpdate(
63
+ { ...standing, state: FilterState.Disabled, disabledReason: reason },
64
+ { state: FilterState.Active },
65
+ NOW,
66
+ ),
67
+ {
68
+ ok: true,
69
+ value: {
70
+ state: FilterState.Active,
71
+ disabledReason: FilterDisabledReason.None,
72
+ },
73
+ },
74
+ );
75
+ });
76
+ }
77
+
78
+ it("keeps a disabled filter disabled, with its reason, through an expiry edit", () => {
79
+ const resolved = acceptedValue(
80
+ resolveFilterUpdate(
81
+ { ...standing, state: FilterState.Disabled },
82
+ {
83
+ scope: FilterScope.Temporary,
84
+ expiresAt: "2027-01-01T00:00:00+00:00",
85
+ },
86
+ NOW,
87
+ ),
88
+ );
89
+ assert.equal(resolved.state, FilterState.Disabled);
90
+ assert.equal("disabledReason" in resolved, false);
91
+ });
92
+
93
+ for (const reason of [
94
+ FilterDisabledReason.AwaitingFolder,
95
+ FilterDisabledReason.FolderCreateFailed,
96
+ ]) {
97
+ it(`refuses to turn on a ${reason} filter that has no folder`, () => {
98
+ refusedWith(
99
+ resolveFilterUpdate(
100
+ { ...awaiting, disabledReason: reason },
101
+ { state: FilterState.Active },
102
+ NOW,
103
+ ),
104
+ /pick a folder in the rule/i,
105
+ );
106
+ });
107
+
108
+ it(`turns on a ${reason} filter once the patch names a folder`, () => {
109
+ const resolved = acceptedValue(
110
+ resolveFilterUpdate(
111
+ { ...awaiting, disabledReason: reason },
112
+ { state: FilterState.Active, actionMailboxId: "mbx-picked" },
113
+ NOW,
114
+ ),
115
+ );
116
+ assert.equal(resolved.state, FilterState.Active);
117
+ });
118
+ }
119
+
120
+ it("keeps a folderless reason when the user turns an awaiting filter off again", () => {
121
+ const resolved = acceptedValue(
122
+ resolveFilterUpdate(awaiting, { state: FilterState.Disabled }, NOW),
123
+ );
124
+ assert.equal(resolved.state, FilterState.Disabled);
125
+ assert.equal("disabledReason" in resolved, false);
126
+ refusedWith(
127
+ resolveFilterUpdate(awaiting, { state: FilterState.Active }, NOW),
128
+ /pick a folder in the rule/i,
129
+ );
130
+ });
131
+
132
+ it("keeps a lapsed folderless filter off when its date moves forward", () => {
133
+ const lapsed = {
134
+ ...awaiting,
135
+ scope: FilterScope.Temporary,
136
+ expiresAt: "2026-01-01T00:00:00+00:00",
137
+ disabledReason: FilterDisabledReason.FolderCreateFailed,
138
+ };
139
+ const resolved = acceptedValue(
140
+ resolveFilterUpdate(
141
+ lapsed,
142
+ {
143
+ scope: FilterScope.Temporary,
144
+ expiresAt: "2027-01-01T00:00:00+00:00",
145
+ },
146
+ NOW,
147
+ ),
148
+ );
149
+ assert.equal(resolved.state, FilterState.Disabled);
150
+ assert.equal("disabledReason" in resolved, false);
151
+ refusedWith(
152
+ resolveFilterUpdate(
153
+ { ...lapsed, expiresAt: "2027-01-01T00:00:00+00:00" },
154
+ { state: FilterState.Active },
155
+ NOW,
156
+ ),
157
+ /pick a folder in the rule/i,
158
+ );
159
+ });
160
+
161
+ it("marks a folderless filter UserDisabled once the user picks a folder", () => {
162
+ assert.deepEqual(
163
+ resolveFilterUpdate(awaiting, { actionMailboxId: "mbx-picked" }, NOW),
164
+ {
165
+ ok: true,
166
+ value: {
167
+ actionMailboxId: "mbx-picked",
168
+ disabledReason: FilterDisabledReason.UserDisabled,
169
+ },
170
+ },
171
+ );
172
+ });
173
+
174
+ it("refuses Expired as a state the user sets", () => {
175
+ refusedWith(
176
+ resolveFilterUpdate(standing, { state: FilterState.Expired }, NOW),
177
+ /expires through its date/,
178
+ );
179
+ });
180
+
181
+ it("refuses to turn on a temporary filter whose date has passed", () => {
182
+ refusedWith(
183
+ resolveFilterUpdate(
184
+ {
185
+ ...standing,
186
+ scope: FilterScope.Temporary,
187
+ expiresAt: "2026-01-01T00:00:00+00:00",
188
+ state: FilterState.Disabled,
189
+ },
190
+ { state: FilterState.Active },
191
+ NOW,
192
+ ),
193
+ /has expired/,
194
+ );
195
+ });
196
+
197
+ it("leaves a patch that names neither state nor timing untouched", () => {
198
+ assert.deepEqual(resolveFilterUpdate(standing, { name: "Receipts" }, NOW), {
199
+ ok: true,
200
+ value: { name: "Receipts" },
201
+ });
202
+ });
203
+ });
204
+
205
+ describe("resolveFilterUpdate: scope and expiry (reader #266)", () => {
206
+ it("moves Standing to Temporary given a future expiresAt", () => {
207
+ const resolved = acceptedValue(
208
+ resolveFilterUpdate(standing, {
209
+ scope: FilterScope.Temporary,
210
+ expiresAt: "2099-01-01T00:00:00+00:00",
211
+ }),
212
+ );
213
+ assert.equal(resolved.scope, FilterScope.Temporary);
214
+ assert.equal(resolved.expiresAt, "2099-01-01T00:00:00+00:00");
215
+ assert.equal(resolved.state, FilterState.Active);
216
+ assert.ok(resolved.ttl && resolved.ttl > 0);
217
+ });
218
+
219
+ it("refuses moving to Temporary without an expiresAt", () => {
220
+ refusedWith(
221
+ resolveFilterUpdate(standing, { scope: FilterScope.Temporary }),
222
+ /needs expiresAt/,
223
+ );
224
+ });
225
+
226
+ it("refuses an expiresAt that isn't paired with a Temporary scope", () => {
227
+ refusedWith(
228
+ resolveFilterUpdate(standing, { expiresAt: "2099-01-01T00:00:00+00:00" }),
229
+ /only applies to a Temporary/,
230
+ );
231
+ });
232
+
233
+ it("clears expiresAt/ttl and reports Active when moving Temporary to Standing", () => {
234
+ const resolved = acceptedValue(
235
+ resolveFilterUpdate(
236
+ {
237
+ ...standing,
238
+ scope: FilterScope.Temporary,
239
+ expiresAt: "2020-01-01T00:00:00+00:00",
240
+ state: FilterState.Expired,
241
+ },
242
+ { scope: FilterScope.Standing },
243
+ ),
244
+ );
245
+ assert.equal(resolved.scope, FilterScope.Standing);
246
+ assert.equal(resolved.expiresAt, undefined);
247
+ assert.equal(resolved.ttl, undefined);
248
+ assert.equal(resolved.state, FilterState.Active);
249
+ });
250
+
251
+ it("recomputes ttl for an expiresAt-only change on an already-Temporary filter", () => {
252
+ const resolved = acceptedValue(
253
+ resolveFilterUpdate(
254
+ {
255
+ ...standing,
256
+ scope: FilterScope.Temporary,
257
+ expiresAt: "2099-01-01T00:00:00+00:00",
258
+ },
259
+ { expiresAt: "2099-06-01T00:00:00+00:00" },
260
+ ),
261
+ );
262
+ assert.equal(resolved.scope, FilterScope.Temporary);
263
+ assert.equal(resolved.expiresAt, "2099-06-01T00:00:00+00:00");
264
+ assert.equal(
265
+ resolved.ttl,
266
+ Math.floor(new Date("2099-06-01T00:00:00+00:00").getTime() / 1000),
267
+ );
268
+ assert.equal(resolved.state, FilterState.Active);
269
+ });
270
+
271
+ it("reactivates a lapsed filter extended into the future", () => {
272
+ const resolved = acceptedValue(
273
+ resolveFilterUpdate(
274
+ {
275
+ ...standing,
276
+ scope: FilterScope.Temporary,
277
+ expiresAt: "2020-01-01T00:00:00+00:00",
278
+ state: FilterState.Expired,
279
+ },
280
+ { expiresAt: "2099-01-01T00:00:00+00:00" },
281
+ ),
282
+ );
283
+ assert.equal(resolved.state, FilterState.Active);
284
+ });
285
+
286
+ it("reads Expired immediately when the new expiresAt is already in the past", () => {
287
+ const resolved = acceptedValue(
288
+ resolveFilterUpdate(
289
+ {
290
+ ...standing,
291
+ scope: FilterScope.Temporary,
292
+ expiresAt: "2099-01-01T00:00:00+00:00",
293
+ },
294
+ { expiresAt: "2020-01-01T00:00:00+00:00" },
295
+ ),
296
+ );
297
+ assert.equal(resolved.state, FilterState.Expired);
298
+ });
299
+
300
+ it("rejects an unparseable expiresAt", () => {
301
+ assert.throws(
302
+ () =>
303
+ resolveFilterUpdate(standing, {
304
+ scope: FilterScope.Temporary,
305
+ expiresAt: "not-a-date",
306
+ }),
307
+ (error: unknown) => {
308
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
309
+ return true;
310
+ },
311
+ );
312
+ });
313
+ });
@@ -1,51 +1,58 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- FilterItem,
5
- IFilterAnchorRepository,
6
- IFilterRepository,
7
- IMessageLabelRepository,
8
- } from "@remit/data-ports";
9
- import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
10
- import { _resetEmbeddingCapabilityForTest } from "@remit/search-service/capability";
11
- import type { PlacementMoveService } from "../placement-move.js";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import {
4
+ FilterClauseField,
5
+ FilterMatchOperator,
6
+ FilterScope,
7
+ } from "@remit/domain-enums";
8
+ import { PlacementMoveService } from "../placement-move.js";
9
+ import {
10
+ openStore,
11
+ type Store,
12
+ seedAccount,
13
+ seedMailbox,
14
+ } from "../test-helpers/test-store.js";
12
15
  import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
13
- import { type FilterMessage, NO_ACTION } from "./match.js";
14
- import { _resetMessageEmbedderForTest } from "./message-embedder.js";
15
- import { FilterPipeline, type MessageEmbedder } from "./pipeline.js";
16
-
17
- const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
18
- ({
19
- filterId: "flt-semantic",
16
+ import type { FilterMessage } from "./match.js";
17
+ import { FilterPipeline } from "./pipeline.js";
18
+
19
+ let store: Store;
20
+ let filterId: string;
21
+ let accountId: string;
22
+ let archiveId: string;
23
+
24
+ beforeEach(async () => {
25
+ store = await openStore();
26
+ const account = await seedAccount(store, { accountConfigId: "cfg-1" });
27
+ accountId = account.accountId;
28
+ const archive = await seedMailbox(store, account, "Archive");
29
+ archiveId = archive.mailboxId;
30
+ const filter = await store.filter.create({
20
31
  accountConfigId: "cfg-1",
21
32
  name: "receipts",
22
- scope: "Standing",
23
- state: FilterState.Active,
24
- hasAnchor: true,
25
- ruleChangedAt: 1,
33
+ scope: FilterScope.Standing,
26
34
  matchOperator: FilterMatchOperator.Or,
27
- literalClauses: [],
28
- actionLabelId: NO_ACTION,
29
- actionMailboxId: destinationMailboxId,
30
- createdAt: 1,
31
- updatedAt: 1,
32
- }) as unknown as FilterItem;
33
-
34
- const CURRENT_MODEL = "test-model@3";
35
+ literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
36
+ actionMailboxId: archive.mailboxId,
37
+ });
38
+ filterId = filter.filterId;
39
+ });
35
40
 
36
- const repos = (filter: FilterItem): FilterConfigDeps => ({
37
- filterService: {
38
- listByAccountAndState: async () => [filter],
39
- refreshExpiry: async (f: FilterItem) => f,
40
- } as unknown as IFilterRepository,
41
- filterAnchorService: {
42
- get: async () => ({
43
- anchorEmbedding: [1, 0, 0],
44
- anchorEmbeddingId: CURRENT_MODEL,
45
- }),
46
- } as unknown as IFilterAnchorRepository,
47
- messageLabelService: {} as unknown as IMessageLabelRepository,
48
- placementMoveService: {} as unknown as PlacementMoveService,
41
+ afterEach(() => store.close());
42
+
43
+ const repos = (): FilterConfigDeps => ({
44
+ filterService: store.filter,
45
+ messageLabelService: store.messageLabel,
46
+ mailboxService: store.mailbox,
47
+ placementMoveService: new PlacementMoveService({
48
+ messageService: store.message,
49
+ threadMessageService: store.threadMessage,
50
+ markerService: store.placementMove,
51
+ addressService: store.address,
52
+ mailboxSpecialUseService: store.mailboxSpecialUse,
53
+ mailboxService: store.mailbox,
54
+ sqsQueueUrl: "https://sqs.test/message-mgmt",
55
+ }),
49
56
  });
50
57
 
51
58
  const message: FilterMessage = {
@@ -58,172 +65,23 @@ const message: FilterMessage = {
58
65
 
59
66
  describe("buildFilterConfig", () => {
60
67
  it("returns undefined without a placement mover — no move path, filters stay off", () => {
61
- const deps = repos(anchorOnlyFilter("mbx-archive"));
68
+ const deps = repos();
62
69
  deps.placementMoveService = undefined;
63
70
 
64
71
  assert.equal(buildFilterConfig(deps), undefined);
65
72
  });
66
73
 
67
- it("wires an embedder into the config", () => {
68
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
69
-
70
- assert.ok(config);
71
- assert.ok(config.embedder);
72
- });
73
-
74
- it("lights up a semantic anchor-only filter on the body-sync pass", async () => {
75
- let embedCalls = 0;
76
- const embedder: MessageEmbedder = {
77
- embed: async () => {
78
- embedCalls += 1;
79
- return [1, 0, 0];
80
- },
81
- embeddingId: CURRENT_MODEL,
82
- };
83
- const config = buildFilterConfig(
84
- repos(anchorOnlyFilter("mbx-archive")),
85
- embedder,
86
- );
74
+ it("runs a literal filter on the body-sync pass", async () => {
75
+ const config = buildFilterConfig(repos());
87
76
  assert.ok(config);
88
77
 
89
78
  const decision = await new FilterPipeline(config, {
90
79
  info: () => {},
91
- }).evaluate("cfg-1", "m-1", message);
80
+ }).evaluate("cfg-1", accountId, "m-1", message);
92
81
 
93
82
  assert.deepEqual(decision.move, {
94
- destinationMailboxId: "mbx-archive",
95
- filterId: "flt-semantic",
96
- });
97
- assert.equal(embedCalls, 1);
98
- });
99
-
100
- it("does not match when the message embedding diverges from the anchor", async () => {
101
- const embedder: MessageEmbedder = {
102
- embed: async () => [0, 1, 0],
103
- embeddingId: CURRENT_MODEL,
104
- };
105
- const config = buildFilterConfig(
106
- repos(anchorOnlyFilter("mbx-archive")),
107
- embedder,
108
- );
109
- assert.ok(config);
110
-
111
- const decision = await new FilterPipeline(config, {
112
- info: () => {},
113
- }).evaluate("cfg-1", "m-2", message);
114
-
115
- assert.equal(decision.move, undefined);
116
- });
117
- });
118
-
119
- // An instance with semantic search off (issue #1068). `off` selects an embedder
120
- // that throws by design, and the throw lands in the pipeline's per-filter catch:
121
- // one error-level `filter_anchor_match_failed` for every semantic filter on
122
- // every message it syncs. The pipeline already has a designed skip for a
123
- // missing embedder, so the config passes none.
124
- describe("buildFilterConfig with semantic search off", () => {
125
- const withProviderOff = async (body: () => Promise<void>): Promise<void> => {
126
- const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
127
- process.env.SEARCH_EMBEDDING_PROVIDER = "off";
128
- _resetMessageEmbedderForTest();
129
- try {
130
- await body();
131
- } finally {
132
- if (previous === undefined) {
133
- delete process.env.SEARCH_EMBEDDING_PROVIDER;
134
- } else {
135
- process.env.SEARCH_EMBEDDING_PROVIDER = previous;
136
- }
137
- _resetMessageEmbedderForTest();
138
- }
139
- };
140
-
141
- it("wires no embedder", async () => {
142
- await withProviderOff(async () => {
143
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
144
-
145
- assert.ok(config);
146
- assert.equal(config.embedder, undefined);
147
- });
148
- });
149
-
150
- it("skips the semantic filter without logging an error", async () => {
151
- await withProviderOff(async () => {
152
- const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
153
- assert.ok(config);
154
-
155
- const errors: unknown[] = [];
156
- const decision = await new FilterPipeline(config, {
157
- info: () => {},
158
- debug: () => {},
159
- warn: () => {},
160
- error: (fields) => errors.push(fields.alert),
161
- }).evaluate("cfg-1", "m-off", message);
162
-
163
- assert.deepEqual(decision.labels, []);
164
- assert.equal(decision.move, undefined);
165
- assert.deepEqual(errors, []);
83
+ destinationMailboxId: archiveId,
84
+ filterId,
166
85
  });
167
86
  });
168
87
  });
169
-
170
- // The self-host `local` profile asks for the Transformers.js embedder, but the
171
- // backend and imap-worker images ship without `@huggingface/transformers`
172
- // (npm-scripts/docker-bundle.mjs). The first embed throws ERR_MODULE_NOT_FOUND,
173
- // which is the same capability absence `off` states and must take the same
174
- // designed skip — not an error-level `filter_anchor_match_failed` per semantic
175
- // filter per message on a user-facing GET.
176
- describe("buildFilterConfig when this deployment carries no embedder", () => {
177
- const moduleNotFound = (): Error =>
178
- Object.assign(
179
- new Error("Cannot find package '@huggingface/transformers'"),
180
- { code: "ERR_MODULE_NOT_FOUND" },
181
- );
182
-
183
- it("skips the semantic filter, logs no error, and stops wiring an embedder", async () => {
184
- const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
185
- process.env.SEARCH_EMBEDDING_PROVIDER = "local";
186
- _resetMessageEmbedderForTest();
187
- _resetEmbeddingCapabilityForTest();
188
-
189
- try {
190
- const unavailable: MessageEmbedder = {
191
- embed: async () => {
192
- throw moduleNotFound();
193
- },
194
- embeddingId: CURRENT_MODEL,
195
- };
196
- const config = buildFilterConfig(
197
- repos(anchorOnlyFilter("mbx-archive")),
198
- unavailable,
199
- );
200
- assert.ok(config);
201
-
202
- const errors: unknown[] = [];
203
- const decision = await new FilterPipeline(config, {
204
- info: () => {},
205
- debug: () => {},
206
- warn: () => {},
207
- error: (fields) => errors.push(fields.alert),
208
- }).evaluate("cfg-1", "m-local", message);
209
-
210
- assert.deepEqual(decision.labels, []);
211
- assert.equal(decision.move, undefined);
212
- assert.deepEqual(errors, []);
213
-
214
- // Probed once, not per filter or per message: every later config on this
215
- // process wires no embedder at all, so nothing else attempts the embed.
216
- const next = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
217
- assert.ok(next);
218
- assert.equal(next.embedder, undefined);
219
- } finally {
220
- if (previous === undefined) {
221
- delete process.env.SEARCH_EMBEDDING_PROVIDER;
222
- } else {
223
- process.env.SEARCH_EMBEDDING_PROVIDER = previous;
224
- }
225
- _resetMessageEmbedderForTest();
226
- _resetEmbeddingCapabilityForTest();
227
- }
228
- });
229
- });
@@ -1,52 +1,20 @@
1
1
  import type {
2
- IFilterAnchorRepository,
3
2
  IFilterRepository,
4
3
  IMessageLabelRepository,
5
4
  } from "@remit/data-ports";
6
- import { isEmbeddingCapabilityUnavailable } from "@remit/search-service/capability";
7
- import {
8
- EMBEDDING_PROVIDER_OFF,
9
- readEmbeddingProviderFromEnv,
10
- } from "@remit/search-service/from-env";
11
5
  import type { PlacementMoveService } from "../placement-move.js";
12
- import { getMessageEmbedder } from "./message-embedder.js";
13
- import type { FilterConfig, MessageEmbedder } from "./pipeline.js";
6
+ import type { FilterConfig } from "./pipeline.js";
14
7
 
15
8
  export interface FilterConfigDeps {
16
9
  filterService: IFilterRepository;
17
- filterAnchorService: IFilterAnchorRepository;
18
10
  messageLabelService: IMessageLabelRepository;
11
+ mailboxService: FilterConfig["mailboxService"];
19
12
  placementMoveService?: PlacementMoveService;
20
13
  }
21
14
 
22
- /**
23
- * The env-selected embedder, or none on an instance that cannot embed.
24
- *
25
- * `off` embeds nothing by design, and its embedder says so by throwing
26
- * (EmbeddingDisabledError). The pipeline has a designed skip for a *missing*
27
- * embedder — semantic filters are passed over and logged at debug — but a
28
- * present embedder that throws lands in the per-filter catch instead, which is
29
- * an error-level `filter_anchor_match_failed` for every semantic filter on
30
- * every synced message. Same outcome, a log nobody can read.
31
- *
32
- * `local` on the self-host profiles reaches the same place by a different road:
33
- * the backend and imap-worker images ship without `@huggingface/transformers`,
34
- * so the first embed throws ERR_MODULE_NOT_FOUND. The pipeline classifies that
35
- * one throw through `@remit/search-service/capability` and remembers it, and
36
- * from then on the config here wires no embedder either — one probe per process,
37
- * not one per filter per message.
38
- */
39
- const embedderFromEnv = (): MessageEmbedder | undefined =>
40
- readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF ||
41
- isEmbeddingCapabilityUnavailable()
42
- ? undefined
43
- : getMessageEmbedder();
44
-
45
15
  /**
46
16
  * Assemble the filter config a body-materializing pass runs (RFC 034), shared by
47
- * the imap-worker's sync path and the backend's read-path backfill so both embed
48
- * under the same model that produced the anchors — a semantic (anchor-only)
49
- * filter is evaluated on either path instead of silently skipped.
17
+ * the imap-worker's sync path and the backend's read-path backfill.
50
18
  *
51
19
  * Absent the placement mover there is no move path, so filters stay off — a
52
20
  * matched filter's actions reuse the same enqueue plumbing the placement mover
@@ -54,16 +22,13 @@ const embedderFromEnv = (): MessageEmbedder | undefined =>
54
22
  */
55
23
  export const buildFilterConfig = (
56
24
  deps: FilterConfigDeps,
57
- embedder?: MessageEmbedder,
58
25
  ): FilterConfig | undefined => {
59
26
  const { placementMoveService } = deps;
60
27
  if (!placementMoveService) return undefined;
61
- const resolved = embedder ?? embedderFromEnv();
62
28
  return {
63
29
  filterService: deps.filterService,
64
- filterAnchorService: deps.filterAnchorService,
65
30
  messageLabelService: deps.messageLabelService,
31
+ mailboxService: deps.mailboxService,
66
32
  placementMoveService,
67
- ...(resolved ? { embedder: resolved } : {}),
68
33
  };
69
34
  };