@remit/mailbox-service 0.0.24 → 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.24",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -39,6 +39,7 @@
39
39
  "mailparser": "^3.9.14",
40
40
  "nodemailer": "^9.0.3",
41
41
  "p-map": "^7.0.4",
42
+ "tldts": "^7.4.9",
42
43
  "@remit/data-ports": "*",
43
44
  "@remit/domain-enums": "*",
44
45
  "@remit/mail-oauth-service": "*",
@@ -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
@@ -30,6 +30,7 @@ import { type ParsedMail, simpleParser } from "mailparser";
30
30
  import pMap from "p-map";
31
31
  import { BodyParseError, parseMessageBody } from "./body-parse.js";
32
32
  import { mapBodyPartsToContent } from "./body-part-mapper.js";
33
+ import { extractListId } from "./filters/list-id.js";
33
34
  import type { FilterMessage } from "./filters/match.js";
34
35
  import {
35
36
  type FilterConfig,
@@ -134,8 +135,40 @@ const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
134
135
  fromName: parsed.from?.value?.[0]?.name ?? "",
135
136
  subject: parsed.subject ?? "",
136
137
  text: parsed.text ?? "",
138
+ listId: extractListId(parsed),
137
139
  });
138
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
+
139
172
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
140
173
  text: parsed.text ?? null,
