@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
@@ -1,605 +0,0 @@
1
- /**
2
- * Issue #1011: a forced body re-sync must not re-fire standing rules on an
3
- * already-seen message.
4
- *
5
- * The two re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` IMAP fallback, and
6
- * `syncBodies(..., force: true)` (the read-miss re-arm cue) — both re-fetch a
7
- * body whose row already carries `bodyStorageKey`. That is a body-only fetch:
8
- * the decision pass (placement, filters, classification) already ran when the
9
- * body first landed. Re-running it now would move mail the user has since filed
10
- * by hand, re-fire the engagement counter, re-apply labels, and rewrite the
11
- * write-once category.
12
- *
13
- * Each test below presets a message whose body was already stored and whose
14
- * headers WOULD trigger a confident placement demote or a standing filter move
15
- * if the decision pass ran fresh. A regression that drops the `isReStore` skip
16
- * shows up as an unwanted move, not as a passing test relying on the heuristics
17
- * happening to agree.
18
- */
19
-
20
- import assert from "node:assert/strict";
21
- import { Readable } from "node:stream";
22
- import { describe, it } from "node:test";
23
- import type {
24
- FilterItem,
25
- IAddressRepository,
26
- IEnvelopeRepository,
27
- IFilterAnchorRepository,
28
- IFilterRepository,
29
- IMailboxSpecialUseRepository,
30
- IMessageLabelRepository,
31
- IMessageRepository,
32
- IThreadMessageRepository,
33
- MessageItem,
34
- UpdateMessageInput,
35
- } from "@remit/data-ports";
36
- import { NotFoundError } from "@remit/data-ports/errors";
37
- import {
38
- FilterClauseField,
39
- FilterMatchOperator,
40
- FilterState,
41
- MessageCategory,
42
- } from "@remit/domain-enums";
43
- import type { StorageService } from "@remit/storage-service";
44
- import type { PlacementConfig } from "./body-sync.js";
45
- import { BodySyncService } from "./body-sync.js";
46
- import { NO_ACTION } from "./filters/match.js";
47
- import type { FilterConfig } from "./filters/pipeline.js";
48
- import type { PlacementMoveService } from "./placement-move.js";
49
- import type { IImapConnection } from "./types.js";
50
-
51
- const MAILBOXES = {
52
- inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
53
- junk: { mailboxId: "mb-junk", fullPath: "Junk" },
54
- travel: { mailboxId: "mb-travel", fullPath: "Travel" },
55
- };
56
-
57
- /**
58
- * Demote headers: DKIM domain mismatches From, dmarc=fail, untrusted sender.
59
- * `classifyPlacement`'s demote branch (inbox → junk, HIGH bar) confidently acts
60
- * on this set when the message currently sits in Inbox.
61
- */
62
- const DEMOTE_EML = Buffer.from(
63
- [
64
- "From: Support <support@evil-mimic.example>",
65
- "To: me@example.com",
66
- "Subject: Verify your account",
67
- "Authentication-Results: mx.example.com; dmarc=fail",
68
- "DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
69
- "X-Spam-Status: No, score=0.1",
70
- "Content-Type: text/plain",
71
- "",
72
- "body",
73
- ].join("\r\n"),
74
- );
75
-
76
- /**
77
- * A standing literal filter that matches subjects containing "invitation" and
78
- * moves them to `destinationMailboxId`. Used to assert that a re-store does not
79
- * re-fire the filter on an already-seen message.
80
- */
81
- const buildInvitationFilter = (destinationMailboxId: string): FilterItem =>
82
- ({
83
- filterId: "flt-invite",
84
- accountConfigId: "cfg-1",
85
- name: "Invitations",
86
- scope: "Standing",
87
- state: FilterState.Active,
88
- hasAnchor: false,
89
- ruleChangedAt: 1,
90
- matchOperator: FilterMatchOperator.And,
91
- literalClauses: [{ field: FilterClauseField.Subject, value: "invitation" }],
92
- actionLabelId: NO_ACTION,
93
- actionMailboxId: destinationMailboxId,
94
- createdAt: 1,
95
- updatedAt: 1,
96
- }) as unknown as FilterItem;
97
-
98
- const INVITATION_EML = Buffer.from(
99
- [
100
- "From: Someone <someone@example.com>",
101
- "To: me@example.com",
102
- "Subject: You have a new invitation to Travel",
103
- "Content-Type: text/plain",
104
- "",
105
- "details",
106
- ].join("\r\n"),
107
- );
108
-
109
- interface Harness {
110
- service: BodySyncService;
111
- message: MessageItem;
112
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
113
- moves: Array<{ messageId: string; destinationMailboxId: string }>;
114
- }
115
-
116
- const noSuchKeyError = () =>
117
- Object.assign(new Error("missing"), { name: "NoSuchKey" });
118
-
119
- /**
120
- * Build a BodySyncService harness with both placement and filter configs wired,
121
- * so we can assert that neither fires during a re-store.
122
- */
123
- const buildHarness = (
124
- message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
125
- retrieve: () => Promise<Buffer>,
126
- filter: FilterItem | null,
127
- ): Harness => {
128
- const messageUpdates: Array<{
129
- messageId: string;
130
- input: UpdateMessageInput;
131
- }> = [];
132
- const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
133
-
134
- const messageRow = {
135
- uid: 1,
136
- mailboxId: MAILBOXES.inbox.mailboxId,
137
- ...message,
138
- } as unknown as MessageItem;
139
-
140
- const messageService = {
141
- get: async (messageId: string) =>
142
- messageId === message.messageId ? messageRow : undefined,
143
- update: async (messageId: string, input: UpdateMessageInput) => {
144
- messageUpdates.push({ messageId, input });
145
- Object.assign(messageRow, input);
146
- },
147
- } as unknown as IMessageRepository;
148
-
149
- const threadMessageService = {
150
- findAllByMessageId: async () => [
151
- {
152
- threadMessageId: "tm-1",
153
- messageId: message.messageId,
154
- mailboxId: messageRow.mailboxId,
155
- sentDate: 1,
156
- isRead: false,
157
- isDeleted: false,
158
- hasStars: false,
159
- hasAttachment: false,
160
- },
161
- ],
162
- update: async () => {},
163
- } as unknown as IThreadMessageRepository;
164
-
165
- const storageService = {
166
- retrieve,
167
- storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
168
- storeMessageBodyStream: async () => ({
169
- uri: `s3://bodies/${message.messageId}`,
170
- }),
171
- storeParsedBody: async () => {},
172
- listBodyParts: async () => [],
173
- } as unknown as StorageService;
174
-
175
- const addressService = {
176
- getAddress: async () => {
177
- throw new NotFoundError("Address not found");
178
- },
179
- incrementInboundCount: async () => {},
180
- } as unknown as IAddressRepository;
181
-
182
- const envelopeService = {
183
- listBodyParts: async () => [],
184
- } as unknown as IEnvelopeRepository;
185
-
186
- const mailboxSpecialUseService = {
187
- findJunkMailbox: async () => MAILBOXES.junk,
188
- findArchiveMailbox: async () => null,
189
- findInboxMailbox: async () => MAILBOXES.inbox,
190
- } as unknown as IMailboxSpecialUseRepository;
191
-
192
- const placementMoveService = {
193
- moveMessage: async (
194
- _accountConfigId: string,
195
- messageId: string,
196
- destinationMailboxId: string,
197
- ) => {
198
- moves.push({ messageId, destinationMailboxId });
199
- messageRow.mailboxId = destinationMailboxId;
200
- },
201
- } as unknown as PlacementMoveService;
202
-
203
- const placementConfig: PlacementConfig = {
204
- mailboxSpecialUseService,
205
- placementMoveService,
206
- };
207
-
208
- const filterConfig: FilterConfig | undefined = filter
209
- ? {
210
- filterService: {
211
- listByAccountAndState: async () => [filter],
212
- refreshExpiry: async (f: FilterItem) => f,
213
- } as unknown as IFilterRepository,
214
- filterAnchorService: {
215
- get: async () => undefined,
216
- } as unknown as IFilterAnchorRepository,
217
- messageLabelService: {
218
- apply: async () => {},
219
- } as unknown as IMessageLabelRepository,
220
- placementMoveService,
221
- }
222
- : undefined;
223
-
224
- const service = new BodySyncService(
225
- messageService,
226
- storageService,
227
- threadMessageService,
228
- addressService,
229
- envelopeService,
230
- { info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
231
- placementConfig,
232
- filterConfig,
233
- );
234
-
235
- return { service, message: messageRow, messageUpdates, moves };
236
- };
237
-
238
- const noSuchKeyConn = (body: Buffer): IImapConnection =>
239
- ({
240
- openBox: async () => {},
241
- fetchMessageBody: async () => body,
242
- }) as unknown as IImapConnection;
243
-
244
- const streamBody = (body: Buffer): Readable =>
245
- Readable.from([body]) as unknown as Readable;
246
-
247
- describe("issue #1011: forced re-store does not re-fire standing rules", () => {
248
- it("syncBodies(force: true) does not re-evaluate placement on a stored message", async () => {
249
- const harness = buildHarness(
250
- {
251
- messageId: "m-1",
252
- mailboxId: MAILBOXES.inbox.mailboxId,
253
- bodyStorageKey: "s3://bodies/m-1",
254
- // Already classified and placed — category and placementDecidedAt
255
- // set by a prior, genuine first-store pass.
256
- category: MessageCategory.personal,
257
- placementDecidedAt: 500,
258
- },
259
- async () => {
260
- throw new Error("force path must not retrieve from storage");
261
- },
262
- null, // no filter config
263
- );
264
-
265
- const connection = {
266
- openBox: async () => {},
267
- async *fetchMessageBodies(uids: number[]) {
268
- for (const uid of uids) {
269
- yield { uid, source: streamBody(DEMOTE_EML) };
270
- }
271
- },
272
- } as unknown as IImapConnection;
273
-
274
- const result = await harness.service.syncBodies(
275
- ["m-1"],
276
- "acc-1",
277
- "cfg-1",
278
- { fullPath: "INBOX", placement: "inbox" },
279
- async () => connection,
280
- true,
281
- );
282
-
283
- assert.deepEqual(result.syncedMessageIds, ["m-1"], "message is synced");
284
-
285
- // No placement move — a hand-rescued message stays where the user put it.
286
- assert.deepEqual(
287
- harness.moves,
288
- [],
289
- "a forced re-store must not re-decide placement, even though headers would demote",
290
- );
291
- assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
292
-
293
- // The update writes only bodyStorageKey — no placement or category churn.
294
- const update = harness.messageUpdates[0];
295
- assert.equal(update?.input.movedByRemit, undefined);
296
- assert.equal(update?.input.placementVerdict, undefined);
297
- assert.equal(update?.input.placementDecidedAt, undefined);
298
- assert.equal(update?.input.category, undefined);
299
- assert.deepEqual(
300
- Object.keys(update?.input ?? {}).sort(),
301
- ["bodyStorageKey"],
302
- "re-store must write only bodyStorageKey",
303
- );
304
- });
305
-
306
- it("syncBodies(force: true) does not re-fire a standing filter on a stored message", async () => {
307
- const harness = buildHarness(
308
- {
309
- messageId: "m-1",
310
- mailboxId: MAILBOXES.travel.mailboxId,
311
- bodyStorageKey: "s3://bodies/m-1",
312
- category: MessageCategory.personal,
313
- },
314
- async () => {
315
- throw new Error("force path must not retrieve from storage");
316
- },
317
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
318
- );
319
-
320
- const connection = {
321
- openBox: async () => {},
322
- async *fetchMessageBodies(uids: number[]) {
323
- for (const uid of uids) {
324
- yield { uid, source: streamBody(INVITATION_EML) };
325
- }
326
- },
327
- } as unknown as IImapConnection;
328
-
329
- const result = await harness.service.syncBodies(
330
- ["m-1"],
331
- "acc-1",
332
- "cfg-1",
333
- { fullPath: "INBOX", placement: "inbox" },
334
- async () => connection,
335
- true,
336
- );
337
-
338
- assert.deepEqual(result.syncedMessageIds, ["m-1"]);
339
-
340
- // No filter move — the message already sits in its filter destination
341
- // (Travel) and a re-store must not re-fire the filter.
342
- assert.deepEqual(
343
- harness.moves,
344
- [],
345
- "a forced re-store must not re-fire standing filters",
346
- );
347
- // The update writes only bodyStorageKey — no filterMove marker.
348
- const update = harness.messageUpdates[0];
349
- assert.equal(update?.input.filterMove, undefined);
350
- assert.equal(update?.input.movedByRemit, undefined);
351
- assert.deepEqual(
352
- Object.keys(update?.input ?? {}).sort(),
353
- ["bodyStorageKey"],
354
- "re-store must write only bodyStorageKey",
355
- );
356
- });
357
-
358
- it("fetchAndGetBody NoSuchKey fallback does not re-fire placement or filters", async () => {
359
- const harness = buildHarness(
360
- {
361
- messageId: "m-1",
362
- mailboxId: MAILBOXES.inbox.mailboxId,
363
- bodyStorageKey: "s3://bodies/m-1",
364
- category: MessageCategory.personal,
365
- placementDecidedAt: 500,
366
- },
367
- async () => {
368
- throw noSuchKeyError();
369
- },
370
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
371
- );
372
-
373
- await harness.service.fetchAndGetBody(
374
- "m-1",
375
- "acc-1",
376
- "cfg-1",
377
- { fullPath: "INBOX", placement: "inbox" },
378
- async () => noSuchKeyConn(DEMOTE_EML),
379
- );
380
-
381
- assert.deepEqual(
382
- harness.moves,
383
- [],
384
- "the NoSuchKey fallback must not re-decide placement or re-fire filters",
385
- );
386
- assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
387
-
388
- const update = harness.messageUpdates[0];
389
- assert.equal(update?.input.movedByRemit, undefined);
390
- assert.equal(update?.input.placementVerdict, undefined);
391
- assert.equal(update?.input.filterMove, undefined);
392
- assert.deepEqual(
393
- Object.keys(update?.input ?? {}).sort(),
394
- ["bodyStorageKey"],
395
- "re-store must write only bodyStorageKey",
396
- );
397
- });
398
-
399
- it("a first-store via fetchAndGetBody (no bodyStorageKey) still runs placement and filters", async () => {
400
- // Regression guard: the re-store skip must not disable decisions on a
401
- // genuine first store that happens to arrive via the read path.
402
- const harness = buildHarness(
403
- {
404
- messageId: "m-1",
405
- mailboxId: MAILBOXES.inbox.mailboxId,
406
- // No bodyStorageKey — a genuine first store.
407
- },
408
- async () => {
409
- throw new Error("must not retrieve from storage on a first store");
410
- },
411
- buildInvitationFilter(MAILBOXES.travel.mailboxId),
412
- );
413
-
414
- await harness.service.fetchAndGetBody(
415
- "m-1",
416
- "acc-1",
417
- "cfg-1",
418
- { fullPath: "INBOX", placement: "inbox" },
419
- async () => noSuchKeyConn(INVITATION_EML),
420
- );
421
-
422
- // The filter fires — it is a first store, not a re-store.
423
- assert.deepEqual(
424
- harness.moves,
425
- [{ messageId: "m-1", destinationMailboxId: MAILBOXES.travel.mailboxId }],
426
- "a genuine first store must run the standing filter",
427
- );
428
- const update = harness.messageUpdates[0];
429
- assert.equal(update?.input.filterMove?.filterId, "flt-invite");
430
- assert.equal(update?.input.movedByRemit, true);
431
- });
432
-
433
- it("a forced batch re-stores a stored message (bytes only) but first-stores a never-stored one", async () => {
434
- // A forced batch may contain both never-stored messages (first store —
435
- // full decisions) and already-stored ones (re-store — bytes only).
436
- const storedMessage = {
437
- messageId: "m-stored",
438
- mailboxId: MAILBOXES.inbox.mailboxId,
439
- uid: 1,
440
- bodyStorageKey: "s3://bodies/m-stored",
441
- category: MessageCategory.personal,
442
- } as unknown as MessageItem;
443
- const newMessage = {
444
- messageId: "m-new",
445
- mailboxId: MAILBOXES.inbox.mailboxId,
446
- uid: 2,
447
- } as unknown as MessageItem;
448
-
449
- const messageUpdates: Array<{
450
- messageId: string;
451
- input: UpdateMessageInput;
452
- }> = [];
453
- const moves: Array<{ messageId: string; destinationMailboxId: string }> =
454
- [];
455
-
456
- const messageService = {
457
- get: async (messageId: string) =>
458
- messageId === "m-stored"
459
- ? storedMessage
460
- : messageId === "m-new"
461
- ? newMessage
462
- : undefined,
463
- update: async (id: string, input: UpdateMessageInput) => {
464
- messageUpdates.push({ messageId: id, input });
465
- const row = id === "m-stored" ? storedMessage : newMessage;
466
- Object.assign(row, input);
467
- },
468
- } as unknown as IMessageRepository;
469
-
470
- const threadMessageService = {
471
- findAllByMessageId: async (
472
- _accountConfigId: string,
473
- messageId: string,
474
- ) => [
475
- {
476
- threadMessageId: `tm-${messageId}`,
477
- messageId,
478
- mailboxId:
479
- messageId === "m-stored"
480
- ? storedMessage.mailboxId
481
- : newMessage.mailboxId,
482
- sentDate: 1,
483
- isRead: false,
484
- isDeleted: false,
485
- hasStars: false,
486
- hasAttachment: false,
487
- },
488
- ],
489
- update: async () => {},
490
- } as unknown as IThreadMessageRepository;
491
-
492
- const storageService = {
493
- retrieve: async () => {
494
- throw new Error("must not retrieve on force path");
495
- },
496
- storeMessageBodyStream: async () => ({
497
- uri: "s3://bodies/m",
498
- }),
499
- storeParsedBody: async () => {},
500
- listBodyParts: async () => [],
501
- } as unknown as StorageService;
502
-
503
- const addressService = {
504
- getAddress: async () => {
505
- throw new NotFoundError("not found");
506
- },
507
- incrementInboundCount: async () => {},
508
- } as unknown as IAddressRepository;
509
-
510
- const envelopeService = {
511
- listBodyParts: async () => [],
512
- } as unknown as IEnvelopeRepository;
513
-
514
- const mailboxSpecialUseService = {
515
- findJunkMailbox: async () => MAILBOXES.junk,
516
- findArchiveMailbox: async () => null,
517
- findInboxMailbox: async () => MAILBOXES.inbox,
518
- } as unknown as IMailboxSpecialUseRepository;
519
-
520
- const placementMoveService = {
521
- moveMessage: async (
522
- _accountConfigId: string,
523
- messageId: string,
524
- destinationMailboxId: string,
525
- ) => {
526
- moves.push({ messageId, destinationMailboxId });
527
- },
528
- } as unknown as PlacementMoveService;
529
-
530
- const filterConfig: FilterConfig = {
531
- filterService: {
532
- listByAccountAndState: async () => [
533
- buildInvitationFilter(MAILBOXES.junk.mailboxId),
534
- ],
535
- refreshExpiry: async (f: FilterItem) => f,
536
- } as unknown as IFilterRepository,
537
- filterAnchorService: {
538
- get: async () => undefined,
539
- } as unknown as IFilterAnchorRepository,
540
- messageLabelService: {
541
- apply: async () => {},
542
- } as unknown as IMessageLabelRepository,
543
- placementMoveService,
544
- };
545
-
546
- const service = new BodySyncService(
547
- messageService,
548
- storageService,
549
- threadMessageService,
550
- addressService,
551
- envelopeService,
552
- { info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
553
- { mailboxSpecialUseService, placementMoveService },
554
- filterConfig,
555
- );
556
-
557
- // UID 1 is the stored message, UID 2 is new. fetchMessageBodies yields
558
- // in order; the stored one (DEMOTE_EML) would demote if placement ran,
559
- // the new one (INVITATION_EML) would trigger the filter.
560
- const connection = {
561
- openBox: async () => {},
562
- async *fetchMessageBodies() {
563
- yield { uid: 1, source: streamBody(DEMOTE_EML) };
564
- yield { uid: 2, source: streamBody(INVITATION_EML) };
565
- },
566
- } as unknown as IImapConnection;
567
-
568
- const result = await service.syncBodies(
569
- ["m-stored", "m-new"],
570
- "acc-1",
571
- "cfg-1",
572
- { fullPath: "INBOX", placement: "inbox" },
573
- async () => connection,
574
- true,
575
- );
576
-
577
- // Both messages were handled.
578
- assert.deepEqual(result.syncedMessageIds.sort(), ["m-new", "m-stored"]);
579
-
580
- // The stored message is a re-store: no placement move, no filter run.
581
- const storedUpdate = messageUpdates.find((u) => u.messageId === "m-stored");
582
- assert.equal(storedUpdate?.input.movedByRemit, undefined);
583
- assert.equal(storedUpdate?.input.filterMove, undefined);
584
- assert.deepEqual(
585
- Object.keys(storedUpdate?.input ?? {}).sort(),
586
- ["bodyStorageKey"],
587
- "stored message in a forced batch must re-store (bytes only)",
588
- );
589
-
590
- // The new message is a first-store: the filter fires.
591
- assert.deepEqual(
592
- moves,
593
- [
594
- {
595
- messageId: "m-new",
596
- destinationMailboxId: MAILBOXES.junk.mailboxId,
597
- },
598
- ],
599
- "only the never-stored message in the forced batch takes the decision pass",
600
- );
601
- const newUpdate = messageUpdates.find((u) => u.messageId === "m-new");
602
- assert.equal(newUpdate?.input.movedByRemit, true);
603
- assert.equal(newUpdate?.input.filterMove?.filterId, "flt-invite");
604
- });
605
- });