@bobfrankston/mailx-types 0.1.78 → 0.1.82
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/trust.d.ts +4 -0
- package/trust.js +81 -9
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -109,6 +109,10 @@ export interface SpamScore {
|
|
|
109
109
|
* may not exist (Bob's own mail carries no Authentication-Results at
|
|
110
110
|
* all). */
|
|
111
111
|
provedBy: "dmarc" | "dkim" | "trusted" | "";
|
|
112
|
+
/** Every rule that scored is a bulk-mail or blocklist test. The chip's
|
|
113
|
+
* tooltip may say so ONLY when this is true — it is a claim about all of
|
|
114
|
+
* the rules, and the reader can read them underneath it. */
|
|
115
|
+
bulkOnly: boolean;
|
|
112
116
|
/** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
|
|
113
117
|
* listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
|
|
114
118
|
* this can be judged in a second. */
|
package/trust.js
CHANGED
|
@@ -56,6 +56,20 @@ function bare(addr) {
|
|
|
56
56
|
const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
|
|
57
57
|
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
58
58
|
}
|
|
59
|
+
/** Do two hostnames belong to the same organisation?
|
|
60
|
+
*
|
|
61
|
+
* Equality or a sub/parent-domain relation, never "the last two labels
|
|
62
|
+
* match": that would make paypal.co.uk and evil.co.uk relatives, which is
|
|
63
|
+
* the case this whole file exists to catch. A subdomain relationship cannot
|
|
64
|
+
* be forged across organisations, so it is safe in the other direction.
|
|
65
|
+
* Same test displayNameClaimsOtherAddress uses on addresses. */
|
|
66
|
+
function sameOrg(a, b) {
|
|
67
|
+
const x = (a || "").toLowerCase();
|
|
68
|
+
const y = (b || "").toLowerCase();
|
|
69
|
+
if (!x || !y)
|
|
70
|
+
return false;
|
|
71
|
+
return x === y || x.endsWith("." + y) || y.endsWith("." + x);
|
|
72
|
+
}
|
|
59
73
|
/** Rules that say THIS message lies about who sent it. The only class that
|
|
60
74
|
* earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
|
|
61
75
|
* 0.0-scoring status marker that fired on Mokin, a message the receiving
|
|
@@ -268,6 +282,16 @@ function analyzeServerSpam(input) {
|
|
|
268
282
|
const unknownCouldFlagAlone = !unknown.length ? false
|
|
269
283
|
: !Number.isFinite(unknownWeight) || !Number.isFinite(threshold) ? true
|
|
270
284
|
: unknownWeight >= threshold;
|
|
285
|
+
// What the chip may claim about the rule set. "Every rule that scored is a
|
|
286
|
+
// bulk-mail test" is a statement about ALL of them, so it has to be
|
|
287
|
+
// checked against all of them — deriving it from `proved` (which says
|
|
288
|
+
// nothing about the rules) or from `kind` (the worst class, which says
|
|
289
|
+
// nothing about the rest) asserts something the code never looked at.
|
|
290
|
+
// The Altis Hotels welcome mail, 2026-09-03 (Bob: "marked as spam but is
|
|
291
|
+
// valid"): DMARC-proved, so the chip said "scored by bulk-mail or
|
|
292
|
+
// blocklist rules" — with 3.2 of its 8.3 coming from KAM_ACCOUNTPHISH,
|
|
293
|
+
// which is neither, listed directly underneath.
|
|
294
|
+
const bulkOnly = rules.every(r => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
|
|
271
295
|
const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
|
|
272
296
|
: present("association") ? "association"
|
|
273
297
|
: unknownCouldFlagAlone ? "unclassified"
|
|
@@ -305,7 +329,7 @@ function analyzeServerSpam(input) {
|
|
|
305
329
|
});
|
|
306
330
|
return {
|
|
307
331
|
score, threshold, flagged, rules, kind,
|
|
308
|
-
proved: !!provedBy, provedBy,
|
|
332
|
+
proved: !!provedBy, provedBy, bulkOnly,
|
|
309
333
|
provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
|
|
310
334
|
bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
|
|
311
335
|
authFailures: dmarc.authFailures,
|
|
@@ -387,11 +411,19 @@ function serverSpamVerdict(input) {
|
|
|
387
411
|
: `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
|
|
388
412
|
detail: `${provedNote(a)} ${numbers}${why}${bayes}`,
|
|
389
413
|
};
|
|
414
|
+
// Same discipline as the chip: "nothing that scored tests who sent it" is
|
|
415
|
+
// a claim about every rule, so it is made only when every rule was
|
|
416
|
+
// actually looked at and classified. `kind === "bulk"` does not establish
|
|
417
|
+
// that — an unclassified rule that could not have flagged the message on
|
|
418
|
+
// its own leaves the kind at bulk while remaining, by definition, a rule
|
|
419
|
+
// this file cannot say anything about.
|
|
390
420
|
if (a.kind === "bulk")
|
|
391
421
|
return {
|
|
392
422
|
id: "server-spam-verdict",
|
|
393
423
|
severity: "info",
|
|
394
|
-
text:
|
|
424
|
+
text: a.bulkOnly
|
|
425
|
+
? "Your mail server rates this bulk mail — nothing that scored tests who sent it."
|
|
426
|
+
: "Your mail server rates this bulk mail.",
|
|
395
427
|
detail: `${numbers}${why}${bayes}`,
|
|
396
428
|
};
|
|
397
429
|
const proof = a.proved ? `${provedNote(a)} ` : "";
|
|
@@ -514,6 +546,23 @@ function hiddenLinkOverlay(bodyHtml) {
|
|
|
514
546
|
}
|
|
515
547
|
return null;
|
|
516
548
|
}
|
|
549
|
+
/** The domain the receiving server proved the From line for — "" when nothing
|
|
550
|
+
* did. One place answers "is this sender who they say they are", so the
|
|
551
|
+
* banner, the chip and the link checks can never disagree about it.
|
|
552
|
+
*
|
|
553
|
+
* dmarcProof is asked first because it needs no SpamAssassin: an
|
|
554
|
+
* Authentication-Results header alone is enough, which is what mail arriving
|
|
555
|
+
* through the Gmail API carries. analyzeServerSpam adds the two proofs
|
|
556
|
+
* SpamAssassin states in its own rule list instead (DKIM_VALID_AU,
|
|
557
|
+
* ALL_TRUSTED) and returns null when the server ran no filter at all. */
|
|
558
|
+
function provedSenderDomain(input) {
|
|
559
|
+
const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
|
|
560
|
+
if (!fromDomain)
|
|
561
|
+
return "";
|
|
562
|
+
if (dmarcProof(input).pass)
|
|
563
|
+
return fromDomain;
|
|
564
|
+
return analyzeServerSpam(input)?.proved ? fromDomain : "";
|
|
565
|
+
}
|
|
517
566
|
/**
|
|
518
567
|
* Does a link route through a redirector carrying its real destination in the
|
|
519
568
|
* query string?
|
|
@@ -523,8 +572,30 @@ function hiddenLinkOverlay(bodyHtml) {
|
|
|
523
572
|
* whose redirect script was left open, and the destination appears only
|
|
524
573
|
* percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
|
|
525
574
|
* reader cannot be expected to URL-decode a query string.
|
|
575
|
+
*
|
|
576
|
+
* Laundering needs a SECOND PARTY — someone whose reputation is being spent
|
|
577
|
+
* without their consent. Two ways a link has none, both checked below, and
|
|
578
|
+
* neither of them a threshold:
|
|
579
|
+
*
|
|
580
|
+
* 1. The redirector and the destination are the same organisation. A site
|
|
581
|
+
* bouncing through its own hostnames is routing, not hiding.
|
|
582
|
+
* 2. The redirector IS the proved sender. Google's account-security notice
|
|
583
|
+
* (2026-09-02, Bob: "this is valid") links through
|
|
584
|
+
* `accounts.google.com/AccountChooser?continue=https://myaccount.google.com/…`
|
|
585
|
+
* — two hostnames that are neither equal nor sub/parent of each other,
|
|
586
|
+
* so rule 1 alone does not save it. But the receiving server recorded
|
|
587
|
+
* `dmarc=pass header.from=accounts.google.com`: the host in the visible
|
|
588
|
+
* link belongs to the party that provably sent the mail, spending its
|
|
589
|
+
* own reputation. That the sender might itself be malicious is a
|
|
590
|
+
* different question than the one this check asks.
|
|
591
|
+
*
|
|
592
|
+
* The proof has to come from the topmost Authentication-Results, which the
|
|
593
|
+
* sender cannot write — otherwise the escape is a switch a forger flips by
|
|
594
|
+
* typing a domain into From. No proof means the check stays on.
|
|
526
595
|
*/
|
|
527
|
-
function redirectorLink(
|
|
596
|
+
function redirectorLink(input) {
|
|
597
|
+
const bodyHtml = input.bodyHtml || "";
|
|
598
|
+
const senderDomain = provedSenderDomain(input);
|
|
528
599
|
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
529
600
|
let url;
|
|
530
601
|
try {
|
|
@@ -535,6 +606,8 @@ function redirectorLink(bodyHtml) {
|
|
|
535
606
|
}
|
|
536
607
|
if (!/^https?:$/.test(url.protocol))
|
|
537
608
|
continue;
|
|
609
|
+
if (senderDomain && sameOrg(url.hostname, senderDomain))
|
|
610
|
+
continue; // the sender's own host
|
|
538
611
|
for (const [, value] of url.searchParams) {
|
|
539
612
|
let target;
|
|
540
613
|
try {
|
|
@@ -545,8 +618,8 @@ function redirectorLink(bodyHtml) {
|
|
|
545
618
|
}
|
|
546
619
|
if (!/^https?:$/.test(target.protocol))
|
|
547
620
|
continue;
|
|
548
|
-
if (target.hostname
|
|
549
|
-
continue; // same
|
|
621
|
+
if (sameOrg(target.hostname, url.hostname))
|
|
622
|
+
continue; // same site, not laundering
|
|
550
623
|
return {
|
|
551
624
|
id: "redirector-link",
|
|
552
625
|
severity: "caution",
|
|
@@ -571,7 +644,7 @@ export function assessMessageTrust(input) {
|
|
|
571
644
|
relayAuthMismatch(input),
|
|
572
645
|
zeroWidthObfuscation(input.bodyText || ""),
|
|
573
646
|
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
574
|
-
redirectorLink(input
|
|
647
|
+
redirectorLink(input),
|
|
575
648
|
].filter(Boolean);
|
|
576
649
|
const rank = { danger: 0, caution: 1, info: 2 };
|
|
577
650
|
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
@@ -614,11 +687,9 @@ function relayAuthMismatch(input) {
|
|
|
614
687
|
for (const name of RELAY_IDENTITY_HEADERS) {
|
|
615
688
|
const stamped = bare(header(input.headerLines, name));
|
|
616
689
|
const stampedDomain = stamped.split("@")[1] || "";
|
|
617
|
-
if (!stampedDomain || stampedDomain === fromDomain)
|
|
618
|
-
continue;
|
|
619
690
|
// Sub/parent-domain is the same organisation — mail.example.com
|
|
620
691
|
// sending for example.com is ordinary. Cross-organisation is not.
|
|
621
|
-
if (stampedDomain
|
|
692
|
+
if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
|
|
622
693
|
continue;
|
|
623
694
|
return {
|
|
624
695
|
id: "relay-auth-mismatch",
|
|
@@ -675,6 +746,7 @@ export function spamScoreOf(input) {
|
|
|
675
746
|
kind: a.kind,
|
|
676
747
|
proved: a.proved,
|
|
677
748
|
provedBy: a.provedBy,
|
|
749
|
+
bulkOnly: a.bulkOnly,
|
|
678
750
|
trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
|
|
679
751
|
reasons: a.reasons,
|
|
680
752
|
};
|