141
174
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -940,12 +973,26 @@ export class BodySyncService {
940
973
  const parsed = await parseMessageBody(body);
941
974
  const classification = this.classifyMessage(parsed);
942
975
 
943
- 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.
944
988
  await this.denormalizeCategory(
945
989
  accountConfigId,
946
990
  message.messageId,
947
991
  classification.category,
992
+ extractSnippet(parsed),
993
+ extractListId(parsed),
948
994
  );
995
+ await this.messageService.update(message.messageId, classification);
949
996
 
950
997
  this.log.info(
951
998
  { messageId: message.messageId, category: classification.category },
@@ -1423,10 +1470,11 @@ export class BodySyncService {
1423
1470
  }
1424
1471
 
1425
1472
  /**
1426
- * Extract snippet and header category from the body and denormalize both
1427
- * onto the ThreadMessage. `category` mirrors the Message: created as
1428
- * `uncategorized` at metadata-sync and set to the classified value here, so
1429
- * the list/search read path carries it without a per-row Message fetch.
1473
+ * Extract snippet, header category and the normalized `List-Id` from the body
1474
+ * and denormalize them onto the ThreadMessage. `category` mirrors the Message:
1475
+ * created as `uncategorized` at metadata-sync and set to the classified value
1476
+ * here; `listId` is written so the back-apply corpus projection can match a
1477
+ * `ListId` clause vector-free, off the same row the list/search path reads.
1430
1478
  * Returns the parsed mail so callers can reuse it (e.g., to write the
1431
1479
  * parsed-body cache) without paying for mailparser twice.
1432
1480
  */
@@ -1442,20 +1490,16 @@ export class BodySyncService {
1442
1490
  // two apart and quarantine only this one (issue #72).
1443
1491
  const parsed = await parseMessageBody(body);
1444
1492
 
1445
- // Extract snippet from text or HTML content
1446
- const snippet = extractSnippetFromEmail(
1447
- parsed.text,
1448
- typeof parsed.html === "string" ? parsed.html : undefined,
1449
- 256,
1450
- );
1451
-
1493
+ const snippet = extractSnippet(parsed);
1452
1494
  const category = classifyByHeaders(parsed);
1495
+ const listId = extractListId(parsed);
1453
1496
 
1454
1497
  await this.denormalizeCategory(
1455
1498
  accountConfigId,
1456
1499
  messageId,
1457
1500
  category,
1458
1501
  snippet,
1502
+ listId,
1459
1503
  );
1460
1504
 
1461
1505
  this.log.debug?.(
@@ -1467,41 +1511,71 @@ export class BodySyncService {
1467
1511
  }
1468
1512
 
1469
1513
  /**
1470
- * Write the denormalized `category` (and optionally the snippet) onto the
1471
- * message's ThreadMessage row the copy the list/search read path serves
1472
- * 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.
1517
+ *
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.
1473
1530
  *
1474
- * The ThreadMessage is looked up by messageId (GSI), so it does not depend
1475
- * on the RFC822 Message-ID header — a headerless message still gets
1476
- * denormalized, matching the unconditional Message.category write. The full
1477
- * composite set is passed so that a future key-attribute addition touching
1478
- * the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows consistent.
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.
1479
1538
  */
1480
1539
  private async denormalizeCategory(
1481
1540
  accountConfigId: string,
1482
1541
  messageId: string,
1483
1542
  category: ThreadMessageCategory,
1484
1543
  snippet?: string,
1544
+ listId?: string,
1485
1545
  ): Promise<void> {
1486
- const threadMessage = await this.threadMessageService.getByMessageId(
1546
+ const rows = await this.threadMessageService.findAllByMessageId(
1487
1547
  accountConfigId,
1488
1548
  messageId,
1489
1549
  );
1550
+ if (rows.length === 0) {
1551
+ throw new NotFoundError(
1552
+ `ThreadMessage not found for message ${messageId}`,
1553
+ );
1554
+ }
1490
1555
 
1491
- await this.threadMessageService.update(
1492
- accountConfigId,
1493
- threadMessage.threadMessageId,
1494
- { category, ...(snippet ? { snippet } : {}) },
1495
- {
1496
- composites: {
1497
- sentDate: threadMessage.sentDate,
1498
- mailboxId: threadMessage.mailboxId,
1499
- isRead: threadMessage.isRead,
1500
- isDeleted: threadMessage.isDeleted,
1501
- hasStars: threadMessage.hasStars,
1502
- 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
+ },
1503
1577
  },
1504
- },
1505
- );
1578
+ );
1579
+ }
1506
1580
  }
1507
1581
  }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { simpleParser } from "mailparser";
4
+ import { extractListId, normalizeListId } from "./list-id.js";
5
+
6
+ const parse = async (lines: string[]) =>
7
+ simpleParser(Buffer.from(lines.join("\r\n")));
8
+
9
+ describe("normalizeListId", () => {
10
+ it("extracts the bracketed identifier and folds case", () => {
11
+ assert.equal(
12
+ normalizeListId("Weekly News <Weekly.News.Example.COM>"),
13
+ "weekly.news.example.com",
14
+ );
15
+ });
16
+
17
+ it("keeps a bare value and folds case", () => {
18
+ assert.equal(
19
+ normalizeListId(" Weekly.News.Example.COM "),
20
+ "weekly.news.example.com",
21
+ );
22
+ });
23
+
24
+ it("normalizes an empty value to the empty string", () => {
25
+ assert.equal(normalizeListId(" "), "");
26
+ });
27
+ });
28
+
29
+ describe("extractListId", () => {
30
+ it("reads and normalizes a List-Id header", async () => {
31
+ const parsed = await parse([
32
+ "From: list@example.com",
33
+ "Subject: hi",
34
+ "List-Id: Weekly News <weekly.news.example.com>",
35
+ "",
36
+ "body",
37
+ ]);
38
+ assert.equal(extractListId(parsed), "weekly.news.example.com");
39
+ });
40
+
41
+ it("returns the empty string when there is no List-Id header", async () => {
42
+ const parsed = await parse([
43
+ "From: alice@example.com",
44
+ "Subject: hi",
45
+ "",
46
+ "body",
47
+ ]);
48
+ assert.equal(extractListId(parsed), "");
49
+ });
50
+ });
@@ -0,0 +1,29 @@
1
+ import type { ParsedMail } from "mailparser";
2
+
3
+ /**
4
+ * Canonical form of a `List-Id` value for exact comparison: the bracketed
5
+ * identifier when the header carries the RFC 2919 `Name <list.id>` shape,
6
+ * otherwise the whole value, trimmed and case-folded. Both the stored copy and
7
+ * a `ListId` clause pass through this, so `<weekly.news.example.com>` and
8
+ * `weekly.news.example.com` are one list. An empty input normalizes to `""`.
9
+ */
10
+ export const normalizeListId = (value: string): string => {
11
+ const trimmed = value.trim();
12
+ const bracketed = trimmed.match(/<([^>]+)>/);
13
+ return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
14
+ };
15
+
16
+ /**
17
+ * The normalized `List-Id` header value of a parsed message, or `""` when the
18
+ * message carries no `List-Id`. Read from the raw header line so the exact
19
+ * value survives regardless of how the parser structures the header.
20
+ */
21
+ export const extractListId = (parsed: ParsedMail): string => {
22
+ const line = parsed.headerLines.find(
23
+ (header) => header.key.toLowerCase() === "list-id",
24
+ );
25
+ if (!line) return "";
26
+ const colon = line.line.indexOf(":");
27
+ if (colon < 0) return "";
28
+ return normalizeListId(line.line.slice(colon + 1));
29
+ };
@@ -18,6 +18,7 @@ const message = (overrides: Partial<FilterMessage> = {}): FilterMessage => ({
18
18
  fromName: "Alice Example",
19
19
  subject: "Q3 invoice attached",
20
20
  text: "Please find the invoice for the quarter attached.",
21
+ listId: "",
21
22
  ...overrides,
22
23
  });
23
24
 
@@ -69,6 +70,93 @@ describe("clauseMatches", () => {
69
70
  false,
70
71
  );
71
72
  });
