@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,352 +0,0 @@
1
- /**
2
- * `thread_message.category` is the copy the list read path is about to filter
3
- * on, and its write-path defects only appear under conditions a happy-path test
4
- * never creates (issue #320).
5
- *
6
- * 1. The classifying pass wrote the Message before the ThreadMessage while the
7
- * skip guard keyed off the Message. A failure between the two left the guard
8
- * satisfied and the denormalized row stranded at `uncategorized` forever,
9
- * because the requeued retry returned early.
10
- * 2. `denormalizeCategory` resolved one arbitrary row per message through an
11
- * unordered `.limit(1)`. More than one row per messageId is schema-legal —
12
- * reachable through thread-root drift, not through a second mailbox, whose ids
13
- * collapse to one row — and every row a message has must end up correct.
14
- * 3. It also wrote only `category` on one of its paths, so the snippet and the
15
- * `listId` derived from the same bytes were dropped on the floor.
16
- *
17
- * None of these show up on a single row in a single successful pass.
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
- IAddressRepository,
25
- IEnvelopeRepository,
26
- IMessageRepository,
27
- IThreadMessageRepository,
28
- MessageItem,
29
- ThreadMessageItem,
30
- UpdateMessageInput,
31
- UpdateThreadMessageInput,
32
- } from "@remit/data-ports";
33
- import { NotFoundError } from "@remit/data-ports/errors";
34
- import { MessageCategory } from "@remit/domain-enums";
35
- import type { StorageService } from "@remit/storage-service";
36
- import { BodySyncService } from "./body-sync.js";
37
- import type { IImapConnection } from "./types.js";
38
-
39
- const LINKEDIN_EML = Buffer.from(
40
- [
41
- "From: LinkedIn <messages-noreply@linkedin.com>",
42
- "To: me@example.com",
43
- "Subject: You have a new invitation",
44
- "List-Id: LinkedIn Invitations <invitations.linkedin.com>",
45
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
46
- "Content-Type: text/plain",
47
- "",
48
- "invitation body text",
49
- ].join("\r\n"),
50
- );
51
-
52
- type Row = Pick<
53
- ThreadMessageItem,
54
- | "threadMessageId"
55
- | "messageId"
56
- | "accountConfigId"
57
- | "mailboxId"
58
- | "sentDate"
59
- | "isRead"
60
- | "isDeleted"
61
- | "hasStars"
62
- | "hasAttachment"
63
- | "category"
64
- > &
65
- Partial<Pick<ThreadMessageItem, "snippet" | "listId">>;
66
-
67
- interface ThreadUpdate {
68
- threadMessageId: string;
69
- input: UpdateThreadMessageInput;
70
- composites: Record<string, unknown> | undefined;
71
- }
72
-
73
- interface Harness {
74
- service: BodySyncService;
75
- message: MessageItem;
76
- rows: Row[];
77
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
78
- threadUpdates: ThreadUpdate[];
79
- writeOrder: string[];
80
- }
81
-
82
- const buildRow = (
83
- overrides: Partial<Row> & Pick<Row, "threadMessageId">,
84
- ): Row =>
85
- ({
86
- messageId: "m-1",
87
- accountConfigId: "cfg-1",
88
- mailboxId: "mb-inbox",
89
- sentDate: 1,
90
- isRead: false,
91
- isDeleted: false,
92
- hasStars: false,
93
- hasAttachment: false,
94
- category: MessageCategory.uncategorized,
95
- ...overrides,
96
- }) as Row;
97
-
98
- /**
99
- * The Message and ThreadMessage fixtures are mutated by their writes, so the
100
- * skip guard sees the state a requeued delivery would. A harness that only
101
- * records the write cannot show a stranded row: the strand is the guard reading
102
- * a value that outlived a failed pass.
103
- */
104
- const buildHarness = (
105
- rows: Row[],
106
- options: {
107
- failThreadUpdates?: number;
108
- } = {},
109
- ): Harness => {
110
- const messageUpdates: Array<{
111
- messageId: string;
112
- input: UpdateMessageInput;
113
- }> = [];
114
- const threadUpdates: ThreadUpdate[] = [];
115
- const writeOrder: string[] = [];
116
- let threadUpdateAttempts = 0;
117
-
118
- const message = {
119
- messageId: "m-1",
120
- mailboxId: "mb-inbox",
121
- uid: 1,
122
- category: MessageCategory.uncategorized,
123
- } as unknown as MessageItem;
124
-
125
- const messageService = {
126
- get: async () => message,
127
- update: async (messageId: string, input: UpdateMessageInput) => {
128
- writeOrder.push("message");
129
- messageUpdates.push({ messageId, input });
130
- Object.assign(message, input);
131
- },
132
- } as unknown as IMessageRepository;
133
-
134
- const threadMessageService = {
135
- findAllByMessageId: async () => rows,
136
- // Load-bearing, not a leftover: the current code never calls it, but
137
- // reverting body-sync.ts to reproduce these failures does. Deleting it
138
- // makes the fail-before run throw instead of demonstrating the defect.
139
- getByMessageId: async () => rows[0],
140
- update: async (
141
- _accountConfigId: string,
142
- threadMessageId: string,
143
- input: UpdateThreadMessageInput,
144
- updateOptions?: { composites?: Record<string, unknown> },
145
- ) => {
146
- threadUpdateAttempts++;
147
- if (threadUpdateAttempts <= (options.failThreadUpdates ?? 0)) {
148
- throw new Error("thread_message write failed");
149
- }
150
- writeOrder.push("thread");
151
- threadUpdates.push({
152
- threadMessageId,
153
- input,
154
- composites: updateOptions?.composites,
155
- });
156
- const row = rows.find((r) => r.threadMessageId === threadMessageId);
157
- if (row) Object.assign(row, input);
158
- },
159
- } as unknown as IThreadMessageRepository;
160
-
161
- const storageService = {
162
- retrieve: async () => LINKEDIN_EML,
163
- storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
164
- storeMessageBodyStream: async () => ({ uri: "s3://bodies/m-1" }),
165
- storeParsedBody: async () => {},
166
- listBodyParts: async () => [],
167
- } as unknown as StorageService;
168
-
169
- const service = new BodySyncService(
170
- messageService,
171
- storageService,
172
- threadMessageService,
173
- {
174
- getAddress: async () => {
175
- throw new NotFoundError("Address not found");
176
- },
177
- incrementInboundCount: async () => {},
178
- } as unknown as IAddressRepository,
179
- { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
180
- { info: () => {}, error: () => {} },
181
- );
182
-
183
- return {
184
- service,
185
- message,
186
- rows,
187
- messageUpdates,
188
- threadUpdates,
189
- writeOrder,
190
- };
191
- };
192
-
193
- const syncBatch = (harness: Harness) =>
194
- harness.service.syncBodies(
195
- ["m-1"],
196
- "acc-1",
197
- "cfg-1",
198
- { fullPath: "INBOX", placement: "inbox" },
199
- async () =>
200
- ({
201
- openBox: async () => {},
202
- async *fetchMessageBodies(uids: number[]) {
203
- for (const uid of uids) {
204
- yield { uid, source: Readable.from([LINKEDIN_EML]) };
205
- }
206
- },
207
- }) as unknown as IImapConnection,
208
- );
209
-
210
- const syncFromImap = (harness: Harness) =>
211
- harness.service.fetchAndGetBody(
212
- "m-1",
213
- "acc-1",
214
- "cfg-1",
215
- { fullPath: "INBOX", placement: "inbox" },
216
- async () =>
217
- ({
218
- openBox: async () => {},
219
- fetchMessageBody: async () => LINKEDIN_EML,
220
- }) as unknown as IImapConnection,
221
- );
222
-
223
- describe("the classifying pass survives an interrupted write", () => {
224
- it("leaves the Message unclassified when the ThreadMessage write fails, so the retry runs again", async () => {
225
- const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })], {
226
- failThreadUpdates: 1,
227
- });
228
-
229
- const failed = await syncBatch(harness);
230
-
231
- assert.deepEqual(failed.failedMessageIds, ["m-1"]);
232
- assert.equal(harness.message.category, MessageCategory.uncategorized);
233
- assert.deepEqual(harness.messageUpdates, []);
234
-
235
- const retried = await syncBatch(harness);
236
-
237
- assert.deepEqual(retried.failedMessageIds, []);
238
- assert.equal(harness.message.category, MessageCategory.social);
239
- assert.equal(harness.rows[0].category, MessageCategory.social);
240
- });
241
-
242
- it("writes the Message only after the denormalized row is durable", async () => {
243
- const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
244
-
245
- await syncBatch(harness);
246
-
247
- assert.deepEqual(harness.writeOrder, ["thread", "message"]);
248
- });
249
- });
250
-
251
- describe("denormalization reaches every row a message has", () => {
252
- it("carries the classified category onto all of a message's rows", async () => {
253
- const harness = buildHarness([
254
- buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
255
- buildRow({
256
- threadMessageId: "tm-archive",
257
- mailboxId: "mb-archive",
258
- isRead: true,
259
- }),
260
- ]);
261
-
262
- await syncBatch(harness);
263
-
264
- assert.deepEqual(
265
- harness.rows.map((r) => r.category),
266
- [MessageCategory.social, MessageCategory.social],
267
- );
268
- });
269
-
270
- it("builds the composite block from each row rather than reusing the first", async () => {
271
- const harness = buildHarness([
272
- buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
273
- buildRow({
274
- threadMessageId: "tm-archive",
275
- mailboxId: "mb-archive",
276
- isRead: true,
277
- }),
278
- ]);
279
-
280
- await syncBatch(harness);
281
-
282
- assert.deepEqual(
283
- harness.threadUpdates.map((u) => ({
284
- threadMessageId: u.threadMessageId,
285
- mailboxId: u.composites?.mailboxId,
286
- isRead: u.composites?.isRead,
287
- })),
288
- [
289
- { threadMessageId: "tm-inbox", mailboxId: "mb-inbox", isRead: false },
290
- {
291
- threadMessageId: "tm-archive",
292
- mailboxId: "mb-archive",
293
- isRead: true,
294
- },
295
- ],
296
- );
297
- });
298
-
299
- it("fans the snippet and the list id out to every row on the read path", async () => {
300
- // A snippet and a List-Id belong to the message, not to the mailbox a row
301
- // happens to name, so every row carries them.
302
- const harness = buildHarness([
303
- buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
304
- buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
305
- ]);
306
-
307
- await syncFromImap(harness);
308
-
309
- for (const row of harness.rows) {
310
- assert.equal(row.category, MessageCategory.social);
311
- assert.equal(row.listId, "invitations.linkedin.com");
312
- assert.equal(row.snippet, "invitation body text");
313
- }
314
- });
315
-
316
- it("writes nothing for a row that already carries every value", async () => {
317
- const harness = buildHarness([
318
- buildRow({
319
- threadMessageId: "tm-inbox",
320
- mailboxId: "mb-inbox",
321
- category: MessageCategory.social,
322
- snippet: "invitation body text",
323
- listId: "invitations.linkedin.com",
324
- }),
325
- buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
326
- ]);
327
-
328
- await syncBatch(harness);
329
-
330
- assert.deepEqual(
331
- harness.threadUpdates.map((u) => u.threadMessageId),
332
- ["tm-archive"],
333
- );
334
- });
335
- });
336
-
337
- describe("the classifying pass writes the whole denormalized set", () => {
338
- // Nothing but this pass ever derives `listId` from a message's bytes, so a
339
- // write that carried only `category` left the row's `list_id` NULL for good.
340
- it("carries the list id and the snippet, not just the category", async () => {
341
- const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
342
-
343
- await syncBatch(harness);
344
-
345
- assert.deepEqual(harness.threadUpdates.length, 1);
346
- assert.deepEqual(harness.threadUpdates[0].input, {
347
- category: MessageCategory.social,
348
- snippet: "invitation body text",
349
- listId: "invitations.linkedin.com",
350
- });
351
- });
352
- });