@remit/backend 0.0.1

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.
Files changed (87) hide show
  1. package/.env.test +7 -0
  2. package/dev-server/content-auth.test.ts +113 -0
  3. package/dev-server/content-auth.ts +54 -0
  4. package/dev-server/content-handler.test.ts +105 -0
  5. package/dev-server/content-handler.ts +79 -0
  6. package/dev-server/content-path.test.ts +37 -0
  7. package/dev-server/content-path.ts +27 -0
  8. package/dev-server/cors.test.ts +48 -0
  9. package/dev-server/cors.ts +25 -0
  10. package/dev-server/lambda-helpers.ts +69 -0
  11. package/dev-server/server.ts +289 -0
  12. package/package.json +82 -0
  13. package/src/auth.ts +92 -0
  14. package/src/config/msoauth.ts +60 -0
  15. package/src/data-backend.test.ts +25 -0
  16. package/src/data-backend.ts +20 -0
  17. package/src/derive/autoMoved.ts +28 -0
  18. package/src/derive/contentSignature.test.ts +153 -0
  19. package/src/derive/contentSignature.ts +139 -0
  20. package/src/derive/contentUrl.test.ts +156 -0
  21. package/src/derive/contentUrl.ts +70 -0
  22. package/src/derive/enrichThreadRows.ts +155 -0
  23. package/src/derive/filterThreadCriteria.test.ts +119 -0
  24. package/src/derive/filterThreadCriteria.ts +60 -0
  25. package/src/derive/pendingMoveCounts.ts +90 -0
  26. package/src/derive/senderTrust.test.ts +67 -0
  27. package/src/derive/senderTrust.ts +23 -0
  28. package/src/error.ts +55 -0
  29. package/src/handlers/account-guards.ts +137 -0
  30. package/src/handlers/account-oauth.test.ts +383 -0
  31. package/src/handlers/account-oauth.ts +452 -0
  32. package/src/handlers/account-overrides.test.ts +69 -0
  33. package/src/handlers/account-overrides.ts +286 -0
  34. package/src/handlers/account-ownership.ts +25 -0
  35. package/src/handlers/account-signature.ts +129 -0
  36. package/src/handlers/account.ts +524 -0
  37. package/src/handlers/address.ts +136 -0
  38. package/src/handlers/config.test.ts +184 -0
  39. package/src/handlers/config.ts +219 -0
  40. package/src/handlers/ensure-account-config.ts +30 -0
  41. package/src/handlers/filter.ts +294 -0
  42. package/src/handlers/folder-role-appointments.test.ts +250 -0
  43. package/src/handlers/folder-role-appointments.ts +272 -0
  44. package/src/handlers/folder-role.ts +76 -0
  45. package/src/handlers/index.ts +48 -0
  46. package/src/handlers/mailbox.ts +411 -0
  47. package/src/handlers/me.ts +190 -0
  48. package/src/handlers/message.ts +886 -0
  49. package/src/handlers/organize.test.ts +43 -0
  50. package/src/handlers/organize.ts +196 -0
  51. package/src/handlers/outbox.ts +218 -0
  52. package/src/handlers/search.test.ts +182 -0
  53. package/src/handlers/search.ts +105 -0
  54. package/src/handlers/sync-progress.test.ts +158 -0
  55. package/src/handlers/sync-progress.ts +64 -0
  56. package/src/handlers/sync.test.ts +146 -0
  57. package/src/handlers/sync.ts +119 -0
  58. package/src/handlers/thread.ts +361 -0
  59. package/src/handlers/unified-threads.ts +196 -0
  60. package/src/handlers/vip-suggestions.ts +21 -0
  61. package/src/index.ts +170 -0
  62. package/src/json.ts +11 -0
  63. package/src/jwt-auth.test.ts +89 -0
  64. package/src/jwt-auth.ts +95 -0
  65. package/src/request-context.test.ts +61 -0
  66. package/src/request-context.ts +29 -0
  67. package/src/request.ts +10 -0
  68. package/src/response.test.ts +107 -0
  69. package/src/response.ts +80 -0
  70. package/src/service/compose-postgres.ts +72 -0
  71. package/src/service/compose-sqlite.ts +80 -0
  72. package/src/service/create-remit-client.ts +358 -0
  73. package/src/service/dynamodb.test.ts +100 -0
  74. package/src/service/dynamodb.ts +58 -0
  75. package/src/service/filter.ts +55 -0
  76. package/src/service/fire-and-forget.test.ts +126 -0
  77. package/src/service/fire-and-forget.ts +79 -0
  78. package/src/service/localhost-env-config.test.ts +40 -0
  79. package/src/service/organize.test.ts +384 -0
  80. package/src/service/organize.ts +354 -0
  81. package/src/service/semantic-capability.test.ts +64 -0
  82. package/src/service/semantic-capability.ts +62 -0
  83. package/src/service/sqs.ts +4 -0
  84. package/src/service/trigger-sync.test.ts +211 -0
  85. package/src/service/trigger-sync.ts +102 -0
  86. package/src/types.ts +161 -0
  87. package/tsconfig.json +7 -0