73
+
74
+ it("matches ListId exactly, never as a substring", () => {
75
+ const msg = message({ listId: "weekly.news.example.com" });
76
+ assert.equal(
77
+ clauseMatches(
78
+ clause(FilterClauseField.ListId, "weekly.news.example.com"),
79
+ msg,
80
+ ),
81
+ true,
82
+ );
83
+ assert.equal(
84
+ clauseMatches(clause(FilterClauseField.ListId, "news.example.com"), msg),
85
+ false,
86
+ );
87
+ assert.equal(
88
+ clauseMatches(
89
+ clause(FilterClauseField.ListId, "weekly.news.example.com.other"),
90
+ msg,
91
+ ),
92
+ false,
93
+ );
94
+ });
95
+
96
+ it("normalizes ListId brackets and case on both sides", () => {
97
+ const msg = message({ listId: "weekly.news.example.com" });
98
+ assert.equal(
99
+ clauseMatches(
100
+ clause(FilterClauseField.ListId, "<Weekly.News.Example.COM>"),
101
+ msg,
102
+ ),
103
+ true,
104
+ );
105
+ });
106
+
107
+ it("never matches ListId on a message with no List-Id", () => {
108
+ assert.equal(
109
+ clauseMatches(
110
+ clause(FilterClauseField.ListId, "weekly.news.example.com"),
111
+ message({ listId: "" }),
112
+ ),
113
+ false,
114
+ );
115
+ });
116
+
117
+ it("matches FromDomain on the registrable domain, including subdomains", () => {
118
+ assert.equal(
119
+ clauseMatches(
120
+ clause(FilterClauseField.FromDomain, "github.com"),
121
+ message({ from: "notifications@github.com" }),
122
+ ),
123
+ true,
124
+ );
125
+ assert.equal(
126
+ clauseMatches(
127
+ clause(FilterClauseField.FromDomain, "github.com"),
128
+ message({ from: "notifications@sub.github.com" }),
129
+ ),
130
+ true,
131
+ );
132
+ });
133
+
134
+ it("never matches FromDomain on a look-alike subdomain (public-suffix aware)", () => {
135
+ assert.equal(
136
+ clauseMatches(
137
+ clause(FilterClauseField.FromDomain, "github.com"),
138
+ message({ from: "attacker@github.com.evil.example" }),
139
+ ),
140
+ false,
141
+ );
142
+ });
143
+
144
+ it("matches FromDomain across multi-level public suffixes", () => {
145
+ assert.equal(
146
+ clauseMatches(
147
+ clause(FilterClauseField.FromDomain, "example.co.uk"),
148
+ message({ from: "hr@mail.example.co.uk" }),
149
+ ),
150
+ true,
151
+ );
152
+ assert.equal(
153
+ clauseMatches(
154
+ clause(FilterClauseField.FromDomain, "example.co.uk"),
155
+ message({ from: "hr@example.co.uk.evil.example" }),
156
+ ),
157
+ false,
158
+ );
159
+ });
72
160
  });
