@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
package/.commitmsg CHANGED
@@ -1,19 +1,62 @@
1
- About: show the bundled editor version
2
-
3
- CORRECTION to 1.2.279. That release added the resolved tinymce / tinymce-i18n
4
- versions to build-tinymce.js's console output, on the claim that this put the
5
- answer in the build transcript every publish archives. It does not:
6
- npmglobalize swallows a sub-build's stdout and reports only "✓ Build
7
- succeeded", so the line never reaches c:\temp\rebuild\. The echo is still
8
- correct when the script is run directly, but the build log is not where this
9
- question gets answered.
10
-
11
- About… now carries an "Editor bundle" row — "TinyMCE 8.8.2 · 61 languages" —
12
- read at display time from client/lib/tinymce/package.json and langs/index.json,
13
- the same tree the editor loads from, so it cannot drift from what is actually
14
- running. That answers it on the machine showing the symptom, which is where it
15
- matters, rather than requiring a checkout of the matching tag.
16
-
17
- Why this keeps mattering: both specs are caret ranges, so the resolved version
18
- moves whenever npm re-resolves the tree — tinymce sat at 8.6.0 for weeks under
19
- a ^8.5.0 spec with nothing to notice.
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-obfuscation — the 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.
@@ -2199,6 +2199,215 @@ var init_contact_rules = __esm({
2199
2199
  }
2200
2200
  });
2201
2201
 
2202
+ // packages/mailx-types/trust.js
2203
+ function header(lines, name) {
2204
+ const want = name.toLowerCase();
2205
+ for (const h of lines) {
2206
+ if ((h.key || "").toLowerCase() !== want)
2207
+ continue;
2208
+ const colon = h.line.indexOf(":");
2209
+ return colon < 0 ? "" : h.line.slice(colon + 1).trim();
2210
+ }
2211
+ return "";
2212
+ }
2213
+ function headerAll(lines, name) {
2214
+ const want = name.toLowerCase();
2215
+ const out = [];
2216
+ for (const h of lines) {
2217
+ if ((h.key || "").toLowerCase() !== want)
2218
+ continue;
2219
+ const colon = h.line.indexOf(":");
2220
+ if (colon >= 0)
2221
+ out.push(h.line.slice(colon + 1).trim());
2222
+ }
2223
+ return out;
2224
+ }
2225
+ function bare(addr) {
2226
+ const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
2227
+ return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
2228
+ }
2229
+ function serverSpamVerdict(lines) {
2230
+ const flag = header(lines, "x-spam-flag");
2231
+ const status = header(lines, "x-spam-status");
2232
+ if (!/^yes/i.test(flag) && !/^yes/i.test(status))
2233
+ return null;
2234
+ const score = status.match(/score=([-\d.]+)/)?.[1];
2235
+ const required = status.match(/required=([-\d.]+)/)?.[1];
2236
+ return {
2237
+ id: "server-spam-verdict",
2238
+ severity: "danger",
2239
+ text: "Your mail server classified this as spam before delivering it.",
2240
+ detail: score && required ? `SpamAssassin score ${score}, threshold ${required}` : status || flag || "X-Spam-Flag: YES"
2241
+ };
2242
+ }
2243
+ function selfSpoof(input) {
2244
+ const from = bare(input.fromAddress);
2245
+ if (!from)
2246
+ return null;
2247
+ const own = new Set(input.ownAddresses.map(bare).filter(Boolean));
2248
+ if (!own.has(from))
2249
+ return null;
2250
+ const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
2251
+ if (!auth)
2252
+ return null;
2253
+ const failed = [
2254
+ /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
2255
+ /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
2256
+ /\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : ""
2257
+ ].filter(Boolean);
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
+ }
2270
+ const envelope = bare(header(input.headerLines, "return-path"));
2271
+ const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
2272
+ return {
2273
+ id: "self-spoof",
2274
+ severity: "danger",
2275
+ text: `This claims to be from your own address, ${from}, but it did not come from your account.`,
2276
+ detail: `${failed.join(" and ")} authentication failed${envelopeNote}`
2277
+ };
2278
+ }
2279
+ function hiddenLinkOverlay(bodyHtml) {
2280
+ for (const m of bodyHtml.matchAll(/<area\b[^>]*>/gi)) {
2281
+ const tag = m[0];
2282
+ const href = tag.match(/href\s*=\s*["']([^"']+)["']/i)?.[1];
2283
+ if (!href)
2284
+ continue;
2285
+ const coords = (tag.match(/coords\s*=\s*["']([^"']+)["']/i)?.[1] || "").split(/[,\s]+/).map(Number).filter((n) => Number.isFinite(n));
2286
+ if (coords.length < 4)
2287
+ continue;
2288
+ const width = Math.abs(coords[2] - coords[0]);
2289
+ const height = Math.abs(coords[3] - coords[1]);
2290
+ if (width < OVERLAY_MIN_EDGE_PX || height < OVERLAY_MIN_EDGE_PX)
2291
+ continue;
2292
+ let host = href;
2293
+ try {
2294
+ host = new URL(href).hostname;
2295
+ } catch {
2296
+ }
2297
+ return {
2298
+ id: "hidden-link-overlay",
2299
+ severity: "danger",
2300
+ text: "The picture in this message is one large hidden link \u2014 clicking anywhere on it goes to the same place.",
2301
+ detail: `${width}x${height} clickable area over the image, pointing at ${host}`
2302
+ };
2303
+ }
2304
+ return null;
2305
+ }
2306
+ function redirectorLink(bodyHtml) {
2307
+ for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
2308
+ let url;
2309
+ try {
2310
+ url = new URL(m[1]);
2311
+ } catch {
2312
+ continue;
2313
+ }
2314
+ if (!/^https?:$/.test(url.protocol))
2315
+ continue;
2316
+ for (const [, value] of url.searchParams) {
2317
+ let target;
2318
+ try {
2319
+ target = new URL(value);
2320
+ } catch {
2321
+ continue;
2322
+ }
2323
+ if (!/^https?:$/.test(target.protocol))
2324
+ continue;
2325
+ if (target.hostname === url.hostname)
2326
+ continue;
2327
+ return {
2328
+ id: "redirector-link",
2329
+ severity: "caution",
2330
+ text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
2331
+ detail: `${url.hostname} -> ${target.hostname}`
2332
+ };
2333
+ }
2334
+ }
2335
+ return null;
2336
+ }
2337
+ function assessMessageTrust(input) {
2338
+ const findings = [
2339
+ serverSpamVerdict(input.headerLines),
2340
+ selfSpoof(input),
2341
+ relayAuthMismatch(input),
2342
+ zeroWidthObfuscation(input.bodyText || ""),
2343
+ hiddenLinkOverlay(input.bodyHtml || ""),
2344
+ redirectorLink(input.bodyHtml || "")
2345
+ ].filter(Boolean);
2346
+ const rank = { danger: 0, caution: 1 };
2347
+ return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
2348
+ }
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;
2395
+ var init_trust = __esm({
2396
+ "packages/mailx-types/trust.js"() {
2397
+ "use strict";
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;
2408
+ }
2409
+ });
2410
+
2202
2411
  // packages/mailx-types/contacts-config.js
