@remit/mailbox-service 0.0.25 → 0.0.26

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.25",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -72,15 +72,17 @@ const buildHarness = (
72
72
  } as unknown as IMessageRepository;
73
73
 
74
74
  const threadMessageService = {
75
- getByMessageId: async () => ({
76
- threadMessageId: "tm-1",
77
- sentDate: 1,
78
- mailboxId: "mb-1",
79
- isRead: false,
80
- isDeleted: false,
81
- hasStars: false,
82
- hasAttachment: false,
83
- }),
75
+ findAllByMessageId: async () => [
76
+ {
77
+ threadMessageId: "tm-1",
78
+ sentDate: 1,
79
+ mailboxId: "mb-1",
80
+ isRead: false,
81
+ isDeleted: false,
82
+ hasStars: false,
83
+ hasAttachment: false,
84
+ },
85
+ ],
84
86
  update: async (
85
87
  _accountConfigId: string,
86
88
  _threadMessageId: string,
@@ -0,0 +1,341 @@
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 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.
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 the retro path, so a copied message, which
15
+ * reaches no other denormalizing path, never got a `listId` at all.
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 { describe, it } from "node:test";
22
+ import type {
23
+ IAddressRepository,
24
+ IEnvelopeRepository,
25
+ IMessageRepository,
26
+ IThreadMessageRepository,
27
+ MessageItem,
28
+ ThreadMessageItem,
29
+ UpdateMessageInput,
30
+ UpdateThreadMessageInput,
31
+ } from "@remit/data-ports";
32
+ import { MessageCategory } from "@remit/domain-enums";
33
+ import type { StorageService } from "@remit/storage-service";
34
+ import { BodySyncService } from "./body-sync.js";
35
+ import type { IImapConnection } from "./types.js";
36
+
37
+ const LINKEDIN_EML = Buffer.from(
38
+ [
39
+ "From: LinkedIn <messages-noreply@linkedin.com>",
40
+ "To: me@example.com",
41
+ "Subject: You have a new invitation",
42
+ "List-Id: LinkedIn Invitations <invitations.linkedin.com>",
43
+ "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
44
+ "Content-Type: text/plain",
45
+ "",
46
+ "invitation body text",
47
+ ].join("\r\n"),
48
+ );
49
+
50
+ type Row = Pick<
51
+ ThreadMessageItem,
52
+ | "threadMessageId"
53
+ | "messageId"
54
+ | "accountConfigId"
55
+ | "mailboxId"
56
+ | "sentDate"
57
+ | "isRead"
58
+ | "isDeleted"
59
+ | "hasStars"
60
+ | "hasAttachment"
61
+ | "category"
62
+ > &
63
+ Partial<Pick<ThreadMessageItem, "snippet" | "listId">>;
64
+
65
+ interface ThreadUpdate {
66
+ threadMessageId: string;
67
+ input: UpdateThreadMessageInput;
68
+ composites: Record<string, unknown> | undefined;
69
+ }
70
+
71
+ interface Harness {
72
+ service: BodySyncService;
73
+ message: MessageItem;
74
+ rows: Row[];
75
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
76
+ threadUpdates: ThreadUpdate[];
77
+ writeOrder: string[];
78
+ }
79
+
80
+ const buildRow = (
81
+ overrides: Partial<Row> & Pick<Row, "threadMessageId">,
82
+ ): Row =>
83
+ ({
84
+ messageId: "m-1",
85
+ accountConfigId: "cfg-1",
86
+ mailboxId: "mb-inbox",
87
+ sentDate: 1,
88
+ isRead: false,
89
+ isDeleted: false,
90
+ hasStars: false,
91
+ hasAttachment: false,
92
+ category: MessageCategory.uncategorized,
93
+ ...overrides,
94
+ }) as Row;
95
+
96
+ /**
97
+ * The Message and ThreadMessage fixtures are mutated by their writes, so the
98
+ * skip guard sees the state a requeued delivery would. A harness that only
99
+ * records the write cannot show a stranded row: the strand is the guard reading
100
+ * a value that outlived a failed pass.
101
+ */
102
+ const buildHarness = (
103
+ rows: Row[],
104
+ options: {
105
+ failThreadUpdates?: number;
106
+ bodyStored?: boolean;
107
+ } = {},
108
+ ): Harness => {
109
+ const messageUpdates: Array<{
110
+ messageId: string;
111
+ input: UpdateMessageInput;
112
+ }> = [];
113
+ const threadUpdates: ThreadUpdate[] = [];
114
+ const writeOrder: string[] = [];
115
+ let threadUpdateAttempts = 0;
116
+
117
+ const message = {
118
+ messageId: "m-1",
119
+ mailboxId: "mb-inbox",
120
+ uid: 1,
121
+ category: MessageCategory.uncategorized,
122
+ ...(options.bodyStored === false
123
+ ? {}
124
+ : { bodyStorageKey: "s3://bodies/m-1" }),
125
+ } as unknown as MessageItem;
126
+
127
+ const messageService = {
128
+ get: async () => message,
129
+ update: async (messageId: string, input: UpdateMessageInput) => {
130
+ writeOrder.push("message");
131
+ messageUpdates.push({ messageId, input });
132
+ Object.assign(message, input);
133
+ },
134
+ } as unknown as IMessageRepository;
135
+
136
+ const threadMessageService = {
137
+ findAllByMessageId: async () => rows,
138
+ // Load-bearing, not a leftover: the current code never calls it, but
139
+ // reverting body-sync.ts to reproduce these failures does. Deleting it
140
+ // makes the fail-before run throw instead of demonstrating the defect.
141
+ getByMessageId: async () => rows[0],
142
+ update: async (
143
+ _accountConfigId: string,
144
+ threadMessageId: string,
145
+ input: UpdateThreadMessageInput,
146
+ updateOptions?: { composites?: Record<string, unknown> },
147
+ ) => {
148
+ threadUpdateAttempts++;
149
+ if (threadUpdateAttempts <= (options.failThreadUpdates ?? 0)) {
150
+ throw new Error("thread_message write failed");
151
+ }
152
+ writeOrder.push("thread");
153
+ threadUpdates.push({
154
+ threadMessageId,
155
+ input,
156
+ composites: updateOptions?.composites,
157
+ });
158
+ const row = rows.find((r) => r.threadMessageId === threadMessageId);
159
+ if (row) Object.assign(row, input);
160
+ },
161
+ } as unknown as IThreadMessageRepository;
162
+
163
+ const storageService = {
164
+ retrieve: async () => LINKEDIN_EML,
165
+ storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
166
+ storeParsedBody: async () => {},
167
+ listBodyParts: async () => [],
168
+ } as unknown as StorageService;
169
+
170
+ const service = new BodySyncService(
171
+ messageService,
172
+ storageService,
173
+ threadMessageService,
174
+ { incrementInboundCount: async () => {} } as unknown as IAddressRepository,
175
+ { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
176
+ { info: () => {}, error: () => {} },
177
+ );
178
+
179
+ return {
180
+ service,
181
+ message,
182
+ rows,
183
+ messageUpdates,
184
+ threadUpdates,
185
+ writeOrder,
186
+ };
187
+ };
188
+
189
+ const backfill = (harness: Harness) =>
190
+ harness.service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () => {
191
+ throw new Error("the backfill must not open IMAP");
192
+ });
193
+
194
+ const syncFromImap = (harness: Harness) =>
195
+ harness.service.fetchAndGetBody(
196
+ "m-1",
197
+ "acc-1",
198
+ "cfg-1",
199
+ "INBOX",
200
+ async () =>
201
+ ({
202
+ openBox: async () => {},
203
+ fetchMessageBody: async () => LINKEDIN_EML,
204
+ }) as unknown as IImapConnection,
205
+ );
206
+
207
+ describe("the classification backfill survives an interrupted write", () => {
208
+ it("leaves the Message unclassified when the ThreadMessage write fails, so the retry runs again", async () => {
209
+ const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })], {
210
+ failThreadUpdates: 1,
211
+ });
212
+
213
+ const failed = await backfill(harness);
214
+
215
+ assert.deepEqual(failed.failedMessageIds, ["m-1"]);
216
+ assert.equal(harness.message.category, MessageCategory.uncategorized);
217
+ assert.deepEqual(harness.messageUpdates, []);
218
+
219
+ const retried = await backfill(harness);
220
+
221
+ assert.deepEqual(retried.failedMessageIds, []);
222
+ assert.equal(harness.message.category, MessageCategory.social);
223
+ assert.equal(harness.rows[0].category, MessageCategory.social);
224
+ });
225
+
226
+ it("writes the Message only after the denormalized row is durable", async () => {
227
+ const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
228
+
229
+ await backfill(harness);
230
+
231
+ assert.deepEqual(harness.writeOrder, ["thread", "message"]);
232
+ });
233
+ });
234
+
235
+ describe("denormalization reaches every row a message has", () => {
236
+ it("carries the backfilled category onto all of a message's rows", async () => {
237
+ const harness = buildHarness([
238
+ buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
239
+ buildRow({
240
+ threadMessageId: "tm-archive",
241
+ mailboxId: "mb-archive",
242
+ isRead: true,
243
+ }),
244
+ ]);
245
+
246
+ await backfill(harness);
247
+
248
+ assert.deepEqual(
249
+ harness.rows.map((r) => r.category),
250
+ [MessageCategory.social, MessageCategory.social],
251
+ );
252
+ });
253
+
254
+ it("builds the composite block from each row rather than reusing the first", async () => {
255
+ const harness = buildHarness([
256
+ buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
257
+ buildRow({
258
+ threadMessageId: "tm-archive",
259
+ mailboxId: "mb-archive",
260
+ isRead: true,
261
+ }),
262
+ ]);
263
+
264
+ await backfill(harness);
265
+
266
+ assert.deepEqual(
267
+ harness.threadUpdates.map((u) => ({
268
+ threadMessageId: u.threadMessageId,
269
+ mailboxId: u.composites?.mailboxId,
270
+ isRead: u.composites?.isRead,
271
+ })),
272
+ [
273
+ { threadMessageId: "tm-inbox", mailboxId: "mb-inbox", isRead: false },
274
+ {
275
+ threadMessageId: "tm-archive",
276
+ mailboxId: "mb-archive",
277
+ isRead: true,
278
+ },
279
+ ],
280
+ );
281
+ });
282
+
283
+ it("fans the snippet and the list id out to every row on the sync path", async () => {
284
+ // A snippet and a List-Id belong to the message, not to the mailbox a row
285
+ // happens to name, so every row carries them.
286
+ const harness = buildHarness(
287
+ [
288
+ buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
289
+ buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
290
+ ],
291
+ { bodyStored: false },
292
+ );
293
+
294
+ await syncFromImap(harness);
295
+
296
+ for (const row of harness.rows) {
297
+ assert.equal(row.category, MessageCategory.social);
298
+ assert.equal(row.listId, "invitations.linkedin.com");
299
+ assert.equal(row.snippet, "invitation body text");
300
+ }
301
+ });
302
+
303
+ it("writes nothing for a row that already carries every value", async () => {
304
+ const harness = buildHarness([
305
+ buildRow({
306
+ threadMessageId: "tm-inbox",
307
+ mailboxId: "mb-inbox",
308
+ category: MessageCategory.social,
309
+ snippet: "invitation body text",
310
+ listId: "invitations.linkedin.com",
311
+ }),
312
+ buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
313
+ ]);
314
+
315
+ await backfill(harness);
316
+
317
+ assert.deepEqual(
318
+ harness.threadUpdates.map((u) => u.threadMessageId),
319
+ ["tm-archive"],
320
+ );
321
+ });
322
+ });
323
+
324
+ describe("the classification backfill writes the whole denormalized set", () => {
325
+ // A copied message inherits its source's stored body AND its decided
326
+ // category, so the full body-store path skips it and this path's guard
327
+ // returns early — and nothing but these two paths ever writes `listId`. The
328
+ // backfill passing only `category` left every copy's `list_id` NULL for good.
329
+ it("carries the list id and the snippet, not just the category", async () => {
330
+ const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
331
+
332
+ await backfill(harness);
333
+
334
+ assert.deepEqual(harness.threadUpdates.length, 1);
335
+ assert.deepEqual(harness.threadUpdates[0].input, {
336
+ category: MessageCategory.social,
337
+ snippet: "invitation body text",
338
+ listId: "invitations.linkedin.com",
339
+ });
340
+ });
341
+ });
@@ -102,15 +102,17 @@ const buildHarness = (
102
102
  } as unknown as IMessageRepository;
