@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.
- package/package.json +1 -1
- package/trust.d.ts +21 -0
- package/trust.js +84 -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.
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|