@bobfrankston/rmfmail 1.2.281 → 1.2.283

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 +17 -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 +24 -0
  7. package/client/components/message-viewer.js.map +1 -1
  8. package/client/components/message-viewer.ts +26 -0
  9. package/client/styles/components.css +18 -0
  10. package/npmchanges.md +131 -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-23452}/.package-lock.json +0 -0
package/.commitmsg CHANGED
@@ -1,63 +1,17 @@
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
+ Show the spam score only when it means something
2
+
3
+ "Only show the spam score if it is high and likely." The first cut put a chip on
4
+ every message that carried a score, which is nearly all of them a badge that
5
+ is always there carries no information and turns into furniture the eye stops
6
+ reading, which is the same failure mode the findings themselves are built to
7
+ avoid.
8
+
9
+ The chip now renders only at or above halfway to the server's own threshold.
10
+ That floor is a proportion, not a constant, so it follows if the threshold
11
+ changes. Halfway is where "did not quite trip the wire" stops meaning the same
12
+ thing as "clean": the sextortion mail scored 2.7 against 4.5 and was delivered
13
+ unflagged, so showing only what the server flagged would miss precisely the
14
+ case worth showing. Two levels remain flagged, and past halfway — because
15
+ anything quieter no longer renders.
16
+
17
+ Findings are unaffected: they never depended on the score.
@@ -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