@remit/backend 0.0.107 → 0.0.108

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.107",
3
+ "version": "0.0.108",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -37,6 +37,7 @@ import {
37
37
  BodySyncQueueService,
38
38
  BodySyncService,
39
39
  createConnection,
40
+ type FilterConfig,
40
41
  FlagPushService,
41
42
  FlagQueueService,
42
43
  type IImapConnection,
@@ -47,6 +48,7 @@ import {
47
48
  PlacementMoveService,
48
49
  SpamReportService,
49
50
  } from "@remit/mailbox-service";
51
+ import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
50
52
  import { createSearchService, type SearchService } from "@remit/search-service";
51
53
  import {
52
54
  buildEmbeddingServiceFromEnv,
@@ -339,6 +341,39 @@ export const buildSharedDeps = (): RemitClientSharedDeps => {
339
341
  };
340
342
  };
341
343
 
344
+ // The read-path body backfill (describeMessage / getRawMessage →
345
+ // fetchAndGetBody) materializes a body that was never sync-synced, and must run
346
+ // the account's standing filters the same way the imap-worker's sync path does.
347
+ // Without this, a message a user opens before background body-sync is classified
348
+ // but never filter-evaluated — and once its body is stored the filter-capable
349
+ // sync path skips it, so an explicit standing filter silently never fires (issue
350
+ // #223). Only the explicit user-rule half is wired here; heuristic classifier
351
+ // placement stays on the sync path. It shares `buildFilterConfig` with
352
+ // `sync-message-body.ts`, so both paths embed a candidate message under the same
353
+ // env-selected model that produced the anchors and a semantic (anchor-only)
354
+ // filter fires on either (issue #298). Over the message-management queue's
355
+ // local-first PlacementMoveService; filters stay off when that queue is unset.
356
+ export const buildReadPathFilterConfig = (
357
+ repositories: RemitClientRepositories,
358
+ ): FilterConfig | undefined => {
359
+ const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
360
+ return buildFilterConfig({
361
+ filterService: repositories.filter,
362
+ filterAnchorService: repositories.filterAnchor,
363
+ messageLabelService: repositories.messageLabel,
364
+ placementMoveService: placementMoveQueueUrl
365
+ ? new PlacementMoveService({
366
+ messageService: repositories.message,
367
+ threadMessageService: repositories.threadMessage,
368
+ markerService: repositories.placementMove,
369
+ addressService: repositories.address,
370
+ mailboxSpecialUseService: repositories.mailboxSpecialUse,
371
+ sqsQueueUrl: placementMoveQueueUrl,
372
+ })
373
+ : undefined,
374
+ });
375
+ };
376
+
342
377
  // Backend-neutral composition root: given repositories (from any data-ports
343
378
  // implementation) and the shared services, wire the domain and queue services
344
379
  // and assemble a RemitClient. Imports neither ElectroDB nor Drizzle — the
@@ -354,35 +389,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
354
389
  bodySyncQueue,
355
390
  } = deps;
356
391
 
357
- // The read-path body backfill (describeMessage / getRawMessage →
358
- // fetchAndGetBody) materializes a body that was never sync-synced, and must
359
- // run the account's standing filters the same way the imap-worker's sync path
360
- // does. Without this, a message a user opens before background body-sync is
361
- // classified but never filter-evaluated — and once its body is stored the
362
- // filter-capable sync path skips it, so an explicit standing filter silently
363
- // never fires (issue #223). Only the explicit user-rule half is wired here;
364
- // heuristic classifier placement stays on the sync path. Wired like
365
- // `sync-message-body.ts`, over the message-management queue's local-first
366
- // PlacementMoveService; filters stay off when that queue is unset.
367
- const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
368
- const placementMoveService = placementMoveQueueUrl
369
- ? new PlacementMoveService({
370
- messageService: repositories.message,
371
- threadMessageService: repositories.threadMessage,
372
- markerService: repositories.placementMove,
373
- addressService: repositories.address,
374
- mailboxSpecialUseService: repositories.mailboxSpecialUse,
375
- sqsQueueUrl: placementMoveQueueUrl,
376
- })
377
- : undefined;
378
- const filterConfig = placementMoveService
379
- ? {
380
- filterService: repositories.filter,
381
- filterAnchorService: repositories.filterAnchor,
382
- messageLabelService: repositories.messageLabel,
383
- placementMoveService,
384
- }
385
- : undefined;
392
+ const filterConfig = buildReadPathFilterConfig(repositories);
386
393
 
