@bobfrankston/rmfmail 1.2.281 → 1.2.282

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 (33) hide show
  1. package/.commitmsg +62 -63
  2. package/client/android-bootstrap.bundle.js +72 -3
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +9 -0
  5. package/client/app.bundle.js.map +2 -2
  6. package/client/components/message-viewer.js +23 -0
  7. package/client/components/message-viewer.js.map +1 -1
  8. package/client/components/message-viewer.ts +25 -0
  9. package/client/styles/components.css +17 -0
  10. package/npmchanges.md +66 -0
  11. package/package.json +5 -5
  12. package/packages/mailx-imap/package-lock.json +2 -2
  13. package/packages/mailx-imap/package.json +1 -1
  14. package/packages/mailx-settings/package.json +1 -1
  15. package/packages/mailx-store/package.json +1 -1
  16. package/packages/mailx-store/store.d.ts +4 -1
  17. package/packages/mailx-store/store.d.ts.map +1 -1
  18. package/packages/mailx-store/store.js +11 -3
  19. package/packages/mailx-store/store.js.map +1 -1
  20. package/packages/mailx-store/store.ts +14 -3
  21. package/packages/mailx-store-web/package.json +1 -1
  22. package/packages/mailx-types/index.d.ts +2 -2
  23. package/packages/mailx-types/index.d.ts.map +1 -1
  24. package/packages/mailx-types/index.js +1 -1
  25. package/packages/mailx-types/index.js.map +1 -1
  26. package/packages/mailx-types/index.ts +2 -2
  27. package/packages/mailx-types/package.json +1 -1
  28. package/packages/mailx-types/trust.d.ts +26 -1
  29. package/packages/mailx-types/trust.d.ts.map +1 -1
  30. package/packages/mailx-types/trust.js +131 -5
  31. package/packages/mailx-types/trust.js.map +1 -1
  32. package/packages/mailx-types/trust.ts +155 -5
  33. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-65528 → node_modules.npmglobalize-stash-56556}/.package-lock.json +0 -0