73
161
 
74
162
  describe("literalClausesMatch", () => {
@@ -1,5 +1,7 @@
1
1
  import type { FilterItem } from "@remit/data-ports";
2
2
  import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
3
+ import { getDomain } from "tldts";
4
+ import { normalizeListId } from "./list-id.js";
3
5
 
4
6
  type FilterClause = FilterItem["literalClauses"][number];
5
7
 
@@ -36,15 +38,32 @@ export interface FilterMessage {
36
38
  fromName: string;
37
39
  subject: string;
38
40
  text: string;
41
+ /** Normalized `List-Id` header value (see `normalizeListId`); `""` when absent. */
42
+ listId: string;
39
43
  }
40
44
 
41
45
  const includesFold = (haystack: string, needle: string): boolean =>
42
46
  haystack.toLowerCase().includes(needle.toLowerCase());
43
47
 
48
+ const hostOf = (address: string): string => {
49
+ const at = address.lastIndexOf("@");
50
+ return at >= 0 ? address.slice(at + 1) : address;
51
+ };
52
+
53
+ /**
54
+ * The registrable, public-suffix-aware domain of an email address or host, or
55
+ * `null` when none resolves. `getDomain` folds case and applies the ICANN
56
+ * suffix list, so `github.com.evil.example` yields `evil.example`, never
57
+ * `github.com` — a `FromDomain` clause cannot be spoofed by a crafted subdomain.
58
+ */
59
+ const registrableDomain = (addressOrHost: string): string | null =>
60
+ getDomain(hostOf(addressOrHost.trim()));
61
+
44
62
  /**
45
63
  * Whether one literal clause matches the message. From matches against the
46
64
  * sender address and display name; Subject against the subject; HasWords against
47
- * subject or body. An empty clause value never matches.
65
+ * subject or body; ListId against the exact normalized `List-Id`; FromDomain
66
+ * against the sender's registrable domain. An empty clause value never matches.
48
67
  */
49
68
  export const clauseMatches = (
50
69
  clause: FilterClause,
@@ -59,6 +78,15 @@ export const clauseMatches = (
59
78
  return includesFold(msg.subject, value);
60
79
  case FilterClauseField.HasWords:
61
80
  return includesFold(msg.subject, value) || includesFold(msg.text, value);
81
+ case FilterClauseField.ListId: {
82
+ const target = normalizeListId(value);
83
+ return target !== "" && normalizeListId(msg.listId) === target;
84
+ }
85
+ case FilterClauseField.FromDomain: {
86
+ const target = registrableDomain(value);
87
+ if (target === null) return false;
88
+ return registrableDomain(msg.from) === target;
89
+ }
62
90
  default:
63
91
  return false;
64
92
  }
@@ -74,6 +74,7 @@ describe("FilterPipeline — anchorless From/Or filter at index time", () => {
74
74
  fromName: "npm",
75
75
  subject: "A new version of left-pad is available",
76
76
  text: "body",
77
+ listId: "",
77
78
  });
78
79
 
79
80
  assert.deepEqual(decision.move, {
@@ -92,6 +93,7 @@ describe("FilterPipeline — anchorless From/Or filter at index time", () => {
92
93
  fromName: "Stripe",
93
94
  subject: "Your receipt",
94
95
  text: "body",
96
+ listId: "",
95
97
  });
96
98
 
97
99
  assert.equal(decision.move, undefined);
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ export {
59
59
  testImapConnection,
60
60
  testSmtpConnection,
61
61
  } from "./connection-test.js";
62
+ export { extractListId, normalizeListId } from "./filters/list-id.js";
62
63
  export {
63
64
  buildMatchText,
64
65
  clauseMatches,
@@ -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)