@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,396 +0,0 @@
1
- /**
2
- * Issue #845 item 3. While a move is in flight the Message row names the
3
- * destination folder but still carries the SOURCE folder's uid, so a delete
4
- * that binds the pair as it stands sends the worker to expunge the destination
5
- * folder's own message at that uid — an unrelated message, destroyed for good.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { describe, it } from "node:test";
10
- import type {
11
- IAddressRepository,
12
- IMailboxRepository,
13
- IMailboxSpecialUseRepository,
14
- IMessageRepository,
15
- IThreadMessageRepository,
16
- } from "@remit/data-ports";
17
- import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
18
- import type { RoleResolution } from "@remit/data-ports/folder-role";
19
- import { MessageMoveService } from "./message-move.js";
20
- import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
21
-
22
- const ACCOUNT = "acc-1";
23
- const ACCOUNT_CONFIG = "cfg-1";
24
- const INBOX = "mbx-inbox";
25
- const ARCHIVE = "mbx-archive";
26
- const TRASH = "mbx-trash";
27
-
28
- const MOVING_ID = "msg-moving";
29
- const SETTLED_ID = "msg-settled";
30
- const STRANDED_ID = "msg-stranded";
31
- const COPY_ID = "msg-copy";
32
-
33
- const INBOX_UID = 42;
34
- const ARCHIVE_UID = 907;
35
- const SETTLED_UID = 11;
36
-
37
- type TrashMailbox = { mailboxId: string; fullPath: string };
38
-
39
- const flaggedTrash: RoleResolution<TrashMailbox> = {
40
- kind: "flagged",
41
- mailbox: { mailboxId: TRASH, fullPath: "INBOX/Trash" },
42
- };
43
-
44
- interface CapturedEvent {
45
- type: string;
46
- messageId?: string;
47
- operation?: string;
48
- mailboxId?: string;
49
- mailboxPath?: string;
50
- uid?: number;
51
- sourceMailboxPath?: string;
52
- }
53
-
54
- const mailboxes = [
55
- { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
56
- { mailboxId: ARCHIVE, fullPath: "Archive", accountId: ACCOUNT },
57
- { mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
58
- ];
59
-
60
- /** A row mid-move: Archive is already written, the uid is still INBOX's. */
61
- const movingRow = () => ({
62
- messageId: MOVING_ID,
63
- mailboxId: ARCHIVE,
64
- uid: INBOX_UID,
65
- status: "moving",
66
- syncStatus: "pending",
67
- originalMailboxId: INBOX,
68
- originalUid: INBOX_UID,
69
- });
70
-
71
- /**
72
- * A row a delete gave up on. R3 defines the give-up as a hand-back: the row
73
- * goes back on the placement the mail server confirmed, and only then carries
74
- * `abandoned`. The pair is honest, so nothing downstream refuses it — the
75
- * failure is a statement the reading pane makes, never a gate.
76
- */
77
- const strandedRow = () => ({
78
- messageId: STRANDED_ID,
79
- mailboxId: INBOX,
80
- uid: INBOX_UID,
81
- status: "active",
82
- syncStatus: "abandoned",
83
- abandonedMutation: "delete",
84
- originalMailboxId: INBOX,
85
- });
86
-
87
- /** An ordinary settled row. */
88
- const settledRow = () => ({
89
- messageId: SETTLED_ID,
90
- mailboxId: INBOX,
91
- uid: SETTLED_UID,
92
- status: "active",
93
- syncStatus: "synced",
94
- });
95
-
96
- /**
97
- * A freshly copied row: `moving` until COPYUID lands, with no server-side uid
98
- * at all and no move behind it. Its uid is not ready; it does not name anyone.
99
- */
100
- const freshCopyRow = () => ({
101
- messageId: COPY_ID,
102
- mailboxId: ARCHIVE,
103
- uid: 0,
104
- status: "moving",
105
- syncStatus: "pending",
106
- });
107
-
108
- const buildWorld = (seed: Array<Record<string, unknown>>) => {
109
- const patches: Array<{ messageId: string; patch: Record<string, unknown> }> =
110
- [];
111
- const events: CapturedEvent[] = [];
112
- const rows = new Map<string, Record<string, unknown>>(
113
- seed.map((row): [string, Record<string, unknown>] => [
114
- String(row.messageId),
115
- row,
116
- ]),
117
- );
118
-
119
- const settle = (messageId: string) => {
120
- Object.assign(rows.get(messageId) ?? {}, {
121
- uid: ARCHIVE_UID,
122
- status: "active",
123
- syncStatus: "synced",
124
- });
125
- };
126
-
127
- const messageService = {
128
- get: async (ids: string | string[]) =>
129
- Array.isArray(ids)
130
- ? ids.flatMap((id) => {
131
- const row = rows.get(id);
132
- return row ? [row] : [];
133
- })
134
- : rows.get(ids),
135
- update: async (id: string, patch: Record<string, unknown>) => {
136
- patches.push({ messageId: id, patch });
137
- return Object.assign(rows.get(id) ?? {}, patch);
138
- },
139
- transitionPlacement: async (
140
- id: string,
141
- _expected: Record<string, unknown>,
142
- patch: Record<string, unknown>,
143
- ) => {
144
- patches.push({ messageId: id, patch });
145
- return Object.assign(rows.get(id) ?? {}, patch);
146
- },
147
- upsert: async (row: Record<string, unknown>) => row,
148
- } as unknown as IMessageRepository;
149
-
150
- const threadMessageService = {
151
- findAllByMessageId: async () => [],
152
- getByMessageId: async (_cfg: string, messageId: string) => ({
153
- accountConfigId: ACCOUNT_CONFIG,
154
- threadMessageId: `tm-${messageId}`,
155
- messageId,
156
- threadId: "thr-1",
157
- mailboxId: rows.get(messageId)?.mailboxId,
158
- sentDate: 1,
159
- isRead: false,
160
- isDeleted: false,
161
- hasStars: false,
162
- hasAttachment: false,
163
- }),
164
- create: async () => {},
165
- update: async () => {},
166
- delete: async () => {},
167
- } as unknown as IThreadMessageRepository;
168
-
169
- const mailboxService = {
170
- get: async (_accountId: string, ids: string | string[]) =>
171
- Array.isArray(ids)
172
- ? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
173
- : mailboxes.find((mailbox) => mailbox.mailboxId === ids),
174
- } as unknown as IMailboxRepository;
175
-
176
- const mailboxSpecialUseService = {
177
- resolveTrashRole: async () => flaggedTrash,
178
- findTrashMailbox: async () => flaggedTrash.mailbox,
179
- resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
180
- } as unknown as IMailboxSpecialUseRepository;
181
-
182
- const addressService = {
183
- reconcileJunkOnlyForMessage: async () => {},
184
- } as unknown as IAddressRepository;
185
-
186
- const service = new MessageMoveService({
187
- messageService,
188
- mailboxService,
189
- mailboxSpecialUseService,
190
- threadMessageService,
191
- addressService,
192
- sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
193
- moveSettleTimeoutMs: 200,
194
- moveSettlePollMs: 10,
195
- });
196
-
197
- const stubs = service as unknown as {
198
- enqueueEventsBatch: (batch: CapturedEvent[]) => Promise<void>;
199
- enqueueEvent: (event: CapturedEvent) => Promise<void>;
200
- };
201
- stubs.enqueueEventsBatch = async (batch) => {
202
- events.push(...batch);
203
- };
204
- stubs.enqueueEvent = async (event) => {
205
- events.push(event);
206
- };
207
-
208
- return { service, patches, events, rows, settle };
209
- };
210
-
211
- describe("a delete never binds the folder/uid pair of an in-flight move (#845.3)", () => {
212
- it("binds the confirmed pair once the move settles", async () => {
213
- const { service, events, settle } = buildWorld([movingRow()]);
214
-
215
- setTimeout(() => settle(MOVING_ID), 30);
216
- await service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT);
217
-
218
- assert.equal(events.length, 1);
219
- assert.equal(events[0].operation, "move_to_trash");
220
- assert.equal(
221
- events[0].mailboxPath,
222
- "Archive",
223
- "the folder the confirmed move left the message in",
224
- );
225
- assert.equal(
226
- events[0].uid,
227
- ARCHIVE_UID,
228
- "and that folder's own uid, never the source folder's",
229
- );
230
- });
231
-
232
- it("refuses the delete, and touches nothing, when the move never settles", async () => {
233
- const { service, patches, events, rows } = buildWorld([movingRow()]);
234
-
235
- await assert.rejects(
236
- () => service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT),
237
- (error: unknown) =>
238
- error instanceof MessagePlacementUnsettledError &&
239
- error.statusCode === 409 &&
240
- error.publicApiError?.code === "message_placement_unsettled" &&
241
- error.publicApiError?.details?.messageId === MOVING_ID &&
242
- error.publicApiError?.details?.accountId === ACCOUNT &&
243
- error.publicApiError?.details?.reason === "in_flight",
244
- );
245
-
246
- assert.deepEqual(events, [], "nothing was enqueued");
247
- assert.deepEqual(patches, [], "and no local write was made");
248
- assert.equal(rows.get(MOVING_ID)?.uid, INBOX_UID);
249
- });
250
-
251
- it("expunges no part of a mixed batch when one row is in flight", async () => {
252
- // All-or-nothing: the settled row is deletable on its own, and would be
253
- // expunged first by a per-row gate. One unverifiable pair refuses the
254
- // batch it arrived in.
255
- const { service, patches, events } = buildWorld([
256
- settledRow(),
257
- movingRow(),
258
- ]);
259
-
260
- await assert.rejects(
261
- () =>
262
- service.deleteMessages(
263
- ACCOUNT_CONFIG,
264
- [SETTLED_ID, MOVING_ID],
265
- ACCOUNT,
266
- { permanent: true },
267
- ),
268
- MessagePlacementUnsettledError,
269
- );
270
-
271
- assert.deepEqual(
272
- events,
273
- [],
274
- "the settled row was not expunged alongside the refusal",
275
- );
276
- assert.deepEqual(patches, []);
277
- });
278
-
279
- it("deletes the whole batch once the in-flight row settles", async () => {
280
- const { service, events, settle } = buildWorld([settledRow(), movingRow()]);
281
-
282
- setTimeout(() => settle(MOVING_ID), 30);
283
- await service.deleteMessages(
284
- ACCOUNT_CONFIG,
285
- [SETTLED_ID, MOVING_ID],
286
- ACCOUNT,
287
- { permanent: true },
288
- );
289
-
290
- assert.deepEqual(
291
- events.map((event) => [event.messageId, event.uid]).sort(),
292
- [
293
- [MOVING_ID, ARCHIVE_UID],
294
- [SETTLED_ID, SETTLED_UID],
295
- ].sort(),
296
- );
297
- });
298
-
299
- it("waits one row's ceiling for a batch, not one per row", async () => {
300
- // The gate is concurrent. Three in-flight rows that never settle must
301
- // refuse at roughly one ceiling — a sequential gate would spend three,
302
- // and at the client's 100-id chunk cap that is a gateway timeout.
303
- const rows = [movingRow(), movingRow(), movingRow()].map((row, index) => ({
304
- ...row,
305
- messageId: `${MOVING_ID}-${index}`,
306
- }));
307
- const { service } = buildWorld(rows);
308
-
309
- const startedAt = Date.now();
310
- await assert.rejects(
311
- () =>
312
- service.deleteMessages(
313
- ACCOUNT_CONFIG,
314
- rows.map((row) => row.messageId),
315
- ACCOUNT,
316
- { permanent: true },
317
- ),
318
- MessagePlacementUnsettledError,
319
- );
320
-
321
- assert.ok(
322
- Date.now() - startedAt < 500,
323
- "three rows refused within one 200ms ceiling, not three",
324
- );
325
- });
326
- });
327
-
328
- describe("the gate refuses only a uid that names somebody else (#845.3)", () => {
329
- it("deletes a freshly copied row rather than waiting on it", async () => {
330
- // A copy is `moving` with no server-side uid yet. That uid is not ready,
331
- // which is not the same as naming another folder's message, and the
332
- // delete of a copy is an ordinary flow that must not stall or refuse.
333
- const { service, events } = buildWorld([freshCopyRow()]);
334
-
335
- const startedAt = Date.now();
336
- await service.deleteMessages(ACCOUNT_CONFIG, [COPY_ID], ACCOUNT, {
337
- permanent: true,
338
- });
339
-
340
- assert.equal(events.length, 1);
341
- assert.equal(events[0].operation, "permanent_delete");
342
- assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
343
- });
344
-
345
- it("acts on a row a delete gave up on, rather than refusing it", async () => {
346
- // A give-up hands the row back to the placement the server confirmed
347
- // before it stops (R3), so the "Delete again" the reading pane offers is
348
- // an ordinary delete. Refusing it — which is what a give-up left `moving`
349
- // used to earn — made that button a dead end.
350
- const { service, events } = buildWorld([strandedRow()]);
351
-
352
- const startedAt = Date.now();
353
- await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
354
-
355
- assert.equal(events.length, 1);
356
- assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
357
- });
358
- });
359
-
360
- describe("a copy binds the same pair and takes the same gate (#845.3)", () => {
361
- it("copies from the confirmed pair once the move settles", async () => {
362
- const { service, events, settle } = buildWorld([movingRow()]);
363
-
364
- setTimeout(() => settle(MOVING_ID), 30);
365
- await service.copyMessage(ACCOUNT_CONFIG, MOVING_ID, INBOX, ACCOUNT);
366
-
367
- assert.equal(events.length, 1);
368
- assert.equal(events[0].type, "MESSAGE_COPY");
369
- assert.equal(events[0].sourceMailboxPath, "Archive");
370
- assert.equal(events[0].uid, ARCHIVE_UID);
371
- });
372
-
373
- it("writes no part of a batch copy when one row is in flight", async () => {
374
- // The gate runs over the whole batch before the first upsert, so a
375
- // refusal cannot leave copies already committed and enqueued.
376
- const { service, events } = buildWorld([settledRow(), movingRow()]);
377
-
378
- const startedAt = Date.now();
379
- await assert.rejects(
380
- () =>
381
- service.copyMessages(
382
- ACCOUNT_CONFIG,
383
- [SETTLED_ID, MOVING_ID],
384
- TRASH,
385
- ACCOUNT,
386
- ),
387
- MessagePlacementUnsettledError,
388
- );
389
-
390
- assert.deepEqual(events, [], "no copy was enqueued");
391
- assert.ok(
392
- Date.now() - startedAt < 500,
393
- "and the batch spent one ceiling, not one per row",
394
- );
395
- });
396
- });