@bobfrankston/mailx-types 0.1.63 → 0.1.67

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 (3) hide show
  1. package/package.json +1 -1
  2. package/trust.d.ts +16 -3
  3. package/trust.js +301 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.63",
3
+ "version": "0.1.67",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/trust.d.ts CHANGED
@@ -32,8 +32,10 @@
32
32
  export interface TrustFinding {
33
33
  /** Stable identifier — for tests, logs, and any future suppression UI. */
34
34
  id: "server-spam-verdict" | "self-spoof" | "relay-auth-mismatch" | "zero-width-obfuscation" | "hidden-link-overlay" | "redirector-link";
35
- /** `danger`: near-certain forgery. `caution`: worth a second look. */
36
- severity: "danger" | "caution";
35
+ /** `danger`: near-certain forgery. `caution`: worth a second look.
36
+ * `info`: something the server said, in a register that is not an
37
+ * accusation — a bulk-mail verdict on a sender DMARC proved. */
38
+ severity: "danger" | "caution" | "info";
37
39
  /** One line, naming what was found. Never "this message is suspicious". */
38
40
  text: string;
39
41
  /** The specific evidence — a header value, a host, a score. */
@@ -86,6 +88,17 @@ export interface SpamScore {
86
88
  threshold: number;
87
89
  /** The server's own yes/no, which is simply score >= threshold. */
88
90
  flagged: boolean;
91
+ /** What the rules that actually scored are evidence OF. `bulk` and a
92
+ * `proved` sender are the two ways a high number can be honest mail —
93
+ * the chip stays visible and says so, rather than going red at a number
94
+ * and teaching the reader to ignore it. (Claude Code 2026-08-27) */
95
+ kind: "forgery" | "association" | "bulk" | "unclassified";
96
+ /** The From domain passed DMARC, aligned, at the receiving server. */
97
+ proved: boolean;
98
+ /** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
99
+ * listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
100
+ * this can be judged in a second. */
101
+ reasons: string[];
89
102
  }
90
- export declare function spamScoreOf(headerLines: TrustInput["headerLines"]): SpamScore;
103
+ export declare function spamScoreOf(input: TrustInput): SpamScore;
91
104
  //# sourceMappingURL=trust.d.ts.map
package/trust.js CHANGED
@@ -56,8 +56,233 @@ function bare(addr) {
56
56
  const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
57
57
  return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
58
58
  }
59
+ /** Rules that say THIS message lies about who sent it. The only class that
60
+ * earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
61
+ * 0.0-scoring status marker that fired on Mokin, a message the receiving
62
+ * server had just recorded as `dmarc=pass`. */
63
+ const FORGERY_RULES = [
64
+ /^SPF_(HELO_)?(FAIL|SOFTFAIL)$/,
65
+ /^(KAM_)?DMARC_(FAIL|REJECT|QUAR)/,
66
+ /^DKIM_ADSP_(ALL|DISCARD|NXDOMAIN)$/,
67
+ /^FORGED_/,
68
+ /SPOOF/,
69
+ /_PHISH/, /PHISHING/, /_MALW/, /MALWARE/,
70
+ ];
71
+ /** Reputation a third party holds about some host the message mentions or
72
+ * travelled through. Real evidence the mail is unwanted; no evidence at all
73
+ * that the From line is false — the listed party is usually not the sender. */
74
+ const ASSOCIATION_RULES = [
75
+ /^URIBL_(SBL|BLACK|RED|DBL|ABUSE)/,
76
+ /^SH_DBL/,
77
+ /^SEM_URIBL/,
78
+ /^SPAMHAUS_/,
79
+ /^RCVD_IN_(SBL|XBL|PBL|BL_SPAMCOP|SORBS|BRBL|VALIDITY|MSPIKE_[LZ])/,
80
+ ];
81
+ /** The shape of mass mailing: bulk-detection networks, marketing markup, list
82
+ * plumbing. A score built entirely from these is a bulk verdict however high
83
+ * it climbs — Adobe MAX reached 3.9 without one rule that looks at identity. */
84
+ const BULK_RULES = [
85
+ /^DCC_/, /^PYZOR_/, /^RAZOR2_/,
86
+ /^MAILING_LIST/, /^LIST_/,
87
+ // KAM_* by name only, never the whole family: it holds marketing and list
88
+ // rules alongside scam-CONTENT rules (KAM_BENEFICIARY is worth 10.0,
89
+ // KAM_FAKE_NORTONLOW 6.5), and calling those bulk would quiet the loudest
90
+ // true positives in the store.
91
+ /^KAM_(BODY_)?MARKETINGBL/, /^KAM_UNSUB/, /^KAM_TRACKIMAGE$/,
92
+ /^KAM_REALLYHUGEIMGSRC$/, /^KAM_DMARC_STATUS$/, /^KAM_COUPON/,
93
+ /^HTML_/, /^MIME_HTML/, /^WORD_INVIS$/,
94
+ /^URIBL_(GREY|CSS|BLOCKED|CT_SURBL)/, /^SH_BODYURI/,
95
+ /^HEADER_FROM_DIFFERENT_DOMAINS$/, // every ESP on earth trips this
96
+ /^MPART_ALT_DIFF/, /^TVD_/, /^T_REMOTE_IMAGE$/, /^UNPARSEABLE_RELAY$/,
97
+ ];
98
+ /** Passes, whitelistings, and Bayes' own opinion — informational, and in
99
+ * BAYES_00's case exculpatory. `DKIM_INVALID` lives here rather than in the
100
+ * forgery class because a broken signature is routine on relayed and
101
+ * list-rewritten mail (Mokin, sent via Shopify and SendGrid, carries it while
102
+ * the receiving server records dkim=pass). It is promoted to forgery only
103
+ * when DMARC also failed — see classifyRule. */
104
+ const NEUTRAL_RULES = [
105
+ /^BAYES_/,
106
+ /^SPF_(PASS|NONE|HELO_(PASS|NONE|NEUTRAL))$/,
107
+ /^DKIM_(SIGNED|VALID|VALID_AU|VALID_EF|INVALID)$/,
108
+ /^DKIMWL_/, /^RCVD_IN_(DNSWL|MSPIKE_H|IADB)/,
109
+ /^SHORTCIRCUIT$/, /^AWL$/, /^NO_RELAYS$/, /^ALL_TRUSTED$/,
110
+ ];
111
+ function classifyRule(name, dmarcFailed) {
112
+ if (dmarcFailed && name === "DKIM_INVALID")
113
+ return "forgery";
114
+ for (const re of FORGERY_RULES)
115
+ if (re.test(name))
116
+ return "forgery";
117
+ for (const re of ASSOCIATION_RULES)
118
+ if (re.test(name))
119
+ return "association";
120
+ for (const re of NEUTRAL_RULES)
121
+ if (re.test(name))
122
+ return "neutral";
123
+ for (const re of BULK_RULES)
124
+ if (re.test(name))
125
+ return "bulk";
126
+ return "other";
127
+ }
59
128
  /**
60
- * Did the receiving server's spam filter already say yes?
129
+ * Did the receiving server prove the From domain?
130
+ *
131
+ * Only the TOPMOST Authentication-Results is read. Headers are prepended, so
132
+ * the first one is the one our own server wrote; every one below it travelled
133
+ * with the message and a forger can write whatever they like there. Same
134
+ * asymmetry serverSpamVerdict relies on, in the other direction: a claim that
135
+ * SILENCES a warning must come from a source the sender cannot control.
136
+ *
137
+ * `dmarc=pass` is aligned by definition (RFC 7489) — SPF or DKIM passed on the
138
+ * same domain the reader sees in From. The `header.from=` it names is checked
139
+ * against the actual From line anyway, because an Authentication-Results about
140
+ * some other message proves nothing about this one.
141
+ */
142
+ function dmarcProof(input) {
143
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
144
+ if (!auth)
145
+ return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
146
+ // What the server caught failing, by name. An outright authentication
147
+ // failure IS an identity test, whatever the tests= list happens to call
148
+ // it — this is what keeps the 2026-08-25 phishes red under the new
149
+ // classification, since spf=fail is the whole point of them.
150
+ const authFailures = [
151
+ /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
152
+ /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
153
+ /\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
154
+ ].filter(Boolean);
155
+ const failed = authFailures.includes("DMARC");
156
+ if (!/\bdmarc=pass\b/i.test(auth))
157
+ return { pass: false, failed, authFailures, domain: "", policy: "" };
158
+ const stated = (auth.match(/header\.from=([^\s;,]+)/i)?.[1] || "").toLowerCase();
159
+ const policy = (auth.match(/policy\.dmarc=([^\s;,]+)/i)?.[1] || "").toLowerCase();
160
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
161
+ if (stated && fromDomain
162
+ && stated !== fromDomain
163
+ && !stated.endsWith("." + fromDomain)
164
+ && !fromDomain.endsWith("." + stated)) {
165
+ return { pass: false, failed, authFailures, domain: "", policy };
166
+ }
167
+ return { pass: true, failed: false, authFailures, domain: stated || fromDomain, policy };
168
+ }
169
+ /** Unfold a header value the way RFC 5322 folds it: delete the line break,
170
+ * KEEP the whitespace that continues the line. SpamAssassin breaks its tests=
171
+ * list right after a comma, so that continuation whitespace is all that
172
+ * separates two rule names from being glued into one — the list is de-spaced
173
+ * after it has been cut out, not here. X-Spam-Report is NOT unfolded: there
174
+ * the line structure is the record structure. */
175
+ function unfold(value) {
176
+ return value.replace(/\r?\n/g, "");
177
+ }
178
+ /** Per-rule scores and descriptions out of X-Spam-Report, which reads:
179
+ *
180
+ * * 6.0 URIBL_SBL Contains an URL's NS IP listed in the Spamhaus SBL
181
+ * * blocklist
182
+ * * [URI: ns12.xincache.com/112.80.181.111]
183
+ *
184
+ * The bracketed evidence lines are dropped: they name the third party, and
185
+ * the finding is about this message. */
186
+ function parseSpamReport(report) {
187
+ const out = new Map();
188
+ let current = null;
189
+ for (const raw of report.split(/\r?\n/)) {
190
+ const line = raw.replace(/^[ \t]*\*?[ \t]*/, "").trimEnd();
191
+ if (!line)
192
+ continue;
193
+ const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
194
+ if (start) {
195
+ current = { score: Number(start[1]), description: start[3].trim() };
196
+ out.set(start[2], current);
197
+ continue;
198
+ }
199
+ if (current && !line.startsWith("["))
200
+ current.description += " " + line;
201
+ }
202
+ for (const rule of out.values())
203
+ rule.description = rule.description
204
+ .replace(/\s+/g, " ")
205
+ .replace(/^(BODY|RAW|HEADER|URI|RBL):\s*/i, "")
206
+ .trim();
207
+ return out;
208
+ }
209
+ function analyzeServerSpam(input) {
210
+ const status = unfold(header(input.headerLines, "x-spam-status"));
211
+ const flag = header(input.headerLines, "x-spam-flag");
212
+ if (!status && !flag)
213
+ return null;
214
+ const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
215
+ const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
216
+ const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
217
+ const dmarc = dmarcProof(input);
218
+ const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
219
+ // Everything up to the first lowercase key=value — SpamAssassin's trailing
220
+ // shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
221
+ // rather than on whitespace is what keeps "...,WORD_INVIS shortcircuit=no"
222
+ // from delivering a rule called WORD_INVISshortcircuit.
223
+ const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
224
+ .replace(/\s+/g, "").split(",").map(n => n.toUpperCase()).filter(Boolean);
225
+ const rules = names.map(name => {
226
+ const found = report.get(name);
227
+ return {
228
+ name,
229
+ score: found ? found.score : NaN,
230
+ description: found ? found.description : "",
231
+ cls: classifyRule(name, dmarc.failed),
232
+ };
233
+ });
234
+ // A rule that scored zero or negative did not put this message over any
235
+ // line, so it cannot be the reason the message is here. Unscored rules
236
+ // (no X-Spam-Report, which is most unflagged mail) still count — absence
237
+ // of a report is not evidence the rule was free.
238
+ const scored = (r) => !(r.score <= 0);
239
+ const present = (cls) => rules.some(r => r.cls === cls && scored(r));
240
+ const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
241
+ : present("association") ? "association"
242
+ : present("other") ? "unclassified"
243
+ : rules.length ? "bulk"
244
+ : "unclassified";
245
+ // Two proofs SpamAssassin states in its own rule list, which mail that
246
+ // never crossed a trust boundary carries INSTEAD of an Authentication-Results
247
+ // header — Bob's own message to John Levine about spam handling, 2026-08-27,
248
+ // had neither AR nor DMARC and got a caution banner reading "links and
249
+ // attachments here should not be opened" on prose he had just written:
250
+ //
251
+ // ALL_TRUSTED Passed through trusted hosts only via SMTP
252
+ // DKIM_VALID_AU Message has a valid DKIM or DK signature from author's domain
253
+ //
254
+ // DKIM_VALID_AU is the same evidence DMARC alignment rests on — the author
255
+ // domain signed it and the signature verified — so it proves the From line on
256
+ // its own. ALL_TRUSTED says the message reached the mailbox without passing
257
+ // through a host the server distrusts, which is Bob's own authenticated
258
+ // submission; nothing could have injected it in transit. (What flagged his
259
+ // message was URIBL_DBL_SPAM 8.0 on blueorchestra.org — the domain he was
260
+ // quoting IN a discussion about spam. Guilt by association at its purest.)
261
+ const has = (name) => rules.some(r => r.name === name);
262
+ const provedBy = dmarc.pass ? "dmarc"
263
+ : has("DKIM_VALID_AU") ? "dkim"
264
+ : has("ALL_TRUSTED") ? "trusted"
265
+ : "";
266
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
267
+ const reasons = rules
268
+ .filter(r => r.cls !== "neutral" && scored(r))
269
+ .sort((a, b) => (b.score || 0) - (a.score || 0))
270
+ .slice(0, 3)
271
+ .map(r => {
272
+ const num = Number.isFinite(r.score) ? `${r.score.toFixed(1)} ` : "";
273
+ return r.description ? `${num}${r.name} (${r.description})` : `${num}${r.name}`;
274
+ });
275
+ return {
276
+ score, threshold, flagged, rules, kind,
277
+ proved: !!provedBy, provedBy,
278
+ provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
279
+ bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
280
+ authFailures: dmarc.authFailures,
281
+ reasons,
282
+ };
283
+ }
284
+ /**
285
+ * Did the receiving server's spam filter already say yes — and if so, to WHAT?
61
286
  *
62
287
  * The highest-value signal available, and it costs nothing: the mail server
63
288
  * ran SpamAssassin before delivery and wrote the answer into the message.
@@ -67,23 +292,73 @@ function bare(addr) {
67
292
  * `X-Spam-Flag: NO` into a message they send, and treating that as a clean
68
293
  * bill of health would hand attackers a switch for turning the check off. A
69
294
  * forged YES only flags the forger's own mail, so the asymmetry is safe.
295
+ *
296
+ * The severity comes from the RULES, never from the number (Claude Code
297
+ * 2026-08-27):
298
+ * - a forgery rule fired -> danger. This is what red is for.
299
+ * - the sender is DMARC-proved -> info. The mail may well be unwanted,
300
+ * and every rule that scored is but "not what it says it is" is a
301
+ * one this file can name, or claim its own evidence contradicts.
302
+ * the whole score is bulk rules
303
+ * - anything else -> caution. Flagged, and the reason is
304
+ * not identity — or is a rule this file
305
+ * cannot classify, which is never a
306
+ * reason to go quiet.
70
307
  */
71
- function serverSpamVerdict(lines) {
72
- const flag = header(lines, "x-spam-flag");
73
- const status = header(lines, "x-spam-status");
74
- if (!/^yes/i.test(flag) && !/^yes/i.test(status))
308
+ /** How the From line was proved, in the reader's terms — named so it can be
309
+ * checked against the headers rather than believed. */
310
+ function provedNote(a) {
311
+ switch (a.provedBy) {
312
+ case "dmarc":
313
+ return `DMARC pass, aligned${a.provedPolicy ? `, policy ${a.provedPolicy}` : ""}.`;
314
+ case "dkim":
315
+ return `Valid signature from ${a.provedDomain}, the sending domain itself (DKIM_VALID_AU).`;
316
+ case "trusted":
317
+ return "It passed through trusted hosts only (ALL_TRUSTED) — nothing untrusted handled it.";
318
+ default:
319
+ return "";
320
+ }
321
+ }
322
+ function serverSpamVerdict(input) {
323
+ const a = analyzeServerSpam(input);
324
+ if (!a || !a.flagged)
75
325
  return null;
76
- // "Yes, score=6.7 required=4.5 tests=..." — quote the numbers when they
77
- // are there, so the reader sees how emphatic the verdict was.
78
- const score = status.match(/score=([-\d.]+)/)?.[1];
79
- const required = status.match(/required=([-\d.]+)/)?.[1];
326
+ const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
327
+ ? `SpamAssassin score ${a.score} of ${a.threshold}`
328
+ : "flagged by SpamAssassin";
329
+ const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
330
+ const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
331
+ if (a.kind === "forgery")
332
+ return {
333
+ id: "server-spam-verdict",
334
+ severity: "danger",
335
+ text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
336
+ detail: a.authFailures.length
337
+ ? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
338
+ : `${numbers}${why}`,
339
+ };
340
+ if (a.proved && a.kind !== "unclassified")
341
+ return {
342
+ id: "server-spam-verdict",
343
+ severity: "info",
344
+ text: a.provedBy === "trusted"
345
+ ? "Your mail server scored this as spam, but it reached you without leaving your own systems."
346
+ : `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
347
+ detail: `${provedNote(a)} ${numbers}${why}${bayes}`,
348
+ };
349
+ if (a.kind === "bulk")
350
+ return {
351
+ id: "server-spam-verdict",
352
+ severity: "info",
353
+ text: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
354
+ detail: `${numbers}${why}${bayes}`,
355
+ };
356
+ const proof = a.proved ? `${provedNote(a)} ` : "";
80
357
  return {
81
358
  id: "server-spam-verdict",
82
- severity: "danger",
359
+ severity: "caution",
83
360
  text: "Your mail server classified this as spam before delivering it.",
84
- detail: score && required
85
- ? `SpamAssassin score ${score}, threshold ${required}`
86
- : (status || flag || "X-Spam-Flag: YES"),
361
+ detail: `${proof}${numbers}${why}${bayes}`,
87
362
  };
88
363
  }
89
364
  /**
@@ -243,14 +518,14 @@ function redirectorLink(bodyHtml) {
243
518
  */
244
519
  export function assessMessageTrust(input) {
245
520
  const findings = [
246
- serverSpamVerdict(input.headerLines),
521
+ serverSpamVerdict(input),
247
522
  selfSpoof(input),
248
523
  relayAuthMismatch(input),
249
524
  zeroWidthObfuscation(input.bodyText || ""),
250
525
  hiddenLinkOverlay(input.bodyHtml || ""),
251
526
  redirectorLink(input.bodyHtml || ""),
252
527
  ].filter(Boolean);
253
- const rank = { danger: 0, caution: 1 };
528
+ const rank = { danger: 0, caution: 1, info: 2 };
254
529
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
255
530
  }
256
531
  /**
@@ -341,14 +616,17 @@ function zeroWidthObfuscation(bodyText) {
341
616
  detail: `${count} zero-width characters hidden inside words — legitimate mail has no reason to do this`,
342
617
  };
343
618
  }
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))
619
+ export function spamScoreOf(input) {
620
+ const a = analyzeServerSpam(input);
621
+ if (!a || !Number.isFinite(a.score) || !Number.isFinite(a.threshold))
351
622
  return null;
352
- return { score, threshold, flagged: /^yes/i.test(status) };
623
+ return {
624
+ score: a.score,
625
+ threshold: a.threshold,
626
+ flagged: a.flagged,
627
+ kind: a.kind,
628
+ proved: a.proved,
629
+ reasons: a.reasons,
630
+ };
353
631
  }
354
632
  //# sourceMappingURL=trust.js.map