@bobfrankston/rmfmail 1.2.280 → 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 (35) hide show
  1. package/.commitmsg +62 -19
  2. package/client/android-bootstrap.bundle.js +214 -0
  3. package/client/android-bootstrap.bundle.js.map +3 -3
  4. package/client/app.bundle.js +25 -0
  5. package/client/app.bundle.js.map +3 -3
  6. package/client/components/message-viewer.js +50 -1
  7. package/client/components/message-viewer.js.map +1 -1
  8. package/client/components/message-viewer.ts +54 -1
  9. package/client/styles/components.css +63 -0
  10. package/npmchanges.md +88 -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/docs/editor.md +29 -3
  15. package/packages/mailx-settings/docs/preferences.md +2 -1
  16. package/packages/mailx-settings/package.json +1 -1
  17. package/packages/mailx-store/package.json +1 -1
  18. package/packages/mailx-store/store.d.ts +7 -0
  19. package/packages/mailx-store/store.d.ts.map +1 -1
  20. package/packages/mailx-store/store.js +26 -1
  21. package/packages/mailx-store/store.js.map +1 -1
  22. package/packages/mailx-store/store.ts +33 -1
  23. package/packages/mailx-store-web/package.json +1 -1
  24. package/packages/mailx-types/index.d.ts +2 -0
  25. package/packages/mailx-types/index.d.ts.map +1 -1
  26. package/packages/mailx-types/index.js +18 -0
  27. package/packages/mailx-types/index.js.map +1 -1
  28. package/packages/mailx-types/index.ts +20 -0
  29. package/packages/mailx-types/package.json +1 -1
  30. package/packages/mailx-types/trust.d.ts +91 -0
  31. package/packages/mailx-types/trust.d.ts.map +1 -0
  32. package/packages/mailx-types/trust.js +354 -0
  33. package/packages/mailx-types/trust.js.map +1 -0
  34. package/packages/mailx-types/trust.ts +389 -0
  35. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-18028 → node_modules.npmglobalize-stash-56556}/.package-lock.json +0 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Why a message looks forged.
