@remit/mailbox-service 0.0.43 → 0.0.45
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 +1 -1
- package/src/body-sync.ts +18 -1
- package/src/heuristics/senderMismatch.test.ts +213 -0
- package/src/heuristics/senderMismatch.ts +213 -0
- package/src/index.ts +7 -0
- package/src/outbox-queue.ts +14 -10
package/package.json
CHANGED
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
|
|
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)
|
package/src/outbox-queue.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
IOutboxMessageRepository,
|
|
6
6
|
OutboxMessageItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
+
import { ConflictError } from "@remit/data-ports/errors";
|
|
8
9
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
9
10
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
10
11
|
|
|
@@ -31,6 +32,7 @@ export interface OutboxQueueConfig {
|
|
|
31
32
|
accountService: IAccountRepository;
|
|
32
33
|
sqsSmtpQueueUrl: string;
|
|
33
34
|
sqsEndpoint?: string;
|
|
35
|
+
sqsClient?: SQSClient;
|
|
34
36
|
logger?: OutboxQueueLogger;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -91,10 +93,12 @@ export class OutboxQueueService {
|
|
|
91
93
|
this.queueUrl = sqsSmtpQueueUrl;
|
|
92
94
|
this.log = config.logger ?? noopLogger;
|
|
93
95
|
|
|
94
|
-
this.sqs =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
this.sqs =
|
|
97
|
+
config.sqsClient ??
|
|
98
|
+
createQueueProducer({
|
|
99
|
+
queueUrl: sqsSmtpQueueUrl,
|
|
100
|
+
endpoint: sqsEndpoint,
|
|
101
|
+
});
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
createDraft = async (input: CreateDraftInput): Promise<OutboxMessageItem> => {
|
|
@@ -137,8 +141,8 @@ export class OutboxQueueService {
|
|
|
137
141
|
"act",
|
|
138
142
|
);
|
|
139
143
|
if (existing.status !== OutboxMessageStatus.draft) {
|
|
140
|
-
throw new
|
|
141
|
-
`
|
|
144
|
+
throw new ConflictError(
|
|
145
|
+
`This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`,
|
|
142
146
|
);
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -184,8 +188,8 @@ export class OutboxQueueService {
|
|
|
184
188
|
existing.status !== OutboxMessageStatus.failed &&
|
|
185
189
|
existing.status !== OutboxMessageStatus.blocked
|
|
186
190
|
) {
|
|
187
|
-
throw new
|
|
188
|
-
`
|
|
191
|
+
throw new ConflictError(
|
|
192
|
+
`This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`,
|
|
189
193
|
);
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -252,8 +256,8 @@ export class OutboxQueueService {
|
|
|
252
256
|
existing.status !== OutboxMessageStatus.failed &&
|
|
253
257
|
existing.status !== OutboxMessageStatus.blocked
|
|
254
258
|
) {
|
|
255
|
-
throw new
|
|
256
|
-
`
|
|
259
|
+
throw new ConflictError(
|
|
260
|
+
`This message is already ${existing.status} and can no longer be discarded. Open the Outbox to see where it stands.`,
|
|
257
261
|
);
|
|
258
262
|
}
|
|
259
263
|
|