@bobfrankston/mailx-types 0.1.61 → 0.1.63
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/index.d.ts +2 -2
- package/index.js +1 -1
- package/package.json +1 -1
- package/trust.d.ts +26 -1
- package/trust.js +131 -5
package/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
6
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
7
|
-
export { assessMessageTrust } from "./trust.js";
|
|
8
|
-
export type { TrustFinding, TrustInput } from "./trust.js";
|
|
7
|
+
export { assessMessageTrust, spamScoreOf } from "./trust.js";
|
|
8
|
+
export type { TrustFinding, TrustInput, SpamScore } from "./trust.js";
|
|
9
9
|
export type { MailxApi } from "./mailx-api.js";
|
|
10
10
|
export { addContactsDenylistEntry, addContactsPreferredEntry, type PreferredContactEntry, type CloudReadFn, type CloudWriteFn, } from "./contacts-config.js";
|
|
11
11
|
export { expandRecipients, splitRecipients, isAddressToken, extractAddress, } from "./groups.js";
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// this barrel so a single source-of-truth (contact-rules.jsonc) drives
|
|
8
8
|
// junk-contact filtering on every platform.
|
|
9
9
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
10
|
-
export { assessMessageTrust } from "./trust.js";
|
|
10
|
+
export { assessMessageTrust, spamScoreOf } from "./trust.js";
|
|
11
11
|
// Shared contacts.jsonc mutations — one implementation for desktop + Android.
|
|
12
12
|
export { addContactsDenylistEntry, addContactsPreferredEntry, } from "./contacts-config.js";
|
|
13
13
|
// Group-name expansion for recipient fields. Lets users type a group name
|
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
/** One piece of evidence that a message is not what it claims to be. */
|
|
32
32
|
export interface TrustFinding {
|
|
33
33
|
/** Stable identifier — for tests, logs, and any future suppression UI. */
|
|
34
|
-
id: "server-spam-verdict" | "self-spoof" | "hidden-link-overlay" | "redirector-link";
|
|
34
|
+
id: "server-spam-verdict" | "self-spoof" | "relay-auth-mismatch" | "zero-width-obfuscation" | "hidden-link-overlay" | "redirector-link";
|
|
35
35
|
/** `danger`: near-certain forgery. `caution`: worth a second look. */
|
|
36
36
|
severity: "danger" | "caution";
|
|
37
37
|
/** One line, naming what was found. Never "this message is suspicious". */
|
|
@@ -54,6 +54,10 @@ export interface TrustInput {
|
|
|
54
54
|
ownAddresses: string[];
|
|
55
55
|
/** Sanitized HTML body, for the link-construct checks. */
|
|
56
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;
|
|
57
61
|
}
|
|
58
62
|
/**
|
|
59
63
|
* Everything findable about this message, worst first.
|
|
@@ -63,4 +67,25 @@ export interface TrustInput {
|
|
|
63
67
|
* evidence is not a clean bill of health.
|
|
64
68
|
*/
|
|
65
69
|
export declare function assessMessageTrust(input: TrustInput): TrustFinding[];
|
|
70
|
+
/**
|
|
71
|
+
* The receiving server's spam score, whether or not it crossed the threshold.
|
|
72
|
+
*
|
|
73
|
+
* Separate from the findings on purpose. A finding is an accusation and only
|
|
74
|
+
* fires on evidence; this is a measurement, and the reader asked to see the
|
|
75
|
+
* number itself (Bob 2026-08-25: "should we have a flag ... with the spam
|
|
76
|
+
* assassin number and a color code?"). The sextortion mail is exactly why:
|
|
77
|
+
* 2.7 against a 4.5 threshold is not "clean", it is "did not quite trip the
|
|
78
|
+
* wire", and those are very different things to a person deciding whether to
|
|
79
|
+
* trust a message.
|
|
80
|
+
*
|
|
81
|
+
* Returns null when the server left no score — most mail from providers that
|
|
82
|
+
* filter server-side and say nothing in the headers.
|
|
83
|
+
*/
|
|
84
|
+
export interface SpamScore {
|
|
85
|
+
score: number;
|
|
86
|
+
threshold: number;
|
|
87
|
+
/** The server's own yes/no, which is simply score >= threshold. */
|
|
88
|
+
flagged: boolean;
|
|
89
|
+
}
|
|
90
|
+
export declare function spamScoreOf(headerLines: TrustInput["headerLines"]): SpamScore;
|
|
66
91
|
//# sourceMappingURL=trust.d.ts.map
|
package/trust.js
CHANGED
|
@@ -96,9 +96,21 @@ function serverSpamVerdict(lines) {
|
|
|
96
96
|
* common shape of targeted phish, because a From line showing your own address
|
|
97
97
|
* defeats every visual check a reader makes.
|
|
98
98
|
*
|
|
99
|
-
* Requires an
|
|
100
|
-
*
|
|
101
|
-
*
|
|
99
|
+
* Requires an Authentication-Results header to exist. A message without one
|
|
100
|
+
* never crossed a trust boundary — it is your own mail, handled internally —
|
|
101
|
+
* and is not evidence of anything. That case is the overwhelming majority:
|
|
102
|
+
* 10,034 of the 10,338 self-claimed messages in a 50,000-message scan of Bob's
|
|
103
|
+
* store had no such header at all.
|
|
104
|
+
*
|
|
105
|
+
* `spf=none` counts as "not authenticated", not just `fail`. A domain with no
|
|
106
|
+
* SPF record published can never produce a failure, only a shrug — so treating
|
|
107
|
+
* `none` as neutral leaves the least-protected domain the least defended.
|
|
108
|
+
* bobf.frankston.com publishes no SPF, and that is precisely the hole the
|
|
109
|
+
* 2026-08-25 sextortion mail walked through: SpamAssassin scored it 2.7
|
|
110
|
+
* against a 4.5 threshold and delivered it. In that same 50,000-message scan
|
|
111
|
+
* `spf=none` on self-claimed mail occurred 18 times and was spam nearly every
|
|
112
|
+
* time ("Settle your debt", "AAA Final notice", "McAfee Protection Plan
|
|
113
|
+
* Ended"); legitimate self-mail was in the no-header group, untouched by this.
|
|
102
114
|
*/
|
|
103
115
|
function selfSpoof(input) {
|
|
104
116
|
const from = bare(input.fromAddress);
|
|
@@ -108,13 +120,27 @@ function selfSpoof(input) {
|
|
|
108
120
|
if (!own.has(from))
|
|
109
121
|
return null;
|
|
110
122
|
const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
|
|
123
|
+
if (!auth)
|
|
124
|
+
return null;
|
|
111
125
|
const failed = [
|
|
112
126
|
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
113
127
|
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
114
128
|
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
|
|
115
129
|
].filter(Boolean);
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
// Nothing failed, but did anything actually PASS? "spf=none dkim=none" is
|
|
131
|
+
// not a clean bill of health for a message claiming to be you.
|
|
132
|
+
if (!failed.length) {
|
|
133
|
+
if (/\b(spf|dkim|dmarc)=pass\b/i.test(auth))
|
|
134
|
+
return null;
|
|
135
|
+
const envelopeAddr = bare(header(input.headerLines, "return-path"));
|
|
136
|
+
const note = envelopeAddr && envelopeAddr !== from ? `; envelope sender ${envelopeAddr}` : "";
|
|
137
|
+
return {
|
|
138
|
+
id: "self-spoof",
|
|
139
|
+
severity: "danger",
|
|
140
|
+
text: `This claims to be from your own address, ${from}, and nothing proves it came from your account.`,
|
|
141
|
+
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`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
118
144
|
// Name the envelope sender when it disagrees — that is the address that
|
|
119
145
|
// actually sent this, and seeing it beside the claim is the whole story.
|
|
120
146
|
const envelope = bare(header(input.headerLines, "return-path"));
|
|
@@ -219,10 +245,110 @@ export function assessMessageTrust(input) {
|
|
|
219
245
|
const findings = [
|
|
220
246
|
serverSpamVerdict(input.headerLines),
|
|
221
247
|
selfSpoof(input),
|
|
248
|
+
relayAuthMismatch(input),
|
|
249
|
+
zeroWidthObfuscation(input.bodyText || ""),
|
|
222
250
|
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
223
251
|
redirectorLink(input.bodyHtml || ""),
|
|
224
252
|
].filter(Boolean);
|
|
225
253
|
const rank = { danger: 0, caution: 1 };
|
|
226
254
|
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
227
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Did the relay record a different account than the one the message claims?
|
|
258
|
+
*
|
|
259
|
+
* Shared hosts stamp the identity that actually authenticated to submit the
|
|
260
|
+
* message. That stamp is added by the RELAY, downstream of whoever sent it, so
|
|
261
|
+
* a forger cannot remove or edit it — which makes disagreement between the
|
|
262
|
+
* stamp and the From line about as close to proof as mail headers get.
|
|
263
|
+
*
|
|
264
|
+
* The 2026-08-25 sextortion mail (Bob: "note that the origin is different than
|
|
265
|
+
* the from domain in antiabuse and in source-auth"):
|
|
266
|
+
*
|
|
267
|
+
* From: universe@bobf.frankston.com
|
|
268
|
+
* X-Source-Auth: admin@calebjross.com
|
|
269
|
+
* X-Source-Cap: (base64) calebjro;calebjro;box2202.bluehost.com
|
|
270
|
+
* Message-ID: <...@calebjross.com>
|
|
271
|
+
*
|
|
272
|
+
* A Bluehost cPanel account belonging to an unrelated domain authenticated and
|
|
273
|
+
* sent mail wearing Bob's address. SpamAssassin scored the whole thing 2.7
|
|
274
|
+
* against a 4.5 threshold and let it through.
|
|
275
|
+
*
|
|
276
|
+
* Compared at the registrable-domain level, not the full address: a host that
|
|
277
|
+
* legitimately sends for a domain authenticates as some account AT that
|
|
278
|
+
* domain, and the local part varies for perfectly ordinary reasons.
|
|
279
|
+
*/
|
|
280
|
+
const RELAY_IDENTITY_HEADERS = [
|
|
281
|
+
"x-source-auth", // cPanel / Exim (Bluehost, HostGator, most shared hosts)
|
|
282
|
+
"x-authenticated-sender", // Postfix / Communigate
|
|
283
|
+
"x-auth-id",
|
|
284
|
+
"x-authenticated-user",
|
|
285
|
+
];
|
|
286
|
+
function relayAuthMismatch(input) {
|
|
287
|
+
const from = bare(input.fromAddress);
|
|
288
|
+
const fromDomain = from.split("@")[1] || "";
|
|
289
|
+
if (!fromDomain)
|
|
290
|
+
return null;
|
|
291
|
+
for (const name of RELAY_IDENTITY_HEADERS) {
|
|
292
|
+
const stamped = bare(header(input.headerLines, name));
|
|
293
|
+
const stampedDomain = stamped.split("@")[1] || "";
|
|
294
|
+
if (!stampedDomain || stampedDomain === fromDomain)
|
|
295
|
+
continue;
|
|
296
|
+
// Sub/parent-domain is the same organisation — mail.example.com
|
|
297
|
+
// sending for example.com is ordinary. Cross-organisation is not.
|
|
298
|
+
if (stampedDomain.endsWith("." + fromDomain) || fromDomain.endsWith("." + stampedDomain))
|
|
299
|
+
continue;
|
|
300
|
+
return {
|
|
301
|
+
id: "relay-auth-mismatch",
|
|
302
|
+
severity: "danger",
|
|
303
|
+
text: `The account that actually sent this belongs to someone else: ${stampedDomain}, not ${fromDomain}.`,
|
|
304
|
+
detail: `the sending server recorded ${stamped} as the authenticated sender, while the message claims to be from ${from}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Is the text stuffed with invisible characters to break word matching?
|
|
311
|
+
*
|
|
312
|
+
* Zero-width space, zero-width non-joiner, zero-width joiner and the
|
|
313
|
+
* zero-width no-break space render as nothing, so a reader sees plain prose
|
|
314
|
+
* while every filter matching on words sees gibberish. The 2026-08-25
|
|
315
|
+
* sextortion mail put one between nearly every pair of letters — "i regret to
|
|
316
|
+
* inform you" reached the parser as `i regret to inform you`. SpamAssassin
|
|
317
|
+
* spotted it (UNICODE_OBFU_ZW_MANY) and still scored the message 2.7 against a
|
|
318
|
+
* 4.5 threshold, so it was delivered.
|
|
319
|
+
*
|
|
320
|
+
* Counted only BETWEEN TWO LATIN LETTERS, which is what obfuscation looks like
|
|
321
|
+
* and what the legitimate uses do not: an emoji ZWJ sequence joins pictographs,
|
|
322
|
+
* Arabic and Indic ZWNJ sit between their own scripts' letters, and a soft
|
|
323
|
+
* hyphen marks a break opportunity rather than splitting a word mid-render.
|
|
324
|
+
*
|
|
325
|
+
* The count guards against a single stray character surviving a copy-paste out
|
|
326
|
+
* of a web page — it is a sanity bound, not a tuned dial. Prose does not
|
|
327
|
+
* accumulate dozens of these by accident; the sample message had over 900.
|
|
328
|
+
*/
|
|
329
|
+
const ZERO_WIDTH_MIN_OCCURRENCES = 12;
|
|
330
|
+
function zeroWidthObfuscation(bodyText) {
|
|
331
|
+
if (!bodyText)
|
|
332
|
+
return null;
|
|
333
|
+
const between = bodyText.match(/[A-Za-z][][A-Za-z]/g);
|
|
334
|
+
const count = between ? between.length : 0;
|
|
335
|
+
if (count < ZERO_WIDTH_MIN_OCCURRENCES)
|
|
336
|
+
return null;
|
|
337
|
+
return {
|
|
338
|
+
id: "zero-width-obfuscation",
|
|
339
|
+
severity: "danger",
|
|
340
|
+
text: "The words in this message are stuffed with invisible characters to get past spam filters.",
|
|
341
|
+
detail: `${count} zero-width characters hidden inside words — legitimate mail has no reason to do this`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
export function spamScoreOf(headerLines) {
|
|
345
|
+
const status = header(headerLines, "x-spam-status");
|
|
346
|
+
if (!status)
|
|
347
|
+
return null;
|
|
348
|
+
const score = Number(status.match(/score=([-\d.]+)/)?.[1]);
|
|
349
|
+
const threshold = Number(status.match(/required=([-\d.]+)/)?.[1]);
|
|
350
|
+
if (!Number.isFinite(score) || !Number.isFinite(threshold))
|
|
351
|
+
return null;
|
|
352
|
+
return { score, threshold, flagged: /^yes/i.test(status) };
|
|
353
|
+
}
|
|
228
354
|
//# sourceMappingURL=trust.js.map
|