@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.
Files changed (50) hide show
  1. package/.commitmsg +23 -18
  2. package/client/android-bootstrap.bundle.js +45 -7
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +26 -1
  5. package/client/app.bundle.js.map +2 -2
  6. package/client/components/message-viewer.js +36 -0
  7. package/client/components/message-viewer.js.map +1 -1
  8. package/client/components/message-viewer.ts +35 -1
  9. package/client/compose/compose.bundle.js +1 -1
  10. package/client/compose/compose.bundle.js.map +2 -2
  11. package/client/lib/api-client.js +1 -1
  12. package/client/lib/api-client.js.map +1 -1
  13. package/client/lib/api-client.ts +3 -3
  14. package/client/package.json +1 -1
  15. package/docs/allowlist.md +19 -1
  16. package/npmchanges.md +23 -0
  17. package/package.json +8 -5
  18. package/packages/mailx-imap/package-lock.json +2 -2
  19. package/packages/mailx-imap/package.json +1 -1
  20. package/packages/mailx-service/index.d.ts +2 -1
  21. package/packages/mailx-service/index.d.ts.map +1 -1
  22. package/packages/mailx-service/index.js +4 -1
  23. package/packages/mailx-service/index.js.map +1 -1
  24. package/packages/mailx-service/index.ts +6 -3
  25. package/packages/mailx-service/package.json +1 -1
  26. package/packages/mailx-settings/docs/allowlist.md +19 -1
  27. package/packages/mailx-settings/docs/rules-design.md +43 -0
  28. package/packages/mailx-settings/index.d.ts +1 -0
  29. package/packages/mailx-settings/index.d.ts.map +1 -1
  30. package/packages/mailx-settings/index.js +4 -0
  31. package/packages/mailx-settings/index.js.map +1 -1
  32. package/packages/mailx-settings/index.ts +4 -0
  33. package/packages/mailx-settings/package.json +1 -1
  34. package/packages/mailx-store/package.json +1 -1
  35. package/packages/mailx-store/store.d.ts.map +1 -1
  36. package/packages/mailx-store/store.js +3 -0
  37. package/packages/mailx-store/store.js.map +1 -1
  38. package/packages/mailx-store/store.ts +3 -0
  39. package/packages/mailx-store-web/package.json +1 -1
  40. package/packages/mailx-store-web/web-service.d.ts +2 -1
  41. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  42. package/packages/mailx-store-web/web-service.js +3 -1
  43. package/packages/mailx-store-web/web-service.js.map +1 -1
  44. package/packages/mailx-store-web/web-service.ts +5 -3
  45. package/packages/mailx-types/package.json +1 -1
  46. package/packages/mailx-types/trust.d.ts +17 -0
  47. package/packages/mailx-types/trust.d.ts.map +1 -1
  48. package/packages/mailx-types/trust.js +63 -2
  49. package/packages/mailx-types/trust.js.map +1 -1
  50. 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
- const trustedAndProved = input.senderTrusted && a.proved;
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
  }