@@ -0,0 +1,55 @@
1
+ import { type AnchorPayload, buildMessageAnchor } from "@remit/search-service";
2
+ import {
3
+ buildEmbeddingServiceFromEnv,
4
+ buildVectorStoreFromEnv,
5
+ } from "@remit/search-service/from-env";
6
+
7
+ /**
8
+ * Build a filter's persisted semantic anchor from an already-indexed message's
9
+ * chunk vectors (RFC 034 Decision 2.1), or `null` when the message has no
10
+ * indexed chunks. Never embeds anything new — the vectors already exist from
11
+ * index time.
12
+ *
13
+ * This is the only piece of filter CRUD not on the `RemitClient` port bundle:
14
+ * the vector store and embedder are selected by env, independent of
15
+ * `DATA_BACKEND`, so it works unchanged on either backend. The filter and
16
+ * anchor rows themselves are written through
17
+ * `client.filter`/`client.filterAnchor` so they land in the same backend as
18
+ * everything else.
19
+ */
20
+ export type BuildFilterAnchor = (
21
+ accountConfigId: string,
22
+ anchorMessageId: string,
23
+ ) => Promise<AnchorPayload | null>;
24
+
25
+ let cached: BuildFilterAnchor | null = null;
26
+
27
+ const build = (): BuildFilterAnchor => {
28
+ const embedder = buildEmbeddingServiceFromEnv();
29
+ const store = buildVectorStoreFromEnv(embedder.dimensions);
30
+ return (accountConfigId, anchorMessageId) =>
31
+ buildMessageAnchor(
32
+ { store, embedder },
33
+ { accountConfigId, anchorMessageId },
34
+ );
35
+ };
36
+
37
+ export const buildFilterAnchor: BuildFilterAnchor = (
38
+ accountConfigId,
39
+ anchorMessageId,
40
+ ) => {
41
+ if (!cached) cached = build();
42
+ return cached(accountConfigId, anchorMessageId);
43
+ };
44
+
45
+ /** Inject the anchor builder — test use only. */
46
+ export const _setBuildFilterAnchorForTest = (
47
+ override: BuildFilterAnchor,
48
+ ): void => {
49
+ cached = override;
50
+ };
51
+
52
+ /** Reset the singleton — test use only. */
53
+ export const _resetBuildFilterAnchorForTest = (): void => {
54
+ cached = null;
55
+ };
@@ -0,0 +1,126 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { type FireAndForgetLogger, fireAndForget } from "./fire-and-forget.js";
4
+
5
+ interface LogCall {
6
+ fields: Record<string, unknown>;
7
+ message: string;
8
+ }
9
+
10
+ const createLoggerSpy = () => {
11
+ const error: LogCall[] = [];
12
+ const logger: FireAndForgetLogger = {
13
+ error: (fields: Record<string, unknown>, message: string): void => {
14
+ error.push({ fields, message });
15
+ },
16
+ };
17
+ return { error, logger };
18
+ };
19
+
20
+ describe("fireAndForget", () => {
21
+ it("runs the work and stays quiet on success", async () => {
22
+ const { error, logger } = createLoggerSpy();
23
+ let ran = false;
24
+
25
+ await fireAndForget(
26
+ async () => {
27
+ ran = true;
28
+ },
29
+ { source: "test", message: "should not log", logger },
30
+ );
31
+
32
+ assert.equal(ran, true);
33
+ assert.equal(error.length, 0);
34
+ });
35
+
36
+ it("does NOT reject when the work throws", async () => {
37
+ const { logger } = createLoggerSpy();
38
+
39
+ await assert.doesNotReject(
40
+ fireAndForget(
41
+ async () => {
42
+ throw new Error("boom");
43
+ },
44
+ { source: "test", message: "contained", logger },
45
+ ),
46
+ );
47
+ });
48
+
49
+ it("logs the failure loudly with the alertable structured fields and ids", async () => {
50
+ const { error, logger } = createLoggerSpy();
51
+ const econnrefused = Object.assign(new Error(""), {
52
+ name: "AggregateError",
53
+ code: "ECONNREFUSED",
54
+ });
55
+
56
+ await fireAndForget(
57
+ async () => {
58
+ throw econnrefused;
59
+ },
60
+ {
61
+ source: "config_load",
62
+ message: "Failed to trigger account sync on config load",
63
+ ids: { accountId: "acc-a", accountConfigId: "config-1" },
64
+ logger,
65
+ },
66
+ );
67
+
68
+ assert.equal(error.length, 1);
69
+ const call = error[0];
70
+ if (!call) throw new Error("expected an error log");
71
+ assert.equal(call.fields.alert, "sync_trigger_failed");
72
+ assert.equal(call.fields.source, "config_load");
73
+ assert.equal(call.fields.accountId, "acc-a");
74
+ assert.equal(call.fields.accountConfigId, "config-1");
75
+ assert.equal(call.fields.errorName, "AggregateError");
76
+ assert.equal(call.fields.errorCode, "ECONNREFUSED");
77
+ assert.equal(call.message, "Failed to trigger account sync on config load");
78
+ });
79
+
80
+ it("reads the SDK error code from the .Code field when present", async () => {
81
+ const { error, logger } = createLoggerSpy();
82
+ const sdkError = Object.assign(new Error("boom"), {
83
+ name: "QueueDoesNotExist",
84
+ Code: "AWS.SimpleQueueService.NonExistentQueue",
85
+ });
86
+
87
+ await fireAndForget(
88
+ async () => {
89
+ throw sdkError;
90
+ },
91
+ { source: "test", message: "contained", logger },
92
+ );
93
+
94
+ const call = error[0];
95
+ if (!call) throw new Error("expected an error log");
96
+ assert.equal(
97
+ call.fields.errorCode,
98
+ "AWS.SimpleQueueService.NonExistentQueue",
99
+ );
100
+ });
101
+
102
+ it("never leaks an unhandled rejection even when void-fired", async () => {
103
+ const leaked: unknown[] = [];
104
+ const onUnhandled = (reason: unknown): void => {
105
+ leaked.push(reason);
106
+ };
107
+ process.on("unhandledRejection", onUnhandled);
108
+
109
+ try {
110
+ // Fire-and-forget exactly as a request handler does: no await.
111
+ void fireAndForget(
112
+ async () => {
113
+ throw Object.assign(new Error(""), { code: "ECONNREFUSED" });
114
+ },
115
+ { source: "test", message: "contained", logger: { error: () => {} } },
116
+ );
117
+ // Let the microtask queue drain so any escaped rejection would surface.
118
+ await new Promise((resolve) => setImmediate(resolve));
119
+ await new Promise((resolve) => setImmediate(resolve));
120
+ } finally {
121
+ process.off("unhandledRejection", onUnhandled);
122
+ }
123
+
124
+ assert.equal(leaked.length, 0);
125
+ });
126
+ });
@@ -0,0 +1,79 @@
1
+ import { inspect } from "node:util";
2
+ import { logger } from "@remit/logger-lambda";
3
+
4
+ type StructuredLog = (fields: Record<string, unknown>, message: string) => void;
5
+
6
+ export interface FireAndForgetLogger {
7
+ error: StructuredLog;
8
+ }
9
+
10
+ export interface FireAndForgetContext {
11
+ /**
12
+ * Stable discriminator for the originating call site (e.g. `"config_load"`,
13
+ * `"account_create"`). Surfaces on the log line as `source` so a CloudWatch
14
+ * metric filter / alarm can attribute a leak to its origin.
15
+ */
16
+ source: string;
17
+ /**
18
+ * Message logged when the background work rejects. Should describe the work
19
+ * and reassure that the failure is contained (e.g. "...best-effort").
20
+ */
21
+ message: string;
22
+ /**
23
+ * Identifiers carried onto the structured error line (accountId,
24
+ * accountConfigId, …) so a failure is traceable without a stack dive.
25
+ */
26
+ ids?: Record<string, unknown>;
27
+ /**
28
+ * Alertable discriminator on the structured error line, the field CloudWatch
29
+ * metric filters key on. Defaults to `"sync_trigger_failed"` for the original
30
+ * sync-kick sites; a different fire-and-forget surface (e.g. a flag enqueue)
31
+ * must pass its own so it is not mis-attributed to the sync-trigger alarm.
32
+ */
33
+ alert?: string;
34
+ logger?: FireAndForgetLogger;
35
+ }
36
+
37
+ /**
38
+ * Run unawaited background work that MUST NOT be able to fail the request that
39
+ * spawned it.
40
+ *
41
+ * The dev/Lambda runtime shares one event loop across concurrent requests, so a
42
+ * rejection escaping an unawaited background promise (a `void trigger()`) lands
43
+ * on whatever request happens to be in flight when the deferred work finally
44
+ * fails — turning an unrelated READ into a spurious 500. That is exactly how an
45
+ * unreachable SQS queue 500'd `/mailboxes`, `/threads`, `/outbox` and `/config`.
46
+ *
47
+ * This helper is the single containment point: it awaits the work inside a
48
+ * try/catch, logs every rejection LOUDLY with the alertable structured fields
49
+ * (`alert` — defaulting to `"sync_trigger_failed"`, `source`, the SDK error
50
+ * name/code, the caller ids), and ALWAYS resolves to void. Callers `void
51
+ * fireAndForget(...)` with no
52
+ * chance of an unhandled rejection escaping. Routing all fire-and-forget sites
53
+ * through here keeps the containment from regressing one handler at a time.
54
+ *
55
+ * Use this ONLY for genuine side effects whose failure must not change the
56
+ * response. A failure the caller is responsible for (the real DynamoDB read, an
57
+ * enqueue that IS the request's purpose) must still propagate and 500.
58
+ */
59
+ export const fireAndForget = async (
60
+ work: () => Promise<unknown>,
61
+ context: FireAndForgetContext,
62
+ ): Promise<void> => {
63
+ await work().catch((error: unknown) => {
64
+ const log = context.logger ?? logger;
65
+ log.error(
66
+ {
67
+ alert: context.alert ?? "sync_trigger_failed",
68
+ source: context.source,
69
+ ...context.ids,
70
+ errorName: (error as { name?: string })?.name,
71
+ errorCode:
72
+ (error as { Code?: string })?.Code ??
73
+ (error as { code?: string })?.code,
74
+ error: inspect(error),
75
+ },
76
+ context.message,
77
+ );
78
+ });
79
+ };
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { describe, it } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
7
+
8
+ const parseEnvFile = (relativePath: string): Record<string, string> => {
9
+ const contents = readFileSync(`${repoRoot}${relativePath}`, "utf8");
10
+ const entries: Record<string, string> = {};
11
+ for (const line of contents.split("\n")) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#")) continue;
14
+ const eq = trimmed.indexOf("=");
15
+ if (eq === -1) continue;
16
+ entries[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
17
+ }
18
+ return entries;
19
+ };
20
+
21
+ describe(
22
+ "local dev SQS routing (localhost-dev-aws.env)",
23
+ {
24
+ // localhost-dev-aws.env is AWS-local-dev config, stripped from the open-core
25
+ // tree; skip where it is absent.
26
+ skip: !existsSync(`${repoRoot}localhost-dev-aws.env`),
27
+ },
28
+ () => {
29
+ const env = parseEnvFile("localhost-dev-aws.env");
30
+
31
+ it("publishes backend writes to the FIFO mailboxes queue the sync consumer drains", () => {
32
+ // Prod wires the API's SQS_QUEUE_URL to the mailboxes FIFO queue
33
+ // (infra remit-api-stack.ts). Onboarding publishes SYNC_MAILBOXES there;
34
+ // the imap worker consumes it. Local dev must mirror that or onboarding
35
+ // events land in a queue nothing drains and accounts never sync.
36
+ assert.equal(env.SQS_QUEUE_URL, env.SQS_QUEUE_URL_MAILBOXES);
37
+ assert.ok(env.SQS_QUEUE_URL?.endsWith(".fifo"));
38
+ });
39
+ },
40
+ );
@@ -0,0 +1,384 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { FilterMatchOperator } from "@remit/domain-enums";
4
+ import type {
5
+ AnchorPayload,
6
+ ChunkMetadata,
7
+ VectorRecord,
8
+ } from "@remit/search-service";
9
+ import { createMemoryVectorStore } from "@remit/search-service";
10
+ import type { RemitClient } from "./dynamodb.js";
11
+ import {
12
+ applyOrganize,
13
+ matchOrganize,
14
+ type OrganizeMatchDeps,
15
+ type OrganizePredicate,
16
+ } from "./organize.js";
17
+
18
+ const ACCOUNT_CONFIG_ID = "cfg-1";
19
+ const ANCHOR_VECTOR = [1, 0, 0, 0];
20
+ const ORTHOGONAL_VECTOR = [0, 1, 0, 0];
21
+
22
+ const anchorPayload: AnchorPayload = {
23
+ anchorEmbedding: ANCHOR_VECTOR,
24
+ anchorEmbeddingId: "test-model@4",
25
+ anchorSourceText: "book me a table",
26
+ };
27
+
28
+ const metadata = (over: Partial<ChunkMetadata>): ChunkMetadata => ({
29
+ messageId: "msg-x",
30
+ threadId: "thread-x",
31
+ accountConfigId: ACCOUNT_CONFIG_ID,
32
+ mailboxIds: ["mbox-1"],
33
+ chunkType: "body",
34
+ sentDate: 1_700_000_000,
35
+ isRead: false,
36
+ hasAttachment: false,
37
+ hasStars: false,
38
+ ...over,
39
+ });
40
+
41
+ const bodyChunk = (
42
+ messageId: string,
43
+ vector: number[],
44
+ over: Partial<ChunkMetadata> = {},
45
+ ): VectorRecord => ({
46
+ chunkId: `${messageId}#body`,
47
+ vector,
48
+ metadata: metadata({ messageId, ...over }),
49
+ });
50
+
51
+ const predicate = (
52
+ over: Partial<OrganizePredicate> = {},
53
+ ): OrganizePredicate => ({
54
+ anchorMessageId: "msg-anchor",
55
+ matchOperator: FilterMatchOperator.And,
56
+ literalClauses: [],
57
+ similarityThreshold: 0.75,
58
+ actionLabelId: "None",
59
+ actionMailboxId: "None",
60
+ ...over,
61
+ });
62
+
63
+ /**
64
+ * A client that records MessageLabel writes and blows up if the back-apply path
65
+ * ever touches Filter/FilterAnchor — the RFC 034 guardrail: this scope never
66
+ * persists a standing rule.
67
+ */
68
+ const trackingClient = () => {
69
+ const labeled: Array<{ messageId: string; labelId: string }> = [];
70
+ let filterWrites = 0;
71
+ let filterAnchorWrites = 0;
72
+ const client = {
73
+ messageLabel: {
74
+ apply: async (input: {
75
+ messageId: string;
76
+ labelId: string;
77
+ accountConfigId: string;
78
+ appliedByFilterId?: string;
79
+ }) => {
80
+ assert.equal(
81
+ input.appliedByFilterId,
82
+ undefined,
83
+ "back-apply must never attribute a filter",
84
+ );
85
+ labeled.push({ messageId: input.messageId, labelId: input.labelId });
86
+ return {} as never;
87
+ },
88
+ },
89
+ message: {
90
+ get: async (messageId: string) => ({ messageId, mailboxId: "mbox-src" }),
91
+ },
92
+ mailbox: {
93
+ resolveAccountId: async () => "acct-1",
94
+ },
95
+ filter: {
96
+ create: async () => {
97
+ filterWrites += 1;
98
+ return {} as never;
99
+ },
100
+ },
101
+ filterAnchor: {
102
+ put: async () => {
103
+ filterAnchorWrites += 1;
104
+ return {} as never;
105
+ },
106
+ },
107
+ } as unknown as RemitClient;
108
+ return {
109
+ client,
110
+ labeled,
111
+ filterWrites: () => filterWrites,
112
+ filterAnchorWrites: () => filterAnchorWrites,
113
+ };
114
+ };
115
+
116
+ /**
117
+ * A stand-in for the local-first placement mover. `moveMessage` is idempotent
118
+ * on the (messageId, destination) pair — mirroring the real
119
+ * `PlacementMoveService` marker engine (#1297) — so a redelivered back-apply
120
+ * re-issues the same move without double-enqueuing. Records every call and the
121
+ * final resting mailbox per message.
122
+ */
123
+ const trackingMoveService = () => {
124
+ const moves: Array<{
125
+ messageId: string;
126
+ destinationMailboxId: string;
127
+ accountId: string;
128
+ }> = [];
129
+ const destinationOf = new Map<string, string>();
130
+ let enqueues = 0;
131
+ return {
132
+ moveService: {
133
+ moveMessage: async (
134
+ _accountConfigId: string,
135
+ messageId: string,
136
+ destinationMailboxId: string,
137
+ accountId: string,
138
+ ): Promise<void> => {
139
+ moves.push({ messageId, destinationMailboxId, accountId });
140
+ if (destinationOf.get(messageId) === destinationMailboxId) return;
141
+ destinationOf.set(messageId, destinationMailboxId);
142
+ enqueues += 1;
143
+ },
144
+ } as unknown as import("@remit/mailbox-service").PlacementMoveService,
145
+ moves,
146
+ enqueues: () => enqueues,
147
+ destinations: () => destinationOf,
148
+ };
149
+ };
150
+
151
+ const matchDeps = (
152
+ store: ReturnType<typeof createMemoryVectorStore>,
153
+ ): OrganizeMatchDeps => ({
154
+ buildAnchor: async () => anchorPayload,
155
+ vectorStore: store,
156
+ listAccountMessageIds: async () => [],
157
+ });
158
+
159
+ describe("matchOrganize", () => {
160
+ it("returns every semantically matching message and excludes the misses", async () => {
161
+ const store = createMemoryVectorStore();
162
+ const matching = ["msg-1", "msg-2", "msg-3", "msg-4", "msg-5"];
163
+ await store.upsert([
164
+ ...matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)),
165
+ bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
166
+ ]);
167
+
168
+ const matched = await matchOrganize(
169
+ matchDeps(store),
170
+ ACCOUNT_CONFIG_ID,
171
+ predicate({ actionLabelId: "lbl-1" }),
172
+ );
173
+
174
+ assert.deepEqual([...matched].sort(), matching);
175
+ });
176
+
177
+ it("matches nothing when the predicate has neither an anchor nor a clause", async () => {
178
+ const store = createMemoryVectorStore();
179
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
180
+
181
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
182
+ ...predicate(),
183
+ anchorMessageId: "None",
184
+ });
185
+
186
+ assert.deepEqual(matched, []);
187
+ });
188
+
189
+ it("refines the semantic set by literal clauses", async () => {
190
+ const store = createMemoryVectorStore();
191
+ await store.upsert([
192
+ bodyChunk("msg-1", ANCHOR_VECTOR, {
193
+ subject: "Dinner reservation",
194
+ textPreview: "your table is booked",
195
+ }),
196
+ bodyChunk("msg-2", ANCHOR_VECTOR, {
197
+ subject: "Newsletter",
198
+ textPreview: "weekly digest",
199
+ }),
200
+ ]);
201
+
202
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
203
+ ...predicate(),
204
+ literalClauses: [{ field: "Subject", value: "reservation" }],
205
+ });
206
+
207
+ assert.deepEqual(matched, ["msg-1"]);
208
+ });
209
+ });
210
+
211
+ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
212
+ it("applies the label to all N matches in one pass and writes zero Filter rows", async () => {
213
+ const store = createMemoryVectorStore();
214
+ const matching = ["msg-1", "msg-2", "msg-3", "msg-4", "msg-5"];
215
+ await store.upsert([
216
+ ...matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)),
217
+ bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
218
+ ]);
219
+
220
+ const p = predicate({ actionLabelId: "lbl-1" });
221
+
222
+ // The preview and the apply share the same matcher — the previewed set is
223
+ // exactly what gets applied.
224
+ const previewed = await matchOrganize(
225
+ matchDeps(store),
226
+ ACCOUNT_CONFIG_ID,
227
+ p,
228
+ );
229
+ const applied = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
230
+ assert.deepEqual(previewed, applied);
231
+
232
+ const tracked = trackingClient();
233
+ const result = await applyOrganize(
234
+ { client: tracked.client },
235
+ ACCOUNT_CONFIG_ID,
236
+ applied,
237
+ p,
238
+ );
239
+
240
+ assert.equal(result.applied, matching.length);
241
+ assert.equal(result.failed, 0);
242
+ assert.deepEqual(
243
+ tracked.labeled.map((row) => row.messageId).sort(),
244
+ matching,
245
+ );
246
+ assert.ok(
247
+ tracked.labeled.every((row) => row.labelId === "lbl-1"),
248
+ "every matching message gets the requested label",
249
+ );
250
+ assert.equal(tracked.filterWrites(), 0, "no Filter row is ever created");
251
+ assert.equal(
252
+ tracked.filterAnchorWrites(),
253
+ 0,
254
+ "no FilterAnchor row is ever created",
255
+ );
256
+ });
257
+
258
+ it("counts a requested move as failed when no move service is wired", async () => {
259
+ const store = createMemoryVectorStore();
260
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
261
+ const p = predicate({ actionMailboxId: "mbox-target" });
262
+
263
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
264
+ const tracked = trackingClient();
265
+ const result = await applyOrganize(
266
+ { client: tracked.client },
267
+ ACCOUNT_CONFIG_ID,
268
+ matched,
269
+ p,
270
+ );
271
+
272
+ assert.equal(result.applied, 0);
273
+ assert.equal(result.failed, 1);
274
+ });
275
+
276
+ it("moves every match through the wired move service and writes zero Filter rows", async () => {
277
+ const store = createMemoryVectorStore();
278
+ const matching = ["msg-1", "msg-2", "msg-3"];
279
+ await store.upsert([
280
+ ...matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)),
281
+ bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
282
+ ]);
283
+ const p = predicate({ actionMailboxId: "mbox-target" });
284
+
285
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
286
+ const tracked = trackingClient();
287
+ const mover = trackingMoveService();
288
+ const result = await applyOrganize(
289
+ { client: tracked.client, moveService: mover.moveService },
290
+ ACCOUNT_CONFIG_ID,
291
+ matched,
292
+ p,
293
+ );
294
+
295
+ assert.equal(result.applied, matching.length);
296
+ assert.equal(result.failed, 0);
297
+ assert.deepEqual(
298
+ mover.moves.map((m) => m.messageId).sort(),
299
+ matching,
300
+ "every matched message is moved once",
301
+ );
302
+ assert.ok(
303
+ mover.moves.every((m) => m.destinationMailboxId === "mbox-target"),
304
+ "every move targets the requested mailbox",
305
+ );
306
+ assert.equal(
307
+ tracked.labeled.length,
308
+ 0,
309
+ "a move-only action applies no label",
310
+ );
311
+ assert.equal(tracked.filterWrites(), 0, "no Filter row is ever created");
312
+ assert.equal(
313
+ tracked.filterAnchorWrites(),
314
+ 0,
315
+ "no FilterAnchor row is ever created",
316
+ );
317
+ });
318
+
319
+ it("applies both a label and a move when both actions are requested", async () => {
320
+ const store = createMemoryVectorStore();
321
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
322
+ const p = predicate({
323
+ actionLabelId: "lbl-1",
324
+ actionMailboxId: "mbox-target",
325
+ });
326
+
327
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
328
+ const tracked = trackingClient();
329
+ const mover = trackingMoveService();
330
+ const result = await applyOrganize(
331
+ { client: tracked.client, moveService: mover.moveService },
332
+ ACCOUNT_CONFIG_ID,
333
+ matched,
334
+ p,
335
+ );
336
+
337
+ assert.equal(result.applied, 1);
338
+ assert.equal(result.failed, 0);
339
+ assert.deepEqual(tracked.labeled, [
340
+ { messageId: "msg-1", labelId: "lbl-1" },
341
+ ]);
342
+ assert.deepEqual(
343
+ mover.moves.map((m) => m.messageId),
344
+ ["msg-1"],
345
+ );
346
+ });
347
+
348
+ it("is idempotent on redelivery: a re-run re-issues the same move without double-enqueuing", async () => {
349
+ const store = createMemoryVectorStore();
350
+ const matching = ["msg-1", "msg-2"];
351
+ await store.upsert(matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)));
352
+ const p = predicate({ actionMailboxId: "mbox-target" });
353
+
354
+ const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
355
+ const tracked = trackingClient();
356
+ const mover = trackingMoveService();
357
+
358
+ const first = await applyOrganize(
359
+ { client: tracked.client, moveService: mover.moveService },
360
+ ACCOUNT_CONFIG_ID,
361
+ matched,
362
+ p,
363
+ );
364
+ const second = await applyOrganize(
365
+ { client: tracked.client, moveService: mover.moveService },
366
+ ACCOUNT_CONFIG_ID,
367
+ matched,
368
+ p,
369
+ );
370
+
371
+ assert.equal(first.applied, matching.length);
372
+ assert.equal(second.applied, matching.length);
373
+ assert.equal(
374
+ mover.enqueues(),
375
+ matching.length,
376
+ "the second pass drives the marker forward without a fresh enqueue",
377
+ );
378
+ assert.deepEqual(
379
+ [...mover.destinations().entries()].sort(),
380
+ matching.map((id): [string, string] => [id, "mbox-target"]),
381
+ "each message rests in the requested mailbox exactly once",
382
+ );
383
+ });
384
+ });