103
103
 
104
104
  const threadMessageService = {
105
- getByMessageId: async () => ({
106
- threadMessageId: "tm-1",
107
- sentDate: 1,
108
- mailboxId: message.mailboxId,
109
- isRead: false,
110
- isDeleted: false,
111
- hasStars: false,
112
- hasAttachment: false,
113
- }),
105
+ findAllByMessageId: async () => [
106
+ {
107
+ threadMessageId: "tm-1",
108
+ sentDate: 1,
109
+ mailboxId: message.mailboxId,
110
+ isRead: false,
111
+ isDeleted: false,
112
+ hasStars: false,
113
+ hasAttachment: false,
114
+ },
115
+ ],
114
116
  update: async () => {},
115
117
  } as unknown as IThreadMessageRepository;
116
118
 
@@ -78,15 +78,17 @@ const buildHarness = (options: {
78
78
  messageService,
79
79
  storageService,
80
80
  {
81
- getByMessageId: async () => ({
82
- threadMessageId: "tm-1",
83
- sentDate: 1,
84
- mailboxId: "mbx-1",
85
- isRead: false,
86
- isDeleted: false,
87
- hasStars: false,
88
- hasAttachment: false,
89
- }),
81
+ findAllByMessageId: async () => [
82
+ {
83
+ threadMessageId: "tm-1",
84
+ sentDate: 1,
85
+ mailboxId: "mbx-1",
86
+ isRead: false,
87
+ isDeleted: false,
88
+ hasStars: false,
89
+ hasAttachment: false,
90
+ },
91
+ ],
90
92
  update: async () => {},
91
93
  } as unknown as IThreadMessageRepository,
92
94
  {} as unknown as IAddressRepository,
package/src/body-sync.ts CHANGED
@@ -138,6 +138,37 @@ const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
138
138
  listId: extractListId(parsed),
139
139
  });
