@remit/mailbox-service 0.0.24 → 0.0.25

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.25",
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": "*",
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,6 +135,7 @@ 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
 
139
141
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
@@ -1423,10 +1425,11 @@ export class BodySyncService {
1423
1425
  }
1424
1426
 
1425
1427
  /**
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.
1428
+ * Extract snippet, header category and the normalized `List-Id` from the body
1429
+ * and denormalize them onto the ThreadMessage. `category` mirrors the Message:
1430
+ * created as `uncategorized` at metadata-sync and set to the classified value
1431
+ * here; `listId` is written so the back-apply corpus projection can match a
1432
+ * `ListId` clause vector-free, off the same row the list/search path reads.
1430
1433
  * Returns the parsed mail so callers can reuse it (e.g., to write the
1431
1434
  * parsed-body cache) without paying for mailparser twice.
1432
1435
  */
@@ -1450,12 +1453,14 @@ export class BodySyncService {
1450
1453
  );
1451
1454
 
1452
1455
  const category = classifyByHeaders(parsed);
1456
+ const listId = extractListId(parsed);
1453
1457
 
1454
1458
  await this.denormalizeCategory(
1455
1459
  accountConfigId,
1456
1460
  messageId,
1457
1461
  category,
1458
1462
  snippet,
1463
+ listId,
1459
1464
  );
1460
1465
 
1461
1466
  this.log.debug?.(
@@ -1482,6 +1487,7 @@ export class BodySyncService {
1482
1487
  messageId: string,
1483
1488
  category: ThreadMessageCategory,
1484
1489
  snippet?: string,
1490
+ listId?: string,
1485
1491
  ): Promise<void> {
1486
1492
  const threadMessage = await this.threadMessageService.getByMessageId(
1487
1493
  accountConfigId,
@@ -1491,7 +1497,11 @@ export class BodySyncService {
1491
1497
  await this.threadMessageService.update(
1492
1498
  accountConfigId,
1493
1499
  threadMessage.threadMessageId,
1494
- { category, ...(snippet ? { snippet } : {}) },
1500
+ {
1501
+ category,
1502
+ ...(snippet ? { snippet } : {}),
1503
+ ...(listId ? { listId } : {}),
1504
+ },
1495
1505
  {
1496
1506
  composites: {
1497
1507
  sentDate: threadMessage.sentDate,
@@ -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,