@remit/mailbox-service 0.0.111 → 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 +156 -235
  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 +5 -4
  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/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  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
@@ -0,0 +1,925 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import { DEFAULT_CALENDAR_URL_SEGMENT } from "@remit/calendar-service";
4
+ import type {
5
+ AccountItem,
6
+ AddressFlags,
7
+ MailboxItem,
8
+ UpdateMessageInput,
9
+ } from "@remit/data-ports";
10
+ import {
11
+ MessageGoneFromServerError,
12
+ MessagePlacementUnsettledError,
13
+ } from "@remit/data-ports/errors";
14
+ import { deriveAddressId } from "@remit/data-ports/id";
15
+ import {
16
+ CalendarInviteMethod,
17
+ CalendarSource,
18
+ CalendarSuggestionState,
19
+ FilterClauseField,
20
+ FilterScope,
21
+ MessageCategory,
22
+ MessageClassificationState,
23
+ } from "@remit/domain-enums";
24
+ import type { StorageService } from "@remit/storage-service";
25
+ import { type BodySyncFolder, BodySyncService } from "./body-sync.js";
26
+ import { NO_ACTION } from "./filters/match.js";
27
+ import { FlagPushService } from "./flag-push.js";
28
+ import { FlagQueueService } from "./flag-queue.js";
29
+ import { PlacementMoveService } from "./placement-move.js";
30
+ import { QuarantineService } from "./quarantine.js";
31
+ import { SyncWarningService } from "./sync-warning.js";
32
+ import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
33
+ import {
34
+ captureQueue,
35
+ messageAtUid,
36
+ openStore,
37
+ type QueueCapture,
38
+ type Store,
39
+ seedAccount,
40
+ seedMailbox,
41
+ silentLog,
42
+ syncFolder,
43
+ } from "./test-helpers/test-store.js";
44
+
45
+ const LINKEDIN = eml(
46
+ {
47
+ From: "LinkedIn <messages-noreply@linkedin.com>",
48
+ Subject: "You have a new invitation",
49
+ "List-Id": "LinkedIn Invitations <invitations.linkedin.com>",
50
+ "List-Unsubscribe": "<https://www.linkedin.com/e/unsub>",
51
+ },
52
+ "invitation body text",
53
+ );
54
+
55
+ const PERSONAL = eml(
56
+ { From: "Alex <alex@example.com>", Subject: "Dinner Friday?" },
57
+ "Are you free Friday night?",
58
+ );
59
+
60
+ const DEMOTE = eml({
61
+ From: "Support <support@evil-mimic.example>",
62
+ Subject: "Verify your account",
63
+ "Authentication-Results": "mx.example.com; dmarc=fail",
64
+ "DKIM-Signature": "v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
65
+ "X-Spam-Status": "No, score=0.1",
66
+ });
67
+
68
+ const SPAM_FLAGGED_DMARC_PASS = eml({
69
+ From: "Sender <someone@example.com>",
70
+ "Authentication-Results": "mx.example.com; dmarc=pass",
71
+ "X-Spam-Status": "Yes, score=6.0",
72
+ });
73
+
74
+ const FROM_SOMEONE = eml({ From: "Sender <someone@example.com>" });
75
+
76
+ const TRAVEL_INVITATION = eml({
77
+ From: "Someone <someone@example.com>",
78
+ Subject: "You have a new invitation to Travel",
79
+ });
80
+
81
+ const ICAL_UID = "invite-4711@example.test";
82
+
83
+ const invitation = (
84
+ sequence = 0,
85
+ method = "REQUEST",
86
+ start = "DTSTART:20260901T080000Z",
87
+ ): Buffer =>
88
+ Buffer.from(
89
+ [
90
+ "From: Organizer <organizer@example.test>",
91
+ "To: user@example.test",
92
+ "Subject: Quarterly review",
93
+ 'Content-Type: multipart/alternative; boundary="bnd"',
94
+ "",
95
+ "--bnd",
96
+ "Content-Type: text/plain",
97
+ "",
98
+ "You are invited.",
99
+ "--bnd",
100
+ `Content-Type: text/calendar; method=${method}; charset="UTF-8"`,
101
+ "",
102
+ "BEGIN:VCALENDAR",
103
+ "VERSION:2.0",
104
+ "PRODID:-//Example Corp//Scheduler//EN",
105
+ `METHOD:${method}`,
106
+ "BEGIN:VEVENT",
107
+ `UID:${ICAL_UID}`,
108
+ "DTSTAMP:20260801T090000Z",
109
+ `SEQUENCE:${sequence}`,
110
+ start,
111
+ "SUMMARY:Quarterly review",
112
+ "ORGANIZER:mailto:organizer@example.test",
113
+ "END:VEVENT",
114
+ "END:VCALENDAR",
115
+ "--bnd--",
116
+ "",
117
+ ].join("\r\n"),
118
+ );
119
+
120
+ const MISSING_BODY = "file:///nonexistent/remit/body.eml";
121
+
122
+ interface World {
123
+ store: Store;
124
+ imap: FakeImapServer;
125
+ queue: QueueCapture;
126
+ account: AccountItem;
127
+ inbox: MailboxItem;
128
+ junk: MailboxItem;
129
+ archive: MailboxItem;
130
+ travel: MailboxItem;
131
+ }
132
+
133
+ let world: World;
134
+
135
+ beforeEach(async () => {
136
+ const store = await openStore();
137
+ const account = await seedAccount(store);
138
+ const imap = new FakeImapServer();
139
+ for (const path of ["INBOX", "Junk", "Archive", "Travel"])
140
+ imap.addFolder(path);
141
+ world = {
142
+ store,
143
+ imap,
144
+ queue: captureQueue(),
145
+ account,
146
+ inbox: await seedMailbox(store, account, "INBOX"),
147
+ junk: await seedMailbox(store, account, "Junk", { specialUse: "Junk" }),
148
+ archive: await seedMailbox(store, account, "Archive", {
149
+ specialUse: "Archive",
150
+ }),
151
+ travel: await seedMailbox(store, account, "Travel"),
152
+ };
153
+ });
154
+
155
+ afterEach(async () => {
156
+ world.queue.restore();
157
+ await world.store.close();
158
+ });
159
+
160
+ interface Wiring {
161
+ storage?: StorageService;
162
+ placement?: boolean;
163
+ filters?: boolean;
164
+ quarantine?: boolean;
165
+ unsubscribe?: boolean;
166
+ calendar?: boolean;
167
+ }
168
+
169
+ const bodySync = (wiring: Wiring = {}): BodySyncService => {
170
+ const { store } = world;
171
+ const placementMoveService = new PlacementMoveService({
172
+ messageService: store.message,
173
+ threadMessageService: store.threadMessage,
174
+ markerService: store.placementMove,
175
+ addressService: store.address,
176
+ mailboxSpecialUseService: store.mailboxSpecialUse,
177
+ mailboxService: store.mailbox,
178
+ sqsQueueUrl: "https://sqs.test/message-mgmt",
179
+ });
180
+ const flagQueueService = new FlagQueueService({
181
+ messageFlagService: store.messageFlag,
182
+ messageService: store.message,
183
+ threadMessageService: store.threadMessage,
184
+ flagPushService: new FlagPushService({
185
+ markerService: store.flagPush,
186
+ sqsQueueUrl: "https://sqs.test/flags",
187
+ }),
188
+ });
189
+ return new BodySyncService(
190
+ store.message,
191
+ wiring.storage ?? store.storage(),
192
+ store.threadMessage,
193
+ store.address,
194
+ store.envelope,
195
+ new SyncWarningService(store.syncWarning),
196
+ silentLog,
197
+ wiring.placement
198
+ ? {
199
+ mailboxSpecialUseService: store.mailboxSpecialUse,
200
+ placementMoveService,
201
+ }
202
+ : undefined,
203
+ wiring.filters
204
+ ? {
205
+ filterService: store.filter,
206
+ messageLabelService: store.messageLabel,
207
+ mailboxService: store.mailbox,
208
+ placementMoveService,
209
+ }
210
+ : undefined,
211
+ wiring.quarantine
212
+ ? {
213
+ quarantineService: new QuarantineService(
214
+ store.quarantine,
215
+ store.mailboxSpecialUse,
216
+ "sha-test",
217
+ silentLog,
218
+ ),
219
+ mailboxId: world.inbox.mailboxId,
220
+ uidValidity: world.inbox.uidValidity,
221
+ attempts: 1,
222
+ }
223
+ : undefined,
224
+ wiring.unsubscribe ? { flagQueueService } : undefined,
225
+ wiring.calendar
226
+ ? {
227
+ calendarSuggestionService: store.calendarSuggestion,
228
+ calendarStore: store.calendarStore,
229
+ filterService: store.filter,
230
+ }
231
+ : undefined,
232
+ );
233
+ };
234
+
235
+ const placementOf = (mailbox: MailboxItem): BodySyncFolder => ({
236
+ mailboxId: mailbox.mailboxId,
237
+ fullPath: mailbox.fullPath,
238
+ placement:
239
+ mailbox === world.inbox
240
+ ? "inbox"
241
+ : mailbox === world.junk
242
+ ? "junk"
243
+ : "other",
244
+ });
245
+
246
+ const arrive = async (
247
+ raw: Buffer,
248
+ mailbox: MailboxItem = world.inbox,
249
+ update: UpdateMessageInput = {},
250
+ ): Promise<string> => {
251
+ const uid = world.imap.deliver(mailbox.fullPath, raw);
252
+ await syncFolder(world.store, world.account, mailbox, world.imap);
253
+ const { messageId } = await messageAtUid(world.store, mailbox, uid);
254
+ await world.store.message.update(messageId, update);
255
+ return messageId;
256
+ };
257
+
258
+ const read = (
259
+ service: BodySyncService,
260
+ messageId: string,
261
+ mailbox: MailboxItem = world.inbox,
262
+ ) =>
263
+ service.fetchAndGetBody(
264
+ messageId,
265
+ world.account.accountId,
266
+ world.account.accountConfigId,
267
+ placementOf(mailbox),
268
+ async () => world.imap.connection(),
269
+ );
270
+
271
+ const sync = (
272
+ service: BodySyncService,
273
+ messageIds: string[],
274
+ options: { force?: boolean; mailbox?: MailboxItem } = {},
275
+ ) =>
276
+ service.syncBodies(
277
+ messageIds,
278
+ world.account.accountId,
279
+ world.account.accountConfigId,
280
+ {
281
+ ...placementOf(options.mailbox ?? world.inbox),
282
+ uidValidity: (options.mailbox ?? world.inbox).uidValidity,
283
+ },
284
+ async () => world.imap.connection(),
285
+ options.force ?? false,
286
+ );
287
+
288
+ const flagSender = async (email: string, flags: AddressFlags) => {
289
+ const [localPart, domain] = email.split("@");
290
+ const addressId = deriveAddressId(world.account.accountConfigId, email);
291
+ await world.store.address.upsertAddress({
292
+ addressId,
293
+ accountConfigId: world.account.accountConfigId,
294
+ localPart,
295
+ domain,
296
+ normalizedEmail: email,
297
+ normalizedCompound: email,
298
+ });
299
+ await world.store.address.updateAddress(
300
+ world.account.accountConfigId,
301
+ addressId,
302
+ { flags },
303
+ );
304
+ };
305
+
306
+ const messageRow = (messageId: string) => world.store.message.get(messageId);
307
+
308
+ const threadRows = (messageId: string) =>
309
+ world.store.threadMessage.findAllByMessageId(
310
+ world.account.accountConfigId,
311
+ messageId,
312
+ );
313
+
314
+ const addFilter = (
315
+ clauses: Array<{ field: string; value: string }>,
316
+ actionMailboxId: string = NO_ACTION,
317
+ actionLabelId: string = NO_ACTION,
318
+ ) =>
319
+ world.store.filter.create({
320
+ accountConfigId: world.account.accountConfigId,
321
+ name: "rule",
322
+ scope: FilterScope.Standing,
323
+ literalClauses: clauses as never,
324
+ actionMailboxId,
325
+ actionLabelId,
326
+ });
327
+
328
+ describe("classification is decided on the pass that first stores a body", () => {
329
+ it("writes the category, the examined mark and every denormalized field", async () => {
330
+ const messageId = await arrive(LINKEDIN);
331
+ await world.store.threadMessage.create({
332
+ ...(await threadRows(messageId))[0],
333
+ threadId: "drifted-root",
334
+ });
335
+
336
+ await sync(bodySync(), [messageId]);
337
+
338
+ const row = await messageRow(messageId);
339
+ assert.equal(row.category, MessageCategory.social);
340
+ assert.equal(row.classificationState, MessageClassificationState.Examined);
341
+ assert.ok(row.bodyStorageKey);
342
+ const rows = await threadRows(messageId);
343
+ assert.equal(rows.length, 2);
344
+ for (const thread of rows) {
345
+ assert.equal(thread.category, MessageCategory.social);
346
+ assert.equal(thread.listId, "invitations.linkedin.com");
347
+ assert.equal(thread.snippet, "invitation body text");
348
+ }
349
+ });
350
+
351
+ it("examines a body the classifier has nothing to say about once", async () => {
352
+ const messageId = await arrive(PERSONAL);
353
+ const service = bodySync();
354
+
355
+ await sync(service, [messageId]);
356
+ const second = await sync(service, [messageId]);
357
+
358
+ assert.equal(second.skippedCount, 1);
359
+ const row = await messageRow(messageId);
360
+ assert.equal(row.classificationState, MessageClassificationState.Examined);
361
+ });
362
+
363
+ it("leaves mail stored before the classifier ran for the backfill to find", async () => {
364
+ const messageId = await arrive(LINKEDIN, world.inbox, {
365
+ bodyStorageKey: MISSING_BODY,
366
+ classificationState: MessageClassificationState.NotExamined,
367
+ });
368
+
369
+ const result = await sync(bodySync(), [messageId]);
370
+
371
+ assert.equal(result.skippedCount, 1);
372
+ const row = await messageRow(messageId);
373
+ assert.equal(
374
+ row.classificationState,
375
+ MessageClassificationState.NotExamined,
376
+ );
377
+ assert.equal(row.category, MessageCategory.uncategorized);
378
+ });
379
+
380
+ it("classifies by the sender's category override instead of the headers", async () => {
381
+ await flagSender("messages-noreply@linkedin.com", {
382
+ category: { value: MessageCategory.personal, setAt: 1 },
383
+ });
384
+ const messageId = await arrive(LINKEDIN);
385
+
386
+ await read(bodySync(), messageId);
387
+
388
+ assert.equal(
389
+ (await messageRow(messageId)).category,
390
+ MessageCategory.personal,
391
+ );
392
+ assert.equal(
393
+ (await threadRows(messageId))[0].category,
394
+ MessageCategory.personal,
395
+ );
396
+ });
397
+
398
+ it("does not reach back to examined mail when the sender is reclassified later", async () => {
399
+ const messageId = await arrive(LINKEDIN, world.inbox, {
400
+ bodyStorageKey: MISSING_BODY,
401
+ classificationState: MessageClassificationState.Examined,
402
+ });
403
+ await flagSender("messages-noreply@linkedin.com", {
404
+ category: { value: MessageCategory.transactional, setAt: 1 },
405
+ });
406
+
407
+ await sync(bodySync(), [messageId]);
408
+
409
+ assert.equal(
410
+ (await messageRow(messageId)).category,
411
+ MessageCategory.uncategorized,
412
+ );
413
+ });
414
+ });
415
+
416
+ describe("a re-store re-fetches bytes and re-decides nothing", () => {
417
+ const decidedRow = (
418
+ overrides: UpdateMessageInput = {},
419
+ ): UpdateMessageInput => ({
420
+ bodyStorageKey: MISSING_BODY,
421
+ category: MessageCategory.marketing,
422
+ classificationState: MessageClassificationState.Examined,
423
+ placementDecidedAt: 500,
424
+ ...overrides,
425
+ });
426
+
427
+ for (const path of ["the missing-object read", "a forced sync"] as const) {
428
+ const restore = (
429
+ service: BodySyncService,
430
+ messageId: string,
431
+ mailbox: MailboxItem = world.inbox,
432
+ ) =>
433
+ path === "a forced sync"
434
+ ? sync(service, [messageId], { force: true, mailbox })
435
+ : read(service, messageId, mailbox);
436
+
437
+ it(`keeps category, placement and read state through ${path}`, async () => {
438
+ await flagSender("support@evil-mimic.example", {
439
+ category: { value: MessageCategory.personal, setAt: 1 },
440
+ unsubscribed: { value: true, setAt: 1 },
441
+ });
442
+ const messageId = await arrive(DEMOTE, world.inbox, decidedRow());
443
+
444
+ await restore(
445
+ bodySync({ placement: true, filters: true, unsubscribe: true }),
446
+ messageId,
447
+ );
448
+
449
+ const row = await messageRow(messageId);
450
+ assert.notEqual(row.bodyStorageKey, MISSING_BODY);
451
+ assert.equal(row.category, MessageCategory.marketing);
452
+ assert.equal(row.mailboxId, world.inbox.mailboxId);
453
+ assert.equal(row.movedByRemit, false);
454
+ assert.equal(row.placementDecidedAt, 500);
455
+ assert.equal((await threadRows(messageId))[0].isRead, false);
456
+ assert.deepEqual(world.queue.sent, []);
457
+ });
458
+
459
+ it(`does not re-fire a standing filter through ${path}`, async () => {
460
+ await addFilter(
461
+ [{ field: FilterClauseField.Subject, value: "invitation" }],
462
+ world.junk.mailboxId,
463
+ );
464
+ const messageId = await arrive(
465
+ TRAVEL_INVITATION,
466
+ world.travel,
467
+ decidedRow(),
468
+ );
469
+
470
+ await restore(bodySync({ filters: true }), messageId, world.travel);
471
+
472
+ const row = await messageRow(messageId);
473
+ assert.equal(row.mailboxId, world.travel.mailboxId);
474
+ assert.equal(row.filterMove, undefined);
475
+ });
476
+ }
477
+
478
+ it("first-stores the never-stored message of a forced batch with the full pass", async () => {
479
+ await addFilter(
480
+ [{ field: FilterClauseField.Subject, value: "invitation" }],
481
+ world.travel.mailboxId,
482
+ );
483
+ const stored = await arrive(DEMOTE, world.inbox, decidedRow());
484
+ const fresh = await arrive(TRAVEL_INVITATION);
485
+
486
+ const result = await sync(
487
+ bodySync({ placement: true, filters: true }),
488
+ [stored, fresh],
489
+ { force: true },
490
+ );
491
+
492
+ assert.deepEqual(result.syncedMessageIds.sort(), [fresh, stored].sort());
493
+ assert.equal((await messageRow(stored)).mailboxId, world.inbox.mailboxId);
494
+ const filed = await messageRow(fresh);
495
+ assert.equal(filed.mailboxId, world.travel.mailboxId);
496
+ assert.equal(filed.movedByRemit, true);
497
+ });
498
+ });
499
+
500
+ describe("placement follows the sender's flags", () => {
501
+ it("files a blocked sender's inbox mail to Junk with no authentication signal", async () => {
502
+ await flagSender("someone@example.com", {
503
+ blocked: { value: true, setAt: 1_000 },
504
+ });
505
+ const messageId = await arrive(FROM_SOMEONE);
506
+
507
+ await read(bodySync({ placement: true }), messageId);
508
+
509
+ const row = await messageRow(messageId);
510
+ assert.equal(row.mailboxId, world.junk.mailboxId);
511
+ assert.equal(row.movedByRemit, true);
512
+ assert.ok(row.placementDecidedAt);
513
+ assert.equal(
514
+ (await world.store.placementMove.find(messageId))?.state,
515
+ "queued",
516
+ );
517
+ assert.deepEqual(
518
+ world.queue.sent.map((event) => event.body.type),
519
+ ["PLACEMENT_MOVE_PUSH"],
520
+ );
521
+ });
522
+
523
+ it("leaves an unflagged sender's inbox mail where it is", async () => {
524
+ const messageId = await arrive(FROM_SOMEONE);
525
+
526
+ await read(bodySync({ placement: true }), messageId);
527
+
528
+ assert.equal(
529
+ (await messageRow(messageId)).mailboxId,
530
+ world.inbox.mailboxId,
531
+ );
532
+ assert.equal(await world.store.placementMove.find(messageId), null);
533
+ });
534
+
535
+ it("rescues from Junk when vip was set after blocked", async () => {
536
+ await flagSender("someone@example.com", {
537
+ blocked: { value: true, setAt: 1_000 },
538
+ vip: { value: true, setAt: 5_000 },
539
+ });
540
+ const messageId = await arrive(SPAM_FLAGGED_DMARC_PASS, world.junk);
541
+
542
+ await read(bodySync({ placement: true }), messageId, world.junk);
543
+
544
+ assert.equal(
545
+ (await messageRow(messageId)).mailboxId,
546
+ world.inbox.mailboxId,
547
+ );
548
+ });
549
+
550
+ it("files an autoArchive sender's inbox mail to Archive", async () => {
551
+ await flagSender("someone@example.com", {
552
+ autoArchive: { value: true, setAt: 1_000 },
553
+ });
554
+ const messageId = await arrive(FROM_SOMEONE);
555
+
556
+ await read(bodySync({ placement: true }), messageId);
557
+
558
+ assert.equal(
559
+ (await messageRow(messageId)).mailboxId,
560
+ world.archive.mailboxId,
561
+ );
562
+ });
563
+
564
+ it("lets a blocked demote win over autoArchive", async () => {
565
+ await flagSender("someone@example.com", {
566
+ blocked: { value: true, setAt: 1_000 },
567
+ autoArchive: { value: true, setAt: 1_000 },
568
+ });
569
+ const messageId = await arrive(FROM_SOMEONE);
570
+
571
+ await read(bodySync({ placement: true }), messageId);
572
+
573
+ assert.equal((await messageRow(messageId)).mailboxId, world.junk.mailboxId);
574
+ });
575
+
576
+ it("does not auto-archive mail out of Junk", async () => {
577
+ await flagSender("someone@example.com", {
578
+ autoArchive: { value: true, setAt: 1_000 },
579
+ });
580
+ const messageId = await arrive(SPAM_FLAGGED_DMARC_PASS, world.junk);
581
+
582
+ await read(bodySync({ placement: true }), messageId, world.junk);
583
+
584
+ assert.equal((await messageRow(messageId)).mailboxId, world.junk.mailboxId);
585
+ });
586
+
587
+ it("does not re-file mail Remit already placed", async () => {
588
+ await flagSender("someone@example.com", {
589
+ autoArchive: { value: true, setAt: 1_000 },
590
+ });
591
+ const messageId = await arrive(FROM_SOMEONE, world.inbox, {
592
+ movedByRemit: true,
593
+ });
594
+
595
+ await read(bodySync({ placement: true }), messageId);
596
+
597
+ assert.equal(
598
+ (await messageRow(messageId)).mailboxId,
599
+ world.inbox.mailboxId,
600
+ );
601
+ });
602
+ });
603
+
604
+ describe("a standing filter runs on the first store", () => {
605
+ it("moves the message and records where it came from", async () => {
606
+ const filter = await addFilter(
607
+ [{ field: FilterClauseField.Subject, value: "invitation" }],
608
+ world.travel.mailboxId,
609
+ );
610
+ const messageId = await arrive(TRAVEL_INVITATION);
611
+
612
+ await read(bodySync({ filters: true }), messageId);
613
+
614
+ const row = await messageRow(messageId);
615
+ assert.equal(row.mailboxId, world.travel.mailboxId);
616
+ assert.equal(row.movedByRemit, true);
617
+ assert.deepEqual(
618
+ {
619
+ filterId: row.filterMove?.filterId,
620
+ sourceMailboxId: row.filterMove?.sourceMailboxId,
621
+ destinationMailboxId: row.filterMove?.destinationMailboxId,
622
+ },
623
+ {
624
+ filterId: filter.filterId,
625
+ sourceMailboxId: world.inbox.mailboxId,
626
+ destinationMailboxId: world.travel.mailboxId,
627
+ },
628
+ );
629
+ });
630
+
631
+ it("records no move for a message already in the filter's destination", async () => {
632
+ await addFilter(
633
+ [{ field: FilterClauseField.Subject, value: "invitation" }],
634
+ world.travel.mailboxId,
635
+ );
636
+ const messageId = await arrive(TRAVEL_INVITATION, world.travel);
637
+
638
+ await read(bodySync({ filters: true }), messageId, world.travel);
639
+
640
+ const row = await messageRow(messageId);
641
+ assert.equal(row.filterMove, undefined);
642
+ assert.equal(row.movedByRemit, false);
643
+ });
644
+ });
645
+
646
+ describe("an unsubscribed sender's mail is marked read once", () => {
647
+ it("marks it read on the first store", async () => {
648
+ await flagSender("someone@example.com", {
649
+ unsubscribed: { value: true, setAt: 1 },
650
+ });
651
+ const messageId = await arrive(FROM_SOMEONE);
652
+
653
+ await read(bodySync({ unsubscribe: true }), messageId);
654
+
655
+ assert.equal((await threadRows(messageId))[0].isRead, true);
656
+ assert.ok(
657
+ await world.store.flagPush.find(messageId, "\\Seen"),
658
+ "the read owes IMAP a \\Seen push",
659
+ );
660
+ });
661
+
662
+ it("leaves read state alone once the flag is unset", async () => {
663
+ await flagSender("someone@example.com", {
664
+ unsubscribed: { value: false, setAt: 1 },
665
+ });
666
+ const messageId = await arrive(FROM_SOMEONE);
667
+
668
+ await read(bodySync({ unsubscribe: true }), messageId);
669
+
670
+ assert.equal((await threadRows(messageId))[0].isRead, false);
671
+ });
672
+ });
673
+
674
+ describe("reading a message the mail server no longer holds", () => {
675
+ const gone = async (inFlight = false) => {
676
+ const messageId = await arrive(FROM_SOMEONE);
677
+ const { uid } = await messageRow(messageId);
678
+ if (inFlight) {
679
+ await world.store.message.transitionPlacement(
680
+ messageId,
681
+ { status: "active" },
682
+ { status: "moving", syncStatus: "pending" },
683
+ );
684
+ }
685
+ world.imap.expunge("INBOX", uid);
686
+ const outcome = await read(bodySync(), messageId).then(
687
+ () => assert.fail("expected the read to be refused"),
688
+ (error: unknown) => error,
689
+ );
690
+ return { messageId, outcome };
691
+ };
692
+
693
+ it("answers 404 and removes the settled row with its thread entries", async () => {
694
+ const { messageId, outcome } = await gone();
695
+
696
+ assert.ok(outcome instanceof MessageGoneFromServerError);
697
+ assert.equal(outcome.statusCode, 404);
698
+ await assert.rejects(messageRow(messageId));
699
+ assert.deepEqual(await threadRows(messageId), []);
700
+ });
701
+
702
+ it("answers 409 and deletes nothing while our own move is in flight", async () => {
703
+ const { messageId, outcome } = await gone(true);
704
+
705
+ assert.ok(outcome instanceof MessagePlacementUnsettledError);
706
+ assert.equal(outcome.statusCode, 409);
707
+ assert.equal((await threadRows(messageId)).length, 1);
708
+ });
709
+
710
+ it("answers 409 when another writer moved the row while the server was asked", async () => {
711
+ const messageId = await arrive(FROM_SOMEONE);
712
+ const { uid } = await messageRow(messageId);
713
+ world.imap.expunge("INBOX", uid);
714
+ world.imap.onFetchBody = async () => {
715
+ await world.store.message.transitionPlacement(
716
+ messageId,
717
+ { status: "active" },
718
+ { mailboxId: world.travel.mailboxId, uid: 99 },
719
+ );
720
+ };
721
+
722
+ await assert.rejects(
723
+ read(bodySync(), messageId),
724
+ MessagePlacementUnsettledError,
725
+ );
726
+ assert.equal((await threadRows(messageId)).length, 1);
727
+ });
728
+
729
+ it("fails loudly and deletes nothing when the server still lists the UID", async () => {
730
+ const messageId = await arrive(FROM_SOMEONE);
731
+ const connection = world.imap.connection();
732
+ connection.fetchMessageBody = async () => null;
733
+
734
+ await assert.rejects(
735
+ bodySync().fetchAndGetBody(
736
+ messageId,
737
+ world.account.accountId,
738
+ world.account.accountConfigId,
739
+ placementOf(world.inbox),
740
+ async () => connection,
741
+ ),
742
+ /which the server still lists/,
743
+ );
744
+ assert.ok(await messageRow(messageId));
745
+ });
746
+ });
747
+
748
+ describe("body sync and the quarantine", () => {
749
+ it("skips a uid already set aside without fetching it", async () => {
750
+ const messageId = await arrive(FROM_SOMEONE);
751
+ const { uid } = await messageRow(messageId);
752
+ await new QuarantineService(
753
+ world.store.quarantine,
754
+ world.store.mailboxSpecialUse,
755
+ "sha-test",
756
+ silentLog,
757
+ ).record(
758
+ {
759
+ accountId: world.account.accountId,
760
+ accountConfigId: world.account.accountConfigId,
761
+ mailboxId: world.inbox.mailboxId,
762
+ mailboxPath: "INBOX",
763
+ uidValidity: world.inbox.uidValidity,
764
+ attempts: 3,
765
+ },
766
+ uid,
767
+ { stage: "BodyParse", code: "UnreadableBody", message: "boundary" },
768
+ );
769
+
770
+ const result = await bodySync({ quarantine: true }).syncBodies(
771
+ [messageId],
772
+ world.account.accountId,
773
+ world.account.accountConfigId,
774
+ { ...placementOf(world.inbox), uidValidity: world.inbox.uidValidity },
775
+ async () => assert.fail("a quarantined message is not fetched"),
776
+ );
777
+
778
+ assert.equal(result.skippedCount, 1);
779
+ assert.equal((await messageRow(messageId)).bodyStorageKey, undefined);
780
+ });
781
+
782
+ it("requeues a storage failure rather than calling the message unreadable", async () => {
783
+ const messageId = await arrive(FROM_SOMEONE);
784
+
785
+ await assert.rejects(
786
+ sync(
787
+ bodySync({ quarantine: true, storage: world.store.brokenStorage() }),
788
+ [messageId],
789
+ ),
790
+ );
791
+
792
+ assert.deepEqual(
793
+ await world.store.quarantine.listByAccountConfigId(
794
+ world.account.accountConfigId,
795
+ ),
796
+ [],
797
+ );
798
+ assert.equal((await messageRow(messageId)).bodyStorageKey, undefined);
799
+ });
800
+ });
801
+
802
+ describe("mail-derived calendar suggestions", () => {
803
+ const suggestions = (messageId: string) =>
804
+ world.store.calendarSuggestion.listByMessage(
805
+ world.account.accountConfigId,
806
+ messageId,
807
+ );
808
+
809
+ it("offers an invitation as one Pending card carrying its bytes", async () => {
810
+ const messageId = await arrive(invitation());
811
+
812
+ await read(bodySync({ calendar: true }), messageId);
813
+
814
+ const [row, ...rest] = await suggestions(messageId);
815
+ assert.deepEqual(rest, []);
816
+ assert.equal(row.state, CalendarSuggestionState.Pending);
817
+ assert.equal(row.icalUid, ICAL_UID);
818
+ assert.equal(row.method, CalendarInviteMethod.Request);
819
+ assert.equal(row.organizer, "organizer@example.test");
820
+ assert.match(row.icalData, /UID:invite-4711@example\.test/);
821
+ });
822
+
823
+ it("reads a floating start in the collection's own zone", async () => {
824
+ await world.store.calendarCollection.create({
825
+ accountConfigId: world.account.accountConfigId,
826
+ urlSegment: DEFAULT_CALENDAR_URL_SEGMENT,
827
+ displayName: "Calendar",
828
+ source: CalendarSource.Default,
829
+ timezone: "Europe/Amsterdam",
830
+ });
831
+ const messageId = await arrive(
832
+ invitation(0, "REQUEST", "DTSTART:20260901T100000"),
833
+ );
834
+
835
+ await read(bodySync({ calendar: true }), messageId);
836
+
837
+ const [row] = await suggestions(messageId);
838
+ assert.equal(row.dtStart, "2026-09-01T10:00:00+02:00");
839
+ assert.equal(row.zoneCertainty, "Local");
840
+ });
841
+
842
+ it("offers no card for a sender the user muted", async () => {
843
+ await addFilter([
844
+ { field: FilterClauseField.From, value: "organizer@example.test" },
845
+ ]);
846
+ const messageId = await arrive(invitation());
847
+
848
+ await read(bodySync({ calendar: true }), messageId);
849
+
850
+ assert.deepEqual(await suggestions(messageId), []);
851
+ });
852
+
853
+ it("still offers a card when the rule about this sender files its mail", async () => {
854
+ await addFilter(
855
+ [{ field: FilterClauseField.From, value: "organizer@example.test" }],
856
+ world.travel.mailboxId,
857
+ );
858
+ const messageId = await arrive(invitation());
859
+
860
+ await read(bodySync({ calendar: true }), messageId);
861
+
862
+ assert.equal((await suggestions(messageId)).length, 1);
863
+ });
864
+
865
+ it("offers nothing on a re-store", async () => {
866
+ const messageId = await arrive(invitation(), world.inbox, {
867
+ bodyStorageKey: MISSING_BODY,
868
+ });
869
+
870
+ await read(bodySync({ calendar: true }), messageId);
871
+
872
+ assert.deepEqual(await suggestions(messageId), []);
873
+ });
874
+
875
+ it("supersedes the earlier revision when a higher SEQUENCE arrives", async () => {
876
+ const service = bodySync({ calendar: true });
877
+ const first = await arrive(invitation(0));
878
+ const second = await arrive(invitation(1));
879
+
880
+ await read(service, first);
881
+ await read(service, second);
882
+
883
+ assert.equal(
884
+ (await suggestions(first))[0].state,
885
+ CalendarSuggestionState.Superseded,
886
+ );
887
+ assert.equal(
888
+ (await suggestions(second))[0].state,
889
+ CalendarSuggestionState.Pending,
890
+ );
891
+ });
892
+
893
+ it("offers a cancellation as its own card", async () => {
894
+ const messageId = await arrive(invitation(1, "CANCEL"));
895
+
896
+ await read(bodySync({ calendar: true }), messageId);
897
+
898
+ const [row] = await suggestions(messageId);
899
+ assert.equal(row.method, CalendarInviteMethod.Cancel);
900
+ assert.equal(row.acceptedCalendarObjectId, "");
901
+ });
902
+
903
+ it("stores the body when the iCalendar is unreadable", async () => {
904
+ const messageId = await arrive(
905
+ Buffer.from(
906
+ [
907
+ "From: Organizer <organizer@example.test>",
908
+ 'Content-Type: multipart/alternative; boundary="bnd"',
909
+ "",
910
+ "--bnd",
911
+ "Content-Type: text/calendar",
912
+ "",
913
+ "BEGIN:VCALENDAR",
914
+ "--bnd--",
915
+ "",
916
+ ].join("\r\n"),
917
+ ),
918
+ );
919
+
920
+ await read(bodySync({ calendar: true }), messageId);
921
+
922
+ assert.ok((await messageRow(messageId)).bodyStorageKey);
923
+ assert.deepEqual(await suggestions(messageId), []);
924
+ });
925
+ });