@remit/backend 0.0.106 → 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.106",
3
+ "version": "0.0.108",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,190 @@
1
+ /**
2
+ * What an operator can tell from the log when the Microsoft OAuth start fails.
3
+ *
4
+ * Every credential failure answers the same 500 with the same body, so the
5
+ * backend log for that request is the only place the four are told apart: an
6
+ * instance with no Microsoft app registered must not read like a live outage.
7
+ * Driven through the real handler so what is asserted is the line a deployment
8
+ * actually writes, correlation id and all.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { randomUUID } from "node:crypto";
13
+ import { after, before, describe, it } from "node:test";
14
+ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
15
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
16
+ import { captureStdout, restoreStdout } from "../stdout-capture-fixture.js";
17
+
18
+ const WEB_ORIGIN = "https://mail.example.com";
19
+ const START_PATH = "/accounts/oauth/microsoft/start";
20
+ const SECRET_ARN =
21
+ "arn:aws:secretsmanager:eu-west-1:000000000000:secret:msoauth-AbCdEf";
22
+
23
+ let handler: (
24
+ event: APIGatewayProxyEvent,
25
+ context: unknown,
26
+ ) => Promise<APIGatewayProxyResult>;
27
+
28
+ const originalSend = SecretsManagerClient.prototype.send;
29
+ let secretString: string | undefined;
30
+
31
+ interface Outcome {
32
+ response: APIGatewayProxyResult;
33
+ log: string;
34
+ logged: string;
35
+ }
36
+
37
+ const headerOf = (
38
+ response: APIGatewayProxyResult,
39
+ name: string,
40
+ ): string | undefined => {
41
+ for (const [key, value] of Object.entries(response.headers ?? {})) {
42
+ if (key.toLowerCase() === name) return String(value);
43
+ }
44
+ return undefined;
45
+ };
46
+
47
+ const start = async (): Promise<Outcome> => {
48
+ const awsRequestId = `req-${randomUUID()}`;
49
+ const { result: response, logged } = await captureStdout(() =>
50
+ handler(
51
+ {
52
+ httpMethod: "POST",
53
+ path: START_PATH,
54
+ headers: {},
55
+ queryStringParameters: null,
56
+ body: JSON.stringify({ email: "matthijs@example.com" }),
57
+ requestContext: { authorizer: { claims: { sub: "cognito-sub" } } },
58
+ } as unknown as APIGatewayProxyEvent,
59
+ { awsRequestId, functionName: "test" },
60
+ ),
61
+ );
62
+
63
+ assert.equal(
64
+ headerOf(response, "x-correlation-id"),
65
+ awsRequestId,
66
+ "the id the client reports has to be the id the log lines carry",
67
+ );
68
+
69
+ const forThisRequest = logged
70
+ .split("\n")
71
+ .filter((line) => line.length > 0)
72
+ .map((line) => JSON.parse(line) as Record<string, unknown>)
73
+ .filter((line) => line.requestId === awsRequestId);
74
+
75
+ assert.ok(
76
+ forThisRequest.length > 0,
77
+ "the failure left no line carrying the correlation id the client was handed",
78
+ );
79
+
80
+ return { response, log: JSON.stringify(forThisRequest), logged };
81
+ };
82
+
83
+ /** The name each condition, and only that condition, puts in the log. */
84
+ const NO_APP_REGISTERED = "MicrosoftOAuthNotConfigured";
85
+ const SECRET_HAS_NO_STRING = "MicrosoftOAuthSecretEmpty";
86
+ const SECRET_MISSING_FIELDS = "MicrosoftOAuthSecretIncomplete";
87
+ const SECRET_IS_NOT_JSON = "MicrosoftOAuthSecretUnparseable";
88
+
89
+ const CONDITIONS = [
90
+ NO_APP_REGISTERED,
91
+ SECRET_HAS_NO_STRING,
92
+ SECRET_MISSING_FIELDS,
93
+ SECRET_IS_NOT_JSON,
94
+ ];
95
+
96
+ const assertNamesOnly = (log: string, condition: string): void => {
97
+ assert.ok(
98
+ log.includes(condition),
99
+ `the log for this request never names the condition ${condition}: ${log}`,
100
+ );
101
+ for (const other of CONDITIONS) {
102
+ if (other === condition) continue;
103
+ assert.ok(
104
+ !log.includes(other),
105
+ `the log also names ${other}, so the two conditions are indistinguishable`,
106
+ );
107
+ }
108
+ };
109
+
110
+ const assertGenericFiveHundred = (response: APIGatewayProxyResult): void => {
111
+ assert.equal(response.statusCode, 500);
112
+ assert.deepEqual(JSON.parse(response.body), {
113
+ message: "Internal server error",
114
+ });
115
+ };
116
+
117
+ before(async () => {
118
+ process.env.LOG_LEVEL = "error";
119
+ process.env.DATA_BACKEND = "sqlite";
120
+ process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
121
+ process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}/accounts/oauth/microsoft/callback`;
122
+
123
+ SecretsManagerClient.prototype.send = (async () => ({
124
+ SecretString: secretString,
125
+ })) as typeof SecretsManagerClient.prototype.send;
126
+
127
+ ({ handler } = (await import("../index.js")) as unknown as {
128
+ handler: typeof handler;
129
+ });
130
+ });
131
+
132
+ after(() => {
133
+ SecretsManagerClient.prototype.send = originalSend;
134
+ restoreStdout();
135
+ });
136
+
137
+ describe("a Microsoft OAuth start that cannot load its credentials", () => {
138
+ it("names an instance with no Microsoft app registered", async () => {
139
+ delete process.env.MSOAUTH_CLIENT_ID;
140
+ delete process.env.MSOAUTH_CLIENT_SECRET;
141
+ delete process.env.MSOAUTH_SECRET_ARN;
142
+
143
+ const { response, log } = await start();
144
+
145
+ assertGenericFiveHundred(response);
146
+ assertNamesOnly(log, NO_APP_REGISTERED);
147
+ });
148
+
149
+ it("names a secret that carries no string value", async () => {
150
+ delete process.env.MSOAUTH_CLIENT_ID;
151
+ delete process.env.MSOAUTH_CLIENT_SECRET;
152
+ process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
153
+ secretString = undefined;
154
+
155
+ const { response, log } = await start();
156
+
157
+ assertGenericFiveHundred(response);
158
+ assertNamesOnly(log, SECRET_HAS_NO_STRING);
159
+ });
160
+
161
+ it("names a secret whose credential fields are absent", async () => {
162
+ delete process.env.MSOAUTH_CLIENT_ID;
163
+ delete process.env.MSOAUTH_CLIENT_SECRET;
164
+ process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
165
+ secretString = JSON.stringify({ clientId: "an-app-id" });
166
+
167
+ const { response, log } = await start();
168
+
169
+ assertGenericFiveHundred(response);
170
+ assertNamesOnly(log, SECRET_MISSING_FIELDS);
171
+ });
172
+
173
+ it("names a secret that is not JSON without quoting what it holds", async () => {
174
+ const secretMaterial = "s3cr3t-not-json-9d41c0b7";
175
+ delete process.env.MSOAUTH_CLIENT_ID;
176
+ delete process.env.MSOAUTH_CLIENT_SECRET;
177
+ process.env.MSOAUTH_SECRET_ARN = SECRET_ARN;
178
+ secretString = secretMaterial;
179
+
180
+ const { response, log, logged } = await start();
181
+
182
+ assertGenericFiveHundred(response);
183
+ assertNamesOnly(log, SECRET_IS_NOT_JSON);
184
+ assert.equal(
185
+ logged.includes(secretMaterial),
186
+ false,
187
+ "a parse failure quoting the secret into the log turns a misconfiguration into a leak",
188
+ );
189
+ });
190
+ });
@@ -123,6 +123,21 @@ function getSecretsManagerClient(): SecretsManagerClient {
123
123
  return smClient;
124
124
  }
125
125
 
126
+ type MsOAuthCredentialFailure =
127
+ | "MicrosoftOAuthNotConfigured"
128
+ | "MicrosoftOAuthSecretEmpty"
129
+ | "MicrosoftOAuthSecretIncomplete"
130
+ | "MicrosoftOAuthSecretUnparseable";
131
+
132
+ function credentialFailure(
133
+ name: MsOAuthCredentialFailure,
134
+ message: string,
135
+ ): Error {
136
+ const error = new Error(message);
137
+ error.name = name;
138
+ return error;
139
+ }
140
+
126
141
  async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
127
142
  const config = getMsOAuthConfig();
128
143
 
@@ -133,8 +148,9 @@ async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
133
148
 
134
149
  // Production: fetch from Secrets Manager
135
150
  if (!config.secretArn) {
136
- throw new Error(
137
- "MSOAUTH_SECRET_ARN is required when MSOAUTH_CLIENT_ID/MSOAUTH_CLIENT_SECRET are not set",
151
+ throw credentialFailure(
152
+ "MicrosoftOAuthNotConfigured",
153
+ "No Microsoft OAuth credentials are configured on this instance: set MSOAUTH_CLIENT_ID and MSOAUTH_CLIENT_SECRET, or MSOAUTH_SECRET_ARN",
138
154
  );
139
155
  }
140
156
 
@@ -144,13 +160,29 @@ async function getMsOAuthCredentials(): Promise<MsOAuthCredentials> {
144
160
  );
145
161
 
146
162
  if (!result.SecretString) {
147
- throw new Error("MSOAUTH secret has no SecretString value");
163
+ throw credentialFailure(
164
+ "MicrosoftOAuthSecretEmpty",
165
+ "The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN holds no SecretString",
166
+ );
167
+ }
168
+
169
+ const parsed = await safeJsonParse<MsOAuthCredentials>(
170
+ result.SecretString,
171
+ ).catch(() => null);
172
+ if (!parsed) {
173
+ throw credentialFailure(
174
+ "MicrosoftOAuthSecretUnparseable",
175
+ "The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN does not hold a JSON object",
176
+ );
148
177
  }
149
178
 
150
- const parsed = JSON.parse(result.SecretString) as MsOAuthCredentials;
151
- if (!parsed.clientId || !parsed.clientSecret) {
152
- throw new Error(
153
- "MSOAUTH secret must contain clientId and clientSecret fields",
179
+ const absent = (["clientId", "clientSecret"] as const).filter(
180
+ (field) => !parsed[field],
181
+ );
182
+ if (absent.length > 0) {
183
+ throw credentialFailure(
184
+ "MicrosoftOAuthSecretIncomplete",
185
+ `The Microsoft OAuth secret named by MSOAUTH_SECRET_ARN is missing ${absent.join(" and ")}`,
154
186
  );
155
187
  }
156
188
 
@@ -11,25 +11,7 @@
11
11
  import assert from "node:assert/strict";
12
12
  import { after, before, describe, it } from "node:test";
13
13
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
14
-
15
- // pino writes to `process.stdout` only when its `write` has been replaced, so
16
- // the hook goes in before the logger is imported — which every import below
17
- // does transitively. Writes pass through whenever nothing is capturing, so the
18
- // test runner's own output still reaches the terminal.
19
- const originalWrite = process.stdout.write.bind(process.stdout);
20
- const written: string[] = [];
21
- let capturing = false;
22
-
23
- process.stdout.write = ((
24
- chunk: string | Uint8Array,
25
- ...rest: unknown[]
26
- ): boolean => {
27
- if (capturing && typeof chunk === "string") {
28
- written.push(chunk);
29
- return true;
30
- }
31
- return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
32
- }) as typeof process.stdout.write;
14
+ import { captureStdout, restoreStdout } from "./stdout-capture-fixture.js";
33
15
 
34
16
  process.env.LOG_LEVEL = "trace";
35
17
 
@@ -45,19 +27,6 @@ let handler: (
45
27
  ) => Promise<APIGatewayProxyResult>;
46
28
  let cleanup: () => void;
47
29
 
48
- const capture = async (
49
- run: () => Promise<APIGatewayProxyResult>,
50
- ): Promise<{ response: APIGatewayProxyResult; logged: string }> => {
51
- written.length = 0;
52
- capturing = true;
53
- try {
54
- const response = await run();
55
- return { response, logged: written.join("") };
56
- } finally {
57
- capturing = false;
58
- }
59
- };
60
-
61
30
  const send = (
62
31
  event: Partial<APIGatewayProxyEvent> & { httpMethod: string; path: string },
63
32
  ): Promise<APIGatewayProxyResult> =>
@@ -85,7 +54,7 @@ before(async () => {
85
54
 
86
55
  after(() => {
87
56
  _resetForTest();
88
- process.stdout.write = originalWrite as typeof process.stdout.write;
57
+ restoreStdout();
89
58
  cleanup();
90
59
  });
91
60
 
@@ -93,7 +62,7 @@ describe("what a request leaves in the log", () => {
93
62
  it("never writes the feed token a subscriber polls with", async () => {
94
63
  const { token } = mintCalendarFeedToken();
95
64
 
96
- const { response, logged } = await capture(() =>
65
+ const { result: response, logged } = await captureStdout(() =>
97
66
  send({ httpMethod: "GET", path: `/feeds/calendar/${token}.ics` }),
98
67
  );
99
68
 
@@ -119,7 +88,7 @@ describe("what a request leaves in the log", () => {
119
88
  // request reaches validation — where the whole parsed request, headers and
120
89
  // all, used to be logged. The delete refuses because it declares
121
90
  // `calendarId` as a required query parameter and none was sent.
122
- const { response, logged } = await capture(() =>
91
+ const { result: response, logged } = await captureStdout(() =>
123
92
  send({
124
93
  httpMethod: "DELETE",
125
94
  path: "/calendar-events/8f14e45f-ceea-467a-9c9e-9c9e9c9e9c9e",
@@ -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
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A test's view of what a request wrote to the log.
3
+ *
4
+ * pino writes through `process.stdout` once its `write` is replaced, so the hook
5
+ * is installed when this module is evaluated — import it above anything that
6
+ * pulls in the logger. Writes pass through whenever nothing is capturing, so the
7
+ * test runner's own output still reaches the terminal.
8
+ */
9
+
10
+ const originalWrite = process.stdout.write.bind(process.stdout);
11
+ const written: string[] = [];
12
+ let capturing = false;
13
+
14
+ process.stdout.write = ((
15
+ chunk: string | Uint8Array,
16
+ ...rest: unknown[]
17
+ ): boolean => {
18
+ if (capturing && typeof chunk === "string") {
19
+ written.push(chunk);
20
+ return true;
21
+ }
22
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
23
+ }) as typeof process.stdout.write;
24
+
25
+ export const captureStdout = async <T>(
26
+ run: () => Promise<T>,
27
+ ): Promise<{ result: T; logged: string }> => {
28
+ written.length = 0;
29
+ capturing = true;
30
+ try {
31
+ const result = await run();
32
+ return { result, logged: written.join("") };
33
+ } finally {
34
+ capturing = false;
35
+ }
36
+ };
37
+
38
+ export const restoreStdout = (): void => {
39
+ process.stdout.write = originalWrite as typeof process.stdout.write;
40
+ };