@remit/mailbox-service 0.0.84 → 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.
@@ -49,6 +49,7 @@ interface ThreadRow {
49
49
  messageIdHeader: string;
50
50
  isRead?: boolean;
51
51
  hasStars?: boolean;
52
+ listId?: string;
52
53
  }
53
54
 
54
55
  const buildWorld = () => {
@@ -66,6 +67,7 @@ const buildWorld = () => {
66
67
  rootBodyPartId: "body-1",
67
68
  bodyStorageKey: "s3://body-1",
68
69
  category: "primary",
70
+ classificationState: "Examined",
69
71
  hasListUnsubscribe: false,
70
72
  },
71
73
  ],
@@ -81,6 +83,7 @@ const buildWorld = () => {
81
83
  messageIdHeader: HEADER,
82
84
  isRead: false,
83
85
  hasStars: false,
86
+ listId: "invitations.linkedin.com",
84
87
  },
85
88
  ];
86
89
 
@@ -144,6 +147,7 @@ const buildWorld = () => {
144
147
  messageId,
145
148
  mailboxId: input.mailboxId as string,
146
149
  messageIdHeader: input.messageIdHeader as string,
150
+ listId: input.listId as string | undefined,
147
151
  };
148
152
  threadRows.push(row);
149
153
  return row;
@@ -338,6 +342,41 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
338
342
  assert.equal(copyRow.messageId, expected);
339
343
  });
340
344
 
345
+ it("carries the source row's list id onto the copy", async () => {
346
+ // The copy inherits its source's stored body, so no classifying pass ever
347
+ // runs over it to derive a list id of its own (issue #331).
348
+ const { service, threadRows } = buildWorld();
349
+
350
+ await service.copyMessages(
351
+ ACCOUNT_CONFIG,
352
+ [SOURCE_ID],
353
+ DEST_MAILBOX,
354
+ ACCOUNT,
355
+ );
356
+
357
+ const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
358
+ assert.equal(copyRow?.listId, "invitations.linkedin.com");
359
+ });
360
+
361
+ it("carries the source's classification state onto the copy", async () => {
362
+ // Defaulting the copy to `NotExamined` would put a row no classifier will
363
+ // ever reach — its body is already stored — into the cohort a backfill
364
+ // selects as never examined (issue #331).
365
+ const { service, messages } = buildWorld();
366
+
367
+ await service.copyMessages(
368
+ ACCOUNT_CONFIG,
369
+ [SOURCE_ID],
370
+ DEST_MAILBOX,
371
+ ACCOUNT,
372
+ );
373
+
374
+ const copy = [...messages.values()].find(
375
+ (m) => m.mailboxId === DEST_MAILBOX,
376
+ );
377
+ assert.equal(copy?.classificationState, "Examined");
378
+ });
379
+
341
380
  it("a replayed copy converges on one row, no duplicate", async () => {
342
381
  const { service, threadRows, messages } = buildWorld();
343
382
 
@@ -716,6 +716,11 @@ export class MessageMoveService {
716
716
  // permanently `uncategorized` while its body says it is fully synced
717
717
  // (issue #45).
718
718
  category: sourceMessage.category,
719
+ // The record of whether that category was the classifier's answer travels
720
+ // with it (issue #331). Defaulting the copy to `NotExamined` would put a
721
+ // row no classifier will ever reach — its body is already stored — into
722
+ // the cohort a backfill selects as never examined.
723
+ classificationState: sourceMessage.classificationState,
719
724
  hasListUnsubscribe: sourceMessage.hasListUnsubscribe,
720
725
  });
721
726
 
@@ -746,6 +751,10 @@ export class MessageMoveService {
746
751
  isDeleted: false,
747
752
  snippet: sourceThreadMessage.snippet,
748
753
  category: sourceThreadMessage.category,
754
+ // Same reason as `category` above, and the same source row: the copy
755
+ // inherits a stored body, so no classifying pass ever runs over it to
756
+ // derive a `listId` of its own (issue #331).
757
+ listId: sourceThreadMessage.listId,
749
758
  });
750
759
 
