@bobfrankston/mailx-types 0.1.82 → 0.1.86

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 +17 -0
  3. package/trust.js +76 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.82",
3
+ "version": "0.1.86",
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.
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)
@@ -334,6 +367,8 @@ function analyzeServerSpam(input) {
334
367
  bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
335
368
  authFailures: dmarc.authFailures,
336
369
  reasons,
370
+ intermediary: provedIntermediary(input),
371
+ signers: dkimSigners(input),
337
372
  };
338
373
  }
339
374
  /**
@@ -385,9 +420,25 @@ function serverSpamVerdict(input) {
385
420
  // into furniture. (Bob 2026-08-28, having just clicked "Always:
386
421
  // *@icecer.com" on a conference announcement: "I accepted the server so
387
422
  // shouldn't this be considered safe?")
388
- 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;
389
427
  if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
390
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 } : {};
435
+ // Say WHICH party and HOW, in the finding itself (Bob 2026-09-08: "the
436
+ // banner should tell which and how"). The action's label is quoted so
437
+ // the reader can match it to the button — and to the allowlist.jsonc
438
+ // entry it writes, `trustedIntermediaries`.
439
+ const relayNote = relay && !a.proved
440
+ ? `. Sent on the author's behalf by ${relay}, which signed it; "Trust ${relay} as an intermediary" stops this note for mail ${relay} signs.`
441
+ : "";
391
442
  const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
392
443
  ? `SpamAssassin score ${a.score} of ${a.threshold}`
393
444
  : "flagged by SpamAssassin";
@@ -438,7 +489,8 @@ function serverSpamVerdict(input) {
438
489
  id: "server-spam-verdict",
439
490
  severity: "caution",
440
491
  text: "Your mail server classified this as spam before delivering it.",
441
- detail: `${proof}${numbers}${why}${bayes}`,
492
+ detail: `${proof}${numbers}${why}${bayes}${relayNote}`,
493
+ ...via,
442
494
  };
443
495
  }
444
496
  /**
@@ -596,6 +648,14 @@ function provedSenderDomain(input) {
596
648
  function redirectorLink(input) {
597
649
  const bodyHtml = input.bodyHtml || "";
598
650
  const senderDomain = provedSenderDomain(input);
651
+ // 3. (2026-09-08) The redirector belongs to a reader-listed intermediary
652
+ // that provably handled the message. Google Calendar wraps every link
653
+ // in an invitation as google.com/url?q=…, signs the mail d=google.com,
654
+ // and is not the From — rule 2 cannot save it, and the reader's answer
655
+ // is "I trust google.com to do that". Same second-party logic: the
656
+ // host spending its reputation is the host that signed.
657
+ const intermediary = provedIntermediary(input);
658
+ const signers = dkimSigners(input);
599
659
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
600
660
  let url;
601
661
  try {
@@ -608,6 +668,8 @@ function redirectorLink(input) {
608
668
  continue;
609
669
  if (senderDomain && sameOrg(url.hostname, senderDomain))
610
670
  continue; // the sender's own host
671
+ if (intermediary && sameOrg(url.hostname, intermediary))
672
+ continue; // a trusted relay's own host
611
673
  for (const [, value] of url.searchParams) {
612
674
  let target;
613
675
  try {
@@ -620,11 +682,21 @@ function redirectorLink(input) {
620
682
  continue;
621
683
  if (sameOrg(target.hostname, url.hostname))
622
684
  continue; // same site, not laundering
685
+ // Name the signer behind this redirector, if there is one, so the
686
+ // viewer can offer "trust google.com as an intermediary" — and
687
+ // only then: a redirector nobody signed for has no party to vouch
688
+ // for, and a list entry could not clear it anyway.
689
+ const signer = signers.find(s => sameOrg(s, url.hostname));
690
+ // Which party, and how — or why there is no party (Bob 2026-09-08).
691
+ const how = signer
692
+ ? ` ${signer} signed this message; "Trust ${signer} as an intermediary" stops this warning for links through ${signer} in mail it signs.`
693
+ : ` Nobody signed this message for ${url.hostname}, so there is no intermediary to trust; only the sender could vouch for the link.`;
623
694
  return {
624
695
  id: "redirector-link",
625
696
  severity: "caution",
626
697
  text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
627
- detail: `${url.hostname} -> ${target.hostname}`,
698
+ detail: `${url.hostname} -> ${target.hostname}.${how}`,
699
+ ...(signer ? { via: signer } : {}),
628
700
  };
629
701
  }
630
702
  }
@@ -747,7 +819,7 @@ export function spamScoreOf(input) {
747
819
  proved: a.proved,
748
820
  provedBy: a.provedBy,
749
821
  bulkOnly: a.bulkOnly,
750
- trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
822
+ trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
751
823
  reasons: a.reasons,
752
824
  };
753
825
  }