@remit/mailbox-service 0.0.85 → 0.0.86

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.85",
3
+ "version": "0.0.86",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -3,21 +3,22 @@
3
3
  * on, and its write-path defects only appear under conditions a happy-path test
4
4
  * never creates (issue #320).
5
5
  *
6
- * 1. The retro classification path wrote the Message before the ThreadMessage
7
- * while its skip guard keyed off `message.category`. A failure between the
8
- * two left the guard satisfied and the denormalized row stranded at
9
- * `uncategorized` forever, because the requeued retry returned early.
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
10
  * 2. `denormalizeCategory` resolved one arbitrary row per message through an
11
11
  * unordered `.limit(1)`. More than one row per messageId is schema-legal —
12
12
  * reachable through thread-root drift, not through a second mailbox, whose ids
13
13
  * collapse to one row — and every row a message has must end up correct.
14
- * 3. It also wrote only `category` on the retro path, so a copied message, which
15
- * reaches no other denormalizing path, never got a `listId` at all.
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
16
  *
17
17
  * None of these show up on a single row in a single successful pass.
18
18
  */
19
19
 
20
20
  import assert from "node:assert/strict";
21
+ import { Readable } from "node:stream";
21
22
  import { describe, it } from "node:test";
22
23
  import type {
23
24
  IAddressRepository,
@@ -104,7 +105,6 @@ const buildHarness = (
104
105
  rows: Row[],
105
106
  options: {
106
107
  failThreadUpdates?: number;
107
- bodyStored?: boolean;
108
108
  } = {},
109
109
  ): Harness => {
110
110
  const messageUpdates: Array<{
@@ -120,9 +120,6 @@ const buildHarness = (
120
120
  mailboxId: "mb-inbox",
121
121
  uid: 1,
122
122
  category: MessageCategory.uncategorized,
123
- ...(options.bodyStored === false
124
- ? {}
125
- : { bodyStorageKey: "s3://bodies/m-1" }),
126
123
  } as unknown as MessageItem;
127
124
 
128
125
  const messageService = {
@@ -164,6 +161,7 @@ const buildHarness = (
164
161
  const storageService = {
165
162
  retrieve: async () => LINKEDIN_EML,
166
163
  storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
164
+ storeMessageBodyStream: async () => ({ uri: "s3://bodies/m-1" }),
167
165
  storeParsedBody: async () => {},
168
166
  listBodyParts: async () => [],
169
167
  } as unknown as StorageService;
@@ -192,10 +190,22 @@ const buildHarness = (
192
190
  };
193
191
  };
194
192
 
195
- const backfill = (harness: Harness) =>
196
- harness.service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () => {
197
- throw new Error("the backfill must not open IMAP");
198
- });
193
+ const syncBatch = (harness: Harness) =>
194
+ harness.service.syncBodies(
195
+ ["m-1"],
196
+ "acc-1",
197
+ "cfg-1",
198
+ "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
+ );
199
209
 
200
210
  const syncFromImap = (harness: Harness) =>
201
211
  harness.service.fetchAndGetBody(
@@ -210,19 +220,19 @@ const syncFromImap = (harness: Harness) =>
210
220
  }) as unknown as IImapConnection,
211
221
  );
212
222
 
213
- describe("the classification backfill survives an interrupted write", () => {
223
+ describe("the classifying pass survives an interrupted write", () => {
214
224
  it("leaves the Message unclassified when the ThreadMessage write fails, so the retry runs again", async () => {
215
225
  const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })], {
216
226
  failThreadUpdates: 1,
217
227
  });
218
228
 
219
- const failed = await backfill(harness);
229
+ const failed = await syncBatch(harness);
220
230
 
221
231
  assert.deepEqual(failed.failedMessageIds, ["m-1"]);
222
232
  assert.equal(harness.message.category, MessageCategory.uncategorized);
223
233
  assert.deepEqual(harness.messageUpdates, []);
224
234
 
225
- const retried = await backfill(harness);
235
+ const retried = await syncBatch(harness);
226
236
 
227
237
  assert.deepEqual(retried.failedMessageIds, []);
228
238
  assert.equal(harness.message.category, MessageCategory.social);
@@ -232,14 +242,14 @@ describe("the classification backfill survives an interrupted write", () => {
232
242
  it("writes the Message only after the denormalized row is durable", async () => {
233
243
  const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
234
244
 
235
- await backfill(harness);
245
+ await syncBatch(harness);
236
246
 
237
247
  assert.deepEqual(harness.writeOrder, ["thread", "message"]);
238
248
  });
239
249
  });
240
250
 
241
251
  describe("denormalization reaches every row a message has", () => {
242
- it("carries the backfilled category onto all of a message's rows", async () => {
252
+ it("carries the classified category onto all of a message's rows", async () => {
243
253
  const harness = buildHarness([
244
254
  buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
245
255
  buildRow({
@@ -249,7 +259,7 @@ describe("denormalization reaches every row a message has", () => {
249
259
  }),
250
260
  ]);
251
261
 
252
- await backfill(harness);
262
+ await syncBatch(harness);
253
263
 
254
264
  assert.deepEqual(
255
265
  harness.rows.map((r) => r.category),
@@ -267,7 +277,7 @@ describe("denormalization reaches every row a message has", () => {
267
277
  }),
268
278
  ]);
269
279
 
270
- await backfill(harness);
280
+ await syncBatch(harness);
271
281
 
272
282
  assert.deepEqual(
273
283
  harness.threadUpdates.map((u) => ({
@@ -286,16 +296,13 @@ describe("denormalization reaches every row a message has", () => {
286
296
  );
287
297
  });
288
298
 
289
- it("fans the snippet and the list id out to every row on the sync path", async () => {
299
+ it("fans the snippet and the list id out to every row on the read path", async () => {
290
300
  // A snippet and a List-Id belong to the message, not to the mailbox a row
291
301
  // happens to name, so every row carries them.
292
- const harness = buildHarness(
293
- [
294
- buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
295
- buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
296
- ],
297
- { bodyStored: false },
298
- );
302
+ const harness = buildHarness([
303
+ buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
304
+ buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
305
+ ]);
299
306
 
300
307
  await syncFromImap(harness);
301
308
 
@@ -318,7 +325,7 @@ describe("denormalization reaches every row a message has", () => {
318
325
  buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
319
326
  ]);
320
327
 
321
- await backfill(harness);
328
+ await syncBatch(harness);
322
329
 
323
330
  assert.deepEqual(
324
331
  harness.threadUpdates.map((u) => u.threadMessageId),
@@ -327,15 +334,13 @@ describe("denormalization reaches every row a message has", () => {
327
334
  });
328
335
  });
329
336
 
330
- describe("the classification backfill writes the whole denormalized set", () => {
331
- // A copied message inherits its source's stored body AND its decided
332
- // category, so the full body-store path skips it and this path's guard
333
- // returns early — and nothing but these two paths ever writes `listId`. The
334
- // backfill passing only `category` left every copy's `list_id` NULL for good.
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.
335
340
  it("carries the list id and the snippet, not just the category", async () => {
336
341
  const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
337
342
 
338
- await backfill(harness);
343
+ await syncBatch(harness);
339
344
 
340
345
  assert.deepEqual(harness.threadUpdates.length, 1);
341
346
  assert.deepEqual(harness.threadUpdates[0].input, {
@@ -15,6 +15,14 @@
15
15
  * `NoSuchKey` IMAP re-fetch and `syncBodies(..., force: true)` — so a
16
16
  * regression that let the override bypass the guard shows up here, not just
17
17
  * in an isolated unit check.
18
+ *
19
+ * The override reaches a message on the pass that examines it, and only then
20
+ * (issue #331). Mail already stored and examined is not revisited, whatever the
21
+ * user later says about the sender: an ordinary sync pass does not re-read a
22
+ * stored body, and the last test here pins that. Applying a reclassification
23
+ * backwards over mail already on disk is a deliberate operation over
24
+ * `Message.classificationState`, tracked in issue #415, and nothing in this
25
+ * file claims it happens today.
18
26
  */
19
27
 
20
28
  import assert from "node:assert/strict";
@@ -278,15 +286,50 @@ describe("Address.flags.category overrides classification at sync time (issue #2
278
286
  );
279
287
  });
280
288
 
281
- it("backfillClassification picks up the same override for a body stored before classification existed", async () => {
289
+ it("reaches a message still awaiting examination on the batch sync path too", async () => {
290
+ const harness = buildHarness(
291
+ { messageId: "m-1", category: MessageCategory.uncategorized },
292
+ overrideFlags(MessageCategory.transactional),
293
+ );
294
+
295
+ const result = await harness.service.syncBodies(
296
+ ["m-1"],
297
+ "acc-1",
298
+ "cfg-1",
299
+ "INBOX",
300
+ async () =>
301
+ ({
302
+ openBox: async () => {},
303
+ async *fetchMessageBodies(uids: number[]) {
304
+ for (const uid of uids) {
305
+ yield { uid, source: Readable.from([LINKEDIN_EML]) };
306
+ }
307
+ },
308
+ }) as unknown as IImapConnection,
309
+ );
310
+
311
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
312
+ assert.equal(harness.messageUpdates.length, 1);
313
+ assert.equal(
314
+ harness.messageUpdates[0].input.category,
315
+ MessageCategory.transactional,
316
+ );
317
+ assert.equal(harness.rows[0].category, MessageCategory.transactional);
318
+ assert.equal(
319
+ harness.messageUpdates[0].input.classificationState,
320
+ "Examined",
321
+ );
322
+ });
323
+
324
+ it("does not reach back to mail it already examined when the sender is reclassified later", async () => {
282
325
  const harness = buildHarness(
283
326
  {
284
327
  messageId: "m-1",
285
328
  bodyStorageKey: "s3://bodies/m-1",
286
329
  category: MessageCategory.uncategorized,
287
- },
330
+ classificationState: "Examined",
331
+ } as Partial<MessageItem> & Pick<MessageItem, "messageId">,
288
332
  overrideFlags(MessageCategory.transactional),
289
- async () => LINKEDIN_EML,
290
333
  );
291
334
 
292
335
  const result = await harness.service.syncBodies(
@@ -295,17 +338,18 @@ describe("Address.flags.category overrides classification at sync time (issue #2
295
338
  "cfg-1",
296
339
  "INBOX",
297
340
  async () => {
298
- throw new Error("backfill must not open IMAP");
341
+ throw new Error("an examined message must not be fetched again");
299
342
  },
300
343
  );
301
344
 
345
+ // The harness's default `retrieve` throws, so a storage read would fail
346
+ // this outright. Issue #415 is where applying the override to this message
347
+ // deliberately belongs; a sync pass is not it.
302
348
  assert.equal(result.skippedCount, 1);
303
- assert.equal(harness.messageUpdates.length, 1);
304
- assert.equal(
305
- harness.messageUpdates[0].input.category,
306
- MessageCategory.transactional,
307
- );
308
- assert.equal(harness.rows[0].category, MessageCategory.transactional);
349
+ assert.deepEqual(result.failedMessageIds, []);
350
+ assert.deepEqual(harness.messageUpdates, []);
351
+ assert.deepEqual(harness.threadUpdates, []);
352
+ assert.equal(harness.message.category, MessageCategory.uncategorized);
309
353
  });
310
354
 
311
355
  describe("survives a re-entrant pass with a differing override already set", () => {
@@ -5,10 +5,19 @@
5
5
  * and database failures — which the same per-message frame catches — keep
6
6
  * propagating to the requeue path, because recording one as a quarantine would
7
7
  * tell the user that mail Remit could not reach was mail Remit could not read,
8
- * and let go of it.
8
+ * and let go of it. That discrimination is the `error instanceof BodyParseError`
9
+ * branch in `syncBodies`' stream loop, and these tests drive it there.
10
+ *
11
+ * The parse cases inject the `BodyParseError` at the storage write rather than
12
+ * feeding mailparser bytes it refuses: mailparser accepts every byte sequence a
13
+ * test can construct, and the branch under test asks only what type of error
14
+ * reached it. That only `parseMessageBody` ever constructs one — so an instance
15
+ * of it really is proof the message and not the infrastructure failed — is
16
+ * pinned in `body-parse.test.ts`.
9
17
  */
10
18
 
11
19
  import assert from "node:assert/strict";
20
+ import { Readable } from "node:stream";
12
21
  import { describe, it } from "node:test";
13
22
  import type {
14
23
  IAddressRepository,
@@ -22,16 +31,29 @@ import type {
22
31
  QuarantineUpsertInput,
23
32
  UpdateMessageInput,
24
33
  } from "@remit/data-ports";
34
+ import { NotFoundError } from "@remit/data-ports/errors";
25
35
  import { MessageCategory } from "@remit/domain-enums";
26
36
  import type { StorageService } from "@remit/storage-service";
37
+ import { BodyParseError } from "./body-parse.js";
27
38
  import { BodySyncService } from "./body-sync.js";
28
39
  import { QuarantineService } from "./quarantine.js";
29
-
30
- const UNPARSEABLE = Symbol("unparseable");
40
+ import type { IImapConnection } from "./types.js";
41
+
42
+ const WELL_FORMED = Buffer.from(
43
+ [
44
+ "From: someone@example.com",
45
+ "To: me@example.com",
46
+ "Subject: hello",
47
+ "Content-Type: text/plain",
48
+ "",
49
+ "body",
50
+ ].join("\r\n"),
51
+ );
31
52
 
32
53
  const buildHarness = (options: {
33
- retrieve?: (key: string) => Promise<Buffer>;
34
54
  existing?: QuarantineItem[];
55
+ storeBodyFails?: unknown;
56
+ storeParsedFails?: unknown;
35
57
  upsertFails?: boolean;
36
58
  }) => {
37
59
  const writes: QuarantineUpsertInput[] = [];
@@ -43,8 +65,8 @@ const buildHarness = (options: {
43
65
  uid: 40217,
44
66
  rfc822Size: 2048,
45
67
  messageIdHeader: "<abc@example.com>",
46
- bodyStorageKey: "s3://bodies/m-1",
47
68
  category: MessageCategory.uncategorized,
69
+ classificationState: "NotExamined",
48
70
  } as MessageItem;
49
71
 
50
72
  const messageService = {
@@ -55,17 +77,32 @@ const buildHarness = (options: {
55
77
  } as unknown as IMessageRepository;
56
78
 
57
79
  const storageService = {
58
- retrieve:
59
- options.retrieve ??
60
- (async () => {
61
- throw new Error("no body configured");
62
- }),
80
+ retrieve: async () => {
81
+ throw new Error("the sync path never reads a body back");
82
+ },
83
+ storeMessageBodyStream: async () => {
84
+ if (options.storeBodyFails !== undefined) throw options.storeBodyFails;
85
+ return { uri: "s3://bodies/m-1" };
86
+ },
87
+ storeParsedBody: async () => {
88
+ if (options.storeParsedFails !== undefined)
89
+ throw options.storeParsedFails;
90
+ },
91
+ listBodyParts: async () => [],
63
92
  } as unknown as StorageService;
64
93
 
65
94
  const envelopeService = {
66
95
  getMessageData: async () => ({ bodyPart: [], bodyPartParameter: [] }),
96
+ listBodyParts: async () => [],
67
97
  } as unknown as IEnvelopeRepository;
68
98
 
99
+ const addressService = {
100
+ getAddress: async () => {
101
+ throw new NotFoundError("Address not found");
102
+ },
103
+ incrementInboundCount: async () => {},
104
+ } as unknown as IAddressRepository;
105
+
69
106
  const repository = {
70
107
  listByAccountConfigId: async () => options.existing ?? [],
71
108
  upsert: async (input: QuarantineUpsertInput) => {
@@ -91,7 +128,7 @@ const buildHarness = (options: {
91
128
  ],
92
129
  update: async () => {},
93
130
  } as unknown as IThreadMessageRepository,
94
- {} as unknown as IAddressRepository,
131
+ addressService,
95
132
  envelopeService,
96
133
  { info: () => {}, error: () => {}, debug: () => {} },
97
134
  undefined,
@@ -114,16 +151,27 @@ const buildHarness = (options: {
114
151
  return { service, writes, messageUpdates };
115
152
  };
116
153
 
154
+ const connection = () =>
155
+ ({
156
+ openBox: async () => {},
157
+ async *fetchMessageBodies(uids: number[]) {
158
+ for (const uid of uids) {
159
+ yield { uid, source: Readable.from([WELL_FORMED]) };
160
+ }
161
+ },
162
+ }) as unknown as IImapConnection;
163
+
117
164
  const sync = (service: BodySyncService) =>
118
- service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () => {
119
- throw new Error("this test must not open IMAP");
120
- });
165
+ service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () =>
166
+ connection(),
167
+ );
121
168
 
122
- describe("body sync quarantines a message the parser refuses", () => {
123
- const retrieveUnparseable = async () => UNPARSEABLE as unknown as Buffer;
169
+ const parseRefusal = () =>
170
+ new BodyParseError(new Error("boundary never closed"));
124
171
 
172
+ describe("body sync quarantines a message the parser refuses", () => {
125
173
  it("records the failure instead of requeueing the message forever", async () => {
126
- const harness = buildHarness({ retrieve: retrieveUnparseable });
174
+ const harness = buildHarness({ storeBodyFails: parseRefusal() });
127
175
 
128
176
  const result = await sync(harness.service);
129
177
 
@@ -132,7 +180,7 @@ describe("body sync quarantines a message the parser refuses", () => {
132
180
  });
133
181
 
134
182
  it("names the message by uid and UIDVALIDITY, so the record is idempotent", async () => {
135
- const harness = buildHarness({ retrieve: retrieveUnparseable });
183
+ const harness = buildHarness({ storeBodyFails: parseRefusal() });
136
184
 
137
185
  await sync(harness.service);
138
186
 
@@ -142,7 +190,7 @@ describe("body sync quarantines a message the parser refuses", () => {
142
190
  });
143
191
 
144
192
  it("does not mark the message synced — it was set aside, not applied", async () => {
145
- const harness = buildHarness({ retrieve: retrieveUnparseable });
193
+ const harness = buildHarness({ storeBodyFails: parseRefusal() });
146
194
 
147
195
  const result = await sync(harness.service);
148
196
 
@@ -154,9 +202,7 @@ describe("body sync quarantines a message the parser refuses", () => {
154
202
  describe("body sync leaves infrastructure failures alone", () => {
155
203
  it("requeues a storage failure rather than calling the message unreadable", async () => {
156
204
  const harness = buildHarness({
157
- retrieve: async () => {
158
- throw new Error("S3 503 SlowDown");
159
- },
205
+ storeBodyFails: new Error("S3 503 SlowDown"),
160
206
  });
161
207
 
162
208
  const result = await sync(harness.service);
@@ -165,15 +211,13 @@ describe("body sync leaves infrastructure failures alone", () => {
165
211
  assert.deepEqual(result.failedMessageIds, ["m-1"]);
166
212
  });
167
213
 
168
- it("requeues a database failure the same way", async () => {
169
- const harness = buildHarness({
170
- retrieve: async () => {
171
- const error = new Error("ProvisionedThroughputExceededException");
172
- error.name = "ProvisionedThroughputExceededException";
173
- throw error;
174
- },
175
- });
214
+ it("requeues a database failure the same way, parse already past", async () => {
215
+ const throughput = new Error("ProvisionedThroughputExceededException");
216
+ throughput.name = "ProvisionedThroughputExceededException";
217
+ const harness = buildHarness({ storeParsedFails: throughput });
176
218
 
219
+ // The body parsed cleanly and the failure came after it, so nothing here
220
+ // may say the message is unreadable.
177
221
  const result = await sync(harness.service);
178
222
 
179
223
  assert.deepEqual(harness.writes, []);
@@ -184,7 +228,7 @@ describe("body sync leaves infrastructure failures alone", () => {
184
228
  describe("body sync contains a failure to write the record", () => {
185
229
  it("requeues the message instead of aborting the batch", async () => {
186
230
  const harness = buildHarness({
187
- retrieve: async () => UNPARSEABLE as unknown as Buffer,
231
+ storeBodyFails: parseRefusal(),
188
232
  upsertFails: true,
189
233
  });
190
234
 
@@ -208,14 +252,21 @@ describe("body sync skips what is already quarantined", () => {
208
252
  uid: 40217,
209
253
  } as QuarantineItem,
210
254
  ],
211
- retrieve: async () => {
212
- throw new Error("a quarantined message must not be read again");
213
- },
214
255
  });
215
256
 
216
- const result = await sync(harness.service);
257
+ const result = await harness.service.syncBodies(
258
+ ["m-1"],
259
+ "acc-1",
260
+ "cfg-1",
261
+ "INBOX",
262
+ async () => {
263
+ throw new Error("a quarantined message must not be fetched again");
264
+ },
265
+ );
217
266
 
218
267
  assert.equal(result.skippedCount, 1);
268
+ assert.deepEqual(result.failedMessageIds, []);
219
269
  assert.deepEqual(harness.writes, []);
270
+ assert.deepEqual(harness.messageUpdates, []);
220
271
  });
221
272
  });