751
760
  this.log.info(
@@ -1,292 +0,0 @@
1
- /**
2
- * Body-sync's skip guard keys on `bodyStorageKey`, but classification is a
3
- * separate derived field written by the same pass. A message that got its body
4
- * before it got a classifier — or whose classifying pass failed after the body
5
- * landed — is skipped forever and stays `uncategorized` (issue #45).
6
- *
7
- * These tests pin the backfill that closes that gap: it reads the stored body
8
- * rather than IMAP, writes only the classification, and never silently absorbs
9
- * a storage failure.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
14
- import type {
15
- IAddressRepository,
16
- IEnvelopeRepository,
17
- IMessageRepository,
18
- IThreadMessageRepository,
19
- MessageItem,
20
- UpdateMessageInput,
21
- UpdateThreadMessageInput,
22
- } from "@remit/data-ports";
23
- import { NotFoundError } from "@remit/data-ports/errors";
24
- import { MessageCategory } from "@remit/domain-enums";
25
- import type { StorageService } from "@remit/storage-service";
26
- import { BodySyncService } from "./body-sync.js";
27
-
28
- const LINKEDIN_EML = Buffer.from(
29
- [
30
- "From: LinkedIn <messages-noreply@linkedin.com>",
31
- "To: me@example.com",
32
- "Subject: You have a new invitation",
33
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
34
- "Content-Type: text/plain",
35
- "",
36
- "invitation",
37
- ].join("\r\n"),
38
- );
39
-
40
- interface Harness {
41
- service: BodySyncService;
42
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
43
- threadUpdates: UpdateThreadMessageInput[];
44
- retrieved: string[];
45
- loggedErrors: Array<Record<string, unknown>>;
46
- }
47
-
48
- type MessageFixture = Partial<MessageItem> & Pick<MessageItem, "messageId">;
49
-
50
- const buildHarness = (
51
- messages: MessageFixture[],
52
- retrieve: (key: string) => Promise<Buffer> = async () => LINKEDIN_EML,
53
- ): Harness => {
54
- const messageUpdates: Array<{
55
- messageId: string;
56
- input: UpdateMessageInput;
57
- }> = [];
58
- const threadUpdates: UpdateThreadMessageInput[] = [];
59
- const retrieved: string[] = [];
60
- const loggedErrors: Array<Record<string, unknown>> = [];
61
-
62
- const byId = new Map(messages.map((m) => [m.messageId, m]));
63
-
64
- const messageService = {
65
- get: async (messageId: string) => {
66
- const message = byId.get(messageId);
67
- if (!message) throw new Error(`no fixture for ${messageId}`);
68
- return { uid: 1, category: MessageCategory.uncategorized, ...message };
69
- },
70
- update: async (messageId: string, input: UpdateMessageInput) => {
71
- messageUpdates.push({ messageId, input });
72
- },
73
- } as unknown as IMessageRepository;
74
-
75
- const threadMessageService = {
76
- findAllByMessageId: async () => [
77
- {
78
- threadMessageId: "tm-1",
79
- sentDate: 1,
80
- mailboxId: "mb-1",
81
- isRead: false,
82
- isDeleted: false,
83
- hasStars: false,
84
- hasAttachment: false,
85
- },
86
- ],
87
- update: async (
88
- _accountConfigId: string,
89
- _threadMessageId: string,
90
- input: UpdateThreadMessageInput,
91
- ) => {
92
- threadUpdates.push(input);
93
- },
94
- } as unknown as IThreadMessageRepository;
95
-
96
- const storageService = {
97
- retrieve: async (key: string) => {
98
- retrieved.push(key);
99
- return retrieve(key);
100
- },
101
- } as unknown as StorageService;
102
-
103
- const service = new BodySyncService(
104
- messageService,
105
- storageService,
106
- threadMessageService,
107
- {
108
- getAddress: async () => {
109
- throw new NotFoundError("Address not found");
110
- },
111
- } as unknown as IAddressRepository,
112
- {} as unknown as IEnvelopeRepository,
113
- {
114
- info: () => {},
115
- error: (obj: Record<string, unknown>) => {
116
- loggedErrors.push(obj);
117
- },
118
- },
119
- );
120
-
121
- return { service, messageUpdates, threadUpdates, retrieved, loggedErrors };
122
- };
123
-
124
- const failingConnection = async () => {
125
- throw new Error("body-sync must not open IMAP to backfill a classification");
126
- };
127
-
128
- const stored = (messageId: string) => ({
129
- messageId,
130
- bodyStorageKey: `s3://bodies/${messageId}`,
131
- category: MessageCategory.uncategorized,
132
- });
133
-
134
- describe("body-sync classification backfill", () => {
135
- it("classifies a skipped message whose body is stored but category is uncategorized", async () => {
136
- const harness = buildHarness([stored("m-1")]);
137
-
138
- const result = await harness.service.syncBodies(
139
- ["m-1"],
140
- "acc-1",
141
- "cfg-1",
142
- "INBOX",
143
- failingConnection,
144
- );
145
-
146
- assert.equal(result.skippedCount, 1);
147
- assert.equal(harness.messageUpdates.length, 1);
148
- assert.equal(
149
- harness.messageUpdates[0].input.category,
150
- MessageCategory.social,
151
- );
152
- });
153
-
154
- it("denormalizes the backfilled category onto the ThreadMessage", async () => {
155
- const harness = buildHarness([stored("m-1")]);
156
-
157
- await harness.service.syncBodies(
158
- ["m-1"],
159
- "acc-1",
160
- "cfg-1",
161
- "INBOX",
162
- failingConnection,
163
- );
164
-
165
- assert.equal(harness.threadUpdates.length, 1);
166
- assert.equal(harness.threadUpdates[0].category, MessageCategory.social);
167
- });
168
-
169
- it("reads the stored body instead of reconnecting to IMAP", async () => {
170
- const harness = buildHarness([stored("m-1")]);
171
-
172
- await harness.service.syncBodies(
173
- ["m-1"],
174
- "acc-1",
175
- "cfg-1",
176
- "INBOX",
177
- failingConnection,
178
- );
179
-
180
- assert.deepEqual(harness.retrieved, ["s3://bodies/m-1"]);
181
- });
182
-
183
- it("classifies a message whose category field is absent entirely", async () => {
184
- // Rows written before the column existed carry no value at all. Reading
185
- // that as "already classified" would strand the oldest mail — exactly
186
- // what this backfill exists to reach.
187
- const harness = buildHarness([
188
- {
189
- messageId: "m-1",
190
- bodyStorageKey: "s3://bodies/m-1",
191
- category: undefined,
192
- },
193
- ]);
194
-
195
- await harness.service.syncBodies(
196
- ["m-1"],
197
- "acc-1",
198
- "cfg-1",
199
- "INBOX",
200
- failingConnection,
201
- );
202
-
203
- assert.equal(harness.messageUpdates.length, 1);
204
- assert.equal(
205
- harness.messageUpdates[0].input.category,
206
- MessageCategory.social,
207
- );
208
- });
209
-
210
- it("leaves an already-classified message untouched", async () => {
211
- const harness = buildHarness([
212
- { ...stored("m-1"), category: MessageCategory.marketing },
213
- ]);
214
-
215
- const result = await harness.service.syncBodies(
216
- ["m-1"],
217
- "acc-1",
218
- "cfg-1",
219
- "INBOX",
220
- failingConnection,
221
- );
222
-
223
- assert.equal(result.skippedCount, 1);
224
- assert.deepEqual(harness.messageUpdates, []);
225
- assert.deepEqual(harness.retrieved, []);
226
- });
227
-
228
- it("requeues the message when its stored body cannot be read", async () => {
229
- // An unreadable body object is an infra fault. Absorbing it would leave
230
- // the message permanently unclassified with nothing to show for it.
231
- const harness = buildHarness([stored("m-1")], async () => {
232
- throw new Error("AccessDenied");
233
- });
234
-
235
- const result = await harness.service.syncBodies(
236
- ["m-1"],
237
- "acc-1",
238
- "cfg-1",
239
- "INBOX",
240
- failingConnection,
241
- );
242
-
243
- assert.deepEqual(result.failedMessageIds, ["m-1"]);
244
- assert.equal(result.skippedCount, 0);
245
- });
246
-
247
- it("logs a backfill failure rather than swallowing it", async () => {
248
- const harness = buildHarness([stored("m-1")], async () => {
249
- throw new Error("AccessDenied");
250
- });
251
-
252
- await harness.service.syncBodies(
253
- ["m-1"],
254
- "acc-1",
255
- "cfg-1",
256
- "INBOX",
257
- failingConnection,
258
- );
259
-
260
- assert.equal(harness.loggedErrors.length, 1);
261
- assert.equal(harness.loggedErrors[0].messageId, "m-1");
262
- });
263
-
264
- it("does not abort the batch when one message's backfill fails", async () => {
265
- // The failure happens in the message-resolution loop, before any body is
266
- // fetched. Letting it escape would strand every other message in the
267
- // batch — including ones that need a genuine IMAP fetch — behind one
268
- // unreadable S3 object.
269
- const harness = buildHarness(
270
- [stored("m-bad"), stored("m-good")],
271
- async (key) => {
272
- if (key.endsWith("m-bad")) throw new Error("AccessDenied");
273
- return LINKEDIN_EML;
274
- },
275
- );
276
-
277
- const result = await harness.service.syncBodies(
278
- ["m-bad", "m-good"],
279
- "acc-1",
280
- "cfg-1",
281
- "INBOX",
282
- failingConnection,
283
- );
284
-
285
- assert.deepEqual(result.failedMessageIds, ["m-bad"]);
286
- assert.equal(result.skippedCount, 1);
287
- assert.deepEqual(
288
- harness.messageUpdates.map((u) => u.messageId),
289
- ["m-good"],
290
- );
291
- });
292
- });