@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.
- package/package.json +1 -1
- package/trust.d.ts +17 -0
- package/trust.js +76 -4
package/package.json
CHANGED
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
|
-
|
|
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
|
}
|