@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/trust.d.ts +4 -0
  3. package/trust.js +81 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.78",
3
+ "version": "0.1.82",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
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(bodyHtml) {
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 === url.hostname)
549
- continue; // same-site, not laundering
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.bodyHtml || ""),
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.endsWith("." + fromDomain) || fromDomain.endsWith("." + 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
  };