@bobfrankston/mailx-types 0.1.82 → 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.
- package/package.json +1 -1
- package/trust.d.ts +17 -0
- package/trust.js +63 -2
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,18 @@ 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 } : {};
|
|
391
435
|
const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
|
|
392
436
|
? `SpamAssassin score ${a.score} of ${a.threshold}`
|
|
393
437
|
: "flagged by SpamAssassin";
|
|
@@ -439,6 +483,7 @@ function serverSpamVerdict(input) {
|
|
|
439
483
|
severity: "caution",
|
|
440
484
|
text: "Your mail server classified this as spam before delivering it.",
|
|
441
485
|
detail: `${proof}${numbers}${why}${bayes}`,
|
|
486
|
+
...via,
|
|
442
487
|
};
|
|
443
488
|
}
|
|
444
489
|
/**
|
|
@@ -596,6 +641,14 @@ function provedSenderDomain(input) {
|
|
|
596
641
|
function redirectorLink(input) {
|
|
597
642
|
const bodyHtml = input.bodyHtml || "";
|
|
598
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);
|
|
599
652
|
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
600
653
|
let url;
|
|
601
654
|
try {
|
|
@@ -608,6 +661,8 @@ function redirectorLink(input) {
|
|
|
608
661
|
continue;
|
|
609
662
|
if (senderDomain && sameOrg(url.hostname, senderDomain))
|
|
610
663
|
continue; // the sender's own host
|
|
664
|
+
if (intermediary && sameOrg(url.hostname, intermediary))
|
|
665
|
+
continue; // a trusted relay's own host
|
|
611
666
|
for (const [, value] of url.searchParams) {
|
|
612
667
|
let target;
|
|
613
668
|
try {
|
|
@@ -620,11 +675,17 @@ function redirectorLink(input) {
|
|
|
620
675
|
continue;
|
|
621
676
|
if (sameOrg(target.hostname, url.hostname))
|
|
622
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));
|
|
623
683
|
return {
|
|
624
684
|
id: "redirector-link",
|
|
625
685
|
severity: "caution",
|
|
626
686
|
text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
|
|
627
687
|
detail: `${url.hostname} -> ${target.hostname}`,
|
|
688
|
+
...(signer ? { via: signer } : {}),
|
|
628
689
|
};
|
|
629
690
|
}
|
|
630
691
|
}
|
|
@@ -747,7 +808,7 @@ export function spamScoreOf(input) {
|
|
|
747
808
|
proved: a.proved,
|
|
748
809
|
provedBy: a.provedBy,
|
|
749
810
|
bulkOnly: a.bulkOnly,
|
|
750
|
-
trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
|
|
811
|
+
trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
|
|
751
812
|
reasons: a.reasons,
|
|
752
813
|
};
|
|
753
814
|
}
|