@remit/backend 0.0.104 → 0.0.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
3
4
  import {
4
5
  MoveNotSettledError,
5
6
  NoJunkMailboxError,
@@ -41,6 +42,32 @@ describe("settleSpamReportBulk", () => {
41
42
  assert.notEqual(reason, GENERIC_FAILURE_REASON);
42
43
  });
43
44
 
45
+ // A refusal the move gate raises used to collapse to the generic retry copy,
46
+ // which is a dead end for the reason no retry clears (#665).
47
+ it("words a placement refusal from its reason, never from the server's sentence", async () => {
48
+ const outcome = await settleSpamReportBulk(
49
+ ["msg-1", "msg-2"],
50
+ async (messageId) => {
51
+ throw new MessagePlacementUnsettledError(
52
+ `Message ${messageId} was not acted on: its folder and uid do not name the same message`,
53
+ "acc-1",
54
+ messageId,
55
+ messageId === "msg-1" ? "in_flight" : "unverified",
56
+ );
57
+ },
58
+ );
59
+
60
+ const [inFlight, unverified] = outcome.failures?.map((f) => f.reason) ?? [];
61
+ assert.match(inFlight ?? "", /try again in a moment/i);
62
+ assert.match(unverified ?? "", /sync the folder/i);
63
+ assert.doesNotMatch(unverified ?? "", /try again in a moment/i);
64
+ for (const reason of [inFlight, unverified]) {
65
+ assert.notEqual(reason, GENERIC_FAILURE_REASON);
66
+ assert.doesNotMatch(reason ?? "", /folder and uid/);
67
+ assert.doesNotMatch(reason ?? "", /acc-1/);
68
+ }
69
+ });
70
+
44
71
  it("omits failures entirely when every message succeeds", async () => {
45
72
  const outcome = await settleSpamReportBulk(
46
73
  ["msg-1", "msg-2"],
@@ -7,6 +7,7 @@ import type { MailboxItem } from "@remit/data-ports";
7
7
  import {
8
8
  BadRequestError,
9
9
  ForbiddenError,
10
+ MessagePlacementUnsettledError,
10
11
  NotFoundError,
11
12
  UnrecoverableBodyError,
12
13
  } from "@remit/data-ports/errors";
@@ -356,13 +357,35 @@ export const GENERIC_FAILURE_REASON =
356
357
  * each names what happened and what the user can do about it, which the
357
358
  * generic text cannot.
358
359
  */
359
- const DESIGNED_FAILURES = [MoveNotSettledError, NoJunkMailboxError];
360
+ const DESIGNED_FAILURES = [
361
+ MoveNotSettledError,
362
+ NoJunkMailboxError,
363
+ MessagePlacementUnsettledError,
364
+ ];
360
365
 
361
366
  const isDesignedFailure = (reason: unknown): reason is Error =>
362
367
  DESIGNED_FAILURES.some((designed) => reason instanceof designed);
363
368
 
364
- const failureReason = (reason: unknown): string =>
365
- isDesignedFailure(reason) ? reason.message : GENERIC_FAILURE_REASON;
369
+ /**
370
+ * The placement refusal is the one designed outcome whose own message is not
371
+ * the text to ship: it names a uuid and no remedy, and the two reasons do not
372
+ * share a remedy — an in-flight move settles on its own, an abandoned one only
373
+ * clears once the folder is resynced. Worded here off `details.reason`, the way
374
+ * the delete path words the same refusal for the client.
375
+ */
376
+ const placementRefusalReason = (
377
+ error: MessagePlacementUnsettledError,
378
+ ): string =>
379
+ error.publicApiError?.details?.reason === "unverified"
380
+ ? "An earlier move of this message never finished, so where it sits is unknown. Sync the folder, then report it again."
381
+ : "This message is still being moved on the mail server. Try again in a moment.";
382
+
383
+ const failureReason = (reason: unknown): string => {
384
+ if (!isDesignedFailure(reason)) return GENERIC_FAILURE_REASON;
385
+ if (reason instanceof MessagePlacementUnsettledError)
386
+ return placementRefusalReason(reason);
387
+ return reason.message;
388
+ };
366
389
 
367
390
  /**
368
391
  * Drive one report-spam/not-spam operation per message, concurrently. Each
@@ -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
  /**