package/.commitmsg CHANGED
@@ -1,63 +1,62 @@
1
- Say when a message is forged
2
-
3
- Two phishes arrived on 2026-08-25 and mailx rendered both without a word of
4
- warning ("this is an attack could you do a better job of identifying it as
5
- bogus?"). They carried, in the message itself:
6
-
7
- X-Spam-Flag: YES <- Bob's own server, score 6.7/4.5
8
- Authentication-Results: ... spf=fail
9
- From: bobip@bobf.frankston.com <- his own alias, forged
10
- Return-Path: <3EDTkWNvbY@visionsurveysqld.com.au>
11
- <area coords="0,0,10000,10000" href="http://.../redirect.php?newurl=...">
12
-
13
- Not one of those headers was read anywhere in the codebase — no X-Spam-*, no
14
- Authentication-Results, nothing. The one impersonation check that existed
15
- (displayNameClaimsOtherAddress) needs a display name, and these had none. Worse,
16
- the card was a cid: image, so hasRemoteContent was false and even the
17
- remote-content banner stayed away: the message displayed clean.
18
-
19
- The whole visible card was one hidden link. sanitizeHtml stripped their forms
20
- and inputs and left <area> untouched, so the trap was live in the reader —
21
- every pixel of the picture pointed at an open redirector on a real French
22
- company's site, which forwarded to a throwaway host with the victim's own
23
- address base64'd into the fragment so the landing page could prefill it.
24
-
25
- packages/mailx-types/trust.ts — assessMessageTrust, four checks, all evidence
26
- rather than scoring:
27
-
28
- server-spam-verdict The receiving server already decided. Free, and it was
29
- being thrown away. Only a POSITIVE verdict is trusted:
30
- anyone can inject X-Spam-Flag: NO into mail they send,
31
- and honouring that would hand attackers an off switch.
32
- A forged YES only flags the forger.
33
- self-spoof From is one of your own addresses AND authentication
34
- explicitly failed. Either half alone is ordinary
35
- people mail themselves notes, forwarded mail fails SPF.
36
- Together they are not. Own addresses come from the
37
- recipient allowlist plus Delivered-To, so an alias
38
- never added to the allowlist is still covered.
39
- hidden-link-overlay An <area> hotspot bigger than any real image. Runs on
40
- the RAW html, before the sanitizer disarms it.
41
- redirector-link A link whose query carries another absolute URL. Names
42
- the ENDPOINT a reader can't URL-decode a query string.
43
-
44
- Deliberately absent: bare SPF failure on mail not claiming to be you.
45
- Forwarded mail fails SPF constantly, and flagging it would be the cry-wolf
46
- banner this is trying not to be. Ordinary mail returns nothing at all — no
47
- "looks fine" reassurance, which would be a claim this cannot support.
48
-
49
- sanitizeHtml now also disarms image maps outright (<map> and usemap=).
50
- Unconditional rather than size-gated: image maps have no legitimate use in
51
- mail, so there is no threshold to tune and nothing to slip past by choosing a
52
- smaller rectangle. The image still renders; only its hidden clickability goes,
53
- and the banner explains why it stopped responding.
54
-
55
- The banner is its own element above the body, not part of the remote-content
56
- one the two are independent, as the cid: case proved and it never
57
- collapses. A reader who must expand a dropdown to learn a message is forged has
58
- already been asked to trust it. Each finding shows its evidence so it can be
59
- checked rather than believed.
60
-
61
- tests/trust.test.ts pins both phishes and, as importantly, the negative cases:
62
- a self-sent note with no auth headers, SPF failure on mail not claiming to be
63
- you, a modest image map, a same-site return-path parameter, and ordinary mail.
1
+ Catch the spam that scores under the threshold
2
+
3
+ A sextortion mail arrived that none of yesterday's checks caught ("yet this
4
+ spam got through"). SpamAssassin scored it 2.7 against a 4.5 threshold and
5
+ delivered it, so server-spam-verdict correctly stayed silent — the server said
6
+ no. Three independent proofs of forgery were sitting in the message anyway.
7
+
8
+ spf=none now counts as "not authenticated" for self-claimed mail, not just
9
+ fail. bobf.frankston.com publishes no SPF record, so nothing sent as that
10
+ domain can ever FAIL — only come back none. Treating none as neutral left the
11
+ least-protected domain the least defended, which is backwards. Checked against
12
+ the real store before changing it, 50,000 messages scanned:
13
+
14
+ From at one of Bob's domains 10,338
15
+ no Authentication-Results 10,034 <- his own mail, still ignored
16
+ spf=pass 243 <- still ignored
17
+ spf=fail / softfail 33
18
+ spf=none 18 <- nearly all spam
19
+
20
+ The 18 were "Settle your debt", "AAA Final notice", "McAfee Protection Plan
21
+ Ended" and similar. Legitimate self-mail sits in the no-header group: it never
22
+ crossed a trust boundary, so there is no Authentication-Results to read, and
23
+ that case is untouched. An explicit spf/dkim/dmarc=pass still silences the
24
+ check.
25
+
26
+ relay-auth-mismatch Bob's own catch: "note that the origin is different than
27
+ the from domain in antiabuse and in source-auth".
28
+
29
+ From: universe@bobf.frankston.com
30
+ X-Source-Auth: admin@calebjross.com
31
+ X-Source-Cap: (base64) calebjro;calebjro;box2202.bluehost.com
32
+ Message-ID: <...@calebjross.com>
33
+
34
+ A Bluehost cPanel account belonging to an unrelated domain authenticated and
35
+ sent mail wearing his address. That stamp is written by the RELAY, downstream
36
+ of whoever submitted the message, so a forger cannot remove or edit it — which
37
+ makes the disagreement about as close to proof as mail headers get. Compared at
38
+ the registrable-domain level, since a host legitimately sending for a domain
39
+ authenticates as some account AT that domain and the local part varies for
40
+ ordinary reasons. Covers the cPanel/Exim, Postfix and Communigate spellings.
41
+
42
+ zero-width-obfuscationthe body had ~1380 zero-width characters stuffed
43
+ between letters, so "i regret to inform you" reached every word-matching filter
44
+ as gibberish while rendering as clean prose. SpamAssassin saw it
45
+ (UNICODE_OBFU_ZW_MANY) and still totalled only 2.7. Counted only BETWEEN TWO
46
+ LATIN LETTERS, which is what obfuscation looks like and what the legitimate
47
+ uses are not: an emoji ZWJ joins pictographs, Arabic and Indic ZWNJ sit between
48
+ their own scripts' letters. The occurrence count guards against a stray
49
+ character surviving a copy-paste; it is a sanity bound, not a sensitivity dial.
50
+
51
+ The score itself is now always visible a chip above the body reading "spam
52
+ score 2.7 of 4.5" ("should we have a flag ... with the spam assassin number and
53
+ a color code?"). Separate from the findings on purpose: a finding is an
54
+ accusation and fires only on evidence, this is a measurement. Colour is a
55
+ PROPORTION of the server's own threshold red once flagged, amber past halfway
56
+ to its line, quiet below so it follows if the server's threshold changes
57
+ rather than hardcoding a number. Viewer only for now; the message-list row
58
+ would need the score stored at sync time.
59
+
60
+ tests/trust.test.ts covers all three, and the negative cases with them: spf=pass
61
+ and no-header both silent, a relay identity at the same organisation, an emoji
62
+ ZWJ family sequence, and a stray zero-width character from a paste.
@@ -2248,13 +2248,25 @@ function selfSpoof(input) {
2248
2248
  if (!own.has(from))
2249
2249
  return null;
2250
2250
  const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
2251
+ if (!auth)
2252
+ return null;
2251
2253
  const failed = [
2252
2254
  /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
2253
2255
  /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
2254
2256
  /\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : ""
2255
2257
  ].filter(Boolean);
2256
- if (!failed.length)
2257
- return null;
2258
+ if (!failed.length) {
2259
+ if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth))
2260
+ return null;
2261
+ const envelopeAddr = bare(header(input.headerLines, "return-path"));
2262
+ const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
2263
+ return {
2264
+ id: "self-spoof",
2265
+ severity: "danger",
2266
+ text: `This claims to be from your own address, ${from}, and nothing proves it came from your account.`,
2267
+ detail: `no SPF, DKIM or DMARC result to check against${note} \u2014 your domain publishes no SPF record, so forgery cannot be detected by the sending side`
2268
+ };
2269
+ }
2258
2270
  const envelope = bare(header(input.headerLines, "return-path"));
