@bobfrankston/mailx-types 0.1.61 → 0.1.65
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/index.d.ts +2 -2
- package/index.js +1 -1
- package/package.json +1 -1
- package/trust.d.ts +41 -3
- package/trust.js +385 -20
package/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
6
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
7
|
-
export { assessMessageTrust } from "./trust.js";
|
|
8
|
-
export type { TrustFinding, TrustInput } from "./trust.js";
|
|
7
|
+
export { assessMessageTrust, spamScoreOf } from "./trust.js";
|
|
8
|
+
export type { TrustFinding, TrustInput, SpamScore } from "./trust.js";
|
|
9
9
|
export type { MailxApi } from "./mailx-api.js";
|
|
10
10
|
export { addContactsDenylistEntry, addContactsPreferredEntry, type PreferredContactEntry, type CloudReadFn, type CloudWriteFn, } from "./contacts-config.js";
|
|
11
11
|
export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// this barrel so a single source-of-truth (contact-rules.jsonc) drives
|
|
8
8
|
// junk-contact filtering on every platform.
|
|
9
9
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
10
|
-
export { assessMessageTrust } from "./trust.js";
|
|
10
|
+
export { assessMessageTrust, spamScoreOf } from "./trust.js";
|
|
11
11
|
// Shared contacts.jsonc mutations — one implementation for desktop + Android.
|
|
12
12
|
export { addContactsDenylistEntry, addContactsPreferredEntry, } from "./contacts-config.js";
|
|
13
13
|
// Group-name expansion for recipient fields. Lets users type a group name
|
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
/** One piece of evidence that a message is not what it claims to be. */
|
|
32
32
|
export interface TrustFinding {
|
|
33
33
|
/** Stable identifier — for tests, logs, and any future suppression UI. */
|
|
34
|
-
id: "server-spam-verdict" | "self-spoof" | "hidden-link-overlay" | "redirector-link";
|
|
35
|
-
/** `danger`: near-certain forgery. `caution`: worth a second look.
|
|
36
|
-
|
|
34
|
+
id: "server-spam-verdict" | "self-spoof" | "relay-auth-mismatch" | "zero-width-obfuscation" | "hidden-link-overlay" | "redirector-link";
|
|
35
|
+
/** `danger`: near-certain forgery. `caution`: worth a second look.
|
|
36
|
+
* `info`: something the server said, in a register that is not an
|
|
37
|
+
* accusation — a bulk-mail verdict on a sender DMARC proved. */
|
|
38
|
+
severity: "danger" | "caution" | "info";
|
|
37
39
|
/** One line, naming what was found. Never "this message is suspicious". */
|
|
38
40
|
text: string;
|
|
39
41
|
/** The specific evidence — a header value, a host, a score. */
|
|
@@ -54,6 +56,10 @@ export interface TrustInput {
|
|
|
54
56
|
ownAddresses: string[];
|
|
55
57
|
/** Sanitized HTML body, for the link-construct checks. */
|
|
56
58
|
bodyHtml: string;
|
|
59
|
+
/** Plain-text body, for the character-level checks. Separate from the HTML
|
|
60
|
+
* because markup legitimately contains things prose does not, and the
|
|
61
|
+
* zero-width test would trip over an entity or an attribute value. */
|
|
62
|
+
bodyText?: string;
|
|
57
63
|
}
|
|
58
64
|
/**
|
|
59
65
|
* Everything findable about this message, worst first.
|
|
@@ -63,4 +69,36 @@ export interface TrustInput {
|
|
|
63
69
|
* evidence is not a clean bill of health.
|
|
64
70
|
*/
|
|
65
71
|
export declare function assessMessageTrust(input: TrustInput): TrustFinding[];
|
|
72
|
+
/**
|
|
73
|
+
* The receiving server's spam score, whether or not it crossed the threshold.
|
|
74
|
+
*
|
|
75
|
+
* Separate from the findings on purpose. A finding is an accusation and only
|
|
76
|
+
* fires on evidence; this is a measurement, and the reader asked to see the
|
|
77
|
+
* number itself (Bob 2026-08-25: "should we have a flag ... with the spam
|
|
78
|
+
* assassin number and a color code?"). The sextortion mail is exactly why:
|
|
79
|
+
* 2.7 against a 4.5 threshold is not "clean", it is "did not quite trip the
|
|
80
|
+
* wire", and those are very different things to a person deciding whether to
|
|
81
|
+
* trust a message.
|
|
82
|
+
*
|
|
83
|
+
* Returns null when the server left no score — most mail from providers that
|
|
84
|
+
* filter server-side and say nothing in the headers.
|
|
85
|
+
*/
|
|
86
|
+
export interface SpamScore {
|
|
87
|
+
score: number;
|
|
88
|
+
threshold: number;
|
|
89
|
+
/** The server's own yes/no, which is simply score >= threshold. */
|
|
90
|
+
flagged: boolean;
|
|
91
|
+
/** What the rules that actually scored are evidence OF. `bulk` and a
|
|
92
|
+
* `proved` sender are the two ways a high number can be honest mail —
|
|
93
|
+
* the chip stays visible and says so, rather than going red at a number
|
|
94
|
+
* and teaching the reader to ignore it. (Claude Code 2026-08-27) */
|
|
95
|
+
kind: "forgery" | "association" | "bulk" | "unclassified";
|
|
96
|
+
/** The From domain passed DMARC, aligned, at the receiving server. */
|
|
97
|
+
proved: boolean;
|
|
98
|
+
/** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
|
|
99
|
+
* listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
|
|
100
|
+
* this can be judged in a second. */
|
|
101
|
+
reasons: string[];
|
|
102
|
+
}
|
|
103
|
+
export declare function spamScoreOf(input: TrustInput): SpamScore;
|
|
66
104
|
//# sourceMappingURL=trust.d.ts.map
|
package/trust.js
CHANGED
|
@@ -56,8 +56,210 @@ function bare(addr) {
|
|
|
56
56
|
const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
|
|
57
57
|
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
58
58
|
}
|
|
59
|
+
/** Rules that say THIS message lies about who sent it. The only class that
|
|
60
|
+
* earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
|
|
61
|
+
* 0.0-scoring status marker that fired on Mokin, a message the receiving
|
|
62
|
+
* server had just recorded as `dmarc=pass`. */
|
|
63
|
+
const FORGERY_RULES = [
|
|
64
|
+
/^SPF_(HELO_)?(FAIL|SOFTFAIL)$/,
|
|
65
|
+
/^(KAM_)?DMARC_(FAIL|REJECT|QUAR)/,
|
|
66
|
+
/^DKIM_ADSP_(ALL|DISCARD|NXDOMAIN)$/,
|
|
67
|
+
/^FORGED_/,
|
|
68
|
+
/SPOOF/,
|
|
69
|
+
/_PHISH/, /PHISHING/, /_MALW/, /MALWARE/,
|
|
70
|
+
];
|
|
71
|
+
/** Reputation a third party holds about some host the message mentions or
|
|
72
|
+
* travelled through. Real evidence the mail is unwanted; no evidence at all
|
|
73
|
+
* that the From line is false — the listed party is usually not the sender. */
|
|
74
|
+
const ASSOCIATION_RULES = [
|
|
75
|
+
/^URIBL_(SBL|BLACK|RED|DBL|ABUSE)/,
|
|
76
|
+
/^SH_DBL/,
|
|
77
|
+
/^SEM_URIBL/,
|
|
78
|
+
/^SPAMHAUS_/,
|
|
79
|
+
/^RCVD_IN_(SBL|XBL|PBL|BL_SPAMCOP|SORBS|BRBL|VALIDITY|MSPIKE_[LZ])/,
|
|
80
|
+
];
|
|
81
|
+
/** The shape of mass mailing: bulk-detection networks, marketing markup, list
|
|
82
|
+
* plumbing. A score built entirely from these is a bulk verdict however high
|
|
83
|
+
* it climbs — Adobe MAX reached 3.9 without one rule that looks at identity. */
|
|
84
|
+
const BULK_RULES = [
|
|
85
|
+
/^DCC_/, /^PYZOR_/, /^RAZOR2_/,
|
|
86
|
+
/^MAILING_LIST/, /^LIST_/,
|
|
87
|
+
// KAM_* by name only, never the whole family: it holds marketing and list
|
|
88
|
+
// rules alongside scam-CONTENT rules (KAM_BENEFICIARY is worth 10.0,
|
|
89
|
+
// KAM_FAKE_NORTONLOW 6.5), and calling those bulk would quiet the loudest
|
|
90
|
+
// true positives in the store.
|
|
91
|
+
/^KAM_(BODY_)?MARKETINGBL/, /^KAM_UNSUB/, /^KAM_TRACKIMAGE$/,
|
|
92
|
+
/^KAM_REALLYHUGEIMGSRC$/, /^KAM_DMARC_STATUS$/, /^KAM_COUPON/,
|
|
93
|
+
/^HTML_/, /^MIME_HTML/, /^WORD_INVIS$/,
|
|
94
|
+
/^URIBL_(GREY|CSS|BLOCKED|CT_SURBL)/, /^SH_BODYURI/,
|
|
95
|
+
/^HEADER_FROM_DIFFERENT_DOMAINS$/, // every ESP on earth trips this
|
|
96
|
+
/^MPART_ALT_DIFF/, /^TVD_/, /^T_REMOTE_IMAGE$/, /^UNPARSEABLE_RELAY$/,
|
|
97
|
+
];
|
|
98
|
+
/** Passes, whitelistings, and Bayes' own opinion — informational, and in
|
|
99
|
+
* BAYES_00's case exculpatory. `DKIM_INVALID` lives here rather than in the
|
|
100
|
+
* forgery class because a broken signature is routine on relayed and
|
|
101
|
+
* list-rewritten mail (Mokin, sent via Shopify and SendGrid, carries it while
|
|
102
|
+
* the receiving server records dkim=pass). It is promoted to forgery only
|
|
103
|
+
* when DMARC also failed — see classifyRule. */
|
|
104
|
+
const NEUTRAL_RULES = [
|
|
105
|
+
/^BAYES_/,
|
|
106
|
+
/^SPF_(PASS|NONE|HELO_(PASS|NONE|NEUTRAL))$/,
|
|
107
|
+
/^DKIM_(SIGNED|VALID|VALID_AU|VALID_EF|INVALID)$/,
|
|
108
|
+
/^DKIMWL_/, /^RCVD_IN_(DNSWL|MSPIKE_H|IADB)/,
|
|
109
|
+
/^SHORTCIRCUIT$/, /^AWL$/, /^NO_RELAYS$/, /^ALL_TRUSTED$/,
|
|
110
|
+
];
|
|
111
|
+
function classifyRule(name, dmarcFailed) {
|
|
112
|
+
if (dmarcFailed && name === "DKIM_INVALID")
|
|
113
|
+
return "forgery";
|
|
114
|
+
for (const re of FORGERY_RULES)
|
|
115
|
+
if (re.test(name))
|
|
116
|
+
return "forgery";
|
|
117
|
+
for (const re of ASSOCIATION_RULES)
|
|
118
|
+
if (re.test(name))
|
|
119
|
+
return "association";
|
|
120
|
+
for (const re of NEUTRAL_RULES)
|
|
121
|
+
if (re.test(name))
|
|
122
|
+
return "neutral";
|
|
123
|
+
for (const re of BULK_RULES)
|
|
124
|
+
if (re.test(name))
|
|
125
|
+
return "bulk";
|
|
126
|
+
return "other";
|
|
127
|
+
}
|
|
59
128
|
/**
|
|
60
|
-
* Did the receiving server
|
|
129
|
+
* Did the receiving server prove the From domain?
|
|
130
|
+
*
|
|
131
|
+
* Only the TOPMOST Authentication-Results is read. Headers are prepended, so
|
|
132
|
+
* the first one is the one our own server wrote; every one below it travelled
|
|
133
|
+
* with the message and a forger can write whatever they like there. Same
|
|
134
|
+
* asymmetry serverSpamVerdict relies on, in the other direction: a claim that
|
|
135
|
+
* SILENCES a warning must come from a source the sender cannot control.
|
|
136
|
+
*
|
|
137
|
+
* `dmarc=pass` is aligned by definition (RFC 7489) — SPF or DKIM passed on the
|
|
138
|
+
* same domain the reader sees in From. The `header.from=` it names is checked
|
|
139
|
+
* against the actual From line anyway, because an Authentication-Results about
|
|
140
|
+
* some other message proves nothing about this one.
|
|
141
|
+
*/
|
|
142
|
+
function dmarcProof(input) {
|
|
143
|
+
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
144
|
+
if (!auth)
|
|
145
|
+
return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
|
|
146
|
+
// What the server caught failing, by name. An outright authentication
|
|
147
|
+
// failure IS an identity test, whatever the tests= list happens to call
|
|
148
|
+
// it — this is what keeps the 2026-08-25 phishes red under the new
|
|
149
|
+
// classification, since spf=fail is the whole point of them.
|
|
150
|
+
const authFailures = [
|
|
151
|
+
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
152
|
+
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
153
|
+
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
|
|
154
|
+
].filter(Boolean);
|
|
155
|
+
const failed = authFailures.includes("DMARC");
|
|
156
|
+
if (!/\bdmarc=pass\b/i.test(auth))
|
|
157
|
+
return { pass: false, failed, authFailures, domain: "", policy: "" };
|
|
158
|
+
const stated = (auth.match(/header\.from=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
159
|
+
const policy = (auth.match(/policy\.dmarc=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
160
|
+
const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
|
|
161
|
+
if (stated && fromDomain
|
|
162
|
+
&& stated !== fromDomain
|
|
163
|
+
&& !stated.endsWith("." + fromDomain)
|
|
164
|
+
&& !fromDomain.endsWith("." + stated)) {
|
|
165
|
+
return { pass: false, failed, authFailures, domain: "", policy };
|
|
166
|
+
}
|
|
167
|
+
return { pass: true, failed: false, authFailures, domain: stated || fromDomain, policy };
|
|
168
|
+
}
|
|
169
|
+
/** Unfold a header value the way RFC 5322 folds it: delete the line break,
|
|
170
|
+
* KEEP the whitespace that continues the line. SpamAssassin breaks its tests=
|
|
171
|
+
* list right after a comma, so that continuation whitespace is all that
|
|
172
|
+
* separates two rule names from being glued into one — the list is de-spaced
|
|
173
|
+
* after it has been cut out, not here. X-Spam-Report is NOT unfolded: there
|
|
174
|
+
* the line structure is the record structure. */
|
|
175
|
+
function unfold(value) {
|
|
176
|
+
return value.replace(/\r?\n/g, "");
|
|
177
|
+
}
|
|
178
|
+
/** Per-rule scores and descriptions out of X-Spam-Report, which reads:
|
|
179
|
+
*
|
|
180
|
+
* * 6.0 URIBL_SBL Contains an URL's NS IP listed in the Spamhaus SBL
|
|
181
|
+
* * blocklist
|
|
182
|
+
* * [URI: ns12.xincache.com/112.80.181.111]
|
|
183
|
+
*
|
|
184
|
+
* The bracketed evidence lines are dropped: they name the third party, and
|
|
185
|
+
* the finding is about this message. */
|
|
186
|
+
function parseSpamReport(report) {
|
|
187
|
+
const out = new Map();
|
|
188
|
+
let current = null;
|
|
189
|
+
for (const raw of report.split(/\r?\n/)) {
|
|
190
|
+
const line = raw.replace(/^[ \t]*\*?[ \t]*/, "").trimEnd();
|
|
191
|
+
if (!line)
|
|
192
|
+
continue;
|
|
193
|
+
const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
|
|
194
|
+
if (start) {
|
|
195
|
+
current = { score: Number(start[1]), description: start[3].trim() };
|
|
196
|
+
out.set(start[2], current);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (current && !line.startsWith("["))
|
|
200
|
+
current.description += " " + line;
|
|
201
|
+
}
|
|
202
|
+
for (const rule of out.values())
|
|
203
|
+
rule.description = rule.description
|
|
204
|
+
.replace(/\s+/g, " ")
|
|
205
|
+
.replace(/^(BODY|RAW|HEADER|URI|RBL):\s*/i, "")
|
|
206
|
+
.trim();
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
function analyzeServerSpam(input) {
|
|
210
|
+
const status = unfold(header(input.headerLines, "x-spam-status"));
|
|
211
|
+
const flag = header(input.headerLines, "x-spam-flag");
|
|
212
|
+
if (!status && !flag)
|
|
213
|
+
return null;
|
|
214
|
+
const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
|
|
215
|
+
const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
|
|
216
|
+
const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
|
|
217
|
+
const dmarc = dmarcProof(input);
|
|
218
|
+
const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
|
|
219
|
+
// Everything up to the first lowercase key=value — SpamAssassin's trailing
|
|
220
|
+
// shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
|
|
221
|
+
// rather than on whitespace is what keeps "...,WORD_INVIS shortcircuit=no"
|
|
222
|
+
// from delivering a rule called WORD_INVISshortcircuit.
|
|
223
|
+
const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
|
|
224
|
+
.replace(/\s+/g, "").split(",").map(n => n.toUpperCase()).filter(Boolean);
|
|
225
|
+
const rules = names.map(name => {
|
|
226
|
+
const found = report.get(name);
|
|
227
|
+
return {
|
|
228
|
+
name,
|
|
229
|
+
score: found ? found.score : NaN,
|
|
230
|
+
description: found ? found.description : "",
|
|
231
|
+
cls: classifyRule(name, dmarc.failed),
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
// A rule that scored zero or negative did not put this message over any
|
|
235
|
+
// line, so it cannot be the reason the message is here. Unscored rules
|
|
236
|
+
// (no X-Spam-Report, which is most unflagged mail) still count — absence
|
|
237
|
+
// of a report is not evidence the rule was free.
|
|
238
|
+
const scored = (r) => !(r.score <= 0);
|
|
239
|
+
const present = (cls) => rules.some(r => r.cls === cls && scored(r));
|
|
240
|
+
const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
|
|
241
|
+
: present("association") ? "association"
|
|
242
|
+
: present("other") ? "unclassified"
|
|
243
|
+
: rules.length ? "bulk"
|
|
244
|
+
: "unclassified";
|
|
245
|
+
const reasons = rules
|
|
246
|
+
.filter(r => r.cls !== "neutral" && scored(r))
|
|
247
|
+
.sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
248
|
+
.slice(0, 3)
|
|
249
|
+
.map(r => {
|
|
250
|
+
const num = Number.isFinite(r.score) ? `${r.score.toFixed(1)} ` : "";
|
|
251
|
+
return r.description ? `${num}${r.name} (${r.description})` : `${num}${r.name}`;
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
score, threshold, flagged, rules, kind,
|
|
255
|
+
proved: dmarc.pass, provedDomain: dmarc.domain, provedPolicy: dmarc.policy,
|
|
256
|
+
bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
|
|
257
|
+
authFailures: dmarc.authFailures,
|
|
258
|
+
reasons,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Did the receiving server's spam filter already say yes — and if so, to WHAT?
|
|
61
263
|
*
|
|
62
264
|
* The highest-value signal available, and it costs nothing: the mail server
|
|
63
265
|
* ran SpamAssassin before delivery and wrote the answer into the message.
|
|
@@ -67,23 +269,57 @@ function bare(addr) {
|
|
|
67
269
|
* `X-Spam-Flag: NO` into a message they send, and treating that as a clean
|
|
68
270
|
* bill of health would hand attackers a switch for turning the check off. A
|
|
69
271
|
* forged YES only flags the forger's own mail, so the asymmetry is safe.
|
|
272
|
+
*
|
|
273
|
+
* The severity comes from the RULES, never from the number (Claude Code
|
|
274
|
+
* 2026-08-27):
|
|
275
|
+
* - a forgery rule fired -> danger. This is what red is for.
|
|
276
|
+
* - the sender is DMARC-proved -> info. The mail may well be unwanted,
|
|
277
|
+
* and every rule that scored is but "not what it says it is" is a
|
|
278
|
+
* one this file can name, or claim its own evidence contradicts.
|
|
279
|
+
* the whole score is bulk rules
|
|
280
|
+
* - anything else -> caution. Flagged, and the reason is
|
|
281
|
+
* not identity — or is a rule this file
|
|
282
|
+
* cannot classify, which is never a
|
|
283
|
+
* reason to go quiet.
|
|
70
284
|
*/
|
|
71
|
-
function serverSpamVerdict(
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (!/^yes/i.test(flag) && !/^yes/i.test(status))
|
|
285
|
+
function serverSpamVerdict(input) {
|
|
286
|
+
const a = analyzeServerSpam(input);
|
|
287
|
+
if (!a || !a.flagged)
|
|
75
288
|
return null;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
289
|
+
const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
|
|
290
|
+
? `SpamAssassin score ${a.score} of ${a.threshold}`
|
|
291
|
+
: "flagged by SpamAssassin";
|
|
292
|
+
const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
|
|
293
|
+
const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
|
|
294
|
+
if (a.kind === "forgery")
|
|
295
|
+
return {
|
|
296
|
+
id: "server-spam-verdict",
|
|
297
|
+
severity: "danger",
|
|
298
|
+
text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
|
|
299
|
+
detail: a.authFailures.length
|
|
300
|
+
? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
|
|
301
|
+
: `${numbers}${why}`,
|
|
302
|
+
};
|
|
303
|
+
if (a.proved && a.kind !== "unclassified")
|
|
304
|
+
return {
|
|
305
|
+
id: "server-spam-verdict",
|
|
306
|
+
severity: "info",
|
|
307
|
+
text: `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
|
|
308
|
+
detail: `DMARC pass, aligned${a.provedPolicy ? `, policy ${a.provedPolicy}` : ""}. ${numbers}${why}${bayes}`,
|
|
309
|
+
};
|
|
310
|
+
if (a.kind === "bulk")
|
|
311
|
+
return {
|
|
312
|
+
id: "server-spam-verdict",
|
|
313
|
+
severity: "info",
|
|
314
|
+
text: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
|
|
315
|
+
detail: `${numbers}${why}${bayes}`,
|
|
316
|
+
};
|
|
317
|
+
const proof = a.proved ? `${a.provedDomain} passed DMARC, so the From line is genuine. ` : "";
|
|
80
318
|
return {
|
|
81
319
|
id: "server-spam-verdict",
|
|
82
|
-
severity: "
|
|
320
|
+
severity: "caution",
|
|
83
321
|
text: "Your mail server classified this as spam before delivering it.",
|
|
84
|
-
detail:
|
|
85
|
-
? `SpamAssassin score ${score}, threshold ${required}`
|
|
86
|
-
: (status || flag || "X-Spam-Flag: YES"),
|
|
322
|
+
detail: `${proof}${numbers}${why}${bayes}`,
|
|
87
323
|
};
|
|
88
324
|
}
|
|
89
325
|
/**
|
|
@@ -96,9 +332,21 @@ function serverSpamVerdict(lines) {
|
|
|
96
332
|
* common shape of targeted phish, because a From line showing your own address
|
|
97
333
|
* defeats every visual check a reader makes.
|
|
98
334
|
*
|
|
99
|
-
* Requires an
|
|
100
|
-
*
|
|
101
|
-
*
|
|
335
|
+
* Requires an Authentication-Results header to exist. A message without one
|
|
336
|
+
* never crossed a trust boundary — it is your own mail, handled internally —
|
|
337
|
+
* and is not evidence of anything. That case is the overwhelming majority:
|
|
338
|
+
* 10,034 of the 10,338 self-claimed messages in a 50,000-message scan of Bob's
|
|
339
|
+
* store had no such header at all.
|
|
340
|
+
*
|
|
341
|
+
* `spf=none` counts as "not authenticated", not just `fail`. A domain with no
|
|
342
|
+
* SPF record published can never produce a failure, only a shrug — so treating
|
|
343
|
+
* `none` as neutral leaves the least-protected domain the least defended.
|
|
344
|
+
* bobf.frankston.com publishes no SPF, and that is precisely the hole the
|
|
345
|
+
* 2026-08-25 sextortion mail walked through: SpamAssassin scored it 2.7
|
|
346
|
+
* against a 4.5 threshold and delivered it. In that same 50,000-message scan
|
|
347
|
+
* `spf=none` on self-claimed mail occurred 18 times and was spam nearly every
|
|
348
|
+
* time ("Settle your debt", "AAA Final notice", "McAfee Protection Plan
|
|
349
|
+
* Ended"); legitimate self-mail was in the no-header group, untouched by this.
|
|
102
350
|
*/
|
|
103
351
|
function selfSpoof(input) {
|
|
104
352
|
const from = bare(input.fromAddress);
|
|
@@ -108,13 +356,27 @@ function selfSpoof(input) {
|
|
|
108
356
|
if (!own.has(from))
|
|
109
357
|
return null;
|
|
110
358
|
const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
|
|
359
|
+
if (!auth)
|
|
360
|
+
return null;
|
|
111
361
|
const failed = [
|
|
112
362
|
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
113
363
|
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
114
364
|
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
|
|
115
365
|
].filter(Boolean);
|
|
116
|
-
|
|
117
|
-
|
|
366
|
+
// Nothing failed, but did anything actually PASS? "spf=none dkim=none" is
|
|
367
|
+
// not a clean bill of health for a message claiming to be you.
|
|
368
|
+
if (!failed.length) {
|
|
369
|
+
if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth))
|
|
370
|
+
return null;
|
|
371
|
+
const envelopeAddr = bare(header(input.headerLines, "return-path"));
|
|
372
|
+
const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
|
|
373
|
+
return {
|
|
374
|
+
id: "self-spoof",
|
|
375
|
+
severity: "danger",
|
|
376
|
+
text: `This claims to be from your own address, ${from}, and nothing proves it came from your account.`,
|
|
377
|
+
detail: `no SPF, DKIM or DMARC result to check against${note} — your domain publishes no SPF record, so forgery cannot be detected by the sending side`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
118
380
|
// Name the envelope sender when it disagrees — that is the address that
|
|
119
381
|
// actually sent this, and seeing it beside the claim is the whole story.
|
|
120
382
|
const envelope = bare(header(input.headerLines, "return-path"));
|
|
@@ -217,12 +479,115 @@ function redirectorLink(bodyHtml) {
|
|
|
217
479
|
*/
|
|
218
480
|
export function assessMessageTrust(input) {
|
|
219
481
|
const findings = [
|
|
220
|
-
serverSpamVerdict(input
|
|
482
|
+
serverSpamVerdict(input),
|
|
221
483
|
selfSpoof(input),
|
|
484
|
+
relayAuthMismatch(input),
|
|
485
|
+
zeroWidthObfuscation(input.bodyText || ""),
|
|
222
486
|
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
223
487
|
redirectorLink(input.bodyHtml || ""),
|
|
224
488
|
].filter(Boolean);
|
|
225
|
-
const rank = { danger: 0, caution: 1 };
|
|
489
|
+
const rank = { danger: 0, caution: 1, info: 2 };
|
|
226
490
|
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
227
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* Did the relay record a different account than the one the message claims?
|
|
494
|
+
*
|
|
495
|
+
* Shared hosts stamp the identity that actually authenticated to submit the
|
|
496
|
+
* message. That stamp is added by the RELAY, downstream of whoever sent it, so
|
|
497
|
+
* a forger cannot remove or edit it — which makes disagreement between the
|
|
498
|
+
* stamp and the From line about as close to proof as mail headers get.
|
|
499
|
+
*
|
|
500
|
+
* The 2026-08-25 sextortion mail (Bob: "note that the origin is different than
|
|
501
|
+
* the from domain in antiabuse and in source-auth"):
|
|
502
|
+
*
|
|
503
|
+
* From: universe@bobf.frankston.com
|
|
504
|
+
* X-Source-Auth: admin@calebjross.com
|
|
505
|
+
* X-Source-Cap: (base64) calebjro;calebjro;box2202.bluehost.com
|
|
506
|
+
* Message-ID: <...@calebjross.com>
|
|
507
|
+
*
|
|
508
|
+
* A Bluehost cPanel account belonging to an unrelated domain authenticated and
|
|
509
|
+
* sent mail wearing Bob's address. SpamAssassin scored the whole thing 2.7
|
|
510
|
+
* against a 4.5 threshold and let it through.
|
|
511
|
+
*
|
|
512
|
+
* Compared at the registrable-domain level, not the full address: a host that
|
|
513
|
+
* legitimately sends for a domain authenticates as some account AT that
|
|
514
|
+
* domain, and the local part varies for perfectly ordinary reasons.
|
|
515
|
+
*/
|
|
516
|
+
const RELAY_IDENTITY_HEADERS = [
|
|
517
|
+
"x-source-auth", // cPanel / Exim (Bluehost, HostGator, most shared hosts)
|
|
518
|
+
"x-authenticated-sender", // Postfix / Communigate
|
|
519
|
+
"x-auth-id",
|
|
520
|
+
"x-authenticated-user",
|
|
521
|
+
];
|
|
522
|
+
function relayAuthMismatch(input) {
|
|
523
|
+
const from = bare(input.fromAddress);
|
|
524
|
+
const fromDomain = from.split("@")[1] || "";
|
|
525
|
+
if (!fromDomain)
|
|
526
|
+
return null;
|
|
527
|
+
for (const name of RELAY_IDENTITY_HEADERS) {
|
|
528
|
+
const stamped = bare(header(input.headerLines, name));
|
|
529
|
+
const stampedDomain = stamped.split("@")[1] || "";
|
|
530
|
+
if (!stampedDomain || stampedDomain === fromDomain)
|
|
531
|
+
continue;
|
|
532
|
+
// Sub/parent-domain is the same organisation — mail.example.com
|
|
533
|
+
// sending for example.com is ordinary. Cross-organisation is not.
|
|
534
|
+
if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
|
|
535
|
+
continue;
|
|
536
|
+
return {
|
|
537
|
+
id: "relay-auth-mismatch",
|
|
538
|
+
severity: "danger",
|
|
539
|
+
text: `The account that actually sent this belongs to someone else: ${stampedDomain}, not ${fromDomain}.`,
|
|
540
|
+
detail: `the sending server recorded ${stamped} as the authenticated sender, while the message claims to be from ${from}`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Is the text stuffed with invisible characters to break word matching?
|
|
547
|
+
*
|
|
548
|
+
* Zero-width space, zero-width non-joiner, zero-width joiner and the
|
|
549
|
+
* zero-width no-break space render as nothing, so a reader sees plain prose
|
|
550
|
+
* while every filter matching on words sees gibberish. The 2026-08-25
|
|
551
|
+
* sextortion mail put one between nearly every pair of letters — "i regret to
|
|
552
|
+
* inform you" reached the parser as `i regret to inform you`. SpamAssassin
|
|
553
|
+
* spotted it (UNICODE_OBFU_ZW_MANY) and still scored the message 2.7 against a
|
|
554
|
+
* 4.5 threshold, so it was delivered.
|
|
555
|
+
*
|
|
556
|
+
* Counted only BETWEEN TWO LATIN LETTERS, which is what obfuscation looks like
|
|
557
|
+
* and what the legitimate uses do not: an emoji ZWJ sequence joins pictographs,
|
|
558
|
+
* Arabic and Indic ZWNJ sit between their own scripts' letters, and a soft
|
|
559
|
+
* hyphen marks a break opportunity rather than splitting a word mid-render.
|
|
560
|
+
*
|
|
561
|
+
* The count guards against a single stray character surviving a copy-paste out
|
|
562
|
+
* of a web page — it is a sanity bound, not a tuned dial. Prose does not
|
|
563
|
+
* accumulate dozens of these by accident; the sample message had over 900.
|
|
564
|
+
*/
|
|
565
|
+
const ZERO_WIDTH_MIN_OCCURRENCES = 12;
|
|
566
|
+
function zeroWidthObfuscation(bodyText) {
|
|
567
|
+
if (!bodyText)
|
|
568
|
+
return null;
|
|
569
|
+
const between = bodyText.match(/[A-Za-z][][A-Za-z]/g);
|
|
570
|
+
const count = between ? between.length : 0;
|
|
571
|
+
if (count < ZERO_WIDTH_MIN_OCCURRENCES)
|
|
572
|
+
return null;
|
|
573
|
+
return {
|
|
574
|
+
id: "zero-width-obfuscation",
|
|
575
|
+
severity: "danger",
|
|
576
|
+
text: "The words in this message are stuffed with invisible characters to get past spam filters.",
|
|
577
|
+
detail: `${count} zero-width characters hidden inside words — legitimate mail has no reason to do this`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
export function spamScoreOf(input) {
|
|
581
|
+
const a = analyzeServerSpam(input);
|
|
582
|
+
if (!a || !Number.isFinite(a.score) || !Number.isFinite(a.threshold))
|
|
583
|
+
return null;
|
|
584
|
+
return {
|
|
585
|
+
score: a.score,
|
|
586
|
+
threshold: a.threshold,
|
|
587
|
+
flagged: a.flagged,
|
|
588
|
+
kind: a.kind,
|
|
589
|
+
proved: a.proved,
|
|
590
|
+
reasons: a.reasons,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
228
593
|
//# sourceMappingURL=trust.js.map
|