2203
2412
  function parseContactsConfig(raw) {
2204
2413
  if (!raw)
@@ -2570,6 +2779,7 @@ __export(mailx_types_exports, {
2570
2779
  addContactsDenylistEntry: () => addContactsDenylistEntry,
2571
2780
  addContactsPreferredEntry: () => addContactsPreferredEntry,
2572
2781
  answeredOf: () => answeredOf,
2782
+ assessMessageTrust: () => assessMessageTrust,
2573
2783
  buildMimeMessage: () => buildMimeMessage,
2574
2784
  deletedOf: () => deletedOf,
2575
2785
  displayNameClaimsOtherAddress: () => displayNameClaimsOtherAddress,
@@ -2597,6 +2807,7 @@ __export(mailx_types_exports, {
2597
2807
  setDraft: () => setDraft,
2598
2808
  setFlagged: () => setFlagged,
2599
2809
  setSeen: () => setSeen,
2810
+ spamScoreOf: () => spamScoreOf,
2600
2811
  splitRecipients: () => splitRecipients,
2601
2812
  toggleFlagged: () => toggleFlagged,
2602
2813
  toggleSeen: () => toggleSeen
@@ -2636,6 +2847,8 @@ function sanitizeHtml(html) {
2636
2847
  clean = clean.replace(/<select\b[^>]*>[\s\S]*?<\/select>/gi, "[blocked: select]");
2637
2848
  clean = clean.replace(/<button\b[^>]*>([\s\S]*?)<\/button>/gi, "$1");
2638
2849
  clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
2850
+ clean = clean.replace(/<map\b[^>]*>[\s\S]*?<\/map>/gi, "");
2851
+ clean = clean.replace(/\s+usemap\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
2639
2852
  return { html: clean, hasRemoteContent };
2640
2853
  }
2641
2854
  function encodeHeaderWord(text) {
@@ -3061,6 +3274,7 @@ var init_mailx_types = __esm({
3061
3274
  "packages/mailx-types/index.js"() {
3062
3275
  "use strict";
3063
3276
  init_contact_rules();
3277
+ init_trust();
3064
3278
  init_contacts_config();
3065
3279
  init_groups();
3066
3280
  init_reminder_state();