@remit/account-worker 0.0.24 → 0.0.26
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/events.ts +32 -1
- package/src/handlers/account-fanout.ts +6 -0
- package/src/handlers/organize-job.test.ts +338 -3
- package/src/handlers/organize-job.ts +77 -23
- package/src/handlers/sender-category-backapply.test.ts +166 -0
- package/src/handlers/sender-category-backapply.ts +98 -0
package/package.json
CHANGED
package/src/events.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
type MessageCategoryValue = ThreadMessageItem["category"];
|
|
4
|
+
|
|
1
5
|
export type AccountDeleteEvent = {
|
|
2
6
|
type: "AccountDelete";
|
|
3
7
|
accountConfigId: string;
|
|
@@ -33,11 +37,38 @@ export type OrganizeJobEvent = {
|
|
|
33
37
|
organizeJobId: string;
|
|
34
38
|
};
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Retroactive half of a sender-category override (#415, RFC 039 Decision 3).
|
|
42
|
+
* Rides the same account-fanout queue as the organize back-apply: the API
|
|
43
|
+
* enqueues one when `Address.flags.category` is SET, and the fanout worker
|
|
44
|
+
* re-labels a bounded recent batch of that sender's already-classified mail.
|
|
45
|
+
* A CLEAR never enqueues one: reverting to auto-classification says nothing
|
|
46
|
+
* about what the classifier would have answered, and re-deriving it would need
|
|
47
|
+
* every message's body back.
|
|
48
|
+
*
|
|
49
|
+
* `category` and `categorySetAt` together name WHICH set this job was fired
|
|
50
|
+
* for, and the worker refuses a job whose set no longer stands on the Address.
|
|
51
|
+
* That check is what the standard queue's semantics make necessary rather than
|
|
52
|
+
* merely tidy: delivery is at-least-once and unordered, so without it a job
|
|
53
|
+
* that fails and redelivers after the user has corrected the category re-labels
|
|
54
|
+
* those 500 messages back to the superseded one, leaving the flag and the mail
|
|
55
|
+
* permanently disagreeing with nothing to reconcile them.
|
|
56
|
+
*/
|
|
57
|
+
export type SenderCategoryBackApplyEvent = {
|
|
58
|
+
type: "SenderCategoryBackApply";
|
|
59
|
+
accountConfigId: string;
|
|
60
|
+
addressId: string;
|
|
61
|
+
normalizedEmail: string;
|
|
62
|
+
category: MessageCategoryValue;
|
|
63
|
+
categorySetAt: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
36
66
|
export type AccountFanoutEvent =
|
|
37
67
|
| AccountDeleteEvent
|
|
38
68
|
| AccountDataPurgeEvent
|
|
39
69
|
| AccountExportEvent
|
|
40
|
-
| OrganizeJobEvent
|
|
70
|
+
| OrganizeJobEvent
|
|
71
|
+
| SenderCategoryBackApplyEvent;
|
|
41
72
|
|
|
42
73
|
export type AccountDeleteFinalizeEvent = {
|
|
43
74
|
type: "FinalizeAccountDelete";
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
import { processAccountExport } from "./account-export.js";
|
|
29
29
|
import { processAccountDataPurge } from "./account-purge.js";
|
|
30
30
|
import { processOrganizeJob } from "./organize-job.js";
|
|
31
|
+
import { processSenderCategoryBackApply } from "./sender-category-backapply.js";
|
|
31
32
|
|
|
32
33
|
export interface ProcessAccountFanoutDeps {
|
|
33
34
|
services?: CascadeServices;
|
|
@@ -74,6 +75,11 @@ export const processAccountFanout = async (
|
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
if (event.type === "SenderCategoryBackApply") {
|
|
79
|
+
await processSenderCategoryBackApply(event, log);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
await processAccountDelete(event, log, services, sqsFor, deps);
|
|
78
84
|
};
|
|
79
85
|
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
5
|
import type { RemitClient } from "@remit/backend/client";
|
|
4
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
buildOrganizeMatchDeps,
|
|
8
|
+
type OrganizeMatchDeps,
|
|
9
|
+
type OrganizeSemanticDeps,
|
|
10
|
+
} from "@remit/backend/organize";
|
|
11
|
+
import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
|
|
12
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
13
|
+
import { DrizzleThreadMessageRepository } from "@remit/drizzle-service";
|
|
14
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
5
15
|
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
6
16
|
import type { OrganizeJobEvent } from "../events.js";
|
|
7
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type ProcessOrganizeJobDeps,
|
|
19
|
+
processOrganizeJob,
|
|
20
|
+
} from "./organize-job.js";
|
|
8
21
|
|
|
9
22
|
interface Update {
|
|
10
23
|
state: string;
|
|
@@ -29,6 +42,7 @@ const jobClient = (
|
|
|
29
42
|
get: async () => ({
|
|
30
43
|
organizeJobId: "job-1",
|
|
31
44
|
accountConfigId: "cfg-1",
|
|
45
|
+
filterId: "None",
|
|
32
46
|
anchorMessageId: "None",
|
|
33
47
|
matchOperator: "And",
|
|
34
48
|
literalClauses,
|
|
@@ -56,6 +70,7 @@ const explodingMatchDeps = (): OrganizeMatchDeps =>
|
|
|
56
70
|
throw new Error("SQLITE_BUSY");
|
|
57
71
|
},
|
|
58
72
|
filterAnchors: {
|
|
73
|
+
get: async () => null,
|
|
59
74
|
listByAccountConfig: async () => [],
|
|
60
75
|
put: async () => {
|
|
61
76
|
throw new Error("unreachable");
|
|
@@ -104,3 +119,323 @@ describe("processOrganizeJob (reader #463)", () => {
|
|
|
104
119
|
assert.equal(updates[1]?.errorMessage, "SQLITE_BUSY");
|
|
105
120
|
});
|
|
106
121
|
});
|
|
122
|
+
|
|
123
|
+
interface Move {
|
|
124
|
+
messageId: string;
|
|
125
|
+
destinationMailboxId: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const CONFIG = "cfg-1";
|
|
129
|
+
const INBOX = "mbx-inbox";
|
|
130
|
+
const ARCHIVE = "mbx-archive";
|
|
131
|
+
|
|
132
|
+
const standingFilter = (over: Partial<FilterItem> = {}): FilterItem => ({
|
|
133
|
+
filterId: "flt-1",
|
|
134
|
+
accountConfigId: CONFIG,
|
|
135
|
+
name: "Invoices",
|
|
136
|
+
scope: "Standing",
|
|
137
|
+
state: "Active",
|
|
138
|
+
disabledReason: "None",
|
|
139
|
+
hasAnchor: false,
|
|
140
|
+
ruleChangedAt: 1,
|
|
141
|
+
actionChangedAt: 1,
|
|
142
|
+
matchOperator: "And",
|
|
143
|
+
literalClauses: [{ field: "From", value: "billing@example.com" }],
|
|
144
|
+
actionLabelId: "None",
|
|
145
|
+
actionMailboxId: "mbx-invoices",
|
|
146
|
+
createdAt: 0,
|
|
147
|
+
updatedAt: 0,
|
|
148
|
+
...over,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The thread rows live in the shipped SQLite schema, read through the real
|
|
153
|
+
* repository, so the inbox narrowing is the store's own query. One invoice
|
|
154
|
+
* sits in the inbox and one in the archive, both from the filter's sender
|
|
155
|
+
* and both saying "overdue" in their preview.
|
|
156
|
+
*/
|
|
157
|
+
const seedThreads = async (
|
|
158
|
+
threadMessage: DrizzleThreadMessageRepository,
|
|
159
|
+
): Promise<{ inbox: string; archive: string }> => {
|
|
160
|
+
const seed = async (mailboxId: string, uid: number): Promise<string> => {
|
|
161
|
+
const messageId = randomUUID();
|
|
162
|
+
await threadMessage.create({
|
|
163
|
+
accountConfigId: CONFIG,
|
|
164
|
+
threadId: randomUUID(),
|
|
165
|
+
messageId,
|
|
166
|
+
mailboxId,
|
|
167
|
+
uid,
|
|
168
|
+
referenceOrder: 0,
|
|
169
|
+
internalDate: uid,
|
|
170
|
+
sentDate: uid,
|
|
171
|
+
subject: "Your invoice",
|
|
172
|
+
fromEmail: "billing@example.com",
|
|
173
|
+
fromName: "Billing",
|
|
174
|
+
snippet: "This invoice is overdue",
|
|
175
|
+
isRead: false,
|
|
176
|
+
isDeleted: false,
|
|
177
|
+
hasAttachment: false,
|
|
178
|
+
hasStars: false,
|
|
179
|
+
});
|
|
180
|
+
return messageId;
|
|
181
|
+
};
|
|
182
|
+
return { inbox: await seed(INBOX, 1), archive: await seed(ARCHIVE, 2) };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
interface World {
|
|
186
|
+
client: RemitClient;
|
|
187
|
+
inbox: string;
|
|
188
|
+
archive: string;
|
|
189
|
+
updates: Update[];
|
|
190
|
+
moves: Move[];
|
|
191
|
+
labeled: string[];
|
|
192
|
+
close: () => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const world = async (
|
|
196
|
+
filters: FilterItem[],
|
|
197
|
+
anchors: FilterAnchorItem[] = [],
|
|
198
|
+
): Promise<World> => {
|
|
199
|
+
const store = createShippedSqliteDb();
|
|
200
|
+
store.sqlite.exec(
|
|
201
|
+
readFileSync(
|
|
202
|
+
new URL(
|
|
203
|
+
"../../../../npm-scripts/sqlite-search-index.sql",
|
|
204
|
+
import.meta.url,
|
|
205
|
+
),
|
|
206
|
+
"utf8",
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
const threadMessage = new DrizzleThreadMessageRepository(store.db as never);
|
|
210
|
+
const { inbox, archive } = await seedThreads(threadMessage);
|
|
211
|
+
const updates: Update[] = [];
|
|
212
|
+
const labeled: string[] = [];
|
|
213
|
+
const mailboxOf: Record<string, string> = {
|
|
214
|
+
[inbox]: INBOX,
|
|
215
|
+
[archive]: ARCHIVE,
|
|
216
|
+
};
|
|
217
|
+
const client = {
|
|
218
|
+
threadMessage,
|
|
219
|
+
organizeJobRequest: {
|
|
220
|
+
get: async () => ({
|
|
221
|
+
organizeJobId: "job-1",
|
|
222
|
+
accountConfigId: CONFIG,
|
|
223
|
+
filterId: "flt-1",
|
|
224
|
+
anchorMessageId: "None",
|
|
225
|
+
matchOperator: "And",
|
|
226
|
+
literalClauses: [],
|
|
227
|
+
similarityThreshold: 0.75,
|
|
228
|
+
actionLabelId: "None",
|
|
229
|
+
actionMailboxId: "None",
|
|
230
|
+
}),
|
|
231
|
+
update: async (_id: string, patch: Update) => {
|
|
232
|
+
updates.push(patch);
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
filter: {
|
|
236
|
+
get: async (_cfg: string, filterId: string) => {
|
|
237
|
+
const found = filters.find((f) => f.filterId === filterId);
|
|
238
|
+
if (!found) throw new NotFoundError(`Filter not found: ${filterId}`);
|
|
239
|
+
return found;
|
|
240
|
+
},
|
|
241
|
+
refreshExpiry: async (filter: FilterItem) => filter,
|
|
242
|
+
listByAccountAndState: async (_cfg: string, state: string) =>
|
|
243
|
+
filters.filter((f) => f.state === state),
|
|
244
|
+
},
|
|
245
|
+
filterAnchor: {
|
|
246
|
+
get: async (_cfg: string, filterId: string) =>
|
|
247
|
+
anchors.find((anchor) => anchor.filterId === filterId) ?? null,
|
|
248
|
+
},
|
|
249
|
+
account: {
|
|
250
|
+
listMailSourcesByAccountConfig: async () => [{ accountId: "acct-1" }],
|
|
251
|
+
},
|
|
252
|
+
mailboxSpecialUse: {
|
|
253
|
+
findInboxMailbox: async () => ({ mailboxId: INBOX, fullPath: "INBOX" }),
|
|
254
|
+
},
|
|
255
|
+
message: {
|
|
256
|
+
get: async (messageId: string) => ({
|
|
257
|
+
messageId,
|
|
258
|
+
mailboxId: mailboxOf[messageId],
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
mailbox: { resolveAccountId: async () => "acct-1" },
|
|
262
|
+
messageLabel: {
|
|
263
|
+
apply: async (input: { messageId: string }) => {
|
|
264
|
+
labeled.push(input.messageId);
|
|
265
|
+
return {};
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
} as unknown as RemitClient;
|
|
269
|
+
return {
|
|
270
|
+
client,
|
|
271
|
+
inbox,
|
|
272
|
+
archive,
|
|
273
|
+
updates,
|
|
274
|
+
moves: [],
|
|
275
|
+
labeled,
|
|
276
|
+
close: store.close,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const recordingMoves = (moves: Move[]) =>
|
|
281
|
+
({
|
|
282
|
+
moveMessage: async (
|
|
283
|
+
_cfg: string,
|
|
284
|
+
messageId: string,
|
|
285
|
+
destinationMailboxId: string,
|
|
286
|
+
) => {
|
|
287
|
+
moves.push({ messageId, destinationMailboxId });
|
|
288
|
+
},
|
|
289
|
+
}) as unknown as NonNullable<ProcessOrganizeJobDeps["moveService"]>;
|
|
290
|
+
|
|
291
|
+
const runFilterJob = (w: World) =>
|
|
292
|
+
processOrganizeJob(event, noopLogger, {
|
|
293
|
+
client: w.client,
|
|
294
|
+
matchDeps: buildOrganizeMatchDeps(w.client),
|
|
295
|
+
moveService: recordingMoves(w.moves),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const EMBEDDING_ID = "test-model@4";
|
|
299
|
+
const ANCHOR_VECTOR = [1, 0, 0, 0];
|
|
300
|
+
const ORTHOGONAL_VECTOR = [0, 1, 0, 0];
|
|
301
|
+
|
|
302
|
+
const persistedAnchor: FilterAnchorItem = {
|
|
303
|
+
filterId: "flt-1",
|
|
304
|
+
accountConfigId: CONFIG,
|
|
305
|
+
anchorMessageId: "msg-anchor",
|
|
306
|
+
anchorEmbedding: ANCHOR_VECTOR,
|
|
307
|
+
anchorEmbeddingId: EMBEDDING_ID,
|
|
308
|
+
anchorSourceText: "Your invoice",
|
|
309
|
+
createdAt: 0,
|
|
310
|
+
updatedAt: 0,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The vector index answers the anchor with the inbox invoice, and only when
|
|
315
|
+
* asked about the inbox: the kNN read is where a similarity filter finds its
|
|
316
|
+
* candidates.
|
|
317
|
+
*/
|
|
318
|
+
const vectorMatchDeps = (w: World): OrganizeMatchDeps => ({
|
|
319
|
+
...buildOrganizeMatchDeps(w.client),
|
|
320
|
+
semantic: () => ({
|
|
321
|
+
buildAnchor: async () => {
|
|
322
|
+
throw new Error("a filter widens on its persisted anchor");
|
|
323
|
+
},
|
|
324
|
+
vectorStore: {
|
|
325
|
+
query: async (query: { filter?: { mailboxId?: string } }) =>
|
|
326
|
+
query.filter?.mailboxId === INBOX
|
|
327
|
+
? [
|
|
328
|
+
{
|
|
329
|
+
chunkId: `${w.inbox}#body`,
|
|
330
|
+
score: 0.9,
|
|
331
|
+
metadata: { messageId: w.inbox, mailboxIds: [INBOX] },
|
|
332
|
+
},
|
|
333
|
+
]
|
|
334
|
+
: [],
|
|
335
|
+
getByMessage: async () => [],
|
|
336
|
+
} as unknown as OrganizeSemanticDeps["vectorStore"],
|
|
337
|
+
embed: async () => ANCHOR_VECTOR,
|
|
338
|
+
embeddingId: EMBEDDING_ID,
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("processOrganizeJob for a filter job (#1354)", () => {
|
|
343
|
+
let current: World | undefined;
|
|
344
|
+
afterEach(() => current?.close());
|
|
345
|
+
|
|
346
|
+
it("moves the pre-existing matching inbox message and leaves the archive alone", async () => {
|
|
347
|
+
current = await world([standingFilter()]);
|
|
348
|
+
|
|
349
|
+
await runFilterJob(current);
|
|
350
|
+
|
|
351
|
+
assert.deepEqual(current.moves, [
|
|
352
|
+
{ messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
|
|
353
|
+
]);
|
|
354
|
+
const done = current.updates.at(-1);
|
|
355
|
+
assert.equal(done?.state, "Complete");
|
|
356
|
+
assert.equal(done?.matchedCount, 1);
|
|
357
|
+
assert.equal(done?.appliedCount, 1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("matches a free-text rule from search on the stored preview", async () => {
|
|
361
|
+
current = await world([
|
|
362
|
+
standingFilter({
|
|
363
|
+
literalClauses: [{ field: "HasWords", value: "overdue" }],
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
await runFilterJob(current);
|
|
368
|
+
|
|
369
|
+
assert.deepEqual(current.moves, [
|
|
370
|
+
{ messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
|
|
371
|
+
]);
|
|
372
|
+
assert.equal(current.updates.at(-1)?.state, "Complete");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("leaves the move to a newer filter that also matches, and still labels", async () => {
|
|
376
|
+
current = await world([
|
|
377
|
+
standingFilter({ actionLabelId: "lbl-bills" }),
|
|
378
|
+
standingFilter({
|
|
379
|
+
filterId: "flt-2",
|
|
380
|
+
name: "Receipts",
|
|
381
|
+
actionChangedAt: 2,
|
|
382
|
+
actionMailboxId: "mbx-receipts",
|
|
383
|
+
}),
|
|
384
|
+
]);
|
|
385
|
+
|
|
386
|
+
await runFilterJob(current);
|
|
387
|
+
|
|
388
|
+
assert.deepEqual(current.moves, []);
|
|
389
|
+
assert.deepEqual(current.labeled, [current.inbox]);
|
|
390
|
+
assert.equal(current.updates.at(-1)?.state, "Complete");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("fails the job without a retry when the filter is turned off", async () => {
|
|
394
|
+
current = await world([
|
|
395
|
+
standingFilter({ state: "Disabled", disabledReason: "UserDisabled" }),
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
await runFilterJob(current);
|
|
399
|
+
|
|
400
|
+
assert.deepEqual(current.moves, []);
|
|
401
|
+
assert.equal(current.updates.at(-1)?.state, "Failed");
|
|
402
|
+
assert.match(String(current.updates.at(-1)?.errorMessage), /turned off/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("fails the job without a retry when the filter was deleted", async () => {
|
|
406
|
+
current = await world([]);
|
|
407
|
+
|
|
408
|
+
await runFilterJob(current);
|
|
409
|
+
|
|
410
|
+
assert.equal(current.updates.at(-1)?.state, "Failed");
|
|
411
|
+
assert.match(String(current.updates.at(-1)?.errorMessage), /deleted/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("moves a message the vector query found, though the preview scores below the threshold", async () => {
|
|
415
|
+
current = await world(
|
|
416
|
+
[standingFilter({ hasAnchor: true, literalClauses: [] })],
|
|
417
|
+
[persistedAnchor],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// The index-time evaluator embeds subject and preview; this embedder puts
|
|
421
|
+
// that text orthogonal to the anchor, so evaluate alone would call the
|
|
422
|
+
// message a miss. It only orders the move, so the vector match stands.
|
|
423
|
+
await processOrganizeJob(event, noopLogger, {
|
|
424
|
+
client: current.client,
|
|
425
|
+
matchDeps: vectorMatchDeps(current),
|
|
426
|
+
moveService: recordingMoves(current.moves),
|
|
427
|
+
embedder: {
|
|
428
|
+
embed: async () => ORTHOGONAL_VECTOR,
|
|
429
|
+
embeddingId: EMBEDDING_ID,
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
assert.deepEqual(current.moves, [
|
|
434
|
+
{ messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
|
|
435
|
+
]);
|
|
436
|
+
const done = current.updates.at(-1);
|
|
437
|
+
assert.equal(done?.state, "Complete");
|
|
438
|
+
assert.equal(done?.matchedCount, 1);
|
|
439
|
+
assert.equal(done?.appliedCount, 1);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { getClient, type RemitClient } from "@remit/backend/client";
|
|
2
2
|
import {
|
|
3
|
+
type ApplyOrganizeDeps,
|
|
3
4
|
applyOrganize,
|
|
5
|
+
backApplyFilter,
|
|
4
6
|
buildOrganizeMatchDeps,
|
|
5
7
|
buildOrganizeMoveService,
|
|
8
|
+
type FilterBackApplyDeps,
|
|
9
|
+
isFilterJob,
|
|
6
10
|
matchOrganize,
|
|
7
11
|
ORGANIZE_MATCH_LIMIT,
|
|
8
12
|
type OrganizeMatchDeps,
|
|
13
|
+
type OrganizeRejection,
|
|
9
14
|
predicateFromJob,
|
|
10
15
|
} from "@remit/backend/organize";
|
|
11
16
|
import type { Logger } from "@remit/logger-lambda";
|
|
@@ -14,12 +19,64 @@ import type { OrganizeJobEvent } from "../events.js";
|
|
|
14
19
|
export interface ProcessOrganizeJobDeps {
|
|
15
20
|
client?: RemitClient;
|
|
16
21
|
matchDeps?: OrganizeMatchDeps;
|
|
22
|
+
moveService?: ApplyOrganizeDeps["moveService"];
|
|
23
|
+
embedder?: FilterBackApplyDeps["embedder"];
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
interface OrganizeOutcome {
|
|
27
|
+
rejected: OrganizeRejection | null;
|
|
28
|
+
matched: number;
|
|
29
|
+
applied: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
semanticUnavailable: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rejectedOutcome = (rejected: OrganizeRejection): OrganizeOutcome => ({
|
|
35
|
+
rejected,
|
|
36
|
+
matched: 0,
|
|
37
|
+
applied: 0,
|
|
38
|
+
failed: 0,
|
|
39
|
+
semanticUnavailable: false,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The "all like these" pass: the predicate snapshotted on the row, over every
|
|
44
|
+
* folder, every requested move applied (reader #497).
|
|
45
|
+
*/
|
|
46
|
+
const runSnapshot = async (
|
|
47
|
+
client: RemitClient,
|
|
48
|
+
matchDeps: OrganizeMatchDeps,
|
|
49
|
+
moveService: ApplyOrganizeDeps["moveService"],
|
|
50
|
+
job: Awaited<ReturnType<RemitClient["organizeJobRequest"]["get"]>>,
|
|
51
|
+
): Promise<OrganizeOutcome> => {
|
|
52
|
+
const predicate = predicateFromJob(job);
|
|
53
|
+
const match = await matchOrganize(
|
|
54
|
+
matchDeps,
|
|
55
|
+
job.accountConfigId,
|
|
56
|
+
predicate,
|
|
57
|
+
ORGANIZE_MATCH_LIMIT,
|
|
58
|
+
);
|
|
59
|
+
if (match.rejected) return rejectedOutcome(match.rejected);
|
|
60
|
+
const { applied, failed } = await applyOrganize(
|
|
61
|
+
{ client, moveService },
|
|
62
|
+
job.accountConfigId,
|
|
63
|
+
match.messageIds,
|
|
64
|
+
predicate,
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
rejected: null,
|
|
68
|
+
matched: match.messageIds.length,
|
|
69
|
+
applied,
|
|
70
|
+
failed,
|
|
71
|
+
semanticUnavailable: match.semanticUnavailable,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
19
75
|
/**
|
|
20
|
-
* Run a
|
|
21
|
-
* against the job's snapshotted predicate
|
|
22
|
-
*
|
|
76
|
+
* Run a back-apply job (RFC 034, #1278): an "all like these" pass matches the
|
|
77
|
+
* corpus against the job's snapshotted predicate; a filter job (#1354) runs the
|
|
78
|
+
* named standing filter over the inbox with index-time move precedence. Either
|
|
79
|
+
* applies the action to every match in one pass, then records the counts. Mirrors the export job's lifecycle —
|
|
23
80
|
* Running, then Complete/Failed — on the same fanout seam.
|
|
24
81
|
*
|
|
25
82
|
* Reuses the shared matcher (so the applied set equals what preview returned)
|
|
@@ -53,41 +110,37 @@ export const processOrganizeJob = async (
|
|
|
53
110
|
);
|
|
54
111
|
|
|
55
112
|
try {
|
|
56
|
-
const predicate = predicateFromJob(job);
|
|
57
113
|
const matchDeps = deps.matchDeps ?? buildOrganizeMatchDeps(client);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
114
|
+
const moveService = deps.moveService ?? buildOrganizeMoveService(client);
|
|
115
|
+
const outcome = isFilterJob(job)
|
|
116
|
+
? await backApplyFilter(
|
|
117
|
+
{ client, matchDeps, moveService, embedder: deps.embedder },
|
|
118
|
+
accountConfigId,
|
|
119
|
+
job.filterId,
|
|
120
|
+
).then((result) =>
|
|
121
|
+
result.rejected ? rejectedOutcome(result.rejected) : result,
|
|
122
|
+
)
|
|
123
|
+
: await runSnapshot(client, matchDeps, moveService, job);
|
|
64
124
|
|
|
65
|
-
if (
|
|
125
|
+
if (outcome.rejected) {
|
|
66
126
|
await client.organizeJobRequest.update(organizeJobId, {
|
|
67
127
|
state: "Failed",
|
|
68
128
|
matchedCount: 0,
|
|
69
129
|
appliedCount: 0,
|
|
70
130
|
failedCount: 0,
|
|
71
|
-
errorMessage:
|
|
131
|
+
errorMessage: outcome.rejected.message,
|
|
72
132
|
});
|
|
73
133
|
log.warn(
|
|
74
|
-
{ accountConfigId, organizeJobId, reason:
|
|
134
|
+
{ accountConfigId, organizeJobId, reason: outcome.rejected.reason },
|
|
75
135
|
"Organize back-apply refused the job's rule; failing the job without a retry",
|
|
76
136
|
);
|
|
77
137
|
return;
|
|
78
138
|
}
|
|
79
139
|
|
|
80
|
-
const {
|
|
81
|
-
const { applied, failed } = await applyOrganize(
|
|
82
|
-
{ client, moveService: buildOrganizeMoveService(client) },
|
|
83
|
-
accountConfigId,
|
|
84
|
-
messageIds,
|
|
85
|
-
predicate,
|
|
86
|
-
);
|
|
87
|
-
|
|
140
|
+
const { matched, applied, failed, semanticUnavailable } = outcome;
|
|
88
141
|
await client.organizeJobRequest.update(organizeJobId, {
|
|
89
142
|
state: "Complete",
|
|
90
|
-
matchedCount:
|
|
143
|
+
matchedCount: matched,
|
|
91
144
|
appliedCount: applied,
|
|
92
145
|
failedCount: failed,
|
|
93
146
|
});
|
|
@@ -95,7 +148,8 @@ export const processOrganizeJob = async (
|
|
|
95
148
|
{
|
|
96
149
|
accountConfigId,
|
|
97
150
|
organizeJobId,
|
|
98
|
-
|
|
151
|
+
filterId: job.filterId,
|
|
152
|
+
matched,
|
|
99
153
|
applied,
|
|
100
154
|
failed,
|
|
101
155
|
semanticUnavailable,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
4
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
5
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
6
|
+
import type { SenderCategoryBackApplyEvent } from "../events.js";
|
|
7
|
+
import { processSenderCategoryBackApply } from "./sender-category-backapply.js";
|
|
8
|
+
|
|
9
|
+
const SET_AT = 1_700;
|
|
10
|
+
|
|
11
|
+
const event: SenderCategoryBackApplyEvent = {
|
|
12
|
+
type: "SenderCategoryBackApply",
|
|
13
|
+
accountConfigId: "cfg-1",
|
|
14
|
+
addressId: "addr-1",
|
|
15
|
+
normalizedEmail: "post@ns.nl",
|
|
16
|
+
category: "newsletter",
|
|
17
|
+
categorySetAt: SET_AT,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const row = {
|
|
21
|
+
threadMessageId: "tm-1",
|
|
22
|
+
messageId: "msg-1",
|
|
23
|
+
mailboxId: "mbx-inbox",
|
|
24
|
+
fromEmail: "post@ns.nl",
|
|
25
|
+
category: "personal",
|
|
26
|
+
sentDate: 1_000,
|
|
27
|
+
isRead: false,
|
|
28
|
+
isDeleted: false,
|
|
29
|
+
hasStars: false,
|
|
30
|
+
hasAttachment: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface ClientShape {
|
|
34
|
+
relabelled: Array<{ messageId: string; category: unknown }>;
|
|
35
|
+
storedFlag?: { value: string; setAt: number } | "cleared" | "address-gone";
|
|
36
|
+
listByFieldTerms?: () => Promise<unknown>;
|
|
37
|
+
updateMessage?: (messageId: string) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const clientOver = ({
|
|
41
|
+
relabelled,
|
|
42
|
+
storedFlag = { value: "newsletter", setAt: SET_AT },
|
|
43
|
+
listByFieldTerms = async () => ({
|
|
44
|
+
items: [row],
|
|
45
|
+
continuationToken: undefined,
|
|
46
|
+
}),
|
|
47
|
+
updateMessage,
|
|
48
|
+
}: ClientShape): RemitClient =>
|
|
49
|
+
({
|
|
50
|
+
address: {
|
|
51
|
+
getAddress: async () => {
|
|
52
|
+
if (storedFlag === "address-gone") {
|
|
53
|
+
throw new NotFoundError("Address addr-1 not found");
|
|
54
|
+
}
|
|
55
|
+
if (storedFlag === "cleared") {
|
|
56
|
+
return { addressId: "addr-1", flags: {} };
|
|
57
|
+
}
|
|
58
|
+
return { addressId: "addr-1", flags: { category: storedFlag } };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
message: {
|
|
62
|
+
update: async (messageId: string, input: { category: unknown }) => {
|
|
63
|
+
if (updateMessage) await updateMessage(messageId);
|
|
64
|
+
relabelled.push({ messageId, category: input.category });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
threadMessage: {
|
|
68
|
+
listByFieldTerms,
|
|
69
|
+
findAllByMessageId: async () => [row],
|
|
70
|
+
update: async () => {},
|
|
71
|
+
},
|
|
72
|
+
}) as unknown as RemitClient;
|
|
73
|
+
|
|
74
|
+
describe("processSenderCategoryBackApply (#415)", () => {
|
|
75
|
+
it("re-labels the sender's stored mail to the category the event carries", async () => {
|
|
76
|
+
const relabelled: Array<{ messageId: string; category: unknown }> = [];
|
|
77
|
+
|
|
78
|
+
await processSenderCategoryBackApply(event, noopLogger, {
|
|
79
|
+
client: clientOver({ relabelled }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(relabelled, [
|
|
83
|
+
{ messageId: "msg-1", category: "newsletter" },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("applies nothing when the user has since corrected the category", async () => {
|
|
88
|
+
const relabelled: Array<{ messageId: string; category: unknown }> = [];
|
|
89
|
+
|
|
90
|
+
await processSenderCategoryBackApply(event, noopLogger, {
|
|
91
|
+
client: clientOver({
|
|
92
|
+
relabelled,
|
|
93
|
+
storedFlag: { value: "promotions", setAt: SET_AT + 60 },
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
relabelled,
|
|
99
|
+
[],
|
|
100
|
+
"a redelivery after a correction must not put the superseded category back",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("applies nothing when the same category was cleared and set again", async () => {
|
|
105
|
+
const relabelled: Array<{ messageId: string; category: unknown }> = [];
|
|
106
|
+
|
|
107
|
+
await processSenderCategoryBackApply(event, noopLogger, {
|
|
108
|
+
client: clientOver({
|
|
109
|
+
relabelled,
|
|
110
|
+
storedFlag: { value: "newsletter", setAt: SET_AT + 60 },
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(relabelled, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("applies nothing when the override was cleared outright", async () => {
|
|
118
|
+
const relabelled: Array<{ messageId: string; category: unknown }> = [];
|
|
119
|
+
|
|
120
|
+
await processSenderCategoryBackApply(event, noopLogger, {
|
|
121
|
+
client: clientOver({ relabelled, storedFlag: "cleared" }),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(relabelled, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("declines quietly when the address is gone", async () => {
|
|
128
|
+
const relabelled: Array<{ messageId: string; category: unknown }> = [];
|
|
129
|
+
|
|
130
|
+
await processSenderCategoryBackApply(event, noopLogger, {
|
|
131
|
+
client: clientOver({ relabelled, storedFlag: "address-gone" }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(relabelled, []);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("leaves a pass with a failed message unacknowledged so it redelivers", async () => {
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
processSenderCategoryBackApply(event, noopLogger, {
|
|
140
|
+
client: clientOver({
|
|
141
|
+
relabelled: [],
|
|
142
|
+
updateMessage: async () => {
|
|
143
|
+
throw new Error("SQLITE_BUSY");
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
}),
|
|
147
|
+
/1 of 1/,
|
|
148
|
+
"acknowledging would strand the thread rows re-labelled and the Message row not",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("propagates a store failure so partial batch failure redelivers the record", async () => {
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
processSenderCategoryBackApply(event, noopLogger, {
|
|
155
|
+
client: clientOver({
|
|
156
|
+
relabelled: [],
|
|
157
|
+
listByFieldTerms: async () => {
|
|
158
|
+
throw new Error("SQLITE_BUSY");
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
}),
|
|
162
|
+
/SQLITE_BUSY/,
|
|
163
|
+
"the pass writes only idempotent local rows, so a retry is safe and must happen",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { getClient, type RemitClient } from "@remit/backend/client";
|
|
2
|
+
import {
|
|
3
|
+
backApplySenderCategory,
|
|
4
|
+
SENDER_CATEGORY_BACKAPPLY_LIMIT,
|
|
5
|
+
} from "@remit/backend/sender-category";
|
|
6
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
8
|
+
import type { SenderCategoryBackApplyEvent } from "../events.js";
|
|
9
|
+
|
|
10
|
+
export interface ProcessSenderCategoryBackApplyDeps {
|
|
11
|
+
client?: RemitClient;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the override this job was fired for is still the one standing on the
|
|
16
|
+
* Address. Both halves are compared: `value` alone would let a set → clear →
|
|
17
|
+
* re-set to the same category count as unchanged, and `setAt` alone would
|
|
18
|
+
* refuse a legitimate redelivery.
|
|
19
|
+
*
|
|
20
|
+
* A vanished Address is a stale job, not a fault — the account was purged
|
|
21
|
+
* between the PATCH and the delivery — so it declines quietly. Every other
|
|
22
|
+
* read failure propagates, because a job that cannot tell whether it is stale
|
|
23
|
+
* must not guess.
|
|
24
|
+
*/
|
|
25
|
+
const overrideStillStands = async (
|
|
26
|
+
client: RemitClient,
|
|
27
|
+
event: SenderCategoryBackApplyEvent,
|
|
28
|
+
): Promise<boolean> => {
|
|
29
|
+
const address = await client.address
|
|
30
|
+
.getAddress(event.accountConfigId, event.addressId)
|
|
31
|
+
.catch((error: unknown) => {
|
|
32
|
+
if (error instanceof NotFoundError) return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
const flag = address?.flags?.category;
|
|
36
|
+
return flag?.value === event.category && flag?.setAt === event.categorySetAt;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the retroactive half of a sender-category override (#415): re-label the
|
|
41
|
+
* bounded recent batch of that sender's already-classified mail, in one pass.
|
|
42
|
+
* Rides the organize back-apply's seam without its job row — a flag PATCH
|
|
43
|
+
* returns the address, not a job to poll, so there is no lifecycle for a client
|
|
44
|
+
* to read and nothing for a row to carry.
|
|
45
|
+
*
|
|
46
|
+
* The delivery is at-least-once and unordered, so the pass starts by
|
|
47
|
+
* re-reading the flag and declines unless the set it was fired for is still
|
|
48
|
+
* the standing one ({@link overrideStillStands}). Re-labelling blind would let
|
|
49
|
+
* a redelivered job undo a correction the user has since made.
|
|
50
|
+
*
|
|
51
|
+
* A pass in which any message failed is NOT acknowledged: the writes are
|
|
52
|
+
* idempotent and the converged rows are skipped on the way through, so
|
|
53
|
+
* redelivery re-runs only what is still wrong. Acknowledging instead would
|
|
54
|
+
* leave a message whose thread rows were re-labelled but whose Message row was
|
|
55
|
+
* not — the badge and the detail view disagreeing, permanently, with nothing
|
|
56
|
+
* left to repair it.
|
|
57
|
+
*/
|
|
58
|
+
export const processSenderCategoryBackApply = async (
|
|
59
|
+
event: SenderCategoryBackApplyEvent,
|
|
60
|
+
log: Logger,
|
|
61
|
+
deps: ProcessSenderCategoryBackApplyDeps = {},
|
|
62
|
+
): Promise<void> => {
|
|
63
|
+
const { accountConfigId, addressId, normalizedEmail, category } = event;
|
|
64
|
+
const client = deps.client ?? (await getClient());
|
|
65
|
+
|
|
66
|
+
if (!(await overrideStillStands(client, event))) {
|
|
67
|
+
log.warn(
|
|
68
|
+
{ accountConfigId, addressId, category },
|
|
69
|
+
"Sender category back-apply skipped: the override it was fired for no longer stands",
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await backApplySenderCategory(
|
|
75
|
+
{ client },
|
|
76
|
+
accountConfigId,
|
|
77
|
+
normalizedEmail,
|
|
78
|
+
category,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// biome-ignore lint/plugin/no-logger-info: a back-apply is an audit-grade signal
|
|
82
|
+
log.info(
|
|
83
|
+
{
|
|
84
|
+
accountConfigId,
|
|
85
|
+
addressId,
|
|
86
|
+
category,
|
|
87
|
+
limit: SENDER_CATEGORY_BACKAPPLY_LIMIT,
|
|
88
|
+
...result,
|
|
89
|
+
},
|
|
90
|
+
"Sender category back-apply pass finished",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (result.failed > 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Sender category back-apply left ${result.failed} of ${result.matched} messages unlabelled for address ${addressId}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|