@remit/account-worker 0.0.23 → 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 +1 -1
- package/src/cascade-enumerate.test.ts +5 -15
- package/src/compose-relational.test.ts +7 -17
- package/src/events.ts +32 -1
- package/src/handlers/account-export.test.ts +4 -14
- package/src/handlers/account-fanout.test.ts +3 -13
- package/src/handlers/account-fanout.ts +6 -0
- package/src/handlers/account-purge.test.ts +7 -17
- package/src/handlers/organize-job.test.ts +3 -13
- package/src/handlers/sender-category-backapply.test.ts +166 -0
- package/src/handlers/sender-category-backapply.ts +98 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { MessageDescription } from "@remit/data-ports";
|
|
4
|
-
import
|
|
4
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
5
5
|
import {
|
|
6
6
|
type CascadeEntity,
|
|
7
7
|
type CascadeServices,
|
|
@@ -9,16 +9,6 @@ import {
|
|
|
9
9
|
enumerateCascadeEntities,
|
|
10
10
|
} from "./cascade.js";
|
|
11
11
|
|
|
12
|
-
const noopLog = {
|
|
13
|
-
info: () => {},
|
|
14
|
-
warn: () => {},
|
|
15
|
-
error: () => {},
|
|
16
|
-
debug: () => {},
|
|
17
|
-
fatal: () => {},
|
|
18
|
-
trace: () => {},
|
|
19
|
-
child: () => noopLog,
|
|
20
|
-
} as unknown as Logger;
|
|
21
|
-
|
|
22
12
|
const messageWithChildren = {
|
|
23
13
|
messageFlag: [{ messageFlagId: "flag-1" }],
|
|
24
14
|
envelope: [{ envelopeId: "env-1" }],
|
|
@@ -127,7 +117,7 @@ describe("enumerateCascadeEntities", () => {
|
|
|
127
117
|
const { entities, messageIds } = await enumerateCascadeEntities(
|
|
128
118
|
"cfg-1",
|
|
129
119
|
fullServices(),
|
|
130
|
-
|
|
120
|
+
noopLogger,
|
|
131
121
|
);
|
|
132
122
|
|
|
133
123
|
assert.deepEqual(messageIds, ["msg-1"]);
|
|
@@ -166,7 +156,7 @@ describe("enumerateCascadeEntities", () => {
|
|
|
166
156
|
const { entities } = await enumerateCascadeEntities(
|
|
167
157
|
"cfg-1",
|
|
168
158
|
services,
|
|
169
|
-
|
|
159
|
+
noopLogger,
|
|
170
160
|
);
|
|
171
161
|
|
|
172
162
|
assert.equal(entities.filter((e) => e.entityType === "Filter").length, 1);
|
|
@@ -183,7 +173,7 @@ describe("enumerateCascadeEntities", () => {
|
|
|
183
173
|
const { entities } = await enumerateCascadeEntities(
|
|
184
174
|
"cfg-1",
|
|
185
175
|
services,
|
|
186
|
-
|
|
176
|
+
noopLogger,
|
|
187
177
|
);
|
|
188
178
|
|
|
189
179
|
assert.equal(
|
|
@@ -204,7 +194,7 @@ describe("enumerateCascadeEntities", () => {
|
|
|
204
194
|
const { entities, messageIds } = await enumerateCascadeEntities(
|
|
205
195
|
"cfg-empty",
|
|
206
196
|
services,
|
|
207
|
-
|
|
197
|
+
noopLogger,
|
|
208
198
|
);
|
|
209
199
|
|
|
210
200
|
assert.equal(messageIds.length, 0);
|
|
@@ -3,19 +3,9 @@ import { mkdir, mkdtemp, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
6
|
-
import
|
|
6
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
7
7
|
import { buildRelationalDeletionCapabilities } from "./compose-relational.js";
|
|
8
8
|
|
|
9
|
-
const noopLog = {
|
|
10
|
-
info: () => {},
|
|
11
|
-
warn: () => {},
|
|
12
|
-
error: () => {},
|
|
13
|
-
debug: () => {},
|
|
14
|
-
fatal: () => {},
|
|
15
|
-
trace: () => {},
|
|
16
|
-
child: () => noopLog,
|
|
17
|
-
} as unknown as Logger;
|
|
18
|
-
|
|
19
9
|
const exists = (path: string): Promise<boolean> =>
|
|
20
10
|
stat(path)
|
|
21
11
|
.then(() => true)
|
|
@@ -54,7 +44,7 @@ describe("relational deletion capabilities — filesystem storage cleanup", () =
|
|
|
54
44
|
"accounts/cfg-2/acc-9/messages/m9/body.eml",
|
|
55
45
|
);
|
|
56
46
|
|
|
57
|
-
await caps.deleteStoragePrefix("accounts/cfg-1/",
|
|
47
|
+
await caps.deleteStoragePrefix("accounts/cfg-1/", noopLogger);
|
|
58
48
|
|
|
59
49
|
for (const path of deleted) {
|
|
60
50
|
assert.equal(await exists(path), false, `${path} must be deleted`);
|
|
@@ -74,7 +64,7 @@ describe("relational deletion capabilities — filesystem storage cleanup", () =
|
|
|
74
64
|
"accounts/cfg-1/acc-2/messages/m2/body.eml",
|
|
75
65
|
);
|
|
76
66
|
|
|
77
|
-
await caps.deleteStoragePrefix("accounts/cfg-1/acc-1/",
|
|
67
|
+
await caps.deleteStoragePrefix("accounts/cfg-1/acc-1/", noopLogger);
|
|
78
68
|
|
|
79
69
|
assert.equal(await exists(deleted), false);
|
|
80
70
|
assert.equal(
|
|
@@ -86,19 +76,19 @@ describe("relational deletion capabilities — filesystem storage cleanup", () =
|
|
|
86
76
|
|
|
87
77
|
it("is replay-safe — deleting a missing prefix does not throw", async () => {
|
|
88
78
|
const caps = buildRelationalDeletionCapabilities();
|
|
89
|
-
await caps.deleteStoragePrefix("accounts/never-existed/",
|
|
79
|
+
await caps.deleteStoragePrefix("accounts/never-existed/", noopLogger);
|
|
90
80
|
});
|
|
91
81
|
});
|
|
92
82
|
|
|
93
83
|
describe("relational deletion capabilities — CDN and sign-out are no-ops", () => {
|
|
94
84
|
it("invalidateContent resolves without a CDN", async () => {
|
|
95
85
|
const caps = buildRelationalDeletionCapabilities();
|
|
96
|
-
await caps.invalidateContent("cfg-1",
|
|
97
|
-
await caps.invalidateContent("cfg-1/acc-1",
|
|
86
|
+
await caps.invalidateContent("cfg-1", noopLogger);
|
|
87
|
+
await caps.invalidateContent("cfg-1/acc-1", noopLogger);
|
|
98
88
|
});
|
|
99
89
|
|
|
100
90
|
it("signOut resolves without a federated session store", async () => {
|
|
101
91
|
const caps = buildRelationalDeletionCapabilities();
|
|
102
|
-
await caps.signOut("user-123",
|
|
92
|
+
await caps.signOut("user-123", noopLogger);
|
|
103
93
|
});
|
|
104
94
|
});
|
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";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { Readable } from "node:stream";
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
|
-
import
|
|
4
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
5
5
|
import type { StorageService } from "@remit/storage-service";
|
|
6
6
|
import type { CascadeServices } from "../cascade.js";
|
|
7
7
|
import type { AccountExportEvent } from "../events.js";
|
|
@@ -10,16 +10,6 @@ import {
|
|
|
10
10
|
processAccountExport,
|
|
11
11
|
} from "./account-export.js";
|
|
12
12
|
|
|
13
|
-
const noopLog = {
|
|
14
|
-
info: () => {},
|
|
15
|
-
warn: () => {},
|
|
16
|
-
error: () => {},
|
|
17
|
-
debug: () => {},
|
|
18
|
-
fatal: () => {},
|
|
19
|
-
trace: () => {},
|
|
20
|
-
child: () => noopLog,
|
|
21
|
-
} as unknown as Logger;
|
|
22
|
-
|
|
23
13
|
interface Update {
|
|
24
14
|
state: string;
|
|
25
15
|
objectKey?: string;
|
|
@@ -84,7 +74,7 @@ const event: AccountExportEvent = {
|
|
|
84
74
|
describe("processAccountExport", () => {
|
|
85
75
|
it("drives the request Processing then Ready and stores the archive key", async () => {
|
|
86
76
|
const updates: Update[] = [];
|
|
87
|
-
await processAccountExport(event,
|
|
77
|
+
await processAccountExport(event, noopLogger, buildDeps(updates));
|
|
88
78
|
|
|
89
79
|
assert.deepEqual(
|
|
90
80
|
updates.map((u) => u.state),
|
|
@@ -103,7 +93,7 @@ describe("processAccountExport", () => {
|
|
|
103
93
|
let retrieved = 0;
|
|
104
94
|
await processAccountExport(
|
|
105
95
|
event,
|
|
106
|
-
|
|
96
|
+
noopLogger,
|
|
107
97
|
buildDeps(updates, {
|
|
108
98
|
retrieveMessageBodyStream: async (
|
|
109
99
|
_cfg: string,
|
|
@@ -125,7 +115,7 @@ describe("processAccountExport", () => {
|
|
|
125
115
|
await assert.rejects(
|
|
126
116
|
processAccountExport(
|
|
127
117
|
event,
|
|
128
|
-
|
|
118
|
+
noopLogger,
|
|
129
119
|
buildDeps(updates, {
|
|
130
120
|
storeExportArchiveStream: async (
|
|
131
121
|
_cfg: string,
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
3
|
import type { SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
-
import
|
|
4
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
5
5
|
import type { CascadeServices } from "../cascade.js";
|
|
6
6
|
import type { AccountDeleteEvent } from "../events.js";
|
|
7
7
|
import { processAccountFanout } from "./account-fanout.js";
|
|
8
8
|
|
|
9
|
-
const noopLog = {
|
|
10
|
-
info: () => {},
|
|
11
|
-
warn: () => {},
|
|
12
|
-
error: () => {},
|
|
13
|
-
debug: () => {},
|
|
14
|
-
fatal: () => {},
|
|
15
|
-
trace: () => {},
|
|
16
|
-
child: () => noopLog,
|
|
17
|
-
} as unknown as Logger;
|
|
18
|
-
|
|
19
9
|
// A CascadeServices fake with one account and no data: enough for
|
|
20
10
|
// enumerateCascadeEntities to yield a single Account row (so the stop-signal
|
|
21
11
|
// loop has one accountId to iterate) without standing up a database.
|
|
@@ -81,7 +71,7 @@ describe("processAccountFanout — imap-worker stop signal on account delete", (
|
|
|
81
71
|
delete process.env.SQS_QUEUE_URL_IMAP_WORKER;
|
|
82
72
|
const sent: SentMessage[] = [];
|
|
83
73
|
|
|
84
|
-
await processAccountFanout(deleteEvent,
|
|
74
|
+
await processAccountFanout(deleteEvent, noopLogger, {
|
|
85
75
|
services: buildServices(),
|
|
86
76
|
sqs: recordingSqs(sent),
|
|
87
77
|
signOut: async () => {},
|
|
@@ -99,7 +89,7 @@ describe("processAccountFanout — imap-worker stop signal on account delete", (
|
|
|
99
89
|
it("enqueues one stop signal per account when a queue is configured", async () => {
|
|
100
90
|
const sent: SentMessage[] = [];
|
|
101
91
|
|
|
102
|
-
await processAccountFanout(deleteEvent,
|
|
92
|
+
await processAccountFanout(deleteEvent, noopLogger, {
|
|
103
93
|
services: buildServices(),
|
|
104
94
|
sqs: recordingSqs(sent),
|
|
105
95
|
signOut: async () => {},
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
-
import
|
|
4
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
5
5
|
import type { CascadeServices } from "../cascade.js";
|
|
6
6
|
import type { AccountDataPurgeEvent } from "../events.js";
|
|
7
7
|
import {
|
|
@@ -9,16 +9,6 @@ import {
|
|
|
9
9
|
processAccountDataPurge,
|
|
10
10
|
} from "./account-purge.js";
|
|
11
11
|
|
|
12
|
-
const noopLog = {
|
|
13
|
-
info: () => {},
|
|
14
|
-
warn: () => {},
|
|
15
|
-
error: () => {},
|
|
16
|
-
debug: () => {},
|
|
17
|
-
fatal: () => {},
|
|
18
|
-
trace: () => {},
|
|
19
|
-
child: () => noopLog,
|
|
20
|
-
} as unknown as Logger;
|
|
21
|
-
|
|
22
12
|
interface Sent {
|
|
23
13
|
queueUrl: string | undefined;
|
|
24
14
|
body: Record<string, unknown>;
|
|
@@ -91,7 +81,7 @@ const baseDeps = (
|
|
|
91
81
|
describe("processAccountDataPurge", () => {
|
|
92
82
|
it("enqueues vector deletes then a subtrees batch and one container leftover", async () => {
|
|
93
83
|
const sent: Sent[] = [];
|
|
94
|
-
await processAccountDataPurge(event,
|
|
84
|
+
await processAccountDataPurge(event, noopLogger, baseDeps(sent));
|
|
95
85
|
|
|
96
86
|
const vectorBatch = sent.find((m) => m.queueUrl === "http://queue/search");
|
|
97
87
|
assert.equal(vectorBatch?.entries?.length, 2, "one entry per message");
|
|
@@ -110,7 +100,7 @@ describe("processAccountDataPurge", () => {
|
|
|
110
100
|
const sent: Sent[] = [];
|
|
111
101
|
await processAccountDataPurge(
|
|
112
102
|
event,
|
|
113
|
-
|
|
103
|
+
noopLogger,
|
|
114
104
|
baseDeps(sent, { dataBackend: "sqlite" }),
|
|
115
105
|
);
|
|
116
106
|
|
|
@@ -129,7 +119,7 @@ describe("processAccountDataPurge", () => {
|
|
|
129
119
|
|
|
130
120
|
it("drops manifest rows outside the account's mailbox set", async () => {
|
|
131
121
|
const sent: Sent[] = [];
|
|
132
|
-
await processAccountDataPurge(event,
|
|
122
|
+
await processAccountDataPurge(event, noopLogger, {
|
|
133
123
|
...baseDeps(sent),
|
|
134
124
|
services: buildServices([
|
|
135
125
|
{ threadMessageId: "tm-1", messageId: "msg-1", mailboxId: "mbx-1" },
|
|
@@ -143,7 +133,7 @@ describe("processAccountDataPurge", () => {
|
|
|
143
133
|
|
|
144
134
|
it("no-ops when the account is already gone", async () => {
|
|
145
135
|
const sent: Sent[] = [];
|
|
146
|
-
await processAccountDataPurge(event,
|
|
136
|
+
await processAccountDataPurge(event, noopLogger, {
|
|
147
137
|
...baseDeps(sent),
|
|
148
138
|
services: {
|
|
149
139
|
accountService: {
|
|
@@ -163,7 +153,7 @@ describe("processAccountDataPurge", () => {
|
|
|
163
153
|
it("rethrows a non-NotFound describe error", async () => {
|
|
164
154
|
const sent: Sent[] = [];
|
|
165
155
|
await assert.rejects(
|
|
166
|
-
processAccountDataPurge(event,
|
|
156
|
+
processAccountDataPurge(event, noopLogger, {
|
|
167
157
|
...baseDeps(sent),
|
|
168
158
|
services: {
|
|
169
159
|
accountService: {
|
|
@@ -181,7 +171,7 @@ describe("processAccountDataPurge", () => {
|
|
|
181
171
|
it("throws when a vector-delete batch reports failed entries", async () => {
|
|
182
172
|
const sent: Sent[] = [];
|
|
183
173
|
await assert.rejects(
|
|
184
|
-
processAccountDataPurge(event,
|
|
174
|
+
processAccountDataPurge(event, noopLogger, {
|
|
185
175
|
...baseDeps(sent),
|
|
186
176
|
sqs: recordingSqs(sent, true),
|
|
187
177
|
}),
|
|
@@ -2,20 +2,10 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { RemitClient } from "@remit/backend/client";
|
|
4
4
|
import type { OrganizeMatchDeps } from "@remit/backend/organize";
|
|
5
|
-
import
|
|
5
|
+
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
6
6
|
import type { OrganizeJobEvent } from "../events.js";
|
|
7
7
|
import { processOrganizeJob } from "./organize-job.js";
|
|
8
8
|
|
|
9
|
-
const noopLog = {
|
|
10
|
-
info: () => {},
|
|
11
|
-
warn: () => {},
|
|
12
|
-
error: () => {},
|
|
13
|
-
debug: () => {},
|
|
14
|
-
fatal: () => {},
|
|
15
|
-
trace: () => {},
|
|
16
|
-
child: () => noopLog,
|
|
17
|
-
} as unknown as Logger;
|
|
18
|
-
|
|
19
9
|
interface Update {
|
|
20
10
|
state: string;
|
|
21
11
|
errorMessage?: string;
|
|
@@ -77,7 +67,7 @@ describe("processOrganizeJob (reader #463)", () => {
|
|
|
77
67
|
it("fails the job on a refused rule and acknowledges the record", async () => {
|
|
78
68
|
const updates: Update[] = [];
|
|
79
69
|
|
|
80
|
-
await processOrganizeJob(event,
|
|
70
|
+
await processOrganizeJob(event, noopLogger, {
|
|
81
71
|
client: jobClient(updates, [{ field: "HasWords", value: "invoice" }]),
|
|
82
72
|
matchDeps: explodingMatchDeps(),
|
|
83
73
|
});
|
|
@@ -97,7 +87,7 @@ describe("processOrganizeJob (reader #463)", () => {
|
|
|
97
87
|
const updates: Update[] = [];
|
|
98
88
|
|
|
99
89
|
await assert.rejects(
|
|
100
|
-
processOrganizeJob(event,
|
|
90
|
+
processOrganizeJob(event, noopLogger, {
|
|
101
91
|
client: jobClient(updates, [
|
|
102
92
|
{ field: "Subject", value: "reservation" },
|
|
103
93
|
]),
|
|
@@ -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
|
+
};
|