@remit/backend 0.0.114 → 0.0.116
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 +5 -1
- package/src/handlers/address.test.ts +99 -1
- package/src/handlers/address.ts +59 -1
- package/src/handlers/unified-threads.test.ts +49 -0
- package/src/handlers/unified-threads.ts +35 -3
- package/src/service/sender-category.test.ts +235 -0
- package/src/service/sender-category.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.116",
|
|
4
4
|
"description": "Remit Mail Inspector API backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"types": "./src/service/organize.ts",
|
|
28
28
|
"default": "./src/service/organize.ts"
|
|
29
29
|
},
|
|
30
|
+
"./sender-category": {
|
|
31
|
+
"types": "./src/service/sender-category.ts",
|
|
32
|
+
"default": "./src/service/sender-category.ts"
|
|
33
|
+
},
|
|
30
34
|
"./trigger-sync": {
|
|
31
35
|
"types": "./src/service/trigger-sync.ts",
|
|
32
36
|
"default": "./src/service/trigger-sync.ts"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, describe, it } from "node:test";
|
|
2
|
+
import { afterEach, describe, it, mock } from "node:test";
|
|
3
|
+
import type { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
3
4
|
import type {
|
|
4
5
|
AddressResponse,
|
|
5
6
|
UpdateAddressInput,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type RemitClient,
|
|
18
19
|
setClient,
|
|
19
20
|
} from "../service/data-client.js";
|
|
21
|
+
import { sqsClient } from "../service/sqs.js";
|
|
20
22
|
import { AddressDetailOperations, AddressOperations } from "./address.js";
|
|
21
23
|
|
|
22
24
|
const searchAddresses =
|
|
@@ -81,6 +83,7 @@ const clientReturning = (items: AddressItem[], seen: Listing[]): RemitClient =>
|
|
|
81
83
|
}) as unknown as RemitClient;
|
|
82
84
|
|
|
83
85
|
afterEach(() => {
|
|
86
|
+
mock.restoreAll();
|
|
84
87
|
_resetForTest();
|
|
85
88
|
});
|
|
86
89
|
|
|
@@ -243,3 +246,98 @@ describe("AddressDetailOperations_updateAddress removing a flag (#615)", () => {
|
|
|
243
246
|
assert.equal(response.flags.muted, undefined);
|
|
244
247
|
});
|
|
245
248
|
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The retroactive half of a sender-category override (#415). Setting the flag
|
|
252
|
+
* enqueues one back-apply carrying the category it was set to; clearing it —
|
|
253
|
+
* the revert-to-auto route — enqueues nothing, because the classifier's own
|
|
254
|
+
* answer for mail already filed cannot be re-derived without every body.
|
|
255
|
+
*/
|
|
256
|
+
describe("AddressDetailOperations_updateAddress category back-apply (#415)", () => {
|
|
257
|
+
const withStubbedQueue = (enqueued: SendMessageCommand[]): void => {
|
|
258
|
+
process.env.SQS_QUEUE_URL_ACCOUNT_FANOUT =
|
|
259
|
+
"http://localhost:9324/queue/account-fanout-test";
|
|
260
|
+
mock.method(sqsClient, "send", async (command: SendMessageCommand) => {
|
|
261
|
+
enqueued.push(command);
|
|
262
|
+
return {};
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
it("enqueues a back-apply naming the sender and the set it was fired for", async () => {
|
|
267
|
+
const enqueued: SendMessageCommand[] = [];
|
|
268
|
+
withStubbedQueue(enqueued);
|
|
269
|
+
setClient(clientHolding(address({ flags: {} }), []));
|
|
270
|
+
|
|
271
|
+
await updateAddress(
|
|
272
|
+
updateContextFor({
|
|
273
|
+
flags: { category: { value: "newsletter", setAt: 20 } },
|
|
274
|
+
}),
|
|
275
|
+
eventFor(SUB),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
assert.equal(enqueued.length, 1);
|
|
279
|
+
assert.deepEqual(JSON.parse(String(enqueued[0]?.input.MessageBody)), {
|
|
280
|
+
type: "SenderCategoryBackApply",
|
|
281
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
282
|
+
addressId: "addr-1",
|
|
283
|
+
normalizedEmail: "amsterdam@pocahondas.nl",
|
|
284
|
+
category: "newsletter",
|
|
285
|
+
categorySetAt: 20,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("says the override is saved and names the retry when the queue refuses", async () => {
|
|
290
|
+
process.env.SQS_QUEUE_URL_ACCOUNT_FANOUT =
|
|
291
|
+
"http://localhost:9324/queue/account-fanout-test";
|
|
292
|
+
mock.method(sqsClient, "send", async () => {
|
|
293
|
+
throw new Error("AWS.SimpleQueueService.NonExistentQueue");
|
|
294
|
+
});
|
|
295
|
+
setClient(clientHolding(address({ flags: {} }), []));
|
|
296
|
+
|
|
297
|
+
await assert.rejects(
|
|
298
|
+
updateAddress(
|
|
299
|
+
updateContextFor({
|
|
300
|
+
flags: { category: { value: "newsletter", setAt: 20 } },
|
|
301
|
+
}),
|
|
302
|
+
eventFor(SUB),
|
|
303
|
+
),
|
|
304
|
+
(error: Error) => {
|
|
305
|
+
assert.match(error.message, /saved/);
|
|
306
|
+
assert.match(error.message, /same category again/);
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("enqueues nothing when the override is cleared back to auto", async () => {
|
|
313
|
+
const enqueued: SendMessageCommand[] = [];
|
|
314
|
+
withStubbedQueue(enqueued);
|
|
315
|
+
setClient(
|
|
316
|
+
clientHolding(
|
|
317
|
+
address({ flags: { category: { value: "newsletter", setAt: 10 } } }),
|
|
318
|
+
[],
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const response = await updateAddress(
|
|
323
|
+
updateContextFor({ clearFlags: ["category"] }),
|
|
324
|
+
eventFor(SUB),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
assert.equal(response.flags.category, undefined);
|
|
328
|
+
assert.deepEqual(enqueued, []);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("enqueues nothing for a flag that has no bearing on classification", async () => {
|
|
332
|
+
const enqueued: SendMessageCommand[] = [];
|
|
333
|
+
withStubbedQueue(enqueued);
|
|
334
|
+
setClient(clientHolding(address({ flags: {} }), []));
|
|
335
|
+
|
|
336
|
+
await updateAddress(
|
|
337
|
+
updateContextFor({ flags: { muted: { value: true, setAt: 20 } } }),
|
|
338
|
+
eventFor(SUB),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
assert.deepEqual(enqueued, []);
|
|
342
|
+
});
|
|
343
|
+
});
|
package/src/handlers/address.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
1
2
|
import type {
|
|
2
3
|
AddressResponse,
|
|
3
4
|
UpdateAddressInput,
|
|
4
5
|
} from "@remit/api-openapi-types";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
AddressFlags,
|
|
8
|
+
AddressItem,
|
|
9
|
+
FlagsMergePatch,
|
|
10
|
+
} from "@remit/data-ports";
|
|
6
11
|
import { ForbiddenError } from "@remit/data-ports/errors";
|
|
7
12
|
import { AddressFlagKey } from "@remit/domain-enums";
|
|
8
13
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
14
|
+
import { env } from "expect-env";
|
|
9
15
|
import type { Context } from "openapi-backend";
|
|
10
16
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
17
|
import { getClient } from "../service/data-client.js";
|
|
18
|
+
import { sqsClient } from "../service/sqs.js";
|
|
12
19
|
import type {
|
|
13
20
|
AddressDetailOperationIds,
|
|
14
21
|
AddressOperationIds,
|
|
@@ -73,6 +80,29 @@ export const buildFlagsPatch = (
|
|
|
73
80
|
return patch as FlagsMergePatch;
|
|
74
81
|
};
|
|
75
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The category flag a back-apply should be enqueued for, or `undefined` when
|
|
85
|
+
* this patch asks for none (issue #415).
|
|
86
|
+
*
|
|
87
|
+
* A SET fires one; a CLEAR — `null` in the patch, whether it came from
|
|
88
|
+
* `clearFlags` or from the nullable flag value — never does. Reverting a sender
|
|
89
|
+
* to auto-classification says nothing about what the classifier would have
|
|
90
|
+
* answered on mail already filed, and re-deriving that would need every
|
|
91
|
+
* message's body back; the revert takes effect on the sender's next message,
|
|
92
|
+
* exactly as the override itself did before this.
|
|
93
|
+
*
|
|
94
|
+
* The whole flag, not just its value: `setAt` identifies WHICH set the job was
|
|
95
|
+
* fired for, and the worker refuses to apply a job whose set is no longer the
|
|
96
|
+
* one standing on the Address.
|
|
97
|
+
*/
|
|
98
|
+
export const backApplyCategoryFlag = (
|
|
99
|
+
patch: FlagsMergePatch,
|
|
100
|
+
): NonNullable<AddressFlags["category"]> | undefined => {
|
|
101
|
+
const flag = patch.category;
|
|
102
|
+
if (flag === null || flag === undefined) return undefined;
|
|
103
|
+
return flag;
|
|
104
|
+
};
|
|
105
|
+
|
|
76
106
|
export const AddressOperations: Record<
|
|
77
107
|
AddressOperationIds,
|
|
78
108
|
OperationHandler<AddressOperationIds>
|
|
@@ -137,6 +167,34 @@ export const AddressDetailOperations: Record<
|
|
|
137
167
|
addressId,
|
|
138
168
|
patch,
|
|
139
169
|
);
|
|
170
|
+
|
|
171
|
+
const flag = backApplyCategoryFlag(patch);
|
|
172
|
+
if (flag) {
|
|
173
|
+
// The flag itself is already durable. A queue failure here costs the
|
|
174
|
+
// user the retroactive pass and nothing else, so the 500 says so and
|
|
175
|
+
// names the retry — re-sending the same category enqueues a fresh job.
|
|
176
|
+
await sqsClient
|
|
177
|
+
.send(
|
|
178
|
+
new SendMessageCommand({
|
|
179
|
+
QueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
|
|
180
|
+
MessageBody: JSON.stringify({
|
|
181
|
+
type: "SenderCategoryBackApply",
|
|
182
|
+
accountConfigId,
|
|
183
|
+
addressId,
|
|
184
|
+
normalizedEmail: updated.normalizedEmail,
|
|
185
|
+
category: flag.value,
|
|
186
|
+
categorySetAt: flag.setAt,
|
|
187
|
+
}),
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
.catch((cause: unknown) => {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`The ${flag.value} override is saved and applies to this sender's next message, but the pass over their existing mail could not be started. Setting the same category again retries it.`,
|
|
193
|
+
{ cause },
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
140
198
|
return toAddressResponse(updated);
|
|
141
199
|
},
|
|
142
200
|
};
|
|
@@ -467,6 +467,7 @@ describe("executeUnifiedThreadListing", () => {
|
|
|
467
467
|
: [
|
|
468
468
|
mailbox("m2-inbox", "a2", "INBOX"),
|
|
469
469
|
mailbox("m2-archive", "a2", "Archive"),
|
|
470
|
+
mailbox("m2-trash", "a2", "Trash", ["Trash"]),
|
|
470
471
|
],
|
|
471
472
|
},
|
|
472
473
|
accountSetting: { listByAccountConfig: async () => [] },
|
|
@@ -730,4 +731,52 @@ describe("executeUnifiedThreadListing", () => {
|
|
|
730
731
|
assert.deepEqual(response.items, []);
|
|
731
732
|
assert.equal(response.count, 0);
|
|
732
733
|
});
|
|
734
|
+
|
|
735
|
+
// #313: the Spam offer stated the junk share of the page the client had
|
|
736
|
+
// loaded, offered as a folder total. A count of one folder under the search's
|
|
737
|
+
// own criteria is what makes that number the folder's.
|
|
738
|
+
test("a folder scope narrows the search and the count to that folder", async () => {
|
|
739
|
+
const calls: Call[] = [];
|
|
740
|
+
const response = await executeUnifiedThreadListing(
|
|
741
|
+
buildListingClient(calls, 12),
|
|
742
|
+
CONFIG_ID,
|
|
743
|
+
{ ...params({ mailboxId: "m2-archive", count: true }), searchText: "x" },
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const searching = calls.find((call) => call.mode === "searchByDate");
|
|
747
|
+
const counting = calls.find((call) => call.mode === "count");
|
|
748
|
+
assert.deepEqual([...(searching?.mailboxIds ?? [])], ["m2-archive"]);
|
|
749
|
+
assert.deepEqual([...(counting?.mailboxIds ?? [])], ["m2-archive"]);
|
|
750
|
+
assert.equal(response.count, 12);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("a folder scope intersects the account scope rather than replacing it", async () => {
|
|
754
|
+
const calls: Call[] = [];
|
|
755
|
+
const response = await executeUnifiedThreadListing(
|
|
756
|
+
buildListingClient(calls, 99),
|
|
757
|
+
CONFIG_ID,
|
|
758
|
+
{
|
|
759
|
+
...params({ accountId: "a1", mailboxId: "m2-archive", count: true }),
|
|
760
|
+
searchText: "x",
|
|
761
|
+
},
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
assert.deepEqual(calls, []);
|
|
765
|
+
assert.equal(response.count, 0);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// Trash is the one folder the unscoped search never reaches, so a caller
|
|
769
|
+
// asking what it holds of a search is asking about mail the search does not
|
|
770
|
+
// see. Answering with the folder's own contents would widen the scope.
|
|
771
|
+
test("a folder the search scope excludes matches nothing", async () => {
|
|
772
|
+
const calls: Call[] = [];
|
|
773
|
+
const response = await executeUnifiedThreadListing(
|
|
774
|
+
buildListingClient(calls, 99),
|
|
775
|
+
CONFIG_ID,
|
|
776
|
+
{ ...params({ mailboxId: "m2-trash", count: true }), searchText: "x" },
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
assert.deepEqual(calls, []);
|
|
780
|
+
assert.equal(response.count, 0);
|
|
781
|
+
});
|
|
733
782
|
});
|
|
@@ -391,6 +391,29 @@ export const scopeToAccount = (
|
|
|
391
391
|
);
|
|
392
392
|
};
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Narrow a mailbox scope to one folder.
|
|
396
|
+
*
|
|
397
|
+
* Intersects rather than replaces: a folder the mode's own scope does not hold —
|
|
398
|
+
* Trash, a muted folder, one of a muted account, one of another account when
|
|
399
|
+
* `accountId` narrowed first — leaves the empty set and matches nothing. A
|
|
400
|
+
* caller asking what one folder holds of a search must not be handed a folder
|
|
401
|
+
* the search itself does not reach.
|
|
402
|
+
*
|
|
403
|
+
* This is what makes a per-folder count of a cross-account search exact. The
|
|
404
|
+
* spam offer states how much of the search sits in a junk folder; before the
|
|
405
|
+
* parameter existed the only number available was the junk share of the page
|
|
406
|
+
* the client had loaded, which is a page length presented as a folder total
|
|
407
|
+
* (#313).
|
|
408
|
+
*/
|
|
409
|
+
export const scopeToMailbox = (
|
|
410
|
+
mailboxIds: Set<string>,
|
|
411
|
+
mailboxId: string | undefined,
|
|
412
|
+
): Set<string> => {
|
|
413
|
+
if (mailboxId === undefined) return mailboxIds;
|
|
414
|
+
return new Set(mailboxIds.has(mailboxId) ? [mailboxId] : []);
|
|
415
|
+
};
|
|
416
|
+
|
|
394
417
|
/**
|
|
395
418
|
* Minimal client surface `executeUnifiedThreadListing` needs, declared
|
|
396
419
|
* structurally (like `ThreadSearchClient`) so the mode selection, the row
|
|
@@ -428,6 +451,7 @@ export type UnifiedThreadParams = {
|
|
|
428
451
|
from?: string;
|
|
429
452
|
subject?: string;
|
|
430
453
|
accountId?: string;
|
|
454
|
+
mailboxId?: string;
|
|
431
455
|
category?: MessageCategory[];
|
|
432
456
|
unread?: boolean;
|
|
433
457
|
attachments?: boolean;
|
|
@@ -462,10 +486,15 @@ export const executeUnifiedThreadListing = async (
|
|
|
462
486
|
virtualCopyMailboxIds,
|
|
463
487
|
} = await buildInboxMailboxMap(accountConfigId, client);
|
|
464
488
|
|
|
465
|
-
// The account
|
|
466
|
-
// search and the count all narrow by the same rule.
|
|
489
|
+
// The account and folder scopes apply before the mode picks a set, so the
|
|
490
|
+
// listing, the search and the count all narrow by the same rule. Account
|
|
491
|
+
// first, folder second: the folder intersects what the account left, so a
|
|
492
|
+
// folder of another account narrows to nothing rather than reinstating one.
|
|
467
493
|
const narrow = (mailboxIds: Set<string>): Set<string> =>
|
|
468
|
-
|
|
494
|
+
scopeToMailbox(
|
|
495
|
+
scopeToAccount(mailboxIds, mailboxIdToAccountId, params.accountId),
|
|
496
|
+
params.mailboxId,
|
|
497
|
+
);
|
|
469
498
|
const inboxMailboxIds = narrow(everyInboxMailboxId);
|
|
470
499
|
const starredMailboxIds = narrow(everyStarredMailboxId);
|
|
471
500
|
const searchMailboxIds = narrow(everySearchMailboxId);
|
|
@@ -581,6 +610,7 @@ export const UnifiedThreadOperations: Record<
|
|
|
581
610
|
from,
|
|
582
611
|
subject,
|
|
583
612
|
accountId,
|
|
613
|
+
mailboxId,
|
|
584
614
|
category,
|
|
585
615
|
unread,
|
|
586
616
|
attachments,
|
|
@@ -596,6 +626,7 @@ export const UnifiedThreadOperations: Record<
|
|
|
596
626
|
from?: string;
|
|
597
627
|
subject?: string;
|
|
598
628
|
accountId?: string;
|
|
629
|
+
mailboxId?: string;
|
|
599
630
|
category?: MessageCategory | MessageCategory[];
|
|
600
631
|
unread?: boolean | string;
|
|
601
632
|
attachments?: boolean | string;
|
|
@@ -624,6 +655,7 @@ export const UnifiedThreadOperations: Record<
|
|
|
624
655
|
from: fromText || undefined,
|
|
625
656
|
subject: subjectText || undefined,
|
|
626
657
|
accountId: accountId || undefined,
|
|
658
|
+
mailboxId: mailboxId || undefined,
|
|
627
659
|
category: toArray(category),
|
|
628
660
|
unread: toBoolean(unread),
|
|
629
661
|
attachments: toBoolean(attachments),
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
ThreadMessageFieldTerm,
|
|
5
|
+
ThreadMessageItem,
|
|
6
|
+
UpdateMessageInput,
|
|
7
|
+
UpdateThreadMessageInput,
|
|
8
|
+
} from "@remit/data-ports";
|
|
9
|
+
import {
|
|
10
|
+
backApplySenderCategory,
|
|
11
|
+
SENDER_CATEGORY_BACKAPPLY_LIMIT,
|
|
12
|
+
type SenderCategoryBackApplyDeps,
|
|
13
|
+
} from "./sender-category.js";
|
|
14
|
+
|
|
15
|
+
const ACCOUNT_CONFIG_ID = "cfg-415";
|
|
16
|
+
const SENDER = "post@ns.nl";
|
|
17
|
+
|
|
18
|
+
const row = (n: number, over: Partial<ThreadMessageItem> = {}) =>
|
|
19
|
+
({
|
|
20
|
+
threadMessageId: `tm-${n}`,
|
|
21
|
+
threadId: `th-${n}`,
|
|
22
|
+
messageId: `msg-${n}`,
|
|
23
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
24
|
+
mailboxId: "mbx-inbox",
|
|
25
|
+
fromEmail: SENDER,
|
|
26
|
+
fromName: "NS Reizigers",
|
|
27
|
+
subject: `reisoverzicht ${n}`,
|
|
28
|
+
category: "personal",
|
|
29
|
+
sentDate: 2_000 - n,
|
|
30
|
+
isRead: false,
|
|
31
|
+
isDeleted: false,
|
|
32
|
+
hasStars: false,
|
|
33
|
+
hasAttachment: false,
|
|
34
|
+
...over,
|
|
35
|
+
}) as unknown as ThreadMessageItem;
|
|
36
|
+
|
|
37
|
+
interface Writes {
|
|
38
|
+
messages: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
39
|
+
threadRows: Array<{
|
|
40
|
+
threadMessageId: string;
|
|
41
|
+
input: UpdateThreadMessageInput;
|
|
42
|
+
}>;
|
|
43
|
+
terms: ThreadMessageFieldTerm[][];
|
|
44
|
+
pageSizes: Array<number | undefined>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A store that pages its rows the way the real listing does — newest first,
|
|
49
|
+
* `limit` rows per page, a continuation token while more remain — and that
|
|
50
|
+
* answers `findAllByMessageId` from the same rows, so a re-label is provably
|
|
51
|
+
* written to both halves of the pair.
|
|
52
|
+
*/
|
|
53
|
+
const storeOf = (
|
|
54
|
+
rows: ThreadMessageItem[],
|
|
55
|
+
writes: Writes,
|
|
56
|
+
): SenderCategoryBackApplyDeps => ({
|
|
57
|
+
client: {
|
|
58
|
+
message: {
|
|
59
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
60
|
+
writes.messages.push({ messageId, input });
|
|
61
|
+
return {} as never;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
threadMessage: {
|
|
65
|
+
listByFieldTerms: async (
|
|
66
|
+
_accountConfigId: string,
|
|
67
|
+
terms: readonly ThreadMessageFieldTerm[],
|
|
68
|
+
options?: { limit?: number; continuationToken?: string },
|
|
69
|
+
) => {
|
|
70
|
+
writes.terms.push([...terms]);
|
|
71
|
+
writes.pageSizes.push(options?.limit);
|
|
72
|
+
const from = Number(options?.continuationToken ?? 0);
|
|
73
|
+
const size = options?.limit ?? rows.length;
|
|
74
|
+
const page = rows.slice(from, from + size);
|
|
75
|
+
const next = from + page.length;
|
|
76
|
+
return {
|
|
77
|
+
items: page,
|
|
78
|
+
continuationToken: next < rows.length ? String(next) : undefined,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
findAllByMessageId: async (_accountConfigId: string, messageId: string) =>
|
|
82
|
+
rows.filter((r) => r.messageId === messageId),
|
|
83
|
+
update: async (
|
|
84
|
+
_accountConfigId: string,
|
|
85
|
+
threadMessageId: string,
|
|
86
|
+
input: UpdateThreadMessageInput,
|
|
87
|
+
) => {
|
|
88
|
+
writes.threadRows.push({ threadMessageId, input });
|
|
89
|
+
return {} as never;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
} as unknown as SenderCategoryBackApplyDeps["client"],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const emptyWrites = (): Writes => ({
|
|
96
|
+
messages: [],
|
|
97
|
+
threadRows: [],
|
|
98
|
+
terms: [],
|
|
99
|
+
pageSizes: [],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("backApplySenderCategory (#415)", () => {
|
|
103
|
+
it("re-labels mail the classifier already decided, on both halves of the pair", async () => {
|
|
104
|
+
const writes = emptyWrites();
|
|
105
|
+
const rows = [row(1), row(2), row(3)];
|
|
106
|
+
|
|
107
|
+
const result = await backApplySenderCategory(
|
|
108
|
+
storeOf(rows, writes),
|
|
109
|
+
ACCOUNT_CONFIG_ID,
|
|
110
|
+
SENDER,
|
|
111
|
+
"newsletter",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(result, { matched: 3, applied: 3, failed: 0 });
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
writes.messages.map((w) => w.messageId),
|
|
117
|
+
["msg-1", "msg-2", "msg-3"],
|
|
118
|
+
);
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
writes.messages.map((w) => w.input.category),
|
|
121
|
+
["newsletter", "newsletter", "newsletter"],
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
writes.threadRows.map((w) => w.threadMessageId),
|
|
125
|
+
["tm-1", "tm-2", "tm-3"],
|
|
126
|
+
);
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
writes.threadRows.map((w) => w.input.category),
|
|
129
|
+
["newsletter", "newsletter", "newsletter"],
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("stops at the bound, keeping the newest of the sender's mail", async () => {
|
|
134
|
+
const writes = emptyWrites();
|
|
135
|
+
const rows = Array.from(
|
|
136
|
+
{ length: SENDER_CATEGORY_BACKAPPLY_LIMIT + 25 },
|
|
137
|
+
(_, i) => row(i + 1),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const result = await backApplySenderCategory(
|
|
141
|
+
storeOf(rows, writes),
|
|
142
|
+
ACCOUNT_CONFIG_ID,
|
|
143
|
+
SENDER,
|
|
144
|
+
"newsletter",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
assert.equal(result.matched, SENDER_CATEGORY_BACKAPPLY_LIMIT);
|
|
148
|
+
assert.equal(result.applied, SENDER_CATEGORY_BACKAPPLY_LIMIT);
|
|
149
|
+
assert.equal(writes.messages.length, SENDER_CATEGORY_BACKAPPLY_LIMIT);
|
|
150
|
+
assert.equal(writes.messages[0]?.messageId, "msg-1");
|
|
151
|
+
assert.equal(
|
|
152
|
+
writes.messages.at(-1)?.messageId,
|
|
153
|
+
`msg-${SENDER_CATEGORY_BACKAPPLY_LIMIT}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("refines the store's substring narrowing to an exact sender match", async () => {
|
|
158
|
+
const writes = emptyWrites();
|
|
159
|
+
const rows = [
|
|
160
|
+
row(1),
|
|
161
|
+
row(2, { messageId: "msg-lookalike", fromEmail: "no-post@ns.nl" }),
|
|
162
|
+
row(3, { messageId: "msg-name-only", fromEmail: "spoof@example.com" }),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const result = await backApplySenderCategory(
|
|
166
|
+
storeOf(rows, writes),
|
|
167
|
+
ACCOUNT_CONFIG_ID,
|
|
168
|
+
SENDER,
|
|
169
|
+
"newsletter",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
assert.equal(result.matched, 1);
|
|
173
|
+
assert.deepEqual(
|
|
174
|
+
writes.messages.map((w) => w.messageId),
|
|
175
|
+
["msg-1"],
|
|
176
|
+
);
|
|
177
|
+
assert.deepEqual(writes.terms[0], [{ field: "sender", contains: SENDER }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("pages the narrowed listing until the bound fills", async () => {
|
|
181
|
+
const writes = emptyWrites();
|
|
182
|
+
const rows = Array.from({ length: 12 }, (_, i) =>
|
|
183
|
+
i % 2 === 0
|
|
184
|
+
? row(i + 1)
|
|
185
|
+
: row(i + 1, {
|
|
186
|
+
messageId: `msg-other-${i}`,
|
|
187
|
+
fromEmail: "someone@example.com",
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const result = await backApplySenderCategory(
|
|
192
|
+
storeOf(rows, writes),
|
|
193
|
+
ACCOUNT_CONFIG_ID,
|
|
194
|
+
SENDER,
|
|
195
|
+
"newsletter",
|
|
196
|
+
4,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
assert.equal(result.matched, 4);
|
|
200
|
+
assert.ok(
|
|
201
|
+
writes.terms.length > 1,
|
|
202
|
+
"a page of narrowed rows that yields fewer matches than the bound must be followed by the next page",
|
|
203
|
+
);
|
|
204
|
+
assert.deepEqual(writes.pageSizes, Array(writes.terms.length).fill(4));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("counts a poisoned message as failed and re-labels the rest", async () => {
|
|
208
|
+
const writes = emptyWrites();
|
|
209
|
+
const rows = [row(1), row(2), row(3)];
|
|
210
|
+
const store = storeOf(rows, writes);
|
|
211
|
+
const threadMessage = store.client.threadMessage as unknown as Record<
|
|
212
|
+
string,
|
|
213
|
+
unknown
|
|
214
|
+
>;
|
|
215
|
+
const findAll = threadMessage.findAllByMessageId as (
|
|
216
|
+
a: string,
|
|
217
|
+
b: string,
|
|
218
|
+
) => Promise<ThreadMessageItem[]>;
|
|
219
|
+
threadMessage.findAllByMessageId = async (a: string, b: string) =>
|
|
220
|
+
b === "msg-2" ? [] : findAll(a, b);
|
|
221
|
+
|
|
222
|
+
const result = await backApplySenderCategory(
|
|
223
|
+
store,
|
|
224
|
+
ACCOUNT_CONFIG_ID,
|
|
225
|
+
SENDER,
|
|
226
|
+
"newsletter",
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
assert.deepEqual(result, { matched: 3, applied: 2, failed: 1 });
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
writes.messages.map((w) => w.messageId),
|
|
232
|
+
["msg-1", "msg-3"],
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { MessageCategoryValue } from "@remit/mailbox-service";
|
|
2
|
+
import { denormalizeMessageCategory } from "@remit/mailbox-service";
|
|
3
|
+
import type { RemitClient } from "./data-client.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How much of a sender's already-classified mail one override reaches: the
|
|
7
|
+
* newest 500 of their messages held in the local store. A back-apply is a
|
|
8
|
+
* bounded recent batch, never the sender's whole history — the store-side
|
|
9
|
+
* narrowing pages until this many MATCHES, so a quiet sender's mail is reached
|
|
10
|
+
* however old it is, and a bulk sender's oldest mail is deliberately left as
|
|
11
|
+
* the classifier decided it. The same constant the organize back-apply bounds
|
|
12
|
+
* its match set with (`ORGANIZE_MATCH_LIMIT`), for the same reason.
|
|
13
|
+
*/
|
|
14
|
+
export const SENDER_CATEGORY_BACKAPPLY_LIMIT = 500;
|
|
15
|
+
|
|
16
|
+
export interface SenderCategoryBackApplyDeps {
|
|
17
|
+
client: Pick<RemitClient, "message" | "threadMessage">;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SenderCategoryBackApplyResult {
|
|
21
|
+
matched: number;
|
|
22
|
+
applied: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The sender's newest messages in the local store, bounded at `limit`, newest
|
|
28
|
+
* first and deduplicated by messageId.
|
|
29
|
+
*
|
|
30
|
+
* The store evaluates `sender` as an accent- and case-insensitive SUBSTRING
|
|
31
|
+
* over the From address and display name together, so it is a narrowing and
|
|
32
|
+
* not the verdict: every page is refined here to an exact `fromEmail` match
|
|
33
|
+
* before it counts. Paging continues until the cap fills or the store runs
|
|
34
|
+
* out, which is what makes the bound a bound on the sender's messages rather
|
|
35
|
+
* than on how far back the read reached (#459).
|
|
36
|
+
*/
|
|
37
|
+
const listSenderMessageIds = async (
|
|
38
|
+
deps: SenderCategoryBackApplyDeps,
|
|
39
|
+
accountConfigId: string,
|
|
40
|
+
normalizedEmail: string,
|
|
41
|
+
limit: number,
|
|
42
|
+
): Promise<string[]> => {
|
|
43
|
+
const wanted = normalizedEmail.toLowerCase();
|
|
44
|
+
const messageIds: string[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
let continuationToken: string | undefined;
|
|
47
|
+
|
|
48
|
+
do {
|
|
49
|
+
const page = await deps.client.threadMessage.listByFieldTerms(
|
|
50
|
+
accountConfigId,
|
|
51
|
+
[{ field: "sender", contains: wanted }],
|
|
52
|
+
{ limit, continuationToken, excludeDeleted: true },
|
|
53
|
+
);
|
|
54
|
+
for (const row of page.items) {
|
|
55
|
+
if (row.fromEmail?.toLowerCase() !== wanted) continue;
|
|
56
|
+
if (seen.has(row.messageId)) continue;
|
|
57
|
+
seen.add(row.messageId);
|
|
58
|
+
messageIds.push(row.messageId);
|
|
59
|
+
if (messageIds.length >= limit) return messageIds;
|
|
60
|
+
}
|
|
61
|
+
continuationToken = page.continuationToken;
|
|
62
|
+
} while (continuationToken);
|
|
63
|
+
|
|
64
|
+
return messageIds;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Back-apply a sender's `Address.flags.category` override to mail that was
|
|
69
|
+
* already classified before the override was set (#415). Issue #299 gave the
|
|
70
|
+
* override full effect on the sender's NEXT message only; this is the
|
|
71
|
+
* retroactive half, deliberately bounded to
|
|
72
|
+
* {@link SENDER_CATEGORY_BACKAPPLY_LIMIT}.
|
|
73
|
+
*
|
|
74
|
+
* This is the one path that intentionally rewrites an already-decided
|
|
75
|
+
* `Message.category`, so it does not — and must not — reuse body sync's
|
|
76
|
+
* `hasDecidedCategory` write-once guard. Its consistency story is that
|
|
77
|
+
* `category` is a key attribute of no provisioned index: the DynamoDB LSI that
|
|
78
|
+
* would have sorted on it was never provisioned and cannot be (#516), and the
|
|
79
|
+
* filtered-mailbox read path is served by the SQL-only
|
|
80
|
+
* `tm_by_mailbox_category_date` index, where an update simply moves the row.
|
|
81
|
+
* What a rewrite can still break is the Message/ThreadMessage pair disagreeing
|
|
82
|
+
* — the badge is served from the thread row and so is the category filter — so
|
|
83
|
+
* both are written per message, thread rows first, and a redelivered job
|
|
84
|
+
* re-runs the same idempotent writes.
|
|
85
|
+
*
|
|
86
|
+
* Nothing here touches placement: `category` is not an input to
|
|
87
|
+
* `classifyPlacement`, so an override re-labels mail where it already sits and
|
|
88
|
+
* files nothing. No remote mail-server state changes, which is why this is not
|
|
89
|
+
* an IMAP mutation and does not take the mutator pattern.
|
|
90
|
+
*
|
|
91
|
+
* Per-message isolation, exactly as `applyOrganize` has it: one poisoned
|
|
92
|
+
* message is counted as failed and the pass continues, so a single missing
|
|
93
|
+
* thread row never costs the other 499 their re-label. Unlike a job row, this
|
|
94
|
+
* count is not the end of the story — the caller is expected to leave a pass
|
|
95
|
+
* with failures unacknowledged so redelivery converges the rest.
|
|
96
|
+
*/
|
|
97
|
+
export const backApplySenderCategory = async (
|
|
98
|
+
deps: SenderCategoryBackApplyDeps,
|
|
99
|
+
accountConfigId: string,
|
|
100
|
+
normalizedEmail: string,
|
|
101
|
+
category: MessageCategoryValue,
|
|
102
|
+
limit: number = SENDER_CATEGORY_BACKAPPLY_LIMIT,
|
|
103
|
+
): Promise<SenderCategoryBackApplyResult> => {
|
|
104
|
+
const messageIds = await listSenderMessageIds(
|
|
105
|
+
deps,
|
|
106
|
+
accountConfigId,
|
|
107
|
+
normalizedEmail,
|
|
108
|
+
limit,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const relabel = async (messageId: string): Promise<void> => {
|
|
112
|
+
await denormalizeMessageCategory(
|
|
113
|
+
{ threadMessageService: deps.client.threadMessage },
|
|
114
|
+
accountConfigId,
|
|
115
|
+
messageId,
|
|
116
|
+
{ category },
|
|
117
|
+
);
|
|
118
|
+
await deps.client.message.update(messageId, { category });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let applied = 0;
|
|
122
|
+
let failed = 0;
|
|
123
|
+
for (const messageId of messageIds) {
|
|
124
|
+
const ok = await relabel(messageId)
|
|
125
|
+
.then(() => true)
|
|
126
|
+
.catch(() => false);
|
|
127
|
+
if (ok) {
|
|
128
|
+
applied += 1;
|
|
129
|
+
} else {
|
|
130
|
+
failed += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { matched: messageIds.length, applied, failed };
|
|
135
|
+
};
|