@bobfrankston/mailx-types 0.1.80 → 0.1.84

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 +21 -0
  3. package/trust.js +84 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.80",
3
+ "version": "0.1.84",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.d.ts CHANGED
@@ -40,6 +40,12 @@ export interface TrustFinding {
40
40
  text: string;
41
41
  /** The specific evidence — a header value, a host, a score. */
42
42
  detail: string;
43
+ /** A party that provably handled this message and could be vouched for
44
+ * as an intermediary (allowlist.jsonc `trustedIntermediaries`) — the
45
+ * DKIM signer behind a redirector, or the Sender: domain that signed on
46
+ * the author's behalf. Set only when such a party exists and is not yet
47
+ * trusted, so the viewer can offer the action by name. (2026-09-08) */
48
+ via?: string;
43
49
  }
44
50
  export interface TrustInput {
45
51
  /** Every header line, in order, as mailparser's `headerLines`. Repeated
@@ -68,6 +74,17 @@ export interface TrustInput {
68
74
  * because markup legitimately contains things prose does not, and the
69
75
  * zero-width test would trip over an entity or an attribute value. */
70
76
  bodyText?: string;
77
+ /** Domains the reader trusts to carry mail on OTHER people's behalf —
78
+ * Google Calendar, Zoom, DocuSign — from allowlist.jsonc
79
+ * `trustedIntermediaries`. Bob 2026-09-08, on a Google Calendar
80
+ * invitation whose every link is wrapped in google.com/url?q=…: "how do
81
+ * I mark zoom and sites like that as trusted intermediaries?" An entry
82
+ * counts only on a message the intermediary PROVABLY handled (a
83
+ * dkim=pass for its domain, or a DMARC pass when it is the From) — an
84
+ * open redirector on google.com is the classic laundering hop, and a
85
+ * list that cleared it on sight would be a switch any forger could
86
+ * flip by pasting the link. See provedIntermediary. */
87
+ trustedIntermediaries?: string[];
71
88
  }
72
89
  /**
73
90
  * Everything findable about this message, worst first.
@@ -109,6 +126,10 @@ export interface SpamScore {
109
126
  * may not exist (Bob's own mail carries no Authentication-Results at
110
127
  * all). */
111
128
  provedBy: "dmarc" | "dkim" | "trusted" | "";
129
+ /** Every rule that scored is a bulk-mail or blocklist test. The chip's
130
+ * tooltip may say so ONLY when this is true — it is a claim about all of
131
+ * the rules, and the reader can read them underneath it. */
132
+ bulkOnly: boolean;
112
133
  /** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
113
134
  * listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
114
135
  * this can be judged in a second. */