3
+ *
4
+ * Not a spam filter — a spam filter guesses, and a guess that fires on real
5
+ * mail teaches you to click past it. Everything here is EVIDENCE: a verdict
6
+ * your own mail server already reached, or a construct in the message that
7
+ * legitimate mail does not contain. Each finding names what was found and what
8
+ * it means, so the reader can check it rather than trust a score.
9
+ *
10
+ * Written 2026-08-25 after two phishes landed looking clean in the reader
11
+ * (Bob: "this is an attack — could you do a better job of identifying it as
12
+ * bogus?"). Both carried all of the following, and mailx surfaced NONE of it:
13
+ *
14
+ * X-Spam-Flag: YES <- his own server, score 6.7/4.5
15
+ * Authentication-Results: ... spf=fail
16
+ * From: bobip@bobf.frankston.com <- his own address, forged
17
+ * To: bobip@bobf.frankston.com
18
+ * Return-Path: <3EDTkWNvbY@visionsurveysqld.com.au>
19
+ * <area coords="0,0,10000,10000" href="http://.../redirect.php?newurl=...">
20
+ *
21
+ * No header in that list was read anywhere in the codebase, and the one
22
+ * impersonation check that existed (displayNameClaimsOtherAddress) needs a
23
+ * display name, which these messages did not have. The whole visible card was
24
+ * one hidden link through an open redirector, with the victim's own address
25
+ * base64'd into the fragment so the landing page could prefill it.
26
+ *
27
+ * Deliberately NOT included: bare SPF failure on mail that isn't claiming to
28
+ * be you. Forwarded mail fails SPF constantly, and flagging that would be
29
+ * exactly the cry-wolf banner this is trying not to be.
30
+ */
31
+
32
+ /** One piece of evidence that a message is not what it claims to be. */
33
+ export interface TrustFinding {
34
+ /** Stable identifier — for tests, logs, and any future suppression UI. */
35
+ id: "server-spam-verdict" | "self-spoof" | "relay-auth-mismatch"
36
+ | "zero-width-obfuscation" | "hidden-link-overlay" | "redirector-link";
37
+ /** `danger`: near-certain forgery. `caution`: worth a second look. */
38
+ severity: "danger" | "caution";
39
+ /** One line, naming what was found. Never "this message is suspicious". */
40
+ text: string;
41
+ /** The specific evidence — a header value, a host, a score. */
42
+ detail: string;
43
+ }
44
+
45
+ export interface TrustInput {
46
+ /** Every header line, in order, as mailparser's `headerLines`. Repeated
47
+ * headers matter: Authentication-Results and Received both appear more
48
+ * than once and the interesting one is not always the first. */
49
+ headerLines: { key: string; line: string }[];
50
+ /** Envelope From address. */
51
+ fromAddress: string;
52
+ /** Addresses belonging to the reader — their own aliases. Used only to
53
+ * recognise a message claiming to BE them. */
54
+ ownAddresses: string[];
55
+ /** Sanitized HTML body, for the link-construct checks. */
56
+ bodyHtml: string;
57
+ /** Plain-text body, for the character-level checks. Separate from the HTML
58
+ * because markup legitimately contains things prose does not, and the
59
+ * zero-width test would trip over an entity or an attribute value. */
60
+ bodyText?: string;
61
+ }
62
+
63
+ /** First value of a header, case-insensitive. */
64
+ function header(lines: TrustInput["headerLines"], name: string): string {
65
+ const want = name.toLowerCase();
66
+ for (const h of lines) {
67
+ if ((h.key || "").toLowerCase() !== want) continue;
68
+ const colon = h.line.indexOf(":");
69
+ return colon < 0 ? "" : h.line.slice(colon + 1).trim();
70
+ }
71
+ return "";
72
+ }
73
+
74
+ /** All values of a repeated header. */
75
+ function headerAll(lines: TrustInput["headerLines"], name: string): string[] {
76
+ const want = name.toLowerCase();
77
+ const out: string[] = [];
78
+ for (const h of lines) {
79
+ if ((h.key || "").toLowerCase() !== want) continue;
80
+ const colon = h.line.indexOf(":");
81
+ if (colon >= 0) out.push(h.line.slice(colon + 1).trim());
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function bare(addr: string): string {
87
+ const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
88
+ return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
89
+ }
90
+
91
+ /**
92
+ * Did the receiving server's spam filter already say yes?
93
+ *
94
+ * The highest-value signal available, and it costs nothing: the mail server
95
+ * ran SpamAssassin before delivery and wrote the answer into the message.
96
+ * mailx was throwing it away.
97
+ *
98
+ * Only a POSITIVE verdict is trusted, never a negative one. Anyone can inject
99
+ * `X-Spam-Flag: NO` into a message they send, and treating that as a clean
100
+ * bill of health would hand attackers a switch for turning the check off. A
101
+ * forged YES only flags the forger's own mail, so the asymmetry is safe.
102
+ */
103
+ function serverSpamVerdict(lines: TrustInput["headerLines"]): TrustFinding {
104
+ const flag = header(lines, "x-spam-flag");
105
+ const status = header(lines, "x-spam-status");
106
+ if (!/^yes/i.test(flag) && !/^yes/i.test(status)) return null;
107
+
108
+ // "Yes, score=6.7 required=4.5 tests=..." — quote the numbers when they
109
+ // are there, so the reader sees how emphatic the verdict was.
110
+ const score = status.match(/score=([-\d.]+)/)?.[1];
111
+ const required = status.match(/required=([-\d.]+)/)?.[1];
112
+ return {
113
+ id: "server-spam-verdict",
114
+ severity: "danger",
115
+ text: "Your mail server classified this as spam before delivering it.",
116
+ detail: score && required
117
+ ? `SpamAssassin score ${score}, threshold ${required}`
118
+ : (status || flag || "X-Spam-Flag: YES"),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Does the message claim to come from the reader themselves, while failing the
124
+ * authentication that would prove it?
125
+ *
126
+ * Either half alone is ordinary — people really do mail themselves notes, and
127
+ * forwarded mail really does fail SPF. Together they are not: mail genuinely
128
+ * from your own account authenticates as your own account. This is the most
129
+ * common shape of targeted phish, because a From line showing your own address
130
+ * defeats every visual check a reader makes.
131
+ *
132
+ * Requires an Authentication-Results header to exist. A message without one
133
+ * never crossed a trust boundary — it is your own mail, handled internally —
134
+ * and is not evidence of anything. That case is the overwhelming majority:
135
+ * 10,034 of the 10,338 self-claimed messages in a 50,000-message scan of Bob's
136
+ * store had no such header at all.
137
+ *
138
+ * `spf=none` counts as "not authenticated", not just `fail`. A domain with no
139
+ * SPF record published can never produce a failure, only a shrug — so treating
140
+ * `none` as neutral leaves the least-protected domain the least defended.
141
+ * bobf.frankston.com publishes no SPF, and that is precisely the hole the
142
+ * 2026-08-25 sextortion mail walked through: SpamAssassin scored it 2.7
143
+ * against a 4.5 threshold and delivered it. In that same 50,000-message scan
144
+ * `spf=none` on self-claimed mail occurred 18 times and was spam nearly every
145
+ * time ("Settle your debt", "AAA Final notice", "McAfee Protection Plan
146
+ * Ended"); legitimate self-mail was in the no-header group, untouched by this.
147
+ */
148
+ function selfSpoof(input: TrustInput): TrustFinding {
149
+ const from = bare(input.fromAddress);
150
+ if (!from) return null;
151
+ const own = new Set(input.ownAddresses.map(bare).filter(Boolean));
152
+ if (!own.has(from)) return null;
153
+
154
+ const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
155
+ if (!auth) return null;
156
+ const failed = [
157
+ /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
158
+ /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
159
+ /\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
160
+ ].filter(Boolean);
161
+ // Nothing failed, but did anything actually PASS? "spf=none dkim=none" is
162
+ // not a clean bill of health for a message claiming to be you.
163
+ if (!failed.length) {
164
+ if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth)) return null;
165
+ const envelopeAddr = bare(header(input.headerLines, "return-path"));
166
+ const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
167
+ return {
168
+ id: "self-spoof",
169
+ severity: "danger",
170
+ text: `This claims to be from your own address, ${from}, and nothing proves it came from your account.`,
171
+ detail: `no SPF, DKIM or DMARC result to check against${note} — your domain publishes no SPF record, so forgery cannot be detected by the sending side`,
172
+ };
173
+ }
174
+
175
+ // Name the envelope sender when it disagrees — that is the address that
176
+ // actually sent this, and seeing it beside the claim is the whole story.
177
+ const envelope = bare(header(input.headerLines, "return-path"));
178
+ const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
179
+ return {
180
+ id: "self-spoof",
181
+ severity: "danger",
182
+ text: `This claims to be from your own address, ${from}, but it did not come from your account.`,
183
+ detail: `${failed.join(" and ")} authentication failed${envelopeNote}`,
184
+ };
185
+ }
186
+
187
+ /** Bigger than any real image. An honest layout never needs a hotspot this
188
+ * large; a forger picks an absurd number so they don't have to know the
189
+ * rendered size. Not a tuned threshold — a sanity bound. */
190
+ const OVERLAY_MIN_EDGE_PX = 2000;
191
+
192
+ /**
193
+ * Is an entire image covered by one invisible link?
194
+ *
195
+ * `<area coords="0,0,10000,10000">` turns every pixel of an image into a link
196
+ * with no underlined text, no visible URL, and no safe area to click. Real
197
+ * mail uses image maps almost never, and never at that size.
198
+ */
199
+ function hiddenLinkOverlay(bodyHtml: string): TrustFinding {
200
+ for (const m of bodyHtml.matchAll(/<area\b[^>]*>/gi)) {
201
+ const tag = m[0];
202
+ const href = tag.match(/href\s*=\s*["']([^"']+)["']/i)?.[1];
203
+ if (!href) continue;
204
+ const coords = (tag.match(/coords\s*=\s*["']([^"']+)["']/i)?.[1] || "")
205
+ .split(/[,\s]+/).map(Number).filter(n => Number.isFinite(n));
206
+ if (coords.length < 4) continue;
207
+ const width = Math.abs(coords[2] - coords[0]);
208
+ const height = Math.abs(coords[3] - coords[1]);
209
+ if (width < OVERLAY_MIN_EDGE_PX || height < OVERLAY_MIN_EDGE_PX) continue;
210
+ let host = href;
211
+ try { host = new URL(href).hostname; } catch { /* keep the raw href */ }
212
+ return {
213
+ id: "hidden-link-overlay",
214
+ severity: "danger",
215
+ text: "The picture in this message is one large hidden link — clicking anywhere on it goes to the same place.",
216
+ detail: `${width}x${height} clickable area over the image, pointing at ${host}`,
217
+ };
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Does a link route through a redirector carrying its real destination in the
224
+ * query string?
225
+ *
226
+ * `castanet-sa.fr/modules/babel/redirect.php?newurl=https%3A%2F%2Fevil.example`
227
+ * borrows a real site's reputation: the visible host is a legitimate business
228
+ * whose redirect script was left open, and the destination appears only
229
+ * percent-encoded inside a parameter. Reporting the ENDPOINT is the point — a
230
+ * reader cannot be expected to URL-decode a query string.
231
+ */
232
+ function redirectorLink(bodyHtml: string): TrustFinding {
233
+ for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
234
+ let url: URL;
235
+ try { url = new URL(m[1]); } catch { continue; }
236
+ if (!/^https?:$/.test(url.protocol)) continue;
237
+ for (const [, value] of url.searchParams) {
238
+ let target: URL;
239
+ try { target = new URL(value); } catch { continue; }
240
+ if (!/^https?:$/.test(target.protocol)) continue;
241
+ if (target.hostname === url.hostname) continue; // same-site, not laundering
242
+ return {
243
+ id: "redirector-link",
244
+ severity: "caution",
245
+ text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
246
+ detail: `${url.hostname} -> ${target.hostname}`,
247
+ };
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Everything findable about this message, worst first.
255
+ *
256
+ * Returns an empty array for ordinary mail — the caller then shows nothing,
257
+ * rather than a reassuring "looks fine" this cannot support. Absence of
258
+ * evidence is not a clean bill of health.
259
+ */
260
+ export function assessMessageTrust(input: TrustInput): TrustFinding[] {
261
+ const findings = [
262
+ serverSpamVerdict(input.headerLines),
263
+ selfSpoof(input),
264
+ relayAuthMismatch(input),
265
+ zeroWidthObfuscation(input.bodyText || ""),
266
+ hiddenLinkOverlay(input.bodyHtml || ""),
267
+ redirectorLink(input.bodyHtml || ""),
268
+ ].filter(Boolean);
269
+ const rank = { danger: 0, caution: 1 };
270
+ return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
271
+ }
272
+
273
+ /**
274
+ * Did the relay record a different account than the one the message claims?
275
+ *
276
+ * Shared hosts stamp the identity that actually authenticated to submit the
277
+ * message. That stamp is added by the RELAY, downstream of whoever sent it, so
278
+ * a forger cannot remove or edit it — which makes disagreement between the
279
+ * stamp and the From line about as close to proof as mail headers get.
280
+ *
281
+ * The 2026-08-25 sextortion mail (Bob: "note that the origin is different than
282
+ * the from domain in antiabuse and in source-auth"):
283
+ *
284
+ * From: universe@bobf.frankston.com
285
+ * X-Source-Auth: admin@calebjross.com
286
+ * X-Source-Cap: (base64) calebjro;calebjro;box2202.bluehost.com
287
+ * Message-ID: <...@calebjross.com>
288
+ *
289
+ * A Bluehost cPanel account belonging to an unrelated domain authenticated and
290
+ * sent mail wearing Bob's address. SpamAssassin scored the whole thing 2.7
291
+ * against a 4.5 threshold and let it through.
292
+ *
293
+ * Compared at the registrable-domain level, not the full address: a host that
294
+ * legitimately sends for a domain authenticates as some account AT that
295
+ * domain, and the local part varies for perfectly ordinary reasons.
296
+ */
297
+ const RELAY_IDENTITY_HEADERS = [
298
+ "x-source-auth", // cPanel / Exim (Bluehost, HostGator, most shared hosts)
299
+ "x-authenticated-sender", // Postfix / Communigate
300
+ "x-auth-id",
301
+ "x-authenticated-user",
302
+ ];
303
+
304
+ function relayAuthMismatch(input: TrustInput): TrustFinding {
305
+ const from = bare(input.fromAddress);
306
+ const fromDomain = from.split("@")[1] || "";
307
+ if (!fromDomain) return null;
308
+
309
+ for (const name of RELAY_IDENTITY_HEADERS) {
310
+ const stamped = bare(header(input.headerLines, name));
311
+ const stampedDomain = stamped.split("@")[1] || "";
312
+ if (!stampedDomain || stampedDomain === fromDomain) continue;
313
+ // Sub/parent-domain is the same organisation — mail.example.com
314
+ // sending for example.com is ordinary. Cross-organisation is not.
315
+ if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain)) continue;
316
+ return {
317
+ id: "relay-auth-mismatch",
318
+ severity: "danger",
319
+ text: `The account that actually sent this belongs to someone else: ${stampedDomain}, not ${fromDomain}.`,
320
+ detail: `the sending server recorded ${stamped} as the authenticated sender, while the message claims to be from ${from}`,
321
+ };
322
+ }
323
+ return null;
324
+ }
325
+
326
+ /**
327
+ * Is the text stuffed with invisible characters to break word matching?
328
+ *
329
+ * Zero-width space, zero-width non-joiner, zero-width joiner and the
330
+ * zero-width no-break space render as nothing, so a reader sees plain prose
331
+ * while every filter matching on words sees gibberish. The 2026-08-25
332
+ * sextortion mail put one between nearly every pair of letters — "i regret to
333
+ * inform you" reached the parser as `i re‍gret t‌o ‌in​f‌orm​ you`. SpamAssassin
334
+ * spotted it (UNICODE_OBFU_ZW_MANY) and still scored the message 2.7 against a
335
+ * 4.5 threshold, so it was delivered.
336
+ *
337
+ * Counted only BETWEEN TWO LATIN LETTERS, which is what obfuscation looks like
338
+ * and what the legitimate uses do not: an emoji ZWJ sequence joins pictographs,
339
+ * Arabic and Indic ZWNJ sit between their own scripts' letters, and a soft
340
+ * hyphen marks a break opportunity rather than splitting a word mid-render.
341
+ *
342
+ * The count guards against a single stray character surviving a copy-paste out
343
+ * of a web page — it is a sanity bound, not a tuned dial. Prose does not
344
+ * accumulate dozens of these by accident; the sample message had over 900.
345
+ */
346
+ const ZERO_WIDTH_MIN_OCCURRENCES = 12;
347
+
348
+ function zeroWidthObfuscation(bodyText: string): TrustFinding {
349
+ if (!bodyText) return null;
350
+ const between = bodyText.match(/[A-Za-z][​‌‍][A-Za-z]/g);
351
+ const count = between ? between.length : 0;
352
+ if (count < ZERO_WIDTH_MIN_OCCURRENCES) return null;
353
+ return {
354
+ id: "zero-width-obfuscation",
355
+ severity: "danger",
356
+ text: "The words in this message are stuffed with invisible characters to get past spam filters.",
357
+ detail: `${count} zero-width characters hidden inside words — legitimate mail has no reason to do this`,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * The receiving server's spam score, whether or not it crossed the threshold.
363
+ *
364
+ * Separate from the findings on purpose. A finding is an accusation and only
365
+ * fires on evidence; this is a measurement, and the reader asked to see the
366
+ * number itself (Bob 2026-08-25: "should we have a flag ... with the spam
367
+ * assassin number and a color code?"). The sextortion mail is exactly why:
368
+ * 2.7 against a 4.5 threshold is not "clean", it is "did not quite trip the
369
+ * wire", and those are very different things to a person deciding whether to
370
+ * trust a message.
371
+ *
372
+ * Returns null when the server left no score — most mail from providers that
373
+ * filter server-side and say nothing in the headers.
374
+ */
375
+ export interface SpamScore {
376
+ score: number;
377
+ threshold: number;
378
+ /** The server's own yes/no, which is simply score >= threshold. */
379
+ flagged: boolean;
380
+ }
381
+
382
+ export function spamScoreOf(headerLines: TrustInput["headerLines"]): SpamScore {
383
+ const status = header(headerLines, "x-spam-status");
384
+ if (!status) return null;
385
+ const score = Number(status.match(/score=([-\d.]+)/)?.[1]);
386
+ const threshold = Number(status.match(/required=([-\d.]+)/)?.[1]);
387
+ if (!Number.isFinite(score) || !Number.isFinite(threshold)) return null;
388
+ return { score, threshold, flagged: /^yes/i.test(status) };
389
+ }