@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.114",
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
+ });
@@ -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 { AddressItem, FlagsMergePatch } from "@remit/data-ports";
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 scope applies before the mode picks a set, so the listing, the
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
- scopeToAccount(mailboxIds, mailboxIdToAccountId, params.accountId);
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
+ };