@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 +1 -1
- package/src/handlers/message.test.ts +27 -0
- package/src/handlers/message.ts +26 -3
- package/src/service/organize.test.ts +179 -6
- package/src/service/organize.ts +84 -51
package/package.json
CHANGED
|
@@ -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"],
|
package/src/handlers/message.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
365
|
-
|
|
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
|
+
});
|
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
|
/**
|