@bobfrankston/rmfmail 1.2.307 → 1.2.308
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/.commitmsg +23 -18
- package/client/android-bootstrap.bundle.js +45 -7
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +26 -1
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-viewer.js +36 -0
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +35 -1
- package/client/compose/compose.bundle.js +1 -1
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/lib/api-client.js +1 -1
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +3 -3
- package/client/package.json +1 -1
- package/docs/allowlist.md +19 -1
- package/npmchanges.md +23 -0
- package/package.json +8 -5
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +2 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +4 -1
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +6 -3
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/docs/allowlist.md +19 -1
- package/packages/mailx-settings/docs/rules-design.md +43 -0
- package/packages/mailx-settings/index.d.ts +1 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +4 -0
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +4 -0
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +3 -0
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +3 -0
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts +2 -1
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +3 -1
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +5 -3
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +17 -0
- package/packages/mailx-types/trust.d.ts.map +1 -1
- package/packages/mailx-types/trust.js +63 -2
- package/packages/mailx-types/trust.js.map +1 -1
- package/packages/mailx-types/trust.ts +80 -2
|
@@ -42,6 +42,12 @@ export interface TrustFinding {
|
|
|
42
42
|
text: string;
|
|
43
43
|
/** The specific evidence — a header value, a host, a score. */
|
|
44
44
|
detail: string;
|
|
45
|
+
/** A party that provably handled this message and could be vouched for
|
|
46
|
+
* as an intermediary (allowlist.jsonc `trustedIntermediaries`) — the
|
|
47
|
+
* DKIM signer behind a redirector, or the Sender: domain that signed on
|
|
48
|
+
* the author's behalf. Set only when such a party exists and is not yet
|
|
49
|
+
* trusted, so the viewer can offer the action by name. (2026-09-08) */
|
|
50
|
+
via?: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export interface TrustInput {
|
|
@@ -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
|
/** First value of a header, case-insensitive. */
|
|
@@ -225,6 +242,36 @@ function classifyRule(name: string, dmarcFailed: boolean): RuleClass {
|
|
|
225
242
|
* against the actual From line anyway, because an Authentication-Results about
|
|
226
243
|
* some other message proves nothing about this one.
|
|
227
244
|
*/
|
|
245
|
+
/** Domains whose DKIM signature the receiving server verified, from the
|
|
246
|
+
* topmost Authentication-Results — the one the sender cannot write. A
|
|
247
|
+
* signer is a party that provably handled the message: it need not be the
|
|
248
|
+
* author (Google Calendar signs d=google.com on an invitation From the
|
|
249
|
+
* organizer), which is exactly what makes it an intermediary. */
|
|
250
|
+
function dkimSigners(input: TrustInput): string[] {
|
|
251
|
+
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
252
|
+
const out: string[] = [];
|
|
253
|
+
for (const part of auth.split(";")) {
|
|
254
|
+
if (!/\bdkim=pass\b/i.test(part)) continue;
|
|
255
|
+
const d = (part.match(/header\.d=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
256
|
+
if (d && !out.includes(d)) out.push(d);
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The entry in `trustedIntermediaries` that provably handled this message,
|
|
262
|
+
* or "" — proof being a verified signature from its domain, or a DMARC pass
|
|
263
|
+
* when it is the author. The list alone proves nothing. (2026-09-08) */
|
|
264
|
+
function provedIntermediary(input: TrustInput): string {
|
|
265
|
+
const list = (input.trustedIntermediaries || []).map(d => (d || "").trim().toLowerCase()).filter(Boolean);
|
|
266
|
+
if (!list.length) return "";
|
|
267
|
+
const proved = dkimSigners(input);
|
|
268
|
+
const dmarc = dmarcProof(input);
|
|
269
|
+
if (dmarc.pass && dmarc.domain) proved.push(dmarc.domain);
|
|
270
|
+
for (const entry of list)
|
|
271
|
+
if (proved.some(p => sameOrg(p, entry))) return entry;
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
|
|
228
275
|
function dmarcProof(input: TrustInput): { pass: boolean; failed: boolean; authFailures: string[]; domain: string; policy: string } {
|
|
229
276
|
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
230
277
|
if (!auth) return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
|
|
@@ -337,6 +384,10 @@ interface ServerSpamAnalysis {
|
|
|
337
384
|
authFailures: string[];
|
|
338
385
|
/** Top scoring rules, worst first, named for a human to judge in a second. */
|
|
339
386
|
reasons: string[];
|
|
387
|
+
/** The reader-listed intermediary that provably handled this message, or "". */
|
|
388
|
+
intermediary: string;
|
|
389
|
+
/** Domains whose DKIM signature verified — candidates for that list. */
|
|
390
|
+
signers: string[];
|
|
340
391
|
}
|
|
341
392
|
|
|
342
393
|
function analyzeServerSpam(input: TrustInput): ServerSpamAnalysis {
|
|
@@ -448,6 +499,8 @@ function analyzeServerSpam(input: TrustInput): ServerSpamAnalysis {
|
|
|
448
499
|
bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
|
|
449
500
|
authFailures: dmarc.authFailures,
|
|
450
501
|
reasons,
|
|
502
|
+
intermediary: provedIntermediary(input),
|
|
503
|
+
signers: dkimSigners(input),
|
|
451
504
|
};
|
|
452
505
|
}
|
|
453
506
|
|
|
@@ -501,8 +554,17 @@ function serverSpamVerdict(input: TrustInput): TrustFinding {
|
|
|
501
554
|
// into furniture. (Bob 2026-08-28, having just clicked "Always:
|
|
502
555
|
// *@icecer.com" on a conference announcement: "I accepted the server so
|
|
503
556
|
// shouldn't this be considered safe?")
|
|
504
|
-
|
|
557
|
+
// A trusted INTERMEDIARY that provably handled the message answers the
|
|
558
|
+
// same question the same way — Google Calendar's invitation on Aaron's
|
|
559
|
+
// behalf is wanted because the reader said google.com is (2026-09-08).
|
|
560
|
+
const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary;
|
|
505
561
|
if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length) return null;
|
|
562
|
+
// The party that sent this on the author's behalf, when it signed: the
|
|
563
|
+
// Sender: header names it and a verified signature backs the claim. That
|
|
564
|
+
// is the one the reader can sensibly vouch for as an intermediary.
|
|
565
|
+
const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
|
|
566
|
+
const relay = senderDomain ? a.signers.find(s => sameOrg(s, senderDomain)) : "";
|
|
567
|
+
const via = relay && !a.proved ? { via: relay } : {};
|
|
506
568
|
|
|
507
569
|
const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
|
|
508
570
|
? `SpamAssassin score ${a.score} of ${a.threshold}`
|
|
@@ -559,6 +621,7 @@ function serverSpamVerdict(input: TrustInput): TrustFinding {
|
|
|
559
621
|
severity: "caution",
|
|
560
622
|
text: "Your mail server classified this as spam before delivering it.",
|
|
561
623
|
detail: `${proof}${numbers}${why}${bayes}`,
|
|
624
|
+
...via,
|
|
562
625
|
};
|
|
563
626
|
}
|
|
564
627
|
|
|
@@ -711,21 +774,36 @@ function provedSenderDomain(input: TrustInput): string {
|
|
|
711
774
|
function redirectorLink(input: TrustInput): TrustFinding {
|
|
712
775
|
const bodyHtml = input.bodyHtml || "";
|
|
713
776
|
const senderDomain = provedSenderDomain(input);
|
|
777
|
+
// 3. (2026-09-08) The redirector belongs to a reader-listed intermediary
|
|
778
|
+
// that provably handled the message. Google Calendar wraps every link
|
|
779
|
+
// in an invitation as google.com/url?q=…, signs the mail d=google.com,
|
|
780
|
+
// and is not the From — rule 2 cannot save it, and the reader's answer
|
|
781
|
+
// is "I trust google.com to do that". Same second-party logic: the
|
|
782
|
+
// host spending its reputation is the host that signed.
|
|
783
|
+
const intermediary = provedIntermediary(input);
|
|
784
|
+
const signers = dkimSigners(input);
|
|
714
785
|
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
715
786
|
let url: URL;
|
|
716
787
|
try { url = new URL(m[1]); } catch { continue; }
|
|
717
788
|
if (!/^https?:$/.test(url.protocol)) continue;
|
|
718
789
|
if (senderDomain && sameOrg(url.hostname, senderDomain)) continue; // the sender's own host
|
|
790
|
+
if (intermediary && sameOrg(url.hostname, intermediary)) continue; // a trusted relay's own host
|
|
719
791
|
for (const [, value] of url.searchParams) {
|
|
720
792
|
let target: URL;
|
|
721
793
|
try { target = new URL(value); } catch { continue; }
|
|
722
794
|
if (!/^https?:$/.test(target.protocol)) continue;
|
|
723
795
|
if (sameOrg(target.hostname, url.hostname)) continue; // same site, not laundering
|
|
796
|
+
// Name the signer behind this redirector, if there is one, so the
|
|
797
|
+
// viewer can offer "trust google.com as an intermediary" — and
|
|
798
|
+
// only then: a redirector nobody signed for has no party to vouch
|
|
799
|
+
// for, and a list entry could not clear it anyway.
|
|
800
|
+
const signer = signers.find(s => sameOrg(s, url.hostname));
|
|
724
801
|
return {
|
|
725
802
|
id: "redirector-link",
|
|
726
803
|
severity: "caution",
|
|
727
804
|
text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
|
|
728
805
|
detail: `${url.hostname} -> ${target.hostname}`,
|
|
806
|
+
...(signer ? { via: signer } : {}),
|
|
729
807
|
};
|
|
730
808
|
}
|
|
731
809
|
}
|
|
@@ -895,7 +973,7 @@ export function spamScoreOf(input: TrustInput): SpamScore {
|
|
|
895
973
|
proved: a.proved,
|
|
896
974
|
provedBy: a.provedBy,
|
|
897
975
|
bulkOnly: a.bulkOnly,
|
|
898
|
-
trusted: !!input.senderTrusted && a.proved && a.kind !== "forgery" && !a.authFailures.length,
|
|
976
|
+
trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
|
|
899
977
|
reasons: a.reasons,
|
|
900
978
|
};
|
|
901
979
|
}
|