@bobfrankston/mailx-types 0.1.96 → 0.1.98

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 +18 -0
  3. package/trust.js +42 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.d.ts CHANGED
@@ -39,6 +39,7 @@ export declare const TRUST_LIST_KEYS: {
39
39
  readonly domain: "trustedDomains";
40
40
  readonly intermediary: "trustedIntermediaries";
41
41
  readonly list: "trustedLists";
42
+ readonly via: "trustedSendersVia";
42
43
  };
43
44
  export type TrustListType = keyof typeof TRUST_LIST_KEYS;
44
45
  /** One piece of evidence that a message is not what it claims to be. */
@@ -63,6 +64,11 @@ export interface TrustFinding {
63
64
  * when the list is not yet in allowlist.jsonc `trustedLists`, so the
64
65
  * viewer can offer "Trust the ‹list› list". (2026-09-09) */
65
66
  list?: string;
67
+ /** "‹from domain› via ‹signer›": the reader trusts this sender, nothing
68
+ * proves the From line, but a relay DID sign the message — so the
69
+ * viewer can offer to trust the sender when that relay signs for it
70
+ * (allowlist.jsonc `trustedSendersVia`). (2026-09-10) */
71
+ senderVia?: string;
66
72
  }
67
73
  export interface TrustInput {
68
74
  /** Every header line, in order, as mailparser's `headerLines`. Repeated
@@ -113,6 +119,18 @@ export interface TrustInput {
113
119
  * spf=pass for the list's bounce address, or dkim=pass for its domain.
114
120
  * An entry counts only on such a message. See listProof. */
115
121
  trustedLists?: string[];
122
+ /** "‹from domain› via ‹signer domain›" entries from allowlist.jsonc
123
+ * `trustedSendersVia`. Bob 2026-09-10, Ground News: From
124
+ * groundnews.email, sent through Amazon SES, signed only as
125
+ * amazonses.com, no DMARC record — the sender is on his list and can
126
+ * never be proved, so the list was ignored (correctly) and nothing could
127
+ * be approved. The evidence he does have is the pair: this sender always
128
+ * arrives signed by this relay, and a relay like SES lets a customer send
129
+ * only From identities it has verified. An entry counts only on a
130
+ * message the named relay DKIM-signed, for the named From domain; a
131
+ * forger with their own SES account is not groundnews.email's, and one
132
+ * without a relay signature matches nothing. See provedSenderVia. */
133
+ trustedSendersVia?: string[];
116
134
  }
117
135
  /**
118
136
  * Everything findable about this message, worst first.
package/trust.js CHANGED
@@ -39,6 +39,7 @@ export const TRUST_LIST_KEYS = {
39
39
  domain: "trustedDomains",
40
40
  intermediary: "trustedIntermediaries",
41
41
  list: "trustedLists",
42
+ via: "trustedSendersVia",
42
43
  };
43
44
  /** First value of a header, case-insensitive. */
44
45
  function header(lines, name) {
@@ -256,6 +257,27 @@ function provedIntermediary(input) {
256
257
  return entry;
257
258
  return "";
258
259
  }
260
+ /** Parse a `trustedSendersVia` entry — "groundnews.email via amazonses.com". */
261
+ function parseSenderVia(entry) {
262
+ const m = (entry || "").trim().toLowerCase().match(/^(\S+)\s+via\s+(\S+)$/);
263
+ return m ? { domain: m[1], signer: m[2] } : null;
264
+ }
265
+ /** The `trustedSendersVia` entry this message satisfies, or "": the From
266
+ * domain matches the entry's, and the entry's relay is among the domains
267
+ * whose signature the receiving server verified. (2026-09-10) */
268
+ function provedSenderVia(input) {
269
+ const entries = (input.trustedSendersVia || []).map(parseSenderVia).filter(Boolean);
270
+ if (!entries.length)
271
+ return "";
272
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
273
+ if (!fromDomain)
274
+ return "";
275
+ const signers = dkimSigners(input);
276
+ for (const e of entries)
277
+ if (sameOrg(fromDomain, e.domain) && signers.some(s => sameOrg(s, e.signer)))
278
+ return `${e.domain} via ${e.signer}`;
279
+ return "";
280
+ }
259
281
  /** The mailing list this message came through, and whether the receiving
260
282
  * server proved the list sent it. (2026-09-09)
261
283
  *
@@ -484,6 +506,7 @@ function analyzeServerSpam(input) {
484
506
  intermediary: provedIntermediary(input),
485
507
  signers: dkimSigners(input),
486
508
  list,
509
+ senderVia: provedSenderVia(input),
487
510
  };
488
511
  }
489
512
  /**
@@ -541,7 +564,10 @@ function serverSpamVerdict(input) {
541
564
  // A trusted mailing LIST answers it for every post the list provably
542
565
  // delivered, whoever wrote it — the From line is the list's word, not
543
566
  // the author's, and the reader subscribed to the list (2026-09-09).
544
- const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted;
567
+ // A sender trusted "via" a relay that signed this message answers it
568
+ // too — the pair is the reader's evidence for a sender that never
569
+ // proves itself (2026-09-10).
570
+ const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted || !!a.senderVia;
545
571
  if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
546
572
  return null;
547
573
  // The party that sent this on the author's behalf, when it signed: the
@@ -604,14 +630,26 @@ function serverSpamVerdict(input) {
604
630
  detail: `${numbers}${why}${bayes}`,
605
631
  };
606
632
  const proof = a.proved ? `${provedNote(a)} ` : "";
607
- if (input.senderTrusted && !a.proved)
633
+ if (input.senderTrusted && !a.proved) {
634
+ // The one thing that did verify: a relay's signature. Name it and
635
+ // the action — a trusted sender that can never be proved otherwise
636
+ // leaves the reader with a banner and no way forward (Bob
637
+ // 2026-09-10, Ground News via Amazon SES: "another valid site. How
638
+ // do I approve it?").
639
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
640
+ const relaySigner = a.signers.find(s => !sameOrg(s, fromDomain)) || "";
641
+ const senderViaNote = relaySigner
642
+ ? `. ${relaySigner} signed this message on the sender's behalf; "Trust ${fromDomain} via ${relaySigner}" stops this note for mail from ${fromDomain} that ${relaySigner} signs.`
643
+ : "";
608
644
  return {
609
645
  id: "server-spam-verdict",
610
646
  severity: "caution",
611
647
  text: "This claims to be a sender you trust, but nothing proves it is.",
612
- detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}${listNote}`,
648
+ detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}${listNote}${senderViaNote}`,
613
649
  ...viaList,
650
+ ...(relaySigner ? { senderVia: `${fromDomain} via ${relaySigner}` } : {}),
614
651
  };
652
+ }
615
653
  return {
616
654
  id: "server-spam-verdict",
617
655
  severity: "caution",
@@ -982,7 +1020,7 @@ export function spamScoreOf(input) {
982
1020
  proved: a.proved,
983
1021
  provedBy: a.provedBy,
984
1022
  bulkOnly: a.bulkOnly,
985
- trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted) && a.kind !== "forgery" && !a.authFailures.length,
1023
+ trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted || !!a.senderVia) && a.kind !== "forgery" && !a.authFailures.length,
986
1024
  reasons: a.reasons,
987
1025
  listId: a.list.id,
988
1026
  listProved: a.list.proved,