@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.
- package/.commitmsg +62 -19
- package/client/android-bootstrap.bundle.js +214 -0
- package/client/android-bootstrap.bundle.js.map +3 -3
- package/client/app.bundle.js +25 -0
- package/client/app.bundle.js.map +3 -3
- package/client/components/message-viewer.js +50 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +54 -1
- package/client/styles/components.css +63 -0
- package/npmchanges.md +88 -0
- package/package.json +5 -5
- 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 +7 -0
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +26 -1
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +33 -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 +91 -0
- package/packages/mailx-types/trust.d.ts.map +1 -0
- package/packages/mailx-types/trust.js +354 -0
- package/packages/mailx-types/trust.js.map +1 -0
- package/packages/mailx-types/trust.ts +389 -0
- /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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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();
|