@remit/backend 0.0.103 → 0.0.105

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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The OAuth callback through the dev-server's own app — the process a
3
+ * self-host container runs.
4
+ *
5
+ * One hop further than the handler test: the app object imported from
6
+ * `server.ts`, so what is asserted is the header express actually writes onto
7
+ * the socket for Microsoft's returning browser. The handler answering 302 is
8
+ * not the same claim as the browser being sent anywhere, and the difference
9
+ * between the two was a blank page.
10
+ *
11
+ * The identity tier is left unmounted — this app serves the route either way,
12
+ * and the callback's exemption from the session gate is pinned where that gate
13
+ * runs, in `src/handlers/account-oauth-callback.test.ts`.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import type { Server } from "node:http";
18
+ import { after, before, describe, it } from "node:test";
19
+ import {
20
+ _resetForTest,
21
+ type RemitClient,
22
+ setClient,
23
+ } from "../src/service/data-client.js";
24
+
25
+ const WEB_ORIGIN = "https://mail.example.com";
26
+
27
+ let server: Server;
28
+ let port: number;
29
+
30
+ before(async () => {
31
+ // Microsoft's redirect carries no session, so the callback answers before it
32
+ // reads anything: no store is touched on this path.
33
+ setClient({} as unknown as RemitClient);
34
+
35
+ process.env.BETTER_AUTH_SECRET = "a-signing-secret-of-at-least-32-characters";
36
+ process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
37
+ process.env.SERVER_PORT = "0";
38
+
39
+ const imported = await import("./server.js");
40
+ server = imported.listener;
41
+ if (!server.listening) {
42
+ await new Promise<void>((resolve) => server.once("listening", resolve));
43
+ }
44
+ const address = server.address();
45
+ port = typeof address === "object" && address ? address.port : 0;
46
+ });
47
+
48
+ after(async () => {
49
+ await new Promise<void>((resolve) => server.close(() => resolve()));
50
+ _resetForTest();
51
+ });
52
+
53
+ describe("GET /accounts/oauth/microsoft/callback through the dev-server's own app", () => {
54
+ it("answers the browser with somewhere to go", async () => {
55
+ const response = await fetch(
56
+ `http://127.0.0.1:${port}/accounts/oauth/microsoft/callback?error=access_denied`,
57
+ { redirect: "manual" },
58
+ );
59
+
60
+ assert.equal(response.status, 302);
61
+ assert.equal(
62
+ response.headers.get("location"),
63
+ `${WEB_ORIGIN}/settings/accounts?oauthError=access_denied`,
64
+ );
65
+ });
66
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The browser Microsoft sends back, and where it ends up.
3
+ *
4
+ * The callback answers a redirect, so the whole response is its Location
5
+ * header: an answer that arrives without it is a blank page with the account
6
+ * either connected or not and no way to tell. Driven through the real OpenAPI
7
+ * document and the self-host request path, so what is asserted is what a
8
+ * self-hosted deployment writes onto the wire — the header included, which a
9
+ * handler returning a bare `{statusCode, headers}` shape lost on the way out.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { randomUUID } from "node:crypto";
14
+ import { createServer, type Server } from "node:http";
15
+ import type { AddressInfo } from "node:net";
16
+ import { after, before, describe, it } from "node:test";
17
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
18
+ import {
19
+ _resetForTest,
20
+ type RemitClient,
21
+ setClient,
22
+ } from "../service/data-client.js";
23
+ import { signState } from "./account-oauth.js";
24
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
25
+
26
+ const CLIENT_SECRET = "callback-test-client-secret";
27
+ const WEB_ORIGIN = "https://mail.example.com";
28
+ const CALLBACK_PATH = "/accounts/oauth/microsoft/callback";
29
+
30
+ let handler: (
31
+ event: APIGatewayProxyEvent,
32
+ context: unknown,
33
+ ) => Promise<APIGatewayProxyResult>;
34
+ let client: RemitClient;
35
+ let cleanup: () => void;
36
+ let tokenEndpoint: Server;
37
+
38
+ const jwt = (claims: Record<string, unknown>): string =>
39
+ [
40
+ Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"),
41
+ Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"),
42
+ "signature-not-checked-here",
43
+ ].join(".");
44
+
45
+ /** Microsoft's token endpoint, close enough for the one call the callback makes. */
46
+ const startTokenEndpoint = async (): Promise<string> => {
47
+ tokenEndpoint = createServer((_request, response) => {
48
+ response.writeHead(200, { "Content-Type": "application/json" });
49
+ response.end(
50
+ JSON.stringify({
51
+ access_token: "access-token",
52
+ refresh_token: "refresh-token",
53
+ id_token: jwt({ preferred_username: "matthijs@example.com" }),
54
+ expires_in: 3600,
55
+ }),
56
+ );
57
+ });
58
+ await new Promise<void>((resolve) => tokenEndpoint.listen(0, resolve));
59
+ const { port } = tokenEndpoint.address() as AddressInfo;
60
+ return `http://127.0.0.1:${port}/token`;
61
+ };
62
+
63
+ const callback = (
64
+ query: Record<string, string>,
65
+ ): Promise<APIGatewayProxyResult> =>
66
+ handler(
67
+ {
68
+ httpMethod: "GET",
69
+ path: CALLBACK_PATH,
70
+ headers: {},
71
+ queryStringParameters: query,
72
+ body: null,
73
+ requestContext: {},
74
+ } as unknown as APIGatewayProxyEvent,
75
+ { awsRequestId: `req-${randomUUID()}`, functionName: "test" },
76
+ );
77
+
78
+ const headerOf = (
79
+ response: APIGatewayProxyResult,
80
+ name: string,
81
+ ): string | undefined => {
82
+ for (const [key, value] of Object.entries(response.headers ?? {})) {
83
+ if (key.toLowerCase() === name) return String(value);
84
+ }
85
+ return undefined;
86
+ };
87
+
88
+ before(async () => {
89
+ _resetForTest();
90
+ process.env.DATA_BACKEND = "sqlite";
91
+ process.env.CORS_ALLOWED_ORIGINS = WEB_ORIGIN;
92
+ process.env.MSOAUTH_REDIRECT_URI = `${WEB_ORIGIN}${CALLBACK_PATH}`;
93
+ process.env.MSOAUTH_CLIENT_ID = "callback-test-client-id";
94
+ process.env.MSOAUTH_CLIENT_SECRET = CLIENT_SECRET;
95
+ process.env.MSOAUTH_TOKEN_ENDPOINT = await startTokenEndpoint();
96
+ // The self-host key that encrypts the stored refresh token.
97
+ process.env.FAKE_KMS_DATAKEY = "0".repeat(64);
98
+ // Static credentials so the best-effort sync enqueue fails against the local
99
+ // queue instead of walking the instance-metadata chain for a minute.
100
+ process.env.AWS_REGION = "eu-west-1";
101
+ process.env.AWS_ACCESS_KEY_ID = "test";
102
+ process.env.AWS_SECRET_ACCESS_KEY = "test";
103
+ ({ client, cleanup } = await createCalendarSqliteClient());
104
+ setClient(client);
105
+ ({ handler } = (await import("../index.js")) as unknown as {
106
+ handler: typeof handler;
107
+ });
108
+ });
109
+
110
+ after(async () => {
111
+ _resetForTest();
112
+ cleanup();
113
+ await new Promise<void>((resolve) => tokenEndpoint.close(() => resolve()));
114
+ });
115
+
116
+ describe("the Microsoft OAuth callback", () => {
117
+ it("sends the consenting browser on to the connected account", async () => {
118
+ const accountConfigId = randomUUID();
119
+ const state = await signState(
120
+ { accountConfigId, nonce: "nonce", timestamp: Date.now() },
121
+ CLIENT_SECRET,
122
+ );
123
+
124
+ const response = await callback({ code: "auth-code", state });
125
+
126
+ assert.equal(response.statusCode, 302, response.body);
127
+ const location = headerOf(response, "location");
128
+ assert.ok(location, "a redirect without a Location header is a blank page");
129
+ const [connected] =
130
+ await client.account.listAllByAccountConfig(accountConfigId);
131
+ assert.ok(connected, "the callback stored the account it redirects to");
132
+ assert.equal(
133
+ location,
134
+ `${WEB_ORIGIN}/settings/accounts?connected=${encodeURIComponent(connected.accountId)}`,
135
+ );
136
+ });
137
+
138
+ it("sends a refused consent back with the reason Microsoft gave", async () => {
139
+ const response = await callback({ error: "access_denied" });
140
+
141
+ assert.equal(response.statusCode, 302, response.body);
142
+ assert.equal(
143
+ headerOf(response, "location"),
144
+ `${WEB_ORIGIN}/settings/accounts?oauthError=access_denied`,
145
+ );
146
+ });
147
+ });
@@ -16,6 +16,7 @@ import type { Context } from "openapi-backend";
16
16
  import { getAccountConfigIdFromEvent } from "../auth.js";
17
17
  import { getMsOAuthConfig } from "../config/msoauth.js";
18
18
  import { safeJsonParse } from "../json.js";
19
+ import { rawApiResponse } from "../response.js";
19
20
  import { getClient } from "../service/data-client.js";
20
21
  import type { MicrosoftOAuthOperationIds, OperationHandler } from "../types.js";
21
22
  import { triggerAccountSyncSafe } from "./account.js";
@@ -228,11 +229,15 @@ export const MicrosoftOAuthOperations: Record<
228
229
  const webOrigin = getWebOrigin();
229
230
  const qs = event.queryStringParameters ?? {};
230
231
 
231
- const redirect = (url: string): APIGatewayProxyResult => ({
232
- statusCode: 302,
233
- headers: { Location: url },
234
- body: "",
235
- });
232
+ // Marked raw so the response formatter forwards the Location header
233
+ // instead of serializing this object as a JSON body. Microsoft's browser
234
+ // redirect lands here, so a dropped Location is a blank page.
235
+ const redirect = (url: string): APIGatewayProxyResult =>
236
+ rawApiResponse({
237
+ statusCode: 302,
238
+ headers: { Location: url },
239
+ body: "",
240
+ });
236
241
 
237
242
  // If Microsoft returned an error, pass it through to the frontend
238
243
  if (qs.error) {
@@ -1,50 +1,22 @@
1
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { applyShippedMigrations } from "@remit/drizzle-service/test-sqlite";
4
5
  import Database from "better-sqlite3";
5
6
  import { buildSqliteClient } from "../service/compose-sqlite.js";
6
7
  import type { RemitClient } from "../service/data-client.js";
7
8
 
8
9
  /**
9
- * A calendar handler test's store: the backend's own SQLite composition, over
10
- * the DDL a self-host deployment actually runs.
10
+ * A handler test's store: the backend's own SQLite composition, over the DDL a
11
+ * self-host deployment actually runs.
11
12
  *
12
- * The migrations are read rather than pushed from the drizzle table objects, so
13
- * a handler test exercises the shipped column shapes — the same reason
14
- * `test-shipped-sqlite-schema.ts` exists on the repository side. The client is
15
- * built by `compose-sqlite.ts`, which is the composition root a self-host
16
- * process boots, so nothing here is a second wiring of the calendar repos that
17
- * could drift from the one that ships.
18
- */
19
- const MIGRATIONS = new URL(
20
- "../../../../deploy/vps/migrations-sqlite/entities/",
21
- import.meta.url,
22
- );
23
-
24
- interface Journal {
25
- entries: Array<{ idx: number; tag: string }>;
26
- }
27
-
28
- const applyEntityMigrations = (sqlite: Database.Database): void => {
29
- const journal = JSON.parse(
30
- readFileSync(new URL("meta/_journal.json", MIGRATIONS), "utf8"),
31
- ) as Journal;
32
- const ordered = [...journal.entries].sort(
33
- (left, right) => left.idx - right.idx,
34
- );
35
- for (const entry of ordered) {
36
- const sql = readFileSync(new URL(`${entry.tag}.sql`, MIGRATIONS), "utf8");
37
- for (const statement of sql.split("--> statement-breakpoint")) {
38
- if (statement.trim() === "") continue;
39
- sqlite.exec(statement);
40
- }
41
- }
42
- };
43
-
44
- /**
45
- * A migrated database and the client that reads it. One per test file: every
46
- * calendar row is scoped by account config, so a test that mints its own
47
- * account config sees only what it wrote.
13
+ * The migrations are applied rather than pushed from the drizzle table objects,
14
+ * so a handler test exercises the shipped column shapes. The client is built by
15
+ * `compose-sqlite.ts`, the composition root a self-host process boots, so
16
+ * nothing here is a second wiring that could drift from the one that ships.
17
+ *
18
+ * One per test file: every row is scoped by account config, so a test that
19
+ * mints its own account config sees only what it wrote.
48
20
  */
49
21
  export const createCalendarSqliteClient = async (): Promise<{
50
22
  client: RemitClient;
@@ -53,7 +25,7 @@ export const createCalendarSqliteClient = async (): Promise<{
53
25
  const directory = mkdtempSync(join(tmpdir(), "remit-calendar-handlers-"));
54
26
  const path = join(directory, "remit.db");
55
27
  const sqlite = new Database(path);
56
- applyEntityMigrations(sqlite);
28
+ applyShippedMigrations(sqlite);
57
29
  sqlite.close();
58
30
 
59
31
  process.env.SQLITE_DB_PATH = path;
package/src/response.ts CHANGED
@@ -10,13 +10,14 @@ import {
10
10
  const RAW_API_RESPONSE: unique symbol = Symbol("remit.rawApiResponse");
11
11
 
12
12
  /**
13
- * A response whose bytes are not JSON — an iCalendar feed, and whatever later
14
- * joins it.
13
+ * A response the caller reads by something other than its JSON body — an
14
+ * iCalendar feed, an OAuth redirect's Location, and whatever later joins them.
15
15
  *
16
16
  * Every other handler returns a plain object and lets this module decide the
17
17
  * status, the content type and the serialization. That is the right default and
18
- * stays the default; a handler that has to own its own media type says so with
19
- * `rawApiResponse` rather than by returning a shape this one has to guess at.
18
+ * stays the default; a handler that has to own its own media type or headers
19
+ * says so with `rawApiResponse` rather than by returning a shape this one has
20
+ * to guess at.
20
21
  * The CORS and correlation headers are still added here, so a raw response is
21
22
  * not a way around them.
22
23
  */
@@ -18,6 +18,7 @@ import {
18
18
  applyOrganize,
19
19
  BODY_CONTENT_REJECTION_MESSAGE,
20
20
  matchOrganize,
21
+ ORGANIZE_MATCH_LIMIT,
21
22
  type OrganizeCandidate,
22
23
  type OrganizeMatchDeps,
23
24
  type OrganizeMatched,
@@ -285,7 +286,7 @@ const matchDeps = (
285
286
  embeddingId: CURRENT_EMBEDDING_ID,
286
287
  };
287
288
  },
288
- listAccountFilterMessages: async () => corpus,
289
+ listAccountFilterMessages: async () => ({ items: corpus }),
289
290
  filterAnchors: {
290
291
  listByAccountConfig: async () => filterAnchorRows,
291
292
  put: async () => {
@@ -326,7 +327,7 @@ const vectorlessDeps = (
326
327
  embeddingId: CURRENT_EMBEDDING_ID,
327
328
  };
328
329
  },
329
- listAccountFilterMessages: async () => corpus,
330
+ listAccountFilterMessages: async () => ({ items: corpus }),
330
331
  filterAnchors: {
331
332
  listByAccountConfig: async () => [],
332
333
  put: async () => {
@@ -467,7 +468,7 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
467
468
  embed: async () => ANCHOR_VECTOR,
468
469
  embeddingId: CURRENT_EMBEDDING_ID,
469
470
  }),
470
- listAccountFilterMessages: async () => [],
471
+ listAccountFilterMessages: async () => ({ items: [] }),
471
472
  filterAnchors: {
472
473
  listByAccountConfig: async () => [persistedAnchor],
473
474
  put: async () => {
@@ -652,7 +653,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
652
653
  },
653
654
  embeddingId: CURRENT_EMBEDDING_ID,
654
655
  }),
655
- listAccountFilterMessages: async () => [],
656
+ listAccountFilterMessages: async () => ({ items: [] }),
656
657
  filterAnchors: {
657
658
  listByAccountConfig: async () => [drifted],
658
659
  put: async () => {
@@ -693,7 +694,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
693
694
  embed: async () => [],
694
695
  embeddingId: CURRENT_EMBEDDING_ID,
695
696
  }),
696
- listAccountFilterMessages: async () => [],
697
+ listAccountFilterMessages: async () => ({ items: [] }),
697
698
  filterAnchors: {
698
699
  listByAccountConfig: async () => [],
699
700
  put: async () => {
@@ -1015,7 +1016,7 @@ describe("back-apply repairs a drifted anchor the way index-time matching does (
1015
1016
  embed,
1016
1017
  embeddingId: CURRENT_EMBEDDING_ID,
1017
1018
  }),
1018
- listAccountFilterMessages: async () => [],
1019
+ listAccountFilterMessages: async () => ({ items: [] }),
1019
1020
  filterAnchors: {
1020
1021
  listByAccountConfig: async () => anchors,
1021
1022
  put: async (input: CreateFilterAnchorInput) => {
@@ -1167,3 +1168,175 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
1167
1168
  ]);
1168
1169
  });
1169
1170
  });
1171
+
1172
+ /**
1173
+ * The corpus read a real deployment gets: the store evaluates the terms and
1174
+ * the caller pages the MATCHES. Modelled here rather than stubbed with a fixed
1175
+ * array so a matcher that reads only the newest page cannot pass.
1176
+ */
1177
+ const storeBackedDeps = (
1178
+ rows: readonly OrganizeCandidate[],
1179
+ ): OrganizeMatchDeps & { pagesRead: () => number } => {
1180
+ let pagesRead = 0;
1181
+ const termMatches = (
1182
+ term: { field: string; contains: string },
1183
+ row: OrganizeCandidate,
1184
+ ): boolean => {
1185
+ const needle = term.contains.toLowerCase();
1186
+ if (term.field === "subject") {
1187
+ return row.message.subject.toLowerCase().includes(needle);
1188
+ }
1189
+ if (term.field === "listId") {
1190
+ return row.message.listId.toLowerCase().includes(needle);
1191
+ }
1192
+ return `${row.message.fromName} ${row.message.from}`
1193
+ .toLowerCase()
1194
+ .includes(needle);
1195
+ };
1196
+ return {
1197
+ semantic: () => {
1198
+ throw moduleNotFound();
1199
+ },
1200
+ listAccountFilterMessages: async (_accountConfigId, query) => {
1201
+ pagesRead += 1;
1202
+ const narrowed = rows.filter((row) =>
1203
+ query.terms.length === 0
1204
+ ? true
1205
+ : query.operator === "or"
1206
+ ? query.terms.some((term) => termMatches(term, row))
1207
+ : query.terms.every((term) => termMatches(term, row)),
1208
+ );
1209
+ const offset = query.continuationToken
1210
+ ? Number(query.continuationToken)
1211
+ : 0;
1212
+ const items = narrowed.slice(offset, offset + query.limit);
1213
+ const next = offset + items.length;
1214
+ return {
1215
+ items,
1216
+ continuationToken: next < narrowed.length ? String(next) : undefined,
1217
+ };
1218
+ },
1219
+ filterAnchors: {
1220
+ listByAccountConfig: async () => [],
1221
+ put: async () => {
1222
+ throw new Error("unreachable");
1223
+ },
1224
+ },
1225
+ pagesRead: () => pagesRead,
1226
+ };
1227
+ };
1228
+
1229
+ describe("literal matching bounds the result, not the corpus (reader #459)", () => {
1230
+ const quietSender = candidate("quiet-sender", {
1231
+ from: "noreply@bank.example",
1232
+ fromName: "Statements",
1233
+ subject: "Your March statement",
1234
+ });
1235
+ const newerNoise = Array.from({ length: ORGANIZE_MATCH_LIMIT + 1 }, (_, i) =>
1236
+ candidate(`noise-${i}`, {
1237
+ from: "digest@other.example",
1238
+ fromName: "Digest",
1239
+ subject: `Daily digest ${i}`,
1240
+ }),
1241
+ );
1242
+
1243
+ it("matches a domain rule against mail older than a whole page of newer misses", async () => {
1244
+ const deps = storeBackedDeps([...newerNoise, quietSender]);
1245
+
1246
+ const { messageIds } = await matchAccepted(
1247
+ deps,
1248
+ ACCOUNT_CONFIG_ID,
1249
+ predicate({
1250
+ anchorMessageId: "None",
1251
+ literalClauses: [{ field: "FromDomain", value: "bank.example" }],
1252
+ }),
1253
+ );
1254
+
1255
+ assert.deepEqual(messageIds, ["quiet-sender"]);
1256
+ });
1257
+
1258
+ it("reads one narrowed page rather than paging the whole mailbox", async () => {
1259
+ const deps = storeBackedDeps([...newerNoise, quietSender]);
1260
+
1261
+ await matchAccepted(
1262
+ deps,
1263
+ ACCOUNT_CONFIG_ID,
1264
+ predicate({
1265
+ anchorMessageId: "None",
1266
+ literalClauses: [{ field: "From", value: "noreply@bank.example" }],
1267
+ }),
1268
+ );
1269
+
1270
+ assert.equal(deps.pagesRead(), 1);
1271
+ });
1272
+
1273
+ it("still refines what the store returned: a term is a narrowing, not the verdict", async () => {
1274
+ const deps = storeBackedDeps([
1275
+ candidate("spoofed", { from: "billing@bank.example.evil.test" }),
1276
+ quietSender,
1277
+ ]);
1278
+
1279
+ const { messageIds } = await matchAccepted(
1280
+ deps,
1281
+ ACCOUNT_CONFIG_ID,
1282
+ predicate({
1283
+ anchorMessageId: "None",
1284
+ literalClauses: [{ field: "FromDomain", value: "bank.example" }],
1285
+ }),
1286
+ );
1287
+
1288
+ assert.deepEqual(messageIds, ["quiet-sender"]);
1289
+ });
1290
+
1291
+ it("caps the matches at the limit when more of the corpus matches", async () => {
1292
+ const matching = Array.from({ length: ORGANIZE_MATCH_LIMIT * 2 }, (_, i) =>
1293
+ candidate(`hit-${i}`, { from: "noreply@bank.example" }),
1294
+ );
1295
+ const deps = storeBackedDeps(matching);
1296
+
1297
+ const { messageIds } = await matchAccepted(
1298
+ deps,
1299
+ ACCOUNT_CONFIG_ID,
1300
+ predicate({
1301
+ anchorMessageId: "None",
1302
+ literalClauses: [{ field: "FromDomain", value: "bank.example" }],
1303
+ }),
1304
+ );
1305
+
1306
+ assert.equal(messageIds.length, ORGANIZE_MATCH_LIMIT);
1307
+ });
1308
+
1309
+ it("never queries for clauses nothing can satisfy", async () => {
1310
+ const deps = storeBackedDeps([quietSender]);
1311
+
1312
+ const { messageIds } = await matchAccepted(
1313
+ deps,
1314
+ ACCOUNT_CONFIG_ID,
1315
+ predicate({
1316
+ anchorMessageId: "None",
1317
+ literalClauses: [{ field: "From", value: " " }],
1318
+ }),
1319
+ );
1320
+
1321
+ assert.deepEqual(messageIds, []);
1322
+ assert.equal(deps.pagesRead(), 0);
1323
+ });
1324
+
1325
+ it("counts one match for mail filed in two folders", async () => {
1326
+ const deps = storeBackedDeps([
1327
+ candidate("filed-twice", { from: "noreply@bank.example" }),
1328
+ candidate("filed-twice", { from: "noreply@bank.example" }),
1329
+ ]);
1330
+
1331
+ const { messageIds } = await matchAccepted(
1332
+ deps,
1333
+ ACCOUNT_CONFIG_ID,
1334
+ predicate({
1335
+ anchorMessageId: "None",
1336
+ literalClauses: [{ field: "FromDomain", value: "bank.example" }],
1337
+ }),
1338
+ );
1339
+
1340
+ assert.deepEqual(messageIds, ["filed-twice"]);
1341
+ });
1342
+ });
@@ -10,7 +10,9 @@ import { logger } from "@remit/logger-lambda";
10
10
  import {
11
11
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
12
12
  type FilterMessage,
13
+ type LiteralClauseNarrowing,
13
14
  literalClausesMatch,
15
+ literalClauseTerms,
14
16
  NO_ACTION,
15
17
  PlacementMoveService,
16
18
  refreshAnchorForEmbedder,
@@ -34,8 +36,10 @@ import {
34
36
  /**
35
37
  * Hard cap on both the previewed and the applied set. A back-apply is a
36
38
  * data-heavy corpus pass (remit-data-heavy: frugal): the semantic side is
37
- * bounded by the vector query's `topK`, the literal-only side by a paginated
38
- * message scan, and the final match set never exceeds this.
39
+ * bounded by the vector query's `topK`, the literal-only side by counting
40
+ * MATCHES as it pages a store-side narrowed listing, and the final match set
41
+ * never exceeds this. It bounds the answer, never the corpus the question is
42
+ * asked of (#459).
39
43
  */
40
44
  export const ORGANIZE_MATCH_LIMIT = 500;
41
45
 
@@ -117,6 +121,22 @@ export interface OrganizeCandidate {
117
121
  message: FilterMessage;
118
122
  }
119
123
 
124
+ /**
125
+ * One page of the narrowed corpus, cursored exactly as the underlying listing
126
+ * is: an absent `continuationToken` means the store has no more rows matching
127
+ * the terms, never that a read window ran out.
128
+ */
129
+ export interface OrganizeCandidatePage {
130
+ items: OrganizeCandidate[];
131
+ continuationToken?: string;
132
+ }
133
+
134
+ /** What to ask the store for, and where to resume. */
135
+ export interface OrganizeCandidateQuery extends LiteralClauseNarrowing {
136
+ limit: number;
137
+ continuationToken?: string;
138
+ }
139
+
120
140
  export interface OrganizeMatchDeps {
121
141
  /**
122
142
  * Build the semantic side on demand. Invoked only for an anchored predicate,
@@ -126,13 +146,16 @@ export interface OrganizeMatchDeps {
126
146
  */
127
147
  semantic: () => OrganizeSemanticDeps;
128
148
  /**
129
- * The account's messages as literal-match candidates, bounded and vector-free
130
- * — the corpus slice a purely-literal (or degraded) pass scans.
149
+ * One page of the account's messages as literal-match candidates,
150
+ * vector-free, with the clause terms evaluated by the store — the corpus a
151
+ * purely-literal (or degraded) pass reads. Paged rather than capped, so the
152
+ * caller's bound falls on matches instead of on how far back the read
153
+ * reached (#459).
131
154
  */
132
155
  listAccountFilterMessages: (
133
156
  accountConfigId: string,
134
- limit: number,
135
- ) => Promise<OrganizeCandidate[]>;
157
+ query: OrganizeCandidateQuery,
158
+ ) => Promise<OrganizeCandidatePage>;
136
159
  /**
137
160
  * Every persisted FilterAnchor for the account — read-only, always
138
161
  * available (never gated behind {@link semantic}, since it never touches
@@ -378,12 +401,18 @@ export const organizePredicateRejection = (
378
401
  hasAnchor(predicate) ? null : bodyContentRejection(predicate.literalClauses);
379
402
 
380
403
  /**
381
- * The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
382
- * the messages whose literal clauses match. Used both for a purely-literal
404
+ * The literal-only arm: the store selects on the clause terms, this refines
405
+ * what it returned and stops at `limit` MATCHES. Used both for a purely-literal
383
406
  * predicate and as the degraded fallback when a widen is requested on a
384
407
  * deployment without the vector pipeline. Serves `From`/`Subject` clauses at
385
408
  * full fidelity from the core thread rows; body-content (`HasWords`) clauses are
386
409
  * refused before this runs (see {@link bodyContentRejection}).
410
+ *
411
+ * The terms are a narrowing, not the verdict ({@link literalClauseTerms}), so
412
+ * every page is still run through {@link literalClausesMatch} — and paging
413
+ * continues until the cap fills or the store runs out, which is what makes the
414
+ * bound a bound on results rather than on the newest `limit` rows (#459).
415
+ * Deduped by message id: the same mail filed in two folders is one match.
387
416
  */
388
417
  const matchLiteral = async (
389
418
  deps: OrganizeMatchDeps,
@@ -392,20 +421,29 @@ const matchLiteral = async (
392
421
  limit: number,
393
422
  ): Promise<string[]> => {
394
423
  const clauses = predicate.literalClauses;
395
- const candidates = await deps.listAccountFilterMessages(
396
- accountConfigId,
397
- limit,
398
- );
399
- if (clauses.length === 0) {
400
- return candidates.slice(0, limit).map((c) => c.messageId);
401
- }
424
+ const narrowing = literalClauseTerms(clauses, predicate.matchOperator);
425
+ if (!narrowing) return [];
426
+
402
427
  const matched: string[] = [];
403
- for (const { messageId, message } of candidates) {
404
- if (matched.length >= limit) break;
405
- if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
428
+ const seen = new Set<string>();
429
+ let continuationToken: string | undefined;
430
+ do {
431
+ const page = await deps.listAccountFilterMessages(accountConfigId, {
432
+ ...narrowing,
433
+ limit,
434
+ continuationToken,
435
+ });
436
+ for (const { messageId, message } of page.items) {
437
+ if (seen.has(messageId)) continue;
438
+ seen.add(messageId);
439
+ if (!literalClausesMatch(clauses, predicate.matchOperator, message)) {
440
+ continue;
441
+ }
406
442
  matched.push(messageId);
443
+ if (matched.length >= limit) return matched;
407
444
  }
408
- }
445
+ continuationToken = page.continuationToken;
446
+ } while (continuationToken);
409
447
  return matched;
410
448
  };
411
449
 
@@ -508,11 +546,10 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
508
546
  };
509
547
 
510
548
  /**
511
- * A bounded, vector-free corpus slice for a literal back-apply: the account's
512
- * messages projected onto the literal-match fields, gathered newest-first across
513
- * every mailbox and capped. Reads the core thread rows, so it runs on a
514
- * deployment that ships no vector pipeline. Deduped by message id — the same
515
- * mail filed in two folders is one candidate.
549
+ * The vector-free corpus read for a literal back-apply: one page of the
550
+ * account's messages, newest-first across every mailbox, with the clause terms
551
+ * evaluated in the query. Reads the core thread rows, so it runs on a
552
+ * deployment that ships no vector pipeline.
516
553
  *
517
554
  * `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
518
555
  * `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
@@ -525,34 +562,30 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
525
562
  */
526
563
  const listAccountFilterMessagesFromClient =
527
564
  (client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
528
- async (accountConfigId, limit) => {
529
- const candidates: OrganizeCandidate[] = [];
530
- const seen = new Set<string>();
531
- let continuationToken: string | undefined;
532
- do {
533
- const page = await client.threadMessage.listByDate(accountConfigId, {
534
- limit,
535
- continuationToken,
565
+ async (accountConfigId, query) => {
566
+ const page = await client.threadMessage.listByFieldTerms(
567
+ accountConfigId,
568
+ query.terms,
569
+ {
570
+ operator: query.operator,
571
+ limit: query.limit,
572
+ continuationToken: query.continuationToken,
536
573
  excludeDeleted: true,
537
- });
538
- for (const row of page.items) {
539
- if (seen.has(row.messageId)) continue;
540
- seen.add(row.messageId);
541
- candidates.push({
542
- messageId: row.messageId,
543
- message: {
544
- from: row.fromEmail ?? "",
545
- fromName: row.fromName ?? "",
546
- subject: row.subject ?? "",
547
- text: "",
548
- listId: row.listId ?? "",
549
- },
550
- });
551
- if (candidates.length >= limit) return candidates;
552
- }
553
- continuationToken = page.continuationToken;
554
- } while (continuationToken);
555
- return candidates;
574
+ },
575
+ );
576
+ return {
577
+ items: page.items.map((row) => ({
578
+ messageId: row.messageId,
579
+ message: {
580
+ from: row.fromEmail ?? "",
581
+ fromName: row.fromName ?? "",
582
+ subject: row.subject ?? "",
583
+ text: "",
584
+ listId: row.listId ?? "",
585
+ },
586
+ })),
587
+ continuationToken: page.continuationToken,
588
+ };
556
589
  };
557
590
 
558
591
  /**