@bobfrankston/rmfmail 1.2.280 → 1.2.281
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.
- package/.commitmsg +63 -19
- package/client/android-bootstrap.bundle.js +145 -0
- package/client/android-bootstrap.bundle.js.map +3 -3
- package/client/app.bundle.js +16 -0
- package/client/app.bundle.js.map +3 -3
- package/client/components/message-viewer.js +27 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +29 -1
- package/client/styles/components.css +46 -0
- package/npmchanges.md +22 -0
- package/package.json +1 -1
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-settings/docs/editor.md +29 -3
- package/packages/mailx-settings/docs/preferences.md +2 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts +4 -0
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +18 -1
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +22 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/index.d.ts +2 -0
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js +18 -0
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +20 -0
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +66 -0
- package/packages/mailx-types/trust.d.ts.map +1 -0
- package/packages/mailx-types/trust.js +228 -0
- package/packages/mailx-types/trust.js.map +1 -0
- package/packages/mailx-types/trust.ts +239 -0
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-18028 → node_modules.npmglobalize-stash-65528}/.package-lock.json +0 -0
package/.commitmsg
CHANGED
|
@@ -1,19 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
@@ -2199,6 +2199,147 @@ 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
|
+
const failed = [
|
|
2252
|
+
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
2253
|
+
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
2254
|
+
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : ""
|
|
2255
|
+
].filter(Boolean);
|
|
2256
|
+
if (!failed.length)
|
|
2257
|
+
return null;
|
|
2258
|
+
const envelope = bare(header(input.headerLines, "return-path"));
|
|
2259
|
+
const envelopeNote = envelope && envelope !== from ? `; actually sent by ${envelope}` : "";
|
|
2260
|
+
return {
|
|
2261
|
+
id: "self-spoof",
|
|
2262
|
+
severity: "danger",
|
|
2263
|
+
text: `This claims to be from your own address, ${from}, but it did not come from your account.`,
|
|
2264
|
+
detail: `${failed.join(" and ")} authentication failed${envelopeNote}`
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
function hiddenLinkOverlay(bodyHtml) {
|
|
2268
|
+
for (const m of bodyHtml.matchAll(/<area\b[^>]*>/gi)) {
|
|
2269
|
+
const tag = m[0];
|
|
2270
|
+
const href = tag.match(/href\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
2271
|
+
if (!href)
|
|
2272
|
+
continue;
|
|
2273
|
+
const coords = (tag.match(/coords\s*=\s*["']([^"']+)["']/i)?.[1] || "").split(/[,\s]+/).map(Number).filter((n) => Number.isFinite(n));
|
|
2274
|
+
if (coords.length < 4)
|
|
2275
|
+
continue;
|
|
2276
|
+
const width = Math.abs(coords[2] - coords[0]);
|
|
2277
|
+
const height = Math.abs(coords[3] - coords[1]);
|
|
2278
|
+
if (width < OVERLAY_MIN_EDGE_PX || height < OVERLAY_MIN_EDGE_PX)
|
|
2279
|
+
continue;
|
|
2280
|
+
let host = href;
|
|
2281
|
+
try {
|
|
2282
|
+
host = new URL(href).hostname;
|
|
2283
|
+
} catch {
|
|
2284
|
+
}
|
|
2285
|
+
return {
|
|
2286
|
+
id: "hidden-link-overlay",
|
|
2287
|
+
severity: "danger",
|
|
2288
|
+
text: "The picture in this message is one large hidden link \u2014 clicking anywhere on it goes to the same place.",
|
|
2289
|
+
detail: `${width}x${height} clickable area over the image, pointing at ${host}`
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
return null;
|
|
2293
|
+
}
|
|
2294
|
+
function redirectorLink(bodyHtml) {
|
|
2295
|
+
for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
|
|
2296
|
+
let url;
|
|
2297
|
+
try {
|
|
2298
|
+
url = new URL(m[1]);
|
|
2299
|
+
} catch {
|
|
2300
|
+
continue;
|
|
2301
|
+
}
|
|
2302
|
+
if (!/^https?:$/.test(url.protocol))
|
|
2303
|
+
continue;
|
|
2304
|
+
for (const [, value] of url.searchParams) {
|
|
2305
|
+
let target;
|
|
2306
|
+
try {
|
|
2307
|
+
target = new URL(value);
|
|
2308
|
+
} catch {
|
|
2309
|
+
continue;
|
|
2310
|
+
}
|
|
2311
|
+
if (!/^https?:$/.test(target.protocol))
|
|
2312
|
+
continue;
|
|
2313
|
+
if (target.hostname === url.hostname)
|
|
2314
|
+
continue;
|
|
2315
|
+
return {
|
|
2316
|
+
id: "redirector-link",
|
|
2317
|
+
severity: "caution",
|
|
2318
|
+
text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
|
|
2319
|
+
detail: `${url.hostname} -> ${target.hostname}`
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
function assessMessageTrust(input) {
|
|
2326
|
+
const findings = [
|
|
2327
|
+
serverSpamVerdict(input.headerLines),
|
|
2328
|
+
selfSpoof(input),
|
|
2329
|
+
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
2330
|
+
redirectorLink(input.bodyHtml || "")
|
|
2331
|
+
].filter(Boolean);
|
|
2332
|
+
const rank = { danger: 0, caution: 1 };
|
|
2333
|
+
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
2334
|
+
}
|
|
2335
|
+
var OVERLAY_MIN_EDGE_PX;
|
|
2336
|
+
var init_trust = __esm({
|
|
2337
|
+
"packages/mailx-types/trust.js"() {
|
|
2338
|
+
"use strict";
|
|
2339
|
+
OVERLAY_MIN_EDGE_PX = 2e3;
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2202
2343
|
// packages/mailx-types/contacts-config.js
|
|
2203
2344
|
function parseContactsConfig(raw) {
|
|
2204
2345
|
if (!raw)
|
|
@@ -2570,6 +2711,7 @@ __export(mailx_types_exports, {
|
|
|
2570
2711
|
addContactsDenylistEntry: () => addContactsDenylistEntry,
|
|
2571
2712
|
addContactsPreferredEntry: () => addContactsPreferredEntry,
|
|
2572
2713
|
answeredOf: () => answeredOf,
|
|
2714
|
+
assessMessageTrust: () => assessMessageTrust,
|
|
2573
2715
|
buildMimeMessage: () => buildMimeMessage,
|
|
2574
2716
|
deletedOf: () => deletedOf,
|
|
2575
2717
|
displayNameClaimsOtherAddress: () => displayNameClaimsOtherAddress,
|
|
@@ -2636,6 +2778,8 @@ function sanitizeHtml(html) {
|
|
|
2636
2778
|
clean = clean.replace(/<select\b[^>]*>[\s\S]*?<\/select>/gi, "[blocked: select]");
|
|
2637
2779
|
clean = clean.replace(/<button\b[^>]*>([\s\S]*?)<\/button>/gi, "$1");
|
|
2638
2780
|
clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
|
|
2781
|
+
clean = clean.replace(/<map\b[^>]*>[\s\S]*?<\/map>/gi, "");
|
|
2782
|
+
clean = clean.replace(/\s+usemap\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
2639
2783
|
return { html: clean, hasRemoteContent };
|
|
2640
2784
|
}
|
|
2641
2785
|
function encodeHeaderWord(text) {
|
|
@@ -3061,6 +3205,7 @@ var init_mailx_types = __esm({
|
|
|
3061
3205
|
"packages/mailx-types/index.js"() {
|
|
3062
3206
|
"use strict";
|
|
3063
3207
|
init_contact_rules();
|
|
3208
|
+
init_trust();
|
|
3064
3209
|
init_contacts_config();
|
|
3065
3210
|
init_groups();
|
|
3066
3211
|
init_reminder_state();
|