@remit/mailbox-service 0.0.43 → 0.0.44

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.43",
3
+ "version": "0.0.44",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/body-sync.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  type PlacementVerdict,
52
52
  resolveBlockedVsTrust,
53
53
  } from "./heuristics/classifyPlacement.js";
54
+ import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
54
55
  import type { PlacementMoveService } from "./placement-move.js";
55
56
  import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
56
57
  import { extractSnippetFromEmail } from "./snippet.js";
@@ -1159,6 +1160,20 @@ export class BodySyncService {
1159
1160
  const providerSpam = extractProviderSpam(parsed);
1160
1161
  const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
1161
1162
 
1163
+ // A passing SPF/DKIM/DMARC check proves the sending domain, not the
1164
+ // identity the message claims — on a shared-tenant host the verified
1165
+ // subdomain belongs to whoever signed up. These two comparisons say
1166
+ // whether the claim holds, and run only over mail the provider already
1167
+ // called spam.
1168
+ const senderMismatch =
1169
+ authenticity === null
1170
+ ? {}
1171
+ : extractSenderMismatch(parsed, {
1172
+ fromDomain: authenticity.fromDomain,
1173
+ spamClassified: providerSpam?.classified === true,
1174
+ bulkSender: hasListUnsubscribe,
1175
+ });
1176
+
1162
1177
  const fromEmail = extractPrimaryFromEmail(parsed);
1163
1178
  const categoryOverride = fromEmail
1164
1179
  ? await this.resolveCategoryOverride(accountConfigId, fromEmail)
@@ -1167,7 +1182,9 @@ export class BodySyncService {
1167
1182
  return {
1168
1183
  category: categoryOverride ?? headerCategory,
1169
1184
  hasListUnsubscribe,
1170
- ...(authenticity !== null ? { authenticity } : {}),
1185
+ ...(authenticity !== null
1186
+ ? { authenticity: { ...authenticity, ...senderMismatch } }
1187
+ : {}),
1171
1188
  ...(authResult !== null ? { authResult } : {}),
1172
1189
  ...(providerSpam !== null ? { providerSpam } : {}),
1173
1190
  };
@@ -0,0 +1,213 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { DisplayNameCorrespondence } from "@remit/domain-enums";
4
+ import { simpleParser } from "mailparser";
5
+ import {
6
+ classifyDisplayNameCorrespondence,
7
+ extractOffDomainLinkDomains,
8
+ extractSenderMismatch,
9
+ } from "./senderMismatch.js";
10
+
11
+ const parse = async (lines: string[]) =>
12
+ simpleParser(Buffer.from(lines.join("\r\n")));
13
+
14
+ describe("classifyDisplayNameCorrespondence", () => {
15
+ it("corresponds when the name is a label of the From domain", () => {
16
+ assert.equal(
17
+ classifyDisplayNameCorrespondence("GitHub", "notifications.github.com"),
18
+ DisplayNameCorrespondence.Corresponds,
19
+ );
20
+ });
21
+
22
+ it("corresponds when a word of a decorated name is a label of the From domain", () => {
23
+ assert.equal(
24
+ classifyDisplayNameCorrespondence(
25
+ "GitHub Actions",
26
+ "notifications.github.com",
27
+ ),
28
+ DisplayNameCorrespondence.Corresponds,
29
+ );
30
+ });
31
+
32
+ it("corresponds across a multi-part public suffix", () => {
33
+ assert.equal(
34
+ classifyDisplayNameCorrespondence("Sainsbury's", "mail.sainsburys.co.uk"),
35
+ DisplayNameCorrespondence.Corresponds,
36
+ );
37
+ });
38
+
39
+ it("is unrelated when the name appears nowhere in the From domain", () => {
40
+ assert.equal(
41
+ classifyDisplayNameCorrespondence(
42
+ "InfoMedics",
43
+ "serviceupdatebank.atlassian.net",
44
+ ),
45
+ DisplayNameCorrespondence.Unrelated,
46
+ );
47
+ });
48
+
49
+ it("is a lookalike when a digit stands in for a letter of the domain", () => {
50
+ assert.equal(
51
+ classifyDisplayNameCorrespondence("InfoMedics", "1nfomedics.nl"),
52
+ DisplayNameCorrespondence.Lookalike,
53
+ );
54
+ });
55
+
56
+ it("is a lookalike for a short brand one character off", () => {
57
+ assert.equal(
58
+ classifyDisplayNameCorrespondence("PayPal", "paypa1.com"),
59
+ DisplayNameCorrespondence.Lookalike,
60
+ );
61
+ });
62
+
63
+ it("claims nothing when the display name is empty", () => {
64
+ assert.equal(
65
+ classifyDisplayNameCorrespondence("", "example.com"),
66
+ DisplayNameCorrespondence.NoClaim,
67
+ );
68
+ assert.equal(
69
+ classifyDisplayNameCorrespondence(undefined, "example.com"),
70
+ DisplayNameCorrespondence.NoClaim,
71
+ );
72
+ });
73
+
74
+ it("claims nothing when the display name is the address itself", () => {
75
+ assert.equal(
76
+ classifyDisplayNameCorrespondence(
77
+ "billing@serviceupdatebank.atlassian.net",
78
+ "serviceupdatebank.atlassian.net",
79
+ ),
80
+ DisplayNameCorrespondence.NoClaim,
81
+ );
82
+ });
83
+
84
+ it("does not read the public suffix as a match for a brand containing it", () => {
85
+ assert.equal(
86
+ classifyDisplayNameCorrespondence("Netflix", "mailer.example.net"),
87
+ DisplayNameCorrespondence.Unrelated,
88
+ );
89
+ });
90
+ });
91
+
92
+ describe("extractOffDomainLinkDomains", () => {
93
+ it("names only the registrable domains that leave the sender's own", async () => {
94
+ const parsed = await parse([
95
+ "From: Jira <jira@serviceupdatebank.atlassian.net>",
96
+ "To: bob@example.com",
97
+ "Subject: Vordering",
98
+ "Content-Type: text/html",
99
+ "",
100
+ '<a href="https://serviceupdatebank.atlassian.net/browse/X">ticket</a>',
101
+ '<a href="https://betaal-vordering.example.org/pay">betaal nu</a>',
102
+ '<a href="https://cdn.betaal-vordering.example.org/logo.png">logo</a>',
103
+ ]);
104
+ assert.deepEqual(
105
+ extractOffDomainLinkDomains(parsed, "serviceupdatebank.atlassian.net"),
106
+ ["example.org"],
107
+ );
108
+ });
109
+
110
+ it("compares public-suffix-aware, so a co.uk sibling is not off-domain", async () => {
111
+ const parsed = await parse([
112
+ "From: Shop <shop@mail.example.co.uk>",
113
+ "To: bob@example.com",
114
+ "Subject: order",
115
+ "Content-Type: text/html",
116
+ "",
117
+ '<a href="https://www.example.co.uk/orders">orders</a>',
118
+ ]);
119
+ assert.deepEqual(
120
+ extractOffDomainLinkDomains(parsed, "mail.example.co.uk"),
121
+ [],
122
+ );
123
+ });
124
+
125
+ it("ignores mailto, anchors and relative hrefs", async () => {
126
+ const parsed = await parse([
127
+ "From: Shop <shop@example.com>",
128
+ "To: bob@example.com",
129
+ "Subject: order",
130
+ "Content-Type: text/html",
131
+ "",
132
+ '<a href="mailto:help@elsewhere.example">mail us</a>',
133
+ '<a href="#top">top</a>',
134
+ '<a href="/orders">orders</a>',
135
+ '<a href="tel:+31201234567">call</a>',
136
+ ]);
137
+ assert.deepEqual(extractOffDomainLinkDomains(parsed, "example.com"), []);
138
+ });
139
+
140
+ it("reads bare URLs out of a plain-text body", async () => {
141
+ const parsed = await parse([
142
+ "From: Shop <shop@example.com>",
143
+ "To: bob@example.com",
144
+ "Subject: order",
145
+ "",
146
+ "Betaal hier: https://betaal.elsewhere.example/pay?id=1",
147
+ ]);
148
+ assert.deepEqual(extractOffDomainLinkDomains(parsed, "example.com"), [
149
+ "elsewhere.example",
150
+ ]);
151
+ });
152
+ });
153
+
154
+ describe("extractSenderMismatch", () => {
155
+ const infoMedicsPhish = [
156
+ "From: InfoMedics <jira@serviceupdatebank.atlassian.net>",
157
+ "To: bob@example.com",
158
+ "Subject: Vordering",
159
+ "Authentication-Results: mx.example.com; dmarc=pass; spf=pass; dkim=pass",
160
+ "X-HalOne-Spam-Probability: 1",
161
+ "DKIM-Signature: v=1; a=rsa-sha256; d=custmx.one.com; s=sel; b=xxx",
162
+ "Content-Type: text/html",
163
+ "",
164
+ '<a href="https://betaal-vordering.example.org/pay">Betaal uw factuur</a>',
165
+ ];
166
+
167
+ it("flags the display name and the links on a spam-classified message", async () => {
168
+ const parsed = await parse(infoMedicsPhish);
169
+ assert.deepEqual(
170
+ extractSenderMismatch(parsed, {
171
+ fromDomain: "serviceupdatebank.atlassian.net",
172
+ spamClassified: true,
173
+ bulkSender: false,
174
+ }),
175
+ {
176
+ displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
177
+ offDomainLinkDomains: ["example.org"],
178
+ },
179
+ );
180
+ });
181
+
182
+ it("compares nothing when the provider did not call the message spam", async () => {
183
+ const parsed = await parse(infoMedicsPhish);
184
+ assert.deepEqual(
185
+ extractSenderMismatch(parsed, {
186
+ fromDomain: "serviceupdatebank.atlassian.net",
187
+ spamClassified: false,
188
+ bulkSender: false,
189
+ }),
190
+ {},
191
+ );
192
+ });
193
+
194
+ it("leaves the display name uncompared for a bulk sender", async () => {
195
+ const parsed = await parse([
196
+ "From: Dutch Cycling Weekly <bounce-9f2@mailer.esp.example>",
197
+ "To: bob@example.com",
198
+ "Subject: This week in cycling",
199
+ "List-Unsubscribe: <https://mailer.esp.example/u/9f2>",
200
+ "X-HalOne-Spam-Probability: 1",
201
+ "Content-Type: text/html",
202
+ "",
203
+ '<a href="https://dutchcyclingweekly.example.org/issue/12">read</a>',
204
+ ]);
205
+ const signals = extractSenderMismatch(parsed, {
206
+ fromDomain: "mailer.esp.example",
207
+ spamClassified: true,
208
+ bulkSender: true,
209
+ });
210
+ assert.equal(signals.displayNameCorrespondence, undefined);
211
+ assert.deepEqual(signals.offDomainLinkDomains, ["example.org"]);
212
+ });
213
+ });
@@ -0,0 +1,213 @@
1
+ import { DisplayNameCorrespondence } from "@remit/domain-enums";
2
+ import type { ParsedMail } from "mailparser";
3
+ import { getDomain, parse as parseHost } from "tldts";
4
+
5
+ type CorrespondenceValue =
6
+ (typeof DisplayNameCorrespondence)[keyof typeof DisplayNameCorrespondence];
7
+
8
+ /**
9
+ * The two signals that survive a passing SPF/DKIM/DMARC check: a display name
10
+ * that belongs to nobody at the sending domain, and body links that leave it.
11
+ *
12
+ * Both are deliberately aggressive, and both are computed only for mail the
13
+ * provider's own filter already called spam (see {@link extractSenderMismatch}).
14
+ * On a shared-tenant host — a free Atlassian, Salesforce or Zendesk instance —
15
+ * the verified subdomain is chosen by whoever signed up, so a passing signature
16
+ * proves the domain and nothing about the identity the message claims.
17
+ */
18
+ export interface SenderMismatchSignals {
19
+ displayNameCorrespondence?: CorrespondenceValue;
20
+ offDomainLinkDomains?: string[];
21
+ }
22
+
23
+ export interface SenderMismatchContext {
24
+ fromDomain: string;
25
+ spamClassified: boolean;
26
+ /**
27
+ * List-Unsubscribe present. Bulk mail routinely shows a brand name over an
28
+ * ESP's sending domain, so the display-name comparison says nothing there and
29
+ * is not made.
30
+ */
31
+ bulkSender: boolean;
32
+ }
33
+
34
+ const MAX_LINK_DOMAINS = 10;
35
+
36
+ const normalize = (value: string): string =>
37
+ value.toLowerCase().replace(/[^a-z0-9]/g, "");
38
+
39
+ /**
40
+ * The parts of a host a display name could honestly correspond to: the
41
+ * registrable name without its public suffix, every subdomain label, and the
42
+ * registrable domain run together. The suffix itself is dropped — `net` and
43
+ * `com` sit inside half the brand names in existence.
44
+ */
45
+ const correspondenceCandidates = (host: string): string[] => {
46
+ const parsed = parseHost(host);
47
+ const labels = (parsed.subdomain ?? "").split(".");
48
+ if (parsed.domainWithoutSuffix !== null) {
49
+ labels.push(parsed.domainWithoutSuffix);
50
+ } else {
51
+ labels.push(...host.split(".").slice(0, -1));
52
+ }
53
+ if (parsed.domain !== null) labels.push(parsed.domain);
54
+ return labels.map(normalize).filter((label) => label.length >= 3);
55
+ };
56
+
57
+ const words = (displayName: string): string[] =>
58
+ displayName
59
+ .toLowerCase()
60
+ .split(/[^a-z0-9]+/)
61
+ .filter((word) => word.length >= 3);
62
+
63
+ const editDistance = (a: string, b: string): number => {
64
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
65
+ for (let i = 1; i <= a.length; i++) {
66
+ const current = [i];
67
+ for (let j = 1; j <= b.length; j++) {
68
+ current[j] = Math.min(
69
+ previous[j] + 1,
70
+ current[j - 1] + 1,
71
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
72
+ );
73
+ }
74
+ previous = current;
75
+ }
76
+ return previous[b.length];
77
+ };
78
+
79
+ /**
80
+ * The distance a name of this length may be from a domain label and still be
81
+ * read as an imitation of it. Tight on purpose: an unrelated brand name and an
82
+ * ESP's domain are always far apart, so distance alone must never decide.
83
+ */
84
+ const lookalikeThreshold = (length: number): number => {
85
+ if (length < 5) return 0;
86
+ if (length < 9) return 1;
87
+ return 2;
88
+ };
89
+
90
+ /**
91
+ * Whether the From display name corresponds to the From domain.
92
+ *
93
+ * Containment decides: the normalised name, or any word of it, appearing inside
94
+ * the registrable domain or one of the domain's labels. `GitHub` sits inside
95
+ * `notifications.github.com`; `InfoMedics` sits nowhere inside
96
+ * `serviceupdatebank.atlassian.net`.
97
+ *
98
+ * A bounded edit distance is the secondary test, and only reaches names that
99
+ * nearly match a label — `InfoMedics` against `1nfomedics.nl`. It cannot promote
100
+ * an unrelated name on its own.
101
+ */
102
+ export const classifyDisplayNameCorrespondence = (
103
+ displayName: string | undefined,
104
+ fromDomain: string,
105
+ ): CorrespondenceValue => {
106
+ const raw = (displayName ?? "").trim();
107
+ if (raw === "" || raw.includes("@")) {
108
+ return DisplayNameCorrespondence.NoClaim;
109
+ }
110
+
111
+ const name = normalize(raw);
112
+ if (name.length < 3) return DisplayNameCorrespondence.NoClaim;
113
+
114
+ const candidates = correspondenceCandidates(fromDomain);
115
+ if (candidates.length === 0) return DisplayNameCorrespondence.NoClaim;
116
+
117
+ const terms = [name, ...words(raw)];
118
+ for (const term of terms) {
119
+ for (const candidate of candidates) {
120
+ if (candidate.includes(term) || term.includes(candidate)) {
121
+ return DisplayNameCorrespondence.Corresponds;
122
+ }
123
+ }
124
+ }
125
+
126
+ for (const candidate of candidates) {
127
+ const threshold = lookalikeThreshold(
128
+ Math.min(name.length, candidate.length),
129
+ );
130
+ if (threshold === 0) continue;
131
+ if (Math.abs(name.length - candidate.length) > threshold) continue;
132
+ if (editDistance(name, candidate) <= threshold) {
133
+ return DisplayNameCorrespondence.Lookalike;
134
+ }
135
+ }
136
+
137
+ return DisplayNameCorrespondence.Unrelated;
138
+ };
139
+
140
+ const hrefsFromHtml = (html: string): string[] => {
141
+ const out: string[] = [];
142
+ const pattern = /href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
143
+ let match = pattern.exec(html);
144
+ while (match !== null) {
145
+ out.push(match[1] ?? match[2] ?? match[3] ?? "");
146
+ match = pattern.exec(html);
147
+ }
148
+ return out;
149
+ };
150
+
151
+ const urlsFromText = (text: string): string[] =>
152
+ text.match(/https?:\/\/[^\s<>"')\]]+/gi) ?? [];
153
+
154
+ /**
155
+ * Registrable domains the body's links point at, minus the sender's own.
156
+ *
157
+ * `mailto:`, `tel:`, in-page anchors and relative hrefs carry no destination
158
+ * domain and are skipped; anything else is resolved public-suffix-aware through
159
+ * tldts, so `co.uk` and `atlassian.net` both survive the comparison a naive dot
160
+ * split gets wrong.
161
+ */
162
+ export const extractOffDomainLinkDomains = (
163
+ parsed: ParsedMail,
164
+ fromDomain: string,
165
+ ): string[] => {
166
+ const senderDomain = getDomain(fromDomain);
167
+ const html = typeof parsed.html === "string" ? parsed.html : "";
168
+ const text = typeof parsed.text === "string" ? parsed.text : "";
169
+
170
+ const seen = new Set<string>();
171
+ const out: string[] = [];
172
+ for (const href of [...hrefsFromHtml(html), ...urlsFromText(text)]) {
173
+ const value = href.trim();
174
+ if (value === "" || !/^https?:\/\//i.test(value)) continue;
175
+ const domain = getDomain(value);
176
+ if (domain === null || domain === senderDomain) continue;
177
+ if (seen.has(domain)) continue;
178
+ seen.add(domain);
179
+ out.push(domain);
180
+ if (out.length === MAX_LINK_DOMAINS) break;
181
+ }
182
+ return out;
183
+ };
184
+
185
+ /**
186
+ * Both signals, gated on the provider's own spam verdict.
187
+ *
188
+ * The gate is what lets the checks be this aggressive: ordinary mail never
189
+ * reaches them, so a brand name over an ESP domain or a newsletter full of
190
+ * tracking links can never be flagged. Returns an empty object when the gate
191
+ * does not open — the fields stay absent, meaning "not compared".
192
+ */
193
+ export const extractSenderMismatch = (
194
+ parsed: ParsedMail,
195
+ context: SenderMismatchContext,
196
+ ): SenderMismatchSignals => {
197
+ if (!context.spamClassified) return {};
198
+
199
+ const offDomainLinkDomains = extractOffDomainLinkDomains(
200
+ parsed,
201
+ context.fromDomain,
202
+ );
203
+
204
+ if (context.bulkSender) return { offDomainLinkDomains };
205
+
206
+ return {
207
+ displayNameCorrespondence: classifyDisplayNameCorrespondence(
208
+ parsed.from?.value?.[0]?.name,
209
+ context.fromDomain,
210
+ ),
211
+ offDomainLinkDomains,
212
+ };
213
+ };
package/src/index.ts CHANGED
@@ -114,6 +114,13 @@ export {
114
114
  type PlacementAction,
115
115
  type PlacementVerdict,
116
116
  } from "./heuristics/classifyPlacement.js";
117
+ export {
118
+ classifyDisplayNameCorrespondence,
119
+ extractOffDomainLinkDomains,
120
+ extractSenderMismatch,
121
+ type SenderMismatchContext,
122
+ type SenderMismatchSignals,
123
+ } from "./heuristics/senderMismatch.js";
117
124
  export { SOCIAL_DOMAINS } from "./heuristics/socialDomains.js";
118
125
  export { TRANSACTIONAL_DOMAINS } from "./heuristics/transactionalDomains.js";
119
126
  // IMAP connection (ImapFlow-based)