@bobfrankston/rmfmail 1.2.281 → 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 -63
- package/client/android-bootstrap.bundle.js +72 -3
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +9 -0
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-viewer.js +23 -0
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +25 -0
- package/client/styles/components.css +17 -0
- package/npmchanges.md +66 -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/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts +4 -1
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +11 -3
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +14 -3
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/index.d.ts +2 -2
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js +1 -1
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +2 -2
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +26 -1
- package/packages/mailx-types/trust.d.ts.map +1 -1
- package/packages/mailx-types/trust.js +131 -5
- package/packages/mailx-types/trust.js.map +1 -1
- package/packages/mailx-types/trust.ts +155 -5
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-65528 → node_modules.npmglobalize-stash-56556}/.package-lock.json +0 -0
package/.commitmsg
CHANGED
|
@@ -1,63 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
you, a modest image map, a same-site return-path parameter, and ordinary mail.
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|