@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.
- package/dev-server/oauth-callback-route.test.ts +66 -0
- package/package.json +1 -1
- package/src/handlers/account-oauth-callback.test.ts +147 -0
- package/src/handlers/account-oauth.ts +10 -5
- package/src/handlers/calendar-sqlite-fixture.ts +12 -40
- package/src/response.ts +5 -4
- package/src/service/organize.test.ts +179 -6
- package/src/service/organize.ts +84 -51
|
@@ -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
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
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
|
|
10
|
-
*
|
|
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
|
|
13
|
-
* a handler test exercises the shipped column shapes
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
14
|
-
* joins
|
|
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
|
|
19
|
-
* `rawApiResponse` rather than by returning a shape this one has
|
|
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
|
+
});
|
package/src/service/organize.ts
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
135
|
-
) => Promise<
|
|
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:
|
|
382
|
-
*
|
|
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
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
*
|
|
512
|
-
* messages
|
|
513
|
-
*
|
|
514
|
-
* deployment that ships no vector pipeline.
|
|
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,
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
/**
|