@remit/mailbox-service 0.0.110 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,18 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- AddressFlags,
5
- IAccountRepository,
6
- IAddressRepository,
7
- IMailboxRepository,
8
- IMailboxSpecialUseRepository,
9
- IMessageFlagPushRepository,
10
- IMessageRepository,
11
- IThreadMessageRepository,
12
- } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
13
4
  import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
14
5
  import { deriveAddressId } from "@remit/data-ports/id";
15
- import { AddressRole } from "@remit/domain-enums";
6
+ import { AddressRole, MessageKeywordFlag } from "@remit/domain-enums";
16
7
  import { FlagPushService } from "./flag-push.js";
17
8
  import { MessageMoveService } from "./message-move.js";
18
9
  import {
@@ -20,782 +11,319 @@ import {
20
11
  NoJunkMailboxError,
21
12
  SpamReportService,
22
13
  } from "./spam-report.js";
23
- import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
24
-
25
- const ACCOUNT = "acc-1";
26
- const ACCOUNT_CONFIG = "cfg-1";
27
- const ACCOUNT_EMAIL = "me@example.com";
28
- const INBOX_MAILBOX = "mbx-inbox";
29
- const JUNK_MAILBOX = "mbx-junk";
30
- const MESSAGE_ID = "msg-1";
31
- const SENDER_EMAIL = "sender@example.com";
32
- const ADDRESS_ID = deriveAddressId(ACCOUNT_CONFIG, SENDER_EMAIL);
33
- const THREAD_ID = "thread-1";
34
-
35
- interface ThreadRow {
36
- accountConfigId: string;
37
- threadMessageId: string;
38
- threadId: string;
39
- messageId: string;
40
- mailboxId: string;
41
- messageIdHeader: string;
42
- isRead?: boolean;
43
- hasStars?: boolean;
44
- hasAttachment?: boolean;
45
- isDeleted?: boolean;
46
- }
47
-
48
- interface World {
49
- service: SpamReportService;
50
- messages: Map<string, Record<string, unknown>>;
51
- addresses: Map<string, { flags: AddressFlags }>;
52
- threadRows: ThreadRow[];
53
- sent: unknown[];
54
- sqsImpl: { send: (command: unknown) => Promise<unknown> };
55
- markerPuts: Array<Record<string, unknown>>;
56
- }
57
-
58
- const settleMove = (messages: Map<string, Record<string, unknown>>) => {
59
- const m = messages.get(MESSAGE_ID);
60
- assert.ok(m !== undefined);
61
- m.status = "active";
62
- m.syncStatus = "synced";
63
- };
14
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
15
+ import {
16
+ arriveAt,
17
+ captureQueue,
18
+ openStore,
19
+ type QueueCapture,
20
+ type QueuedEvent,
21
+ type Store,
22
+ seedAccount,
23
+ seedMailbox,
24
+ seedMessage,
25
+ } from "./test-helpers/test-store.js";
26
+
27
+ const SENDER = "sender@example.com";
28
+
29
+ let store: Store;
30
+ let capture: QueueCapture;
31
+ let queue: QueuedEvent[];
32
+ let imap: FakeImapServer;
33
+ let account: AccountItem;
34
+ let inbox: MailboxItem;
35
+ let junk: MailboxItem | undefined;
36
+ let other: MailboxItem;
37
+
38
+ beforeEach(async () => {
39
+ capture = captureQueue();
40
+ queue = capture.sent;
41
+ store = await openStore();
42
+ imap = new FakeImapServer();
43
+ account = await seedAccount(store);
44
+ inbox = await seedMailbox(store, account, "INBOX");
45
+ other = await seedMailbox(store, account, "Other");
46
+ junk = await seedMailbox(store, account, "Junk", { specialUse: "Junk" });
47
+ for (const mailbox of await store.mailbox.listAllByAccount(
48
+ account.accountId,
49
+ )) {
50
+ imap.addFolder(mailbox.fullPath);
51
+ }
52
+ });
64
53
 
65
- const buildWorld = (
66
- opts: {
67
- startMailbox?: string;
68
- fromEmail?: string;
69
- originalMailboxId?: string;
70
- /** Whether the sender was ever harvested into an `address` row. */
71
- harvestedSender?: boolean;
72
- /** Flags already standing on the harvested sender's row. */
73
- senderFlags?: Record<string, unknown>;
74
- junkMailbox?: { mailboxId: string; fullPath: string } | null;
75
- } = {},
76
- ): World => {
77
- const startMailbox = opts.startMailbox ?? INBOX_MAILBOX;
78
- const fromEmail = opts.fromEmail ?? SENDER_EMAIL;
79
- const fromAddressId = deriveAddressId(ACCOUNT_CONFIG, fromEmail);
80
- const harvestedSender = opts.harvestedSender ?? true;
81
- const junkMailbox =
82
- opts.junkMailbox === undefined
83
- ? { mailboxId: JUNK_MAILBOX, fullPath: "Junk" }
84
- : opts.junkMailbox;
85
-
86
- const messages = new Map<string, Record<string, unknown>>([
87
- [
88
- MESSAGE_ID,
89
- {
90
- messageId: MESSAGE_ID,
91
- mailboxId: startMailbox,
92
- uid: 42,
93
- rfc822Size: 100,
94
- internalDate: 1_700_000_000_000,
95
- envelopeId: "env-1",
96
- rootBodyPartId: "body-1",
97
- category: "primary",
98
- hasListUnsubscribe: false,
99
- syncStatus: "synced",
100
- ...(opts.originalMailboxId
101
- ? { originalMailboxId: opts.originalMailboxId }
102
- : {}),
103
- },
104
- ],
105
- ]);
106
-
107
- const addresses = new Map<string, { flags: AddressFlags }>(
108
- harvestedSender
109
- ? [[fromAddressId, { flags: (opts.senderFlags ?? {}) as AddressFlags }]]
110
- : [],
111
- );
54
+ afterEach(async () => {
55
+ capture.restore();
56
+ await store.close();
57
+ });
112
58
 
113
- const threadRows: ThreadRow[] = [
114
- {
115
- accountConfigId: ACCOUNT_CONFIG,
116
- threadMessageId: `tm:${THREAD_ID}::${MESSAGE_ID}`,
117
- threadId: THREAD_ID,
118
- messageId: MESSAGE_ID,
119
- mailboxId: INBOX_MAILBOX,
120
- messageIdHeader: "<abc@example.com>",
121
- isRead: false,
122
- hasStars: false,
123
- },
124
- ];
125
-
126
- const mailboxes = new Map<string, Record<string, unknown>>([
127
- [
128
- INBOX_MAILBOX,
129
- { mailboxId: INBOX_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
130
- ],
131
- [
132
- JUNK_MAILBOX,
133
- { mailboxId: JUNK_MAILBOX, fullPath: "Junk", accountId: ACCOUNT },
134
- ],
135
- ]);
136
-
137
- const messageService = {
138
- get: async (id: string | string[]) => {
139
- if (Array.isArray(id)) {
140
- return id.map((i) => messages.get(i)).filter(Boolean);
141
- }
142
- const m = messages.get(id);
143
- if (!m) throw new Error(`no message ${id}`);
144
- return m;
145
- },
146
- describe: async (id: string) => {
147
- const m = messages.get(id);
148
- if (!m) throw new Error(`no message ${id}`);
149
- return {
150
- message: [m],
151
- messageFlag: [],
152
- envelope: [],
153
- messageReference: [],
154
- envelopeAddress: [
155
- {
156
- envelopeAddressId: "ea-1",
157
- messageId: id,
158
- addressId: fromAddressId,
159
- displayName: "Spammy Sender",
160
- normalizedEmail: fromEmail,
161
- addressRole: AddressRole.From,
162
- addressOrder: 0,
163
- },
164
- ],
165
- bodyPart: [],
166
- bodyPartParameter: [],
167
- rawMessageStorage: [],
168
- bodyPartStorage: [],
169
- bodyPartContent: [],
170
- };
171
- },
172
- update: async (id: string, patch: Record<string, unknown>) => {
173
- const m = messages.get(id);
174
- if (m) Object.assign(m, patch);
175
- return m;
176
- },
177
- transitionPlacement: async (
178
- id: string,
179
- _expected: Record<string, unknown>,
180
- patch: Record<string, unknown>,
181
- ) => {
182
- const m = messages.get(id);
183
- if (!m) return undefined;
184
- for (const [field, value] of Object.entries(patch)) {
185
- if (value === null) {
186
- delete m[field as keyof typeof m];
187
- continue;
188
- }
189
- Object.assign(m, { [field]: value });
190
- }
191
- return m;
192
- },
193
- clearSpamReport: async (id: string) => {
194
- const m = messages.get(id);
195
- if (m) delete m.spamReport;
196
- return m;
197
- },
198
- } as unknown as IMessageRepository;
199
-
200
- const addressService = {
201
- getAddress: async (_accountConfigId: string, addressIds: string[]) =>
202
- addressIds
203
- .filter((id) => addresses.has(id))
204
- .map((id) => ({ addressId: id, ...addresses.get(id) })),
205
- // Deliberately destructive on conflict, mirroring the real repo's
206
- // `onConflictDoUpdate`: a caller that upserts over a row it did not
207
- // create loses what stood on it.
208
- upsertAddress: async (input: { addressId: string }) => {
209
- addresses.set(input.addressId, { flags: {} });
210
- return { ...input, flags: {} };
211
- },
212
- reconcileJunkOnlyForMessage: async () => {},
213
- mergeFlags: async (
214
- _accountConfigId: string,
215
- addressId: string,
216
- patch: Record<string, unknown>,
217
- ) => {
218
- const entry = addresses.get(addressId);
219
- if (!entry) throw new Error(`no address ${addressId}`);
220
- const next = { ...entry.flags } as Record<string, unknown>;
221
- for (const [key, value] of Object.entries(patch)) {
222
- if (value === undefined) continue;
223
- if (value === null) {
224
- delete next[key];
225
- continue;
226
- }
227
- next[key] = value;
228
- }
229
- entry.flags = next as AddressFlags;
230
- return { addressId, flags: entry.flags };
231
- },
232
- } as unknown as IAddressRepository;
233
-
234
- const accountService = {
235
- get: async () => ({ accountId: ACCOUNT, email: ACCOUNT_EMAIL }),
236
- } as unknown as IAccountRepository;
237
-
238
- const mailboxSpecialUseService = {
239
- findJunkMailbox: async () => junkMailbox,
240
- findTrashMailbox: async () => null,
241
- resolveTrashRole: async () => trashRole(null),
242
- resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
243
- } as unknown as IMailboxSpecialUseRepository;
244
-
245
- const mailboxService = {
246
- get: async (_acc: string, id: string | string[]) => {
247
- if (Array.isArray(id)) {
248
- return id.map((i) => mailboxes.get(i)).filter(Boolean);
249
- }
250
- return mailboxes.get(id);
251
- },
252
- } as unknown as IMailboxRepository;
253
-
254
- const threadMessageService = {
255
- getByMessageId: async (_cfg: string, messageId: string) => {
256
- const row = threadRows.find((r) => r.messageId === messageId);
257
- if (!row) throw new Error(`no thread message ${messageId}`);
258
- return row;
259
- },
260
- update: async (
261
- _cfg: string,
262
- threadMessageId: string,
263
- patch: Record<string, unknown>,
264
- ) => {
265
- const row = threadRows.find((r) => r.threadMessageId === threadMessageId);
266
- if (row) Object.assign(row, patch);
267
- return row;
268
- },
269
- } as unknown as IThreadMessageRepository;
270
-
271
- const sent: unknown[] = [];
272
- const sqsImpl = {
273
- send: async (command: unknown) => {
274
- sent.push(command);
275
- return {};
276
- },
277
- };
278
-
279
- const messageMoveService = new MessageMoveService({
280
- messageService,
281
- mailboxService,
282
- mailboxSpecialUseService,
283
- threadMessageService,
284
- addressService,
285
- sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
286
- moveSettleTimeoutMs: 30,
287
- moveSettlePollMs: 5,
288
- });
289
- (
290
- messageMoveService as unknown as {
291
- sqs: { send: (c: unknown) => Promise<unknown> };
292
- }
293
- ).sqs = sqsImpl;
294
-
295
- const markerPuts: Array<Record<string, unknown>> = [];
296
- const markerService: IMessageFlagPushRepository = {
297
- put: async (input: Record<string, unknown>) => {
298
- markerPuts.push(input);
299
- return {
300
- ...input,
301
- state: "pending",
302
- createdAt: 1,
303
- updatedAt: 1,
304
- } as never;
305
- },
306
- find: async () => null,
307
- updateState: async () => ({}) as never,
308
- delete: async () => {},
309
- listByAccountId: async () => [],
310
- listByMailboxId: async () => [],
311
- };
312
-
313
- const flagPushService = new FlagPushService({
314
- markerService,
315
- sqsQueueUrl: "http://localhost:9324/000000000000/flag-push",
59
+ const userMoves = () =>
60
+ new MessageMoveService({
61
+ messageService: store.message,
62
+ mailboxService: store.mailbox,
63
+ mailboxSpecialUseService: store.mailboxSpecialUse,
64
+ threadMessageService: store.threadMessage,
65
+ addressService: store.address,
66
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
316
67
  });
317
- (
318
- flagPushService as unknown as {
319
- sqs: { send: (c: unknown) => Promise<unknown> };
320
- }
321
- ).sqs = sqsImpl;
322
-
323
- const service = new SpamReportService({
324
- messageService,
325
- addressService,
326
- accountService,
327
- mailboxSpecialUseService,
328
- messageMoveService,
329
- flagPushService,
330
- // Small and fast: these tests simulate settlement explicitly (via
331
- // settleMove) rather than waiting out a real timeout.
68
+
69
+ const spam = () =>
70
+ new SpamReportService({
71
+ messageService: store.message,
72
+ addressService: store.address,
73
+ accountService: store.account,
74
+ mailboxSpecialUseService: store.mailboxSpecialUse,
75
+ messageMoveService: new MessageMoveService({
76
+ messageService: store.message,
77
+ mailboxService: store.mailbox,
78
+ mailboxSpecialUseService: store.mailboxSpecialUse,
79
+ threadMessageService: store.threadMessage,
80
+ addressService: store.address,
81
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
82
+ moveSettleTimeoutMs: 30,
83
+ moveSettlePollMs: 5,
84
+ }),
85
+ flagPushService: new FlagPushService({
86
+ markerService: store.flagPush,
87
+ sqsQueueUrl: "https://sqs.test/flags",
88
+ }),
332
89
  moveSettleTimeoutMs: 30,
333
90
  moveSettlePollMs: 5,
334
91
  });
335
92
 
336
- return {
337
- service,
338
- messages,
339
- addresses,
340
- threadRows,
341
- sent,
342
- sqsImpl,
343
- markerPuts,
344
- };
345
- };
93
+ const arrive = (mailbox: MailboxItem = inbox, from = `Sender <${SENDER}>`) =>
94
+ arriveAt(store, imap, account, mailbox, eml({ From: from }));
346
95
 
347
- const moveEvents = (sent: unknown[]) =>
348
- sent.filter(
349
- (cmd) =>
350
- JSON.parse((cmd as { input: { MessageBody: string } }).input.MessageBody)
351
- .type === "MESSAGE_MOVE",
352
- );
96
+ const params = (messageId: string) => ({
97
+ accountConfigId: account.accountConfigId,
98
+ accountId: account.accountId,
99
+ messageId,
100
+ setBy: "user-1",
101
+ });
353
102
 
354
- describe("SpamReportService.reportSpam", () => {
355
- it("sets the blocked flag and enqueues the move to Junk", async () => {
356
- const { service, messages, addresses, sent, markerPuts } = buildWorld();
357
-
358
- await service.reportSpam({
359
- accountConfigId: ACCOUNT_CONFIG,
360
- accountId: ACCOUNT,
361
- messageId: MESSAGE_ID,
362
- setBy: "user-1",
363
- });
103
+ const report = (messageId: string) => spam().reportSpam(params(messageId));
364
104
 
365
- const address = addresses.get(ADDRESS_ID);
366
- assert.equal(address?.flags.blocked?.value, true);
367
- assert.equal(address?.flags.blocked?.setBy, "user-1");
105
+ const undo = (messageId: string) => spam().notSpam(params(messageId));
368
106
 
369
- const message = messages.get(MESSAGE_ID);
370
- assert.equal(message?.mailboxId, JUNK_MAILBOX);
371
- assert.equal(moveEvents(sent).length, 1);
107
+ const sender = (email = SENDER) =>
108
+ store.address.getAddress(
109
+ account.accountConfigId,
110
+ deriveAddressId(account.accountConfigId, email),
111
+ );
372
112
 
373
- assert.equal(markerPuts.length, 1);
374
- assert.equal(markerPuts[0].flagName, "$Junk");
375
- assert.equal(markerPuts[0].operation, "add");
113
+ const settle = async (messageId: string) => {
114
+ const row = await store.message.get(messageId);
115
+ await store.message.updateUid(messageId, row.uid + 100, row.mailboxId);
116
+ };
376
117
 
377
- assert.ok(message !== undefined);
378
- const spamReport = message.spamReport as { reportedAt: number };
379
- assert.ok(spamReport.reportedAt > 0);
380
- });
118
+ const moves = () => queue.filter((event) => event.body.type === "MESSAGE_MOVE");
381
119
 
382
- it("leaves the blocked flag in place when the move fails", async () => {
383
- const world = buildWorld();
384
- world.sqsImpl.send = async () => {
385
- throw new Error("SQS unavailable");
386
- };
120
+ const row = (messageId: string) => store.message.get(messageId);
387
121
 
388
- await assert.rejects(
389
- () =>
390
- world.service.reportSpam({
391
- accountConfigId: ACCOUNT_CONFIG,
392
- accountId: ACCOUNT,
393
- messageId: MESSAGE_ID,
394
- }),
395
- /SQS unavailable/,
122
+ describe("SpamReportService.reportSpam", () => {
123
+ it("blocks the sender, moves the message to Junk and owes the server $Junk", async () => {
124
+ const messageId = await arrive();
125
+
126
+ await report(messageId);
127
+
128
+ const blocked = (await sender()).flags.blocked;
129
+ assert.equal(blocked?.value, true);
130
+ assert.equal(blocked?.setBy, "user-1");
131
+ const moved = await row(messageId);
132
+ assert.equal(moved.mailboxId, junk?.mailboxId);
133
+ assert.ok((moved.spamReport?.reportedAt ?? 0) > 0);
134
+ assert.equal(moves().length, 1);
135
+ assert.equal(
136
+ (await store.flagPush.find(messageId, MessageKeywordFlag.Junk))
137
+ ?.operation,
138
+ "add",
396
139
  );
397
-
398
- const address = world.addresses.get(ADDRESS_ID);
399
- assert.equal(address?.flags.blocked?.value, true);
400
140
  });
401
141
 
402
- it("is idempotent under a double press", async () => {
403
- const { service, messages, sent } = buildWorld();
404
-
405
- await service.reportSpam({
406
- accountConfigId: ACCOUNT_CONFIG,
407
- accountId: ACCOUNT,
408
- messageId: MESSAGE_ID,
409
- });
410
- settleMove(messages);
411
- await service.reportSpam({
412
- accountConfigId: ACCOUNT_CONFIG,
413
- accountId: ACCOUNT,
414
- messageId: MESSAGE_ID,
142
+ it("keeps the block when the move fails", async () => {
143
+ const messageId = await arrive();
144
+ capture.answer(async () => {
145
+ throw new Error("SQS unavailable");
415
146
  });
416
147
 
417
- // The second call's move is a no-op: MessageMoveService.moveMessage sees
418
- // the local mailboxId already equals Junk and skips without enqueueing.
419
- // The skip is read off the settled row — a press inside the first move's
420
- // flight waits for it rather than answering from the folder that move
421
- // wrote optimistically (#665).
422
- assert.equal(moveEvents(sent).length, 1);
423
- });
148
+ await assert.rejects(report(messageId), /SQS unavailable/);
424
149
 
425
- it("refuses a second press inside the first move's flight", async () => {
426
- // No settleMove between the presses: the row names Junk while still
427
- // carrying the inbox's uid, so acting on it would address whatever Junk
428
- // holds at that uid — somebody else's message (#665).
429
- const { service, sent } = buildWorld();
150
+ assert.equal((await sender()).flags.blocked?.value, true);
151
+ });
430
152
 
431
- await service.reportSpam({
432
- accountConfigId: ACCOUNT_CONFIG,
433
- accountId: ACCOUNT,
434
- messageId: MESSAGE_ID,
435
- });
153
+ it("moves once under a double press, and refuses a press inside the first move's flight", async () => {
154
+ const messageId = await arrive();
155
+ await report(messageId);
436
156
 
437
157
  await assert.rejects(
438
- () =>
439
- service.reportSpam({
440
- accountConfigId: ACCOUNT_CONFIG,
441
- accountId: ACCOUNT,
442
- messageId: MESSAGE_ID,
443
- }),
158
+ report(messageId),
444
159
  (error: unknown) =>
445
160
  error instanceof MessagePlacementUnsettledError &&
446
- error.statusCode === 409 &&
447
161
  error.publicApiError?.details?.reason === "in_flight",
448
162
  );
163
+ await settle(messageId);
164
+ await report(messageId);
449
165
 
450
- assert.equal(moveEvents(sent).length, 1);
166
+ assert.equal(moves().length, 1);
451
167
  });
452
168
 
453
- it("moves the message but writes no sender block when the message is forged from the account's own address", async () => {
454
- const { service, messages, addresses, sent } = buildWorld({
455
- fromEmail: ACCOUNT_EMAIL,
456
- });
169
+ it("moves mail forged from the account's own address without blocking the account", async () => {
170
+ const messageId = await arrive(inbox, `Me <${account.email}>`);
457
171
 
458
- await service.reportSpam({
459
- accountConfigId: ACCOUNT_CONFIG,
460
- accountId: ACCOUNT,
461
- messageId: MESSAGE_ID,
462
- });
463
-
464
- const address = addresses.get(ADDRESS_ID);
465
- assert.equal(address?.flags.blocked, undefined);
172
+ await report(messageId);
466
173
 
467
- const message = messages.get(MESSAGE_ID);
468
- assert.equal(message?.mailboxId, JUNK_MAILBOX);
469
- assert.equal(moveEvents(sent).length, 1);
470
- assert.ok(message !== undefined);
471
- assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
174
+ assert.equal((await sender(account.email)).flags.blocked, undefined);
175
+ assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
472
176
  });
473
177
 
474
- it("refuses to mint a row for a sender address carrying no @ at all", async () => {
475
- // slice() around a lastIndexOf of -1 splits "no-at-sign" into a
476
- // plausible-looking local part and domain, so a length check alone
477
- // waves the one input it exists to catch straight through into a
478
- // corrupt address row.
479
- const { service, addresses } = buildWorld({
480
- harvestedSender: false,
481
- fromEmail: "no-at-sign",
482
- });
483
-
484
- await assert.rejects(
485
- () =>
486
- service.reportSpam({
487
- accountConfigId: ACCOUNT_CONFIG,
488
- accountId: ACCOUNT,
489
- messageId: MESSAGE_ID,
490
- }),
491
- /not a usable email address/,
178
+ it("blocks a sender that was never harvested into the address book", async () => {
179
+ const messageId = await arrive();
180
+ await store.address.deleteAddress(
181
+ account.accountConfigId,
182
+ deriveAddressId(account.accountConfigId, SENDER),
492
183
  );
493
184
 
494
- assert.equal(addresses.size, 0);
495
- });
185
+ await report(messageId);
496
186
 
497
- it("reports a message whose sender was never harvested into an address row", async () => {
498
- // The row is what carries the blocked flag, and harvesting is what
499
- // ordinarily writes it. When it is absent, blocking the sender used to
500
- // throw NotFoundError and take the whole report down with it — the
501
- // message never reached Junk and the user got a retry that could not
502
- // work (test.remit.email, 18 Aug 2026).
503
- const { service, messages, addresses, sent } = buildWorld({
504
- harvestedSender: false,
505
- });
187
+ assert.equal((await sender()).flags.blocked?.value, true);
188
+ assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
189
+ });
506
190
 
507
- await service.reportSpam({
508
- accountConfigId: ACCOUNT_CONFIG,
509
- accountId: ACCOUNT,
510
- messageId: MESSAGE_ID,
511
- setBy: "user-1",
191
+ it("refuses to mint an address row for a sender with no @", async () => {
192
+ const { message } = await seedMessage(store, account, inbox, 9);
193
+ await store.address.upsertEnvelopeAddress({
194
+ messageId: message.messageId,
195
+ addressId: deriveAddressId(account.accountConfigId, "no-at-sign"),
196
+ displayName: "",
197
+ normalizedEmail: "no-at-sign",
198
+ addressRole: AddressRole.From,
199
+ addressOrder: 0,
512
200
  });
513
201
 
514
- const address = addresses.get(ADDRESS_ID);
515
- assert.equal(address?.flags.blocked?.value, true);
202
+ await assert.rejects(
203
+ report(message.messageId),
204
+ /not a usable email address/,
205
+ );
516
206
 
517
- const message = messages.get(MESSAGE_ID);
518
- assert.equal(message?.mailboxId, JUNK_MAILBOX);
519
- assert.equal(moveEvents(sent).length, 1);
207
+ assert.deepEqual(
208
+ await store.address.getAddress(account.accountConfigId, [
209
+ deriveAddressId(account.accountConfigId, "no-at-sign"),
210
+ ]),
211
+ [],
212
+ );
520
213
  });
521
214
 
522
215
  it("keeps the junkOnly mark that withholds the sender from autocomplete", async () => {
523
- // Report spam adds the block; it is not a sighting of the sender and
524
- // must not carry an upsert's on-conflict behaviour onto a row it did
525
- // not create. The mark that withholds a spammer from autocomplete (#822)
526
- // lives in these same flags, and clearing it here would put the spammer
527
- // back in the compose picker — the opposite of what the button means.
528
- const { service, addresses } = buildWorld({
529
- senderFlags: {
530
- junkOnly: { value: true, setAt: 1, setBy: "junk-harvest" },
531
- },
532
- });
216
+ const messageId = await arrive(junk);
533
217
 
534
- await service.reportSpam({
535
- accountConfigId: ACCOUNT_CONFIG,
536
- accountId: ACCOUNT,
537
- messageId: MESSAGE_ID,
538
- setBy: "user-1",
539
- });
218
+ await report(messageId);
540
219
 
541
- const flags = addresses.get(ADDRESS_ID)?.flags as Record<string, unknown>;
542
- assert.equal((flags.blocked as { value: boolean }).value, true);
543
- assert.deepEqual(flags.junkOnly, {
544
- value: true,
545
- setAt: 1,
546
- setBy: "junk-harvest",
547
- });
220
+ const flags = (await sender()).flags;
221
+ assert.equal(flags.blocked?.value, true);
222
+ assert.equal(flags.junkOnly?.value, true);
548
223
  });
549
224
 
550
- it("names the missing Junk folder and changes nothing when the account has none", async () => {
551
- const { service, messages, addresses, sent, markerPuts } = buildWorld({
552
- junkMailbox: null,
553
- });
225
+ it("names the missing Junk folder and changes nothing", async () => {
226
+ await store.mailboxSpecialUse.deleteByMailboxId(junk?.mailboxId as string);
227
+ await store.mailbox.delete(account.accountId, junk?.mailboxId as string);
228
+ junk = undefined;
229
+ const messageId = await arrive();
554
230
 
555
231
  await assert.rejects(
556
- () =>
557
- service.reportSpam({
558
- accountConfigId: ACCOUNT_CONFIG,
559
- accountId: ACCOUNT,
560
- messageId: MESSAGE_ID,
561
- }),
232
+ report(messageId),
562
233
  (error: unknown) =>
563
234
  error instanceof NoJunkMailboxError &&
564
235
  /no Junk folder/.test(error.message) &&
565
236
  /Create one/.test(error.message),
566
237
  );
567
238
 
568
- // Nothing half-applied: no sender blocked, no report stamp on a message
569
- // still sitting where it was, no move, no keyword marker.
570
- assert.equal(addresses.get(ADDRESS_ID)?.flags.blocked, undefined);
571
- const message = messages.get(MESSAGE_ID);
572
- assert.equal(message?.spamReport, undefined);
573
- assert.equal(message?.mailboxId, INBOX_MAILBOX);
574
- assert.equal(moveEvents(sent).length, 0);
575
- assert.equal(markerPuts.length, 0);
239
+ assert.equal((await sender()).flags.blocked, undefined);
240
+ const untouched = await row(messageId);
241
+ assert.equal(untouched.mailboxId, inbox.mailboxId);
242
+ assert.equal(untouched.spamReport, undefined);
243
+ assert.equal(queue.length, 0);
576
244
  });
577
245
  });
578
246
 
579
247
  describe("SpamReportService.notSpam", () => {
580
- it("mints no address row for a sender that was never harvested", async () => {
581
- // A sender with no row has no block to lift, so the flag write is
582
- // already true. Creating the row to write it would put an address
583
- // nothing ever harvested into the address book, where autocomplete
584
- // would then offer it.
585
- const { service, messages, addresses } = buildWorld({
586
- harvestedSender: false,
587
- startMailbox: JUNK_MAILBOX,
588
- });
589
-
590
- await service.notSpam({
591
- accountConfigId: ACCOUNT_CONFIG,
592
- accountId: ACCOUNT,
593
- messageId: MESSAGE_ID,
594
- });
595
-
596
- assert.equal(addresses.size, 0);
597
- assert.equal(messages.get(MESSAGE_ID)?.spamReport, undefined);
598
- });
599
-
600
- it("restores the original mailbox and clears the flag without setting trust", async () => {
601
- const { service, messages, addresses } = buildWorld();
602
-
603
- await service.reportSpam({
604
- accountConfigId: ACCOUNT_CONFIG,
605
- accountId: ACCOUNT,
606
- messageId: MESSAGE_ID,
607
- });
608
- settleMove(messages);
609
- await service.notSpam({
610
- accountConfigId: ACCOUNT_CONFIG,
611
- accountId: ACCOUNT,
612
- messageId: MESSAGE_ID,
613
- });
614
-
615
- const message = messages.get(MESSAGE_ID);
616
- assert.equal(message?.mailboxId, INBOX_MAILBOX);
617
- assert.equal(message?.spamReport, undefined);
618
-
619
- const address = addresses.get(ADDRESS_ID);
620
- assert.equal(address?.flags.blocked, undefined);
621
- assert.equal(address?.flags.wellknown, undefined);
622
- assert.equal(address?.flags.trusted, undefined);
623
- assert.equal(address?.flags.vip, undefined);
248
+ it("restores the original folder and lifts the block without granting trust", async () => {
249
+ const messageId = await arrive();
250
+ await report(messageId);
251
+ await settle(messageId);
252
+
253
+ await undo(messageId);
254
+
255
+ const restored = await row(messageId);
256
+ assert.equal(restored.mailboxId, inbox.mailboxId);
257
+ assert.equal(restored.spamReport, undefined);
258
+ const flags = (await sender()).flags;
259
+ assert.equal(flags.blocked, undefined);
260
+ assert.equal(flags.wellknown, undefined);
261
+ assert.equal(flags.vip, undefined);
624
262
  });
625
263
 
626
- it("clears the block and provenance without a 500 when the message never actually moved (same-mailbox no-op)", async () => {
627
- // report-spam pressed on a message the provider's filter already placed
628
- // in Junk: MessageMoveService.moveMessage's same-mailbox guard skips, so
629
- // originalMailboxId is never set — status never becomes "moving" either,
630
- // so notSpam has nothing to wait on.
631
- const { service, messages, addresses } = buildWorld({
632
- startMailbox: JUNK_MAILBOX,
633
- });
634
-
635
- await service.reportSpam({
636
- accountConfigId: ACCOUNT_CONFIG,
637
- accountId: ACCOUNT,
638
- messageId: MESSAGE_ID,
639
- });
640
- await service.notSpam({
641
- accountConfigId: ACCOUNT_CONFIG,
642
- accountId: ACCOUNT,
643
- messageId: MESSAGE_ID,
644
- });
645
-
646
- const message = messages.get(MESSAGE_ID);
647
- assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
648
- assert.equal(message?.spamReport, undefined);
649
-
650
- const address = addresses.get(ADDRESS_ID);
651
- assert.equal(address?.flags.blocked, undefined);
652
- });
653
-
654
- it("clears a stale originalMailboxId left by an earlier, unrelated move instead of restoring to it", async () => {
655
- // The message is already in Junk (an earlier, unrelated move put it
656
- // there) and still carries that move's originalMailboxId. report-spam's
657
- // own move is a same-mailbox no-op — moveMessage never touches
658
- // originalMailboxId — so without an explicit clear, notSpam would
659
- // restore to a folder this report-spam action never moved it out of.
660
- const OTHER_MAILBOX = "mbx-other";
661
- const { service, messages, addresses } = buildWorld({
662
- startMailbox: JUNK_MAILBOX,
663
- originalMailboxId: OTHER_MAILBOX,
664
- });
665
-
666
- await service.reportSpam({
667
- accountConfigId: ACCOUNT_CONFIG,
668
- accountId: ACCOUNT,
669
- messageId: MESSAGE_ID,
670
- });
671
-
672
- assert.equal(messages.get(MESSAGE_ID)?.originalMailboxId, undefined);
673
-
674
- await service.notSpam({
675
- accountConfigId: ACCOUNT_CONFIG,
676
- accountId: ACCOUNT,
677
- messageId: MESSAGE_ID,
678
- });
264
+ it("mints no address row for a sender that was never harvested", async () => {
265
+ const messageId = await arrive(junk);
266
+ await store.address.deleteAddress(
267
+ account.accountConfigId,
268
+ deriveAddressId(account.accountConfigId, SENDER),
269
+ );
679
270
 
680
- const message = messages.get(MESSAGE_ID);
681
- assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
682
- assert.notEqual(message?.mailboxId, OTHER_MAILBOX);
271
+ await undo(messageId);
683
272
 
684
- const address = addresses.get(ADDRESS_ID);
685
- assert.equal(address?.flags.blocked, undefined);
273
+ assert.deepEqual(
274
+ await store.address.getAddress(account.accountConfigId, [
275
+ deriveAddressId(account.accountConfigId, SENDER),
276
+ ]),
277
+ [],
278
+ );
686
279
  });
687
280
 
688
- it("does not clear originalMailboxId set by an earlier report-spam press — a second report then Undo still restores", async () => {
689
- // The most common reason a message is already in Junk when reportSpam
690
- // runs is a PREVIOUS reportSpam press, not some unrelated move — and
691
- // that earlier press is exactly what owns the originalMailboxId Undo
692
- // needs. A second press must not treat its own prior work as stale.
693
- const { service, messages, sent } = buildWorld();
694
-
695
- await service.reportSpam({
696
- accountConfigId: ACCOUNT_CONFIG,
697
- accountId: ACCOUNT,
698
- messageId: MESSAGE_ID,
699
- });
700
- settleMove(messages);
701
-
702
- await service.reportSpam({
703
- accountConfigId: ACCOUNT_CONFIG,
704
- accountId: ACCOUNT,
705
- messageId: MESSAGE_ID,
706
- });
707
-
708
- assert.equal(
709
- messages.get(MESSAGE_ID)?.originalMailboxId,
710
- INBOX_MAILBOX,
711
- "the second press must not destroy the first press's originalMailboxId",
281
+ it("leaves mail an earlier, unrelated move filed in Junk where it is", async () => {
282
+ const messageId = await arrive(other);
283
+ await userMoves().moveMessage(
284
+ account.accountConfigId,
285
+ messageId,
286
+ junk?.mailboxId as string,
287
+ account.accountId,
712
288
  );
289
+ await settle(messageId);
290
+ queue.length = 0;
713
291
 
714
- await service.notSpam({
715
- accountConfigId: ACCOUNT_CONFIG,
716
- accountId: ACCOUNT,
717
- messageId: MESSAGE_ID,
718
- });
292
+ await report(messageId);
293
+ await undo(messageId);
719
294
 
720
- const message = messages.get(MESSAGE_ID);
721
- assert.equal(
722
- message?.mailboxId,
723
- INBOX_MAILBOX,
724
- "undo must actually restore the message, not leave it stuck in Junk",
725
- );
726
- // INBOX->Junk (press 1), Junk->INBOX (undo) — press 2 was a same-mailbox
727
- // no-op and must not have enqueued a move of its own.
728
- assert.equal(moveEvents(sent).length, 2);
295
+ assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
296
+ assert.equal((await sender()).flags.blocked, undefined);
729
297
  });
730
298
 
731
- it("is idempotent under a double press — a second undo does not re-junk the message", async () => {
732
- const { service, messages, sent } = buildWorld();
299
+ it("still restores after a second report, and a second undo moves nothing", async () => {
300
+ const messageId = await arrive();
301
+ await report(messageId);
302
+ await settle(messageId);
303
+ await report(messageId);
733
304
 
734
- await service.reportSpam({
735
- accountConfigId: ACCOUNT_CONFIG,
736
- accountId: ACCOUNT,
737
- messageId: MESSAGE_ID,
738
- });
739
- settleMove(messages);
740
- await service.notSpam({
741
- accountConfigId: ACCOUNT_CONFIG,
742
- accountId: ACCOUNT,
743
- messageId: MESSAGE_ID,
744
- });
745
- // originalMailboxId is already cleared, so the second call has nothing
746
- // to wait on or restore — no need to settle again.
747
- await service.notSpam({
748
- accountConfigId: ACCOUNT_CONFIG,
749
- accountId: ACCOUNT,
750
- messageId: MESSAGE_ID,
751
- });
305
+ await undo(messageId);
306
+ await undo(messageId);
752
307
 
753
- const message = messages.get(MESSAGE_ID);
754
- assert.equal(message?.mailboxId, INBOX_MAILBOX);
755
- assert.equal(message?.originalMailboxId, undefined);
756
- // INBOX->Junk (report), Junk->INBOX (undo #1) — undo #2 must not add a
757
- // third INBOX->Junk move.
758
- assert.equal(moveEvents(sent).length, 2);
308
+ const restored = await row(messageId);
309
+ assert.equal(restored.mailboxId, inbox.mailboxId);
310
+ assert.equal(restored.originalMailboxId, undefined);
311
+ assert.equal(moves().length, 2);
759
312
  });
760
313
 
761
- it("throws without restoring or clearing provenance when the move has not settled yet (R2 wait)", async () => {
762
- const { service, messages, addresses } = buildWorld();
314
+ it("refuses to restore before the move settled, but still lifts the block", async () => {
315
+ const messageId = await arrive();
316
+ await report(messageId);
763
317
 
764
- await service.reportSpam({
765
- accountConfigId: ACCOUNT_CONFIG,
766
- accountId: ACCOUNT,
767
- messageId: MESSAGE_ID,
318
+ await assert.rejects(undo(messageId), (error: unknown) => {
319
+ assert.ok(error instanceof MoveNotSettledError);
320
+ assert.match(error.message, /has not settled yet/);
321
+ return true;
768
322
  });
769
- // Deliberately NOT settled: status stays "moving", as it would while
770
- // the original MESSAGE_MOVE is still genuinely in flight or retrying.
771
-
772
- await assert.rejects(
773
- () =>
774
- service.notSpam({
775
- accountConfigId: ACCOUNT_CONFIG,
776
- accountId: ACCOUNT,
777
- messageId: MESSAGE_ID,
778
- }),
779
- (error: unknown) => {
780
- // Must be the dedicated type, not a plain Error — callers that
781
- // surface failures to the user (packages/backend) allowlist this
782
- // specific type rather than relaying an arbitrary message.
783
- assert.ok(error instanceof MoveNotSettledError);
784
- assert.match(error.message, /has not settled yet/);
785
- return true;
786
- },
787
- );
788
323
 
789
- // The sender block clear is independent of the move (R2 reconcile) and
790
- // still lands even though the restore did not.
791
- const address = addresses.get(ADDRESS_ID);
792
- assert.equal(address?.flags.blocked, undefined);
793
-
794
- // But nothing move-related was touched — a real move #2 must not be
795
- // enqueued against an unsettled move #1.
796
- const message = messages.get(MESSAGE_ID);
797
- assert.equal(message?.mailboxId, JUNK_MAILBOX);
798
- assert.ok(message !== undefined);
799
- assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
324
+ assert.equal((await sender()).flags.blocked, undefined);
325
+ const unmoved = await row(messageId);
326
+ assert.equal(unmoved.mailboxId, junk?.mailboxId);
327
+ assert.ok((unmoved.spamReport?.reportedAt ?? 0) > 0);
800
328
  });
801
329
  });