@bobfrankston/mailx-types 0.1.76 → 0.1.80

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 (3) hide show
  1. package/index.d.ts +10 -4
  2. package/package.json +1 -1
  3. package/trust.js +60 -7
package/index.d.ts CHANGED
@@ -55,11 +55,17 @@ export interface AccountConfig {
55
55
  signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
56
56
  sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
57
57
  }
58
- /** Signature configuration in accounts.jsonc. Initial shape carries `text`
59
- * only; `html: true` reserved for future support of raw HTML signatures. */
58
+ /** Signature configuration in accounts.jsonc. May be set per-account or once
59
+ * at the top level of the file, where it applies to every account that has
60
+ * no `sig` of its own. Appended by compose to new mail, replies and
61
+ * forwards; drafts already carry it in the saved body.
62
+ *
63
+ * 2026-09-02 — Claude Code (Opus 5), at Bob's direction: `html` is honoured
64
+ * (client/compose/compose.ts), not "reserved for future use" as this comment
65
+ * previously claimed. */
60
66
  export interface AccountSignature {
61
- text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
62
- html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
67
+ text: string; /** Signature body. Plain text unless `html` is set. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
68
+ html?: boolean; /** When true, `text` is inserted as raw HTML write your own `<br>` for line breaks. When false/absent, `text` is HTML-escaped and newlines become `<br>`. */
63
69
  }
64
70
  /** Standard IMAP special-use folder types */
65
71
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.76",
3
+ "version": "0.1.80",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.js CHANGED
@@ -56,6 +56,20 @@ function bare(addr) {
56
56
  const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
57
57
  return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
58
58
  }
59
+ /** Do two hostnames belong to the same organisation?
60
+ *
61
+ * Equality or a sub/parent-domain relation, never "the last two labels
62
+ * match": that would make paypal.co.uk and evil.co.uk relatives, which is
63
+ * the case this whole file exists to catch. A subdomain relationship cannot
64
+ * be forged across organisations, so it is safe in the other direction.
65
+ * Same test displayNameClaimsOtherAddress uses on addresses. */
66
+ function sameOrg(a, b) {
67
+ const x = (a || "").toLowerCase();
68
+ const y = (b || "").toLowerCase();
69
+ if (!x || !y)
70
+ return false;
71
+ return x === y || x.endsWith("." + y) || y.endsWith("." + x);
72
+ }
59
73
  /** Rules that say THIS message lies about who sent it. The only class that
60
74
  * earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
61
75
  * 0.0-scoring status marker that fired on Mokin, a message the receiving
@@ -514,6 +528,23 @@ function hiddenLinkOverlay(bodyHtml) {
514
528
  }
515
529
  return null;
516
530
  }
531
+ /** The domain the receiving server proved the From line for — "" when nothing
532
+ * did. One place answers "is this sender who they say they are", so the
533
+ * banner, the chip and the link checks can never disagree about it.
534
+ *
535
+ * dmarcProof is asked first because it needs no SpamAssassin: an
536
+ * Authentication-Results header alone is enough, which is what mail arriving
537
+ * through the Gmail API carries. analyzeServerSpam adds the two proofs
538
+ * SpamAssassin states in its own rule list instead (DKIM_VALID_AU,
539
+ * ALL_TRUSTED) and returns null when the server ran no filter at all. */
540
+ function provedSenderDomain(input) {
541
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
542
+ if (!fromDomain)
543
+ return "";
544
+ if (dmarcProof(input).pass)
545
+ return fromDomain;
546
+ return analyzeServerSpam(input)?.proved ? fromDomain : "";
547
+ }
517
548
  /**
518
549
  * Does a link route through a redirector carrying its real destination in the
519
550
  * query string?
@@ -523,8 +554,30 @@ function hiddenLinkOverlay(bodyHtml) {
523
554
  * whose redirect script was left open, and the destination appears only
524
555
  * percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
525
556
  * reader cannot be expected to URL-decode a query string.
557
+ *
558
+ * Laundering needs a SECOND PARTY — someone whose reputation is being spent
559
+ * without their consent. Two ways a link has none, both checked below, and
560
+ * neither of them a threshold:
561
+ *
562
+ * 1. The redirector and the destination are the same organisation. A site
563
+ * bouncing through its own hostnames is routing, not hiding.
564
+ * 2. The redirector IS the proved sender. Google's account-security notice
565
+ * (2026-09-02, Bob: "this is valid") links through
566
+ * `accounts.google.com/AccountChooser?continue=https://myaccount.google.com/…`
567
+ * — two hostnames that are neither equal nor sub/parent of each other,
568
+ * so rule 1 alone does not save it. But the receiving server recorded
569
+ * `dmarc=pass header.from=accounts.google.com`: the host in the visible
570
+ * link belongs to the party that provably sent the mail, spending its
571
+ * own reputation. That the sender might itself be malicious is a
572
+ * different question than the one this check asks.
573
+ *
574
+ * The proof has to come from the topmost Authentication-Results, which the
575
+ * sender cannot write — otherwise the escape is a switch a forger flips by
576
+ * typing a domain into From. No proof means the check stays on.
526
577
  */
527
- function redirectorLink(bodyHtml) {
578
+ function redirectorLink(input) {
579
+ const bodyHtml = input.bodyHtml || "";
580
+ const senderDomain = provedSenderDomain(input);
528
581
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
529
582
  let url;
530
583
  try {
@@ -535,6 +588,8 @@ function redirectorLink(bodyHtml) {
535
588
  }
536
589
  if (!/^https?:$/.test(url.protocol))
537
590
  continue;
591
+ if (senderDomain && sameOrg(url.hostname, senderDomain))
592
+ continue; // the sender's own host
538
593
  for (const [, value] of url.searchParams) {
539
594
  let target;
540
595
  try {
@@ -545,8 +600,8 @@ function redirectorLink(bodyHtml) {
545
600
  }
546
601
  if (!/^https?:$/.test(target.protocol))
547
602
  continue;
548
- if (target.hostname === url.hostname)
549
- continue; // same-site, not laundering
603
+ if (sameOrg(target.hostname, url.hostname))
604
+ continue; // same site, not laundering
550
605
  return {
551
606
  id: "redirector-link",
552
607
  severity: "caution",
@@ -571,7 +626,7 @@ export function assessMessageTrust(input) {
571
626
  relayAuthMismatch(input),
572
627
  zeroWidthObfuscation(input.bodyText || ""),
573
628
  hiddenLinkOverlay(input.bodyHtml || ""),
574
- redirectorLink(input.bodyHtml || ""),
629
+ redirectorLink(input),
575
630
  ].filter(Boolean);
576
631
  const rank = { danger: 0, caution: 1, info: 2 };
577
632
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
@@ -614,11 +669,9 @@ function relayAuthMismatch(input) {
614
669
  for (const name of RELAY_IDENTITY_HEADERS) {
615
670
  const stamped = bare(header(input.headerLines, name));
616
671
  const stampedDomain = stamped.split("@")[1] || "";
617
- if (!stampedDomain || stampedDomain === fromDomain)
618
- continue;
619
672
  // Sub/parent-domain is the same organisation — mail.example.com
620
673
  // sending for example.com is ordinary. Cross-organisation is not.
621
- if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
674
+ if (!stampedDomain || sameOrg(stampedDomain, fromDomain))
622
675
  continue;
623
676
  return {
624
677
  id: "relay-auth-mismatch",