@remit/account-worker 0.0.24 → 0.0.25

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/account-worker",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
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
 
@@ -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
+ };