387
394
  const flagPushService = new FlagPushService({
388
395
  markerService: repositories.flagPush,
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The read-path body backfill must run a semantic (anchor-only) standing filter,
3
+ * which needs the env-selected embedder wired into the filter config
4
+ * `createRemitClient` hands its BodySyncService (issue #298).
5
+ *
6
+ * These drive the composed client — `createRemitClient(...).bodySync` — rather
7
+ * than `buildReadPathFilterConfig` alone, so replacing that call with an inline
8
+ * filter-config literal (which carries no embedder) fails here.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, beforeEach, describe, it } from "node:test";
13
+ import type {
14
+ CreateMessageLabelInput,
15
+ FilterAnchorItem,
16
+ FilterItem,
17
+ UpdateMessageInput,
18
+ } from "@remit/data-ports";
19
+ import { NotFoundError } from "@remit/data-ports/errors";
20
+ import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
21
+ import {
22
+ buildMatchText,
23
+ type FilterMessage,
24
+ type IImapConnection,
25
+ NO_ACTION,
26
+ } from "@remit/mailbox-service";
27
+ import type { SearchService } from "@remit/search-service";
28
+ import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
29
+ import type { SecretsService } from "@remit/secrets-service";
30
+ import type { StorageService } from "@remit/storage-service";
31
+ import {
32
+ buildReadPathFilterConfig,
33
+ createRemitClient,
34
+ type RemitClientRepositories,
35
+ } from "./create-remit-client.js";
36
+
37
+ const MESSAGE: FilterMessage = {
38
+ from: "billing@stripe.com",
39
+ fromName: "Stripe",
40
+ subject: "Your receipt",
41
+ text: "Thanks for your payment",
42
+ listId: "",
43
+ };
44
+
45
+ const eml = (message: FilterMessage): Buffer =>
46
+ Buffer.from(
47
+ [
48
+ `From: ${message.fromName} <${message.from}>`,
49
+ "To: me@example.com",
50
+ `Subject: ${message.subject}`,
51
+ "Content-Type: text/plain",
52
+ "",
53
+ message.text,
54
+ ].join("\r\n"),
55
+ );
56
+
57
+ /**
58
+ * Anchor-only: no literal clauses, so it matches on the embedding alone. Its
59
+ * action is a label, which the fake MessageLabel repository below records —
60
+ * a move would run the real PlacementMoveService against SQS.
61
+ */
62
+ const anchorOnlyFilter: FilterItem = {
63
+ filterId: "flt-semantic",
64
+ accountConfigId: "cfg-1",
65
+ name: "receipts",
66
+ scope: "Standing",
67
+ state: FilterState.Active,
68
+ hasAnchor: true,
69
+ ruleChangedAt: 1,
70
+ matchOperator: FilterMatchOperator.Or,
71
+ literalClauses: [],
72
+ actionLabelId: "lbl-receipts",
73
+ actionMailboxId: NO_ACTION,
74
+ createdAt: 1,
75
+ updatedAt: 1,
76
+ } as unknown as FilterItem;
77
+
78
+ // The read path must embed a candidate message under the same model that wrote
79
+ // the anchors, so an anchor written from this text matches it exactly.
80
+ const anchorFor = async (message: FilterMessage): Promise<FilterAnchorItem> => {
81
+ const embedding = buildEmbeddingServiceFromEnv();
82
+ const [anchorEmbedding] = await embedding.embed([buildMatchText(message)]);
83
+ return {
84
+ accountConfigId: "cfg-1",
85
+ filterId: "flt-semantic",
86
+ anchorEmbedding,
87
+ anchorEmbeddingId: embedding.embeddingId,
88
+ anchorSourceText: buildMatchText(message),
89
+ anchorMessageId: "m-anchor",
90
+ } as unknown as FilterAnchorItem;
91
+ };
92
+
93
+ const repositoriesWithAnchor = (
94
+ anchor: FilterAnchorItem,
95
+ applied: CreateMessageLabelInput[] = [],
96
+ ): RemitClientRepositories =>
97
+ ({
98
+ filter: {
99
+ listByAccountAndState: async () => [anchorOnlyFilter],
100
+ refreshExpiry: async (filter: FilterItem) => filter,
101
+ },
102
+ filterAnchor: { get: async () => anchor },
103
+ messageLabel: {
104
+ apply: async (input: CreateMessageLabelInput) => {
105
+ applied.push(input);
106
+ },
107
+ },
108
+ message: {
109
+ get: async (messageId: string) => ({
110
+ messageId,
111
+ mailboxId: "mb-inbox",
112
+ uid: 1,
113
+ }),
114
+ update: async (_messageId: string, _input: UpdateMessageInput) => {},
115
+ },
116
+ threadMessage: {
117
+ findAllByMessageId: async () => [
118
+ {
119
+ threadMessageId: "tm-1",
120
+ sentDate: 1,
121
+ mailboxId: "mb-inbox",
122
+ isRead: false,
123
+ isDeleted: false,
124
+ hasStars: false,
125
+ hasAttachment: false,
126
+ },
127
+ ],
128
+ update: async () => {},
129
+ },
130
+ address: {
131
+ getAddress: async () => {
132
+ throw new NotFoundError("Address not found");
133
+ },
134
+ incrementInboundCount: async () => {},
135
+ },
136
+ envelope: { listBodyParts: async () => [] },
137
+ placementMove: {},
138
+ mailboxSpecialUse: {},
139
+ }) as unknown as RemitClientRepositories;
140
+
141
+ const storage = {
142
+ storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
143
+ storeParsedBody: async () => {},
144
+ listBodyParts: async () => [],
145
+ } as unknown as StorageService;
146
+
147
+ const readBodyThroughClient = async (
148
+ repositories: RemitClientRepositories,
149
+ message: FilterMessage,
150
+ ): Promise<void> => {
151
+ const client = createRemitClient({
152
+ repositories,
153
+ storage,
154
+ search: {} as unknown as SearchService,
155
+ secrets: {} as unknown as SecretsService,
156
+ sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/0/test",
157
+ sqsSmtpQueueUrl: "https://sqs.eu-west-1.amazonaws.com/0/test-smtp",
158
+ });
159
+ const connection = {
160
+ openBox: async () => {},
161
+ fetchMessageBody: async () => eml(message),
162
+ } as unknown as IImapConnection;
163
+
164
+ await client.bodySync.fetchAndGetBody(
165
+ "m-1",
166
+ "acc-1",
167
+ "cfg-1",
168
+ "INBOX",
169
+ async () => connection,
170
+ );
171
+ };
172
+
173
+ describe("createRemitClient read-path filter config", () => {
174
+ let previousQueueUrl: string | undefined;
175
+
176
+ beforeEach(() => {
177
+ previousQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
178
+ process.env.SQS_QUEUE_URL_MESSAGE_MGMT =
179
+ "https://sqs.eu-west-1.amazonaws.com/0/test-message-mgmt";
180
+ });
181
+
182
+ afterEach(() => {
183
+ if (previousQueueUrl === undefined) {
184
+ delete process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
185
+ return;
186
+ }
187
+ process.env.SQS_QUEUE_URL_MESSAGE_MGMT = previousQueueUrl;
188
+ });
189
+
190
+ it("labels a message an anchor-only filter matches on a read-path backfill", async () => {
191
+ const applied: CreateMessageLabelInput[] = [];
192
+
193
+ await readBodyThroughClient(
194
+ repositoriesWithAnchor(await anchorFor(MESSAGE), applied),
195
+ MESSAGE,
196
+ );
197
+
198
+ assert.deepEqual(
199
+ applied.map(({ labelId, appliedByFilterId }) => ({
200
+ labelId,
201
+ appliedByFilterId,
202
+ })),
203
+ [{ labelId: "lbl-receipts", appliedByFilterId: "flt-semantic" }],
204
+ );
205
+ });
206
+
207
+ it("leaves a message the anchor does not match unlabelled", async () => {
208
+ const applied: CreateMessageLabelInput[] = [];
209
+
210
+ await readBodyThroughClient(
211
+ repositoriesWithAnchor(
212
+ await anchorFor({
213
+ ...MESSAGE,
214
+ subject: "Scheduled maintenance window",
215
+ text: "Our service will be unavailable on Sunday",
216
+ }),
217
+ applied,
218
+ ),
219
+ MESSAGE,
220
+ );
221
+
222
+ assert.deepEqual(applied, []);
223
+ });
224
+ });
225
+
226
+ describe("buildReadPathFilterConfig", () => {
227
+ it("keeps filters off when the message-management queue is unset", async () => {
228
+ const previous = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
229
+ delete process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
230
+ try {
231
+ assert.equal(
232
+ buildReadPathFilterConfig(
233
+ repositoriesWithAnchor(await anchorFor(MESSAGE)),
234
+ ),
235
+ undefined,
236
+ );
237
+ } finally {
238
+ if (previous !== undefined) {
239
+ process.env.SQS_QUEUE_URL_MESSAGE_MGMT = previous;
240
+ }
241
+ }
242
+ });
243
+ });
@@ -1,4 +1,10 @@
1
1
  import { logger } from "@remit/logger-lambda";
2
+ import {
3
+ _resetEmbeddingCapabilityForTest,
4
+ isEmbeddingCapabilityAbsence,
5
+ isEmbeddingCapabilityUnavailable,
6
+ recordEmbeddingCapabilityAbsence,
7
+ } from "@remit/search-service/capability";
2
8
  import { isSelfHostSqlBackend } from "../data-backend.js";
3
9
 
4
10
  /**
@@ -47,23 +53,20 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
47
53
  * instead was considered and rejected — it would fabricate relevance scores and
48
54
  * matched-chunk labels for the "Related" UI, and cannot serve the cross-account
49
55
  * queries the daily brief issues.
56
+ *
57
+ * The error vocabulary and the memoized absence itself live in
58
+ * `@remit/search-service/capability`, shared with the filter pipeline's semantic
59
+ * skip: whichever path embeds first is the probe, and the other stops trying.
60
+ * This module adds the self-host scoping and the operator-facing log.
50
61
  */
51
62
 
52
- const CAPABILITY_ABSENCE_CODES = new Set([
53
- "ERR_MODULE_NOT_FOUND",
54
- "MODULE_NOT_FOUND",
55
- "ERR_DLOPEN_FAILED",
56
- "ERR_EMBEDDING_MODEL_UNAVAILABLE",
57
- ]);
58
-
59
- let semanticUnavailable = false;
60
-
61
63
  /** Test-only reset for the memoized absence. */
62
64
  export const _resetSemanticCapabilityForTest = (): void => {
63
- semanticUnavailable = false;
65
+ _resetEmbeddingCapabilityForTest();
64
66
  };
65
67
 
66
- export const isSemanticSearchUnavailable = (): boolean => semanticUnavailable;
68
+ export const isSemanticSearchUnavailable = (): boolean =>
69
+ isEmbeddingCapabilityUnavailable();
67
70
 
68
71
  /**
69
72
  * Whether a failure is this deployment simply not carrying the semantic
@@ -71,11 +74,8 @@ export const isSemanticSearchUnavailable = (): boolean => semanticUnavailable;
71
74
  * Records nothing, for the caller that must degrade one operation without
72
75
  * disabling `/search/semantic` process-wide.
73
76
  */
74
- export const isSemanticCapabilityAbsence = (error: unknown): boolean => {
75
- if (!isSelfHostSqlBackend()) return false;
76
- const code = (error as { code?: unknown } | null)?.code;
77
- return typeof code === "string" && CAPABILITY_ABSENCE_CODES.has(code);
78
- };
77
+ export const isSemanticCapabilityAbsence = (error: unknown): boolean =>
78
+ isSelfHostSqlBackend() && isEmbeddingCapabilityAbsence(error);
79
79
 
80
80
  /**
81
81
  * Classify a semantic-search failure. Returns true — and remembers the
@@ -84,12 +84,12 @@ export const isSemanticCapabilityAbsence = (error: unknown): boolean => {
84
84
  */
85
85
  export const noteSemanticCapabilityAbsence = (error: unknown): boolean => {
86
86
  if (!isSemanticCapabilityAbsence(error)) return false;
87
- if (!semanticUnavailable) {
87
+ if (!isEmbeddingCapabilityUnavailable()) {
88
88
  logger.warn(
89
89
  { error: error instanceof Error ? error.message : String(error) },
90
90
  "Semantic search pipeline unavailable in this deployment — serving empty semantic results (see deploy/vps/README.md)",
91
91
  );
92
92
  }
93
- semanticUnavailable = true;
93
+ recordEmbeddingCapabilityAbsence(error);
94
94
  return true;
95
95
  };