@remit/backend 0.0.114 → 0.0.115

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.115",
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
  };
@@ -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
+ };