140
140
 
141
+ const SNIPPET_LENGTH = 256;
142
+
143
+ /**
144
+ * The snippet the list row shows, from whichever body part carries text. Shared
145
+ * by both paths that denormalize onto the ThreadMessage so they cannot derive it
146
+ * differently.
147
+ */
148
+ const extractSnippet = (parsed: ParsedMail): string =>
149
+ extractSnippetFromEmail(
150
+ parsed.text,
151
+ typeof parsed.html === "string" ? parsed.html : undefined,
152
+ SNIPPET_LENGTH,
153
+ );
154
+
155
+ /**
156
+ * A row is skipped only when every field the denormalization would write
157
+ * already matches. `snippet` and `listId` are absent from the update when the
158
+ * message has neither, and an absent field is not a mismatch.
159
+ */
160
+ const alreadyDenormalized = (
161
+ row: ThreadMessageItem,
162
+ update: {
163
+ category: ThreadMessageCategory;
164
+ snippet?: string;
165
+ listId?: string;
166
+ },
167
+ ): boolean =>
168
+ row.category === update.category &&
169
+ (update.snippet === undefined || row.snippet === update.snippet) &&
170
+ (update.listId === undefined || row.listId === update.listId);
171
+
141
172
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
142
173
  text: parsed.text ?? null,