package/trust.js CHANGED
@@ -166,6 +166,39 @@ function classifyRule(name, dmarcFailed) {
166
166
  * against the actual From line anyway, because an Authentication-Results about
167
167
  * some other message proves nothing about this one.
168
168
  */
169
+ /** Domains whose DKIM signature the receiving server verified, from the
170
+ * topmost Authentication-Results — the one the sender cannot write. A
171
+ * signer is a party that provably handled the message: it need not be the
172
+ * author (Google Calendar signs d=google.com on an invitation From the
173
+ * organizer), which is exactly what makes it an intermediary. */
174
+ function dkimSigners(input) {
175
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
176
+ const out = [];
177
+ for (const part of auth.split(";")) {
178
+ if (!/\bdkim=pass\b/i.test(part))
179
+ continue;
180
+ const d = (part.match(/header\.d=([^\s;,]+)/i)?.[1] || "").toLowerCase();
181
+ if (d && !out.includes(d))
182
+ out.push(d);
183
+ }
184
+ return out;
185
+ }
186
+ /** The entry in `trustedIntermediaries` that provably handled this message,
187
+ * or "" — proof being a verified signature from its domain, or a DMARC pass
188
+ * when it is the author. The list alone proves nothing. (2026-09-08) */
189
+ function provedIntermediary(input) {
190
+ const list = (input.trustedIntermediaries || []).map(d => (d || "").trim().toLowerCase()).filter(Boolean);
191
+ if (!list.length)
192
+ return "";
193
+ const proved = dkimSigners(input);
194
+ const dmarc = dmarcProof(input);
195
+ if (dmarc.pass && dmarc.domain)
196
+ proved.push(dmarc.domain);
197
+ for (const entry of list)
198
+ if (proved.some(p => sameOrg(p, entry)))
199
+ return entry;
200
+ return "";
201
+ }
169
202
  function dmarcProof(input) {
170
203
  const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
171
204
  if (!auth)
@@ -282,6 +315,16 @@ function analyzeServerSpam(input) {
282
315
  const unknownCouldFlagAlone = !unknown.length ? false
283
316
  : !Number.isFinite(unknownWeight) || !Number.isFinite(threshold) ? true
284
317
  : unknownWeight >= threshold;
318
+ // What the chip may claim about the rule set. "Every rule that scored is a
319
+ // bulk-mail test" is a statement about ALL of them, so it has to be
320
+ // checked against all of them — deriving it from `proved` (which says
321
+ // nothing about the rules) or from `kind` (the worst class, which says
322
+ // nothing about the rest) asserts something the code never looked at.
323
+ // The Altis Hotels welcome mail, 2026-09-03 (Bob: "marked as spam but is
324
+ // valid"): DMARC-proved, so the chip said "scored by bulk-mail or
325
+ // blocklist rules" — with 3.2 of its 8.3 coming from KAM_ACCOUNTPHISH,
326
+ // which is neither, listed directly underneath.
327
+ const bulkOnly = rules.every(r => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
285
328
  const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
286
329
  : present("association") ? "association"
287
330
  : unknownCouldFlagAlone ? "unclassified"
@@ -319,11 +362,13 @@ function analyzeServerSpam(input) {
319
362
  });
320
363
  return {
321
364
  score, threshold, flagged, rules, kind,
322
- proved: !!provedBy, provedBy,
365
+ proved: !!provedBy, provedBy, bulkOnly,
323
366
  provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
324
367
  bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
325
368
  authFailures: dmarc.authFailures,
326
369
  reasons,
370
+ intermediary: provedIntermediary(input),
371
+ signers: dkimSigners(input),
327
372
  };
328
373
  }
329
374
  /**
@@ -375,9 +420,18 @@ function serverSpamVerdict(input) {
375
420
  // into furniture. (Bob 2026-08-28, having just clicked "Always:
376
421
  // *@icecer.com" on a conference announcement: "I accepted the server so
377
422
  // shouldn't this be considered safe?")
378
- const trustedAndProved = input.senderTrusted && a.proved;
423
+ // A trusted INTERMEDIARY that provably handled the message answers the
424
+ // same question the same way — Google Calendar's invitation on Aaron's
425
+ // behalf is wanted because the reader said google.com is (2026-09-08).
426
+ const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary;
379
427
  if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
380
428
  return null;
429
+ // The party that sent this on the author's behalf, when it signed: the
430
+ // Sender: header names it and a verified signature backs the claim. That
431
+ // is the one the reader can sensibly vouch for as an intermediary.
432
+ const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
433
+ const relay = senderDomain ? a.signers.find(s => sameOrg(s, senderDomain)) : "";
434
+ const via = relay && !a.proved ? { via: relay } : {};
381
435
  const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
382
436
  ? `SpamAssassin score ${a.score} of ${a.threshold}`
383
437
  : "flagged by SpamAssassin";
@@ -401,11 +455,19 @@ function serverSpamVerdict(input) {
401
455
  : `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
402
456
  detail: `${provedNote(a)} ${numbers}${why}${bayes}`,
403
457
  };
458
+ // Same discipline as the chip: "nothing that scored tests who sent it" is
459
+ // a claim about every rule, so it is made only when every rule was
460
+ // actually looked at and classified. `kind === "bulk"` does not establish
461
+ // that — an unclassified rule that could not have flagged the message on
462
+ // its own leaves the kind at bulk while remaining, by definition, a rule
463
+ // this file cannot say anything about.
404
464
  if (a.kind === "bulk")
405
465
  return {
406
466
  id: "server-spam-verdict",
407
467
  severity: "info",
408
- text: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
468
+ text: a.bulkOnly
469
+ ? "Your mail server rates this bulk mail — nothing that scored tests who sent it."
470
+ : "Your mail server rates this bulk mail.",
409
471
  detail: `${numbers}${why}${bayes}`,
410
472
  };
411
473
  const proof = a.proved ? `${provedNote(a)} ` : "";
@@ -421,6 +483,7 @@ function serverSpamVerdict(input) {
421
483
  severity: "caution",
422
484
  text: "Your mail server classified this as spam before delivering it.",
423
485
  detail: `${proof}${numbers}${why}${bayes}`,
486
+ ...via,
424
487
  };
425
488
  }
426
489
  /**
@@ -578,6 +641,14 @@ function provedSenderDomain(input) {
578
641
  function redirectorLink(input) {
579
642
  const bodyHtml = input.bodyHtml || "";
580
643
  const senderDomain = provedSenderDomain(input);
644
+ // 3. (2026-09-08) The redirector belongs to a reader-listed intermediary
645
+ // that provably handled the message. Google Calendar wraps every link
646
+ // in an invitation as google.com/url?q=…, signs the mail d=google.com,
647
+ // and is not the From — rule 2 cannot save it, and the reader's answer
648
+ // is "I trust google.com to do that". Same second-party logic: the
649
+ // host spending its reputation is the host that signed.
650
+ const intermediary = provedIntermediary(input);
651
+ const signers = dkimSigners(input);
581
652
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
582
653
  let url;
583
654
  try {
@@ -590,6 +661,8 @@ function redirectorLink(input) {
590
661
  continue;
591
662
  if (senderDomain && sameOrg(url.hostname, senderDomain))
592
663
  continue; // the sender's own host
664
+ if (intermediary && sameOrg(url.hostname, intermediary))
665
+ continue; // a trusted relay's own host
593
666
  for (const [, value] of url.searchParams) {
594
667
  let target;
595
668
  try {
@@ -602,11 +675,17 @@ function redirectorLink(input) {
602
675
  continue;
603
676
  if (sameOrg(target.hostname, url.hostname))
604
677
  continue; // same site, not laundering
678
+ // Name the signer behind this redirector, if there is one, so the
679
+ // viewer can offer "trust google.com as an intermediary" — and
680
+ // only then: a redirector nobody signed for has no party to vouch
681
+ // for, and a list entry could not clear it anyway.
682
+ const signer = signers.find(s => sameOrg(s, url.hostname));
605
683
  return {
606
684
  id: "redirector-link",
607
685
  severity: "caution",
608
686
  text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
609
687
  detail: `${url.hostname} -> ${target.hostname}`,
688
+ ...(signer ? { via: signer } : {}),
610
689
  };
611
690
  }
612
691
  }
@@ -728,7 +807,8 @@ export function spamScoreOf(input) {
728
807
  kind: a.kind,
729
808
  proved: a.proved,
730
809
  provedBy: a.provedBy,
731
- trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
810
+ bulkOnly: a.bulkOnly,
811
+ trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
732
812
  reasons: a.reasons,
733
813
  };
734
814
  }