@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 +1 -1
- package/src/handlers/account-oauth-start.test.ts +190 -0
- package/src/handlers/account-oauth.ts +39 -7
- package/src/log-redaction.test.ts +4 -35
- package/src/service/create-remit-client.ts +36 -29
- package/src/service/read-path-filter-config.test.ts +243 -0
- package/src/service/semantic-capability.ts +18 -18
- package/src/stdout-capture-fixture.ts +40 -0
package/package.json
CHANGED
|
@@ -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
|
|
137
|
-
"
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
_resetEmbeddingCapabilityForTest();
|
|
64
66
|
};
|
|
65
67
|
|
|
66
|
-
export const isSemanticSearchUnavailable = (): boolean =>
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
};
|