143
174
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -942,12 +973,26 @@ export class BodySyncService {
942
973
  const parsed = await parseMessageBody(body);
943
974
  const classification = this.classifyMessage(parsed);
944
975
 
945
- await this.messageService.update(message.messageId, classification);
976
+ // Same order as {@link applyPostStoreSteps}, for the same reason: the
977
+ // signal the skip guard reads is written last. `message.category` is that
978
+ // signal here, so a failure between the two writes leaves both undone and
979
+ // the requeued retry redoes both. Writing the Message first strands the
980
+ // denormalized row at `uncategorized` forever — the guard is satisfied and
981
+ // the retry returns early (issue #320).
982
+ // The same three denormalized fields `updateSnippets` writes on the
983
+ // full body-store path, not just the category. A copied message inherits
984
+ // `bodyStorageKey` and a decided category from its source, so it reaches
985
+ // neither that path nor this one's classification — but nothing else ever
986
+ // writes `listId`, so leaving it out here made a copy's `list_id`
987
+ // permanently NULL. Both are derived from the same bytes already in hand.
946
988
  await this.denormalizeCategory(
947
989
  accountConfigId,
948
990
  message.messageId,
949
991
  classification.category,
992
+ extractSnippet(parsed),
993
+ extractListId(parsed),
950
994
  );
995
+ await this.messageService.update(message.messageId, classification);
951
996
 
952
997
  this.log.info(
953
998
  { messageId: message.messageId, category: classification.category },
@@ -1445,13 +1490,7 @@ export class BodySyncService {
1445
1490
  // two apart and quarantine only this one (issue #72).
1446
1491
  const parsed = await parseMessageBody(body);
1447
1492
 
1448
- // Extract snippet from text or HTML content
1449
- const snippet = extractSnippetFromEmail(
1450
- parsed.text,
1451
- typeof parsed.html === "string" ? parsed.html : undefined,
1452
- 256,
1453
- );
1454
-
1493
+ const snippet = extractSnippet(parsed);
1455
1494
  const category = classifyByHeaders(parsed);
1456
1495
  const listId = extractListId(parsed);
1457
1496
 
@@ -1472,15 +1511,30 @@ export class BodySyncService {
1472
1511
  }
1473
1512
 
1474
1513
  /**
1475
- * Write the denormalized `category` (and optionally the snippet) onto the
1476
- * message's ThreadMessage row the copy the list/search read path serves
1477
- * without a per-row Message fetch.
1514
+ * Write the denormalized `category` (and optionally the snippet and the
1515
+ * normalized `List-Id`) onto EVERY ThreadMessage row the message has the
1516
+ * copy the list/search read path serves without a per-row Message fetch.
1478
1517
  *
1479
- * The ThreadMessage is looked up by messageId (GSI), so it does not depend
1480
- * on the RFC822 Message-ID header a headerless message still gets
1481
- * denormalized, matching the unconditional Message.category write. The full
1482
- * composite set is passed so that a future key-attribute addition touching
1483
- * the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows consistent.
1518
+ * More than one row per messageId is schema-legal but not normally produced,
1519
+ * and this iterates for the same reason `message-move.ts` does (see the model
1520
+ * stated at its `deleteThreadMessagesForMessage`): the key permits it and
1521
+ * nothing enforces otherwise. It is NOT the second mailbox a message appears
1522
+ * in `deriveMessageId` and `deriveThreadMessageId` are both
1523
+ * mailbox-independent, so INBOX and Archive resolve to one row, and a copy
1524
+ * gets its own messageId. The reachable case is thread-root drift: the same
1525
+ * message re-saved under different `References`, which mints a second
1526
+ * threadId and so a second row. Iterating is therefore hardening against a
1527
+ * legal state, not a repair for one the sync path manufactures, which is why
1528
+ * the tree's other single-row `messageId` lookups are correct as they stand.
1529
+ * `flag-queue.ts` iterates the same list.
1530
+ *
1531
+ * Rows are looked up by messageId, so this does not depend on the RFC822
1532
+ * Message-ID header — a headerless message still gets denormalized, matching
1533
+ * the unconditional Message.category write. The composite set is built per
1534
+ * row, never reused: `mailboxId` and `isRead` can differ between two rows for
1535
+ * one message, and it is passed at all so that a future key-attribute
1536
+ * addition touching the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows
1537
+ * consistent.
1484
1538
  */
1485
1539
  private async denormalizeCategory(
1486
1540
  accountConfigId: string,
@@ -1489,29 +1543,39 @@ export class BodySyncService {
1489
1543
  snippet?: string,
1490
1544
  listId?: string,
1491
1545
  ): Promise<void> {
1492
- const threadMessage = await this.threadMessageService.getByMessageId(
1546
+ const rows = await this.threadMessageService.findAllByMessageId(
1493
1547
  accountConfigId,
1494
1548
  messageId,
1495
1549
  );
1550
+ if (rows.length === 0) {
1551
+ throw new NotFoundError(
1552
+ `ThreadMessage not found for message ${messageId}`,
1553
+ );
1554
+ }
1496
1555
 
1497
- await this.threadMessageService.update(
1498
- accountConfigId,
1499
- threadMessage.threadMessageId,
1500
- {
1501
- category,
1502
- ...(snippet ? { snippet } : {}),
1503
- ...(listId ? { listId } : {}),
1504
- },
1505
- {
1506
- composites: {
1507
- sentDate: threadMessage.sentDate,
1508
- mailboxId: threadMessage.mailboxId,
1509
- isRead: threadMessage.isRead,
1510
- isDeleted: threadMessage.isDeleted,
1511
- hasStars: threadMessage.hasStars,
1512
- hasAttachment: threadMessage.hasAttachment,
1556
+ const update = {
1557
+ category,
1558
+ ...(snippet ? { snippet } : {}),
1559
+ ...(listId ? { listId } : {}),
1560
+ };
1561
+
1562
+ for (const row of rows) {
1563
+ if (alreadyDenormalized(row, update)) continue;
1564
+ await this.threadMessageService.update(
1565
+ accountConfigId,
1566
+ row.threadMessageId,
1567
+ update,
1568
+ {
1569
+ composites: {
1570
+ sentDate: row.sentDate,
1571
+ mailboxId: row.mailboxId,
1572
+ isRead: row.isRead,
1573
+ isDeleted: row.isDeleted,
1574
+ hasStars: row.hasStars,
1575
+ hasAttachment: row.hasAttachment,
1576
+ },
1513
1577
  },
1514
- },
1515
- );
1578
+ );
1579
+ }
1516
1580
  }
1517
1581
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A ThreadMessage row created for a message that is already classified used to
3
+ * start at `uncategorized` and stay there: `denormalizeCategory` runs at
4
+ * body-sync, and body-sync skips a message whose body is already stored (issue
5
+ * #320). A second mailbox does not produce such a row — the ids are
6
+ * mailbox-independent — but thread-root drift does: the same message re-saved
7
+ * under different `References` mints a second threadId, so a new row for a
8
+ * Message whose category is already decided.
9
+ *
10
+ * The row now carries the category of the Message the same save just wrote.
11
+ * These pin the create input, which is where the threading can go wrong; the
12
+ * stored round-trip belongs to #322's conformance suite. The negative case is
13
+ * here because parameter threading is exactly the change that starts writing the
14
+ * wrong value when a caller's variable is undefined — and `uncategorized` is the
15
+ * declared pending state, never to be folded into `personal` (issue #45).
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { describe, it } from "node:test";
20
+ import type {
21
+ CreateMessageInput,
22
+ CreateThreadMessageInput,
23
+ IAddressRepository,
24
+ IEnvelopeRepository,
25
+ IMailboxRepository,
26
+ IMessageRepository,
27
+ IThreadMessageRepository,
28
+ MessageItem,
29
+ ThreadMessageItem,
30
+ } from "@remit/data-ports";
31
+ import { MessageCategory } from "@remit/domain-enums";
32
+ import type { ManagedConnectionFactory } from "./connection-factory.js";
33
+ import { MessageSyncService } from "./message-sync.js";
34
+ import type { ImapMessage } from "./types.js";
35
+
36
+ const envelope = {
37
+ date: new Date(0).toISOString(),
38
+ messageId: "<root@example.com>",
39
+ subject: "Subject",
40
+ from: [{ name: "Sender", mailbox: "sender", host: "example.com" }],
41
+ sender: [],
42
+ replyTo: [],
43
+ to: [],
44
+ cc: [],
45
+ bcc: [],
46
+ inReplyTo: "",
47
+ };
48
+
49
+ const imapMessage = {
50
+ uid: 42,
51
+ seq: 1,
52
+ size: 100,
53
+ internalDate: new Date(0),
54
+ flags: [],
55
+ envelope,
56
+ } as unknown as ImapMessage;
57
+
58
+ const stub = <T>(): T => ({}) as T;
59
+
60
+ /**
61
+ * Drive the real save path so the value the ThreadMessage gets is the one the
62
+ * Message write returned, not one a test handed to a private method.
63
+ */
64
+ const saveIntoMailbox = async (
65
+ storedCategory: MessageItem["category"],
66
+ ): Promise<CreateThreadMessageInput> => {
67
+ const inputs: CreateThreadMessageInput[] = [];
68
+
69
+ const messageService = {
70
+ upsertWithStatus: async (input: CreateMessageInput) => ({
71
+ item: {
72
+ ...input,
73
+ category: storedCategory,
74
+ } as unknown as MessageItem,
75
+ created: false,
76
+ }),
77
+ } as unknown as IMessageRepository;
78
+
79
+ const threadMessageService = {
80
+ create: async (input: CreateThreadMessageInput) => {
81
+ inputs.push(input);
82
+ return input as unknown as ThreadMessageItem;
83
+ },
84
+ } as unknown as IThreadMessageRepository;
85
+
86
+ const envelopeService = {
87
+ upsertEnvelope: async () => {},
88
+ upsertBodyParts: async () => {},
89
+ } as unknown as IEnvelopeRepository;
90
+
91
+ const addressService = {
92
+ upsertAddress: async () => {},
93
+ upsertEnvelopeAddress: async () => {},
94
+ } as unknown as IAddressRepository;
95
+
96
+ const service = new MessageSyncService(
97
+ stub<ManagedConnectionFactory>(),
98
+ stub<IMailboxRepository>(),
99
+ messageService,
100
+ envelopeService,
101
+ addressService,
102
+ threadMessageService,
103
+ );
104
+
105
+ await (
106
+ service as unknown as {
107
+ saveMessage: (
108
+ mailboxId: string,
109
+ accountId: string,
110
+ accountConfigId: string,
111
+ msg: ImapMessage,
112
+ ) => Promise<unknown>;
113
+ }
114
+ ).saveMessage("mbx-1", "acct-1", "cfg-1", imapMessage);
115
+
116
+ assert.equal(inputs.length, 1);
117
+ const [input] = inputs;
118
+ assert.ok(input);
119
+ return input;
120
+ };
121
+
122
+ describe("a created ThreadMessage carries the Message's category", () => {
123
+ it("gives a row for an already-classified message that message's category", async () => {
124
+ const input = await saveIntoMailbox(MessageCategory.newsletter);
125
+ assert.equal(input.category, MessageCategory.newsletter);
126
+ });
127
+
128
+ it("leaves a row for a new, unclassified message at uncategorized", async () => {
129
+ const input = await saveIntoMailbox(MessageCategory.uncategorized);
130
+ assert.equal(input.category, MessageCategory.uncategorized);
131
+ });
132
+ });
@@ -9,7 +9,7 @@ import type {
9
9
  IThreadMessageRepository,
10
10
  ThreadMessageItem,
11
11
  } from "@remit/data-ports";
12
- import { MessageSystemFlag } from "@remit/domain-enums";
12
+ import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
13
13
  import type { ManagedConnectionFactory } from "./connection-factory.js";
14
14
  import { MessageSyncService } from "./message-sync.js";
15
15
  import type { ImapEnvelope } from "./types.js";
@@ -72,6 +72,7 @@ type CreateThreadForMessage = (
72
72
  sentDate: number,
73
73
  envelope: ImapEnvelope,
74
74
  flags: string[],
75
+ category: ThreadMessageItem["category"],
75
76
  references?: string[],
76
77
  hasAttachment?: boolean,
77
78
  ) => Promise<void>;
@@ -103,6 +104,7 @@ const createThreadWithFlags = async (
103
104
  now,
104
105
  envelope,
105
106
  flags,
107
+ MessageCategory.uncategorized,
106
108
  );
107
109
 
108
110
  assert.equal(inputs.length, 1);
@@ -1317,6 +1317,15 @@ export class MessageSyncService {
1317
1317
  sentDate,
1318
1318
  envelope,
1319
1319
  msg.flags,
1320
+ // The category of the Message this same save just wrote. A new
1321
+ // message is `uncategorized` until body-sync classifies it; a row
1322
+ // created for a Message that is ALREADY classified takes the decided
1323
+ // value rather than sitting stale forever behind a body-sync pass that
1324
+ // will skip it. That happens on thread-root drift — the same message
1325
+ // re-saved under different `References` mints a second threadId and so
1326
+ // a second row — not on a second mailbox, which resolves to the row
1327
+ // that already exists (issue #320).
1328
+ item.category,
1320
1329
  msg.references,
1321
1330
  hasAttachment,
1322
1331
  );
@@ -1400,6 +1409,10 @@ export class MessageSyncService {
1400
1409
  * 3. Fall back to Message-ID (this message is a thread root)
1401
1410
  *
1402
1411
  * This ensures proper threading even when messages arrive out of order.
1412
+ *
1413
+ * `category` is the value the caller already holds from the Message it just
1414
+ * wrote, so the row starts out agreeing with it rather than defaulting to
1415
+ * `uncategorized` behind an already-classified message.
1403
1416
  */
1404
1417
  private async createThreadForMessage(
1405
1418
  threadMessageService: IThreadMessageRepository,
@@ -1412,6 +1425,7 @@ export class MessageSyncService {
1412
1425
  sentDate: number,
1413
1426
  envelope: ImapEnvelope,
1414
1427
  flags: string[],
1428
+ category: ThreadMessageItem["category"],
1415
1429
  references?: string[],
1416
1430
  hasAttachment = false,
1417
1431
  ): Promise<void> {
@@ -1484,6 +1498,7 @@ export class MessageSyncService {
1484
1498
  hasAttachment,
1485
1499
  hasStars,
1486
1500
  star: hasStars ? StarColor.Yellow : StarColor.None,
1501
+ category,
1487
1502
  })
1488
1503
  .catch((error: unknown) => {
1489
1504
  // Ignore conflict errors (idempotent create)