2259
2271
  const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
2260
2272
  return {
@@ -2326,17 +2338,73 @@ function assessMessageTrust(input) {
2326
2338
  const findings = [
2327
2339
  serverSpamVerdict(input.headerLines),
2328
2340
  selfSpoof(input),
2341
+ relayAuthMismatch(input),
2342
+ zeroWidthObfuscation(input.bodyText || ""),
2329
2343
  hiddenLinkOverlay(input.bodyHtml || ""),
2330
2344
  redirectorLink(input.bodyHtml || "")
2331
2345
  ].filter(Boolean);
2332
2346
  const rank = { danger: 0, caution: 1 };
2333
2347
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
2334
2348
  }
2335
- var OVERLAY_MIN_EDGE_PX;
2349
+ function relayAuthMismatch(input) {
2350
+ const from = bare(input.fromAddress);
2351
+ const fromDomain = from.split("@")[1] || "";
2352
+ if (!fromDomain)
2353
+ return null;
2354
+ for (const name of RELAY_IDENTITY_HEADERS) {
2355
+ const stamped = bare(header(input.headerLines, name));
2356
+ const stampedDomain = stamped.split("@")[1] || "";
2357
+ if (!stampedDomain || stampedDomain === fromDomain)
2358
+ continue;
2359
+ if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
2360
+ continue;
2361
+ return {
2362
+ id: "relay-auth-mismatch",
2363
+ severity: "danger",
2364
+ text: `The account that actually sent this belongs to someone else: ${stampedDomain}, not ${fromDomain}.`,
2365
+ detail: `the sending server recorded ${stamped} as the authenticated sender, while the message claims to be from ${from}`
2366
+ };
2367
+ }
2368
+ return null;
2369
+ }
2370
+ function zeroWidthObfuscation(bodyText) {
2371
+ if (!bodyText)
2372
+ return null;
2373
+ const between = bodyText.match(/[A-Za-z][​‌‍][A-Za-z]/g);
2374
+ const count = between ? between.length : 0;
2375
+ if (count < ZERO_WIDTH_MIN_OCCURRENCES)
2376
+ return null;
2377
+ return {
2378
+ id: "zero-width-obfuscation",
2379
+ severity: "danger",
2380
+ text: "The words in this message are stuffed with invisible characters to get past spam filters.",
2381
+ detail: `${count} zero-width characters hidden inside words \u2014 legitimate mail has no reason to do this`
2382
+ };
2383
+ }
2384
+ function spamScoreOf(headerLines) {
2385
+ const status = header(headerLines, "x-spam-status");
2386
+ if (!status)
2387
+ return null;
2388
+ const score = Number(status.match(/score=([-\d.]+)/)?.[1]);
2389
+ const threshold = Number(status.match(/required=([-\d.]+)/)?.[1]);
2390
+ if (!Number.isFinite(score) || !Number.isFinite(threshold))
2391
+ return null;
2392
+ return { score, threshold, flagged: /^yes/i.test(status) };
2393
+ }
2394
+ var OVERLAY_MIN_EDGE_PX, RELAY_IDENTITY_HEADERS, ZERO_WIDTH_MIN_OCCURRENCES;
2336
2395
  var init_trust = __esm({
2337
2396
  "packages/mailx-types/trust.js"() {
2338
2397
  "use strict";
2339
2398
  OVERLAY_MIN_EDGE_PX = 2e3;
2399
+ RELAY_IDENTITY_HEADERS = [
2400
+ "x-source-auth",
2401
+ // cPanel / Exim (Bluehost, HostGator, most shared hosts)
2402
+ "x-authenticated-sender",
2403
+ // Postfix / Communigate
2404
+ "x-auth-id",
2405
+ "x-authenticated-user"
2406
+ ];
2407
+ ZERO_WIDTH_MIN_OCCURRENCES = 12;
2340
2408
  }
2341
2409
  });
2342
2410
 
@@ -2739,6 +2807,7 @@ __export(mailx_types_exports, {
2739
2807
  setDraft: () => setDraft,
2740
2808
  setFlagged: () => setFlagged,
2741
2809
  setSeen: () => setSeen,
2810
+ spamScoreOf: () => spamScoreOf,
2742
2811
  splitRecipients: () => splitRecipients,
2743
2812
  toggleFlagged: () => toggleFlagged,
2744
2813
  toggleSeen: () => toggleSeen