@bobfrankston/mailx-types 0.1.86 → 0.1.90
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 +35 -0
- package/trust.js +129 -8
package/index.d.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* This is the contract between client and server.
|
|
5
5
|
*/
|
|
6
6
|
export { CONTACT_RULES } from "./contact-rules.js";
|
|
7
|
-
export { assessMessageTrust, spamScoreOf } from "./trust.js";
|
|
7
|
+
export { assessMessageTrust, spamScoreOf, TRUST_LIST_KEYS } from "./trust.js";
|
|
8
8
|
export { logIfChanged, clearLogState } from "./log-changes.js";
|
|
9
|
-
export type { TrustFinding, TrustInput, SpamScore } from "./trust.js";
|
|
9
|
+
export type { TrustFinding, TrustInput, SpamScore, TrustListType } from "./trust.js";
|
|
10
10
|
export type { MailxApi } from "./mailx-api.js";
|
|
11
11
|
export { addContactsDenylistEntry, addContactsPreferredEntry, type PreferredContactEntry, type CloudReadFn, type CloudWriteFn, } from "./contacts-config.js";
|
|
12
12
|
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, spamScoreOf } from "./trust.js";
|
|
10
|
+
export { assessMessageTrust, spamScoreOf, TRUST_LIST_KEYS } from "./trust.js";
|
|
11
11
|
export { logIfChanged, clearLogState } from "./log-changes.js";
|
|
12
12
|
// Shared contacts.jsonc mutations — one implementation for desktop + Android.
|
|
13
13
|
export { addContactsDenylistEntry, addContactsPreferredEntry, } from "./contacts-config.js";
|
package/package.json
CHANGED
package/trust.d.ts
CHANGED
|
@@ -28,6 +28,19 @@
|
|
|
28
28
|
* be you. Forwarded mail fails SPF constantly, and flagging that would be
|
|
29
29
|
* exactly the cry-wolf banner this is trying not to be.
|
|
30
30
|
*/
|
|
31
|
+
/** What the reader can vouch for, and the allowlist.jsonc key each one is
|
|
32
|
+
* written to. One table, shared by the desktop service and the Android
|
|
33
|
+
* web-service, so "trust X" cannot land in different keys on the two
|
|
34
|
+
* platforms — the merge is by key. (Claude Code 2026-09-09; the two
|
|
35
|
+
* services each carried their own ternary before, and adding `list` to one
|
|
36
|
+
* and not the other is exactly the drift this prevents.) */
|
|
37
|
+
export declare const TRUST_LIST_KEYS: {
|
|
38
|
+
readonly sender: "trustedSenders";
|
|
39
|
+
readonly domain: "trustedDomains";
|
|
40
|
+
readonly intermediary: "trustedIntermediaries";
|
|
41
|
+
readonly list: "trustedLists";
|
|
42
|
+
};
|
|
43
|
+
export type TrustListType = keyof typeof TRUST_LIST_KEYS;
|
|
31
44
|
/** One piece of evidence that a message is not what it claims to be. */
|
|
32
45
|
export interface TrustFinding {
|
|
33
46
|
/** Stable identifier — for tests, logs, and any future suppression UI. */
|
|
@@ -46,6 +59,10 @@ export interface TrustFinding {
|
|
|
46
59
|
* the author's behalf. Set only when such a party exists and is not yet
|
|
47
60
|
* trusted, so the viewer can offer the action by name. (2026-09-08) */
|
|
48
61
|
via?: string;
|
|
62
|
+
/** The mailing list that provably delivered this message (its List-Id),
|
|
63
|
+
* when the list is not yet in allowlist.jsonc `trustedLists`, so the
|
|
64
|
+
* viewer can offer "Trust the ‹list› list". (2026-09-09) */
|
|
65
|
+
list?: string;
|
|
49
66
|
}
|
|
50
67
|
export interface TrustInput {
|
|
51
68
|
/** Every header line, in order, as mailparser's `headerLines`. Repeated
|
|
@@ -85,6 +102,17 @@ export interface TrustInput {
|
|
|
85
102
|
* list that cleared it on sight would be a switch any forger could
|
|
86
103
|
* flip by pasting the link. See provedIntermediary. */
|
|
87
104
|
trustedIntermediaries?: string[];
|
|
105
|
+
/** Mailing lists the reader subscribes to, by List-Id ("ieee-cs.mit.edu"),
|
|
106
|
+
* from allowlist.jsonc `trustedLists`. Bob 2026-09-09: "how do I say
|
|
107
|
+
* that this is a valid mailing list message". A list that does not
|
|
108
|
+
* rewrite From (Mailman only does so when the author's domain publishes
|
|
109
|
+
* a DMARC policy; gmail.com does not) delivers the poster's own address,
|
|
110
|
+
* and its subject tag and footer break every signature on the way — so
|
|
111
|
+
* the author can never be proved, and per-sender trust has nothing to
|
|
112
|
+
* hold on to. The LIST can be proved: the receiving server records
|
|
113
|
+
* spf=pass for the list's bounce address, or dkim=pass for its domain.
|
|
114
|
+
* An entry counts only on such a message. See listProof. */
|
|
115
|
+
trustedLists?: string[];
|
|
88
116
|
}
|
|
89
117
|
/**
|
|
90
118
|
* Everything findable about this message, worst first.
|
|
@@ -137,6 +165,13 @@ export interface SpamScore {
|
|
|
137
165
|
/** The reader approved this sender AND the sender is proved: the score is
|
|
138
166
|
* still true, and no longer worth showing. The viewer hides the chip. */
|
|
139
167
|
trusted: boolean;
|
|
168
|
+
/** The mailing list that delivered this (List-Id), or "". With
|
|
169
|
+
* `listProved` — the receiving server recorded the list's host sending
|
|
170
|
+
* it — the viewer can offer "Trust the ‹list› list"; `listTrusted` says
|
|
171
|
+
* the reader already has. (2026-09-09) */
|
|
172
|
+
listId: string;
|
|
173
|
+
listProved: boolean;
|
|
174
|
+
listTrusted: boolean;
|
|
140
175
|
}
|
|
141
176
|
export declare function spamScoreOf(input: TrustInput): SpamScore;
|
|
142
177
|
//# sourceMappingURL=trust.d.ts.map
|
package/trust.js
CHANGED
|
@@ -28,6 +28,18 @@
|
|
|
28
28
|
* be you. Forwarded mail fails SPF constantly, and flagging that would be
|
|
29
29
|
* exactly the cry-wolf banner this is trying not to be.
|
|
30
30
|
*/
|
|
31
|
+
/** What the reader can vouch for, and the allowlist.jsonc key each one is
|
|
32
|
+
* written to. One table, shared by the desktop service and the Android
|
|
33
|
+
* web-service, so "trust X" cannot land in different keys on the two
|
|
34
|
+
* platforms — the merge is by key. (Claude Code 2026-09-09; the two
|
|
35
|
+
* services each carried their own ternary before, and adding `list` to one
|
|
36
|
+
* and not the other is exactly the drift this prevents.) */
|
|
37
|
+
export const TRUST_LIST_KEYS = {
|
|
38
|
+
sender: "trustedSenders",
|
|
39
|
+
domain: "trustedDomains",
|
|
40
|
+
intermediary: "trustedIntermediaries",
|
|
41
|
+
list: "trustedLists",
|
|
42
|
+
};
|
|
31
43
|
/** First value of a header, case-insensitive. */
|
|
32
44
|
function header(lines, name) {
|
|
33
45
|
const want = name.toLowerCase();
|
|
@@ -135,7 +147,28 @@ const NEUTRAL_RULES = [
|
|
|
135
147
|
/^DKIMWL_/, /^RCVD_IN_(DNSWL|MSPIKE_H|IADB)/,
|
|
136
148
|
/^SHORTCIRCUIT$/, /^AWL$/, /^NO_RELAYS$/, /^ALL_TRUSTED$/,
|
|
137
149
|
];
|
|
138
|
-
|
|
150
|
+
/** Rules that test whether the From domain matches the path the message took
|
|
151
|
+
* — which a mailing list breaks BY DESIGN. Mailman re-sends the poster's
|
|
152
|
+
* message from its own host with its own bounce address, so a Gmail From
|
|
153
|
+
* arrives without a Google hop or a Google envelope, and the author's
|
|
154
|
+
* signature no longer verifies over the tagged subject and appended footer.
|
|
155
|
+
* Every one of these fired on an ordinary IEEE-CS meeting announcement
|
|
156
|
+
* (Bob 2026-09-09) and together they painted it as a phish. They are
|
|
157
|
+
* evidence of forgery only when nothing else explains the mismatch; on a
|
|
158
|
+
* message a list PROVABLY delivered (see listProof) the list is the
|
|
159
|
+
* explanation, and they are set to neutral. URL and phishing listings are
|
|
160
|
+
* not in this set: a list member can post a bad link like anyone else. */
|
|
161
|
+
const LIST_RELAY_RULES = [
|
|
162
|
+
/^POSSIBLE_GMAIL_PHISHER$/, // From gmail.com, not sent through Google
|
|
163
|
+
/^FREEMAIL_FORGED_FROMDOMAIN$/, // From and envelope are different freemail domains
|
|
164
|
+
/^(NML_|DKIM_)ADSP_/, // author domain did not sign what arrived
|
|
165
|
+
/^FORGED_[A-Z]+_RCVD$/, // From ‹provider› without ‹provider›'s Received
|
|
166
|
+
];
|
|
167
|
+
function classifyRule(name, dmarcFailed, listRelayed = false) {
|
|
168
|
+
if (listRelayed)
|
|
169
|
+
for (const re of LIST_RELAY_RULES)
|
|
170
|
+
if (re.test(name))
|
|
171
|
+
return "neutral";
|
|
139
172
|
if (dmarcFailed && name === "DKIM_INVALID")
|
|
140
173
|
return "forgery";
|
|
141
174
|
for (const re of FORGERY_RULES)
|
|
@@ -199,6 +232,49 @@ function provedIntermediary(input) {
|
|
|
199
232
|
return entry;
|
|
200
233
|
return "";
|
|
201
234
|
}
|
|
235
|
+
/** The mailing list this message came through, and whether the receiving
|
|
236
|
+
* server proved the list sent it. (2026-09-09)
|
|
237
|
+
*
|
|
238
|
+
* Identity is the List-Id (RFC 2919), the one header that is the same on
|
|
239
|
+
* every post to the list whoever wrote it — `<ieee-cs.mit.edu>` — so it is
|
|
240
|
+
* what `trustedLists` is keyed on. The list's HOST is the Sender: domain
|
|
241
|
+
* (Mailman sets `Sender: list-bounces@host`) or the List-Id's own domain.
|
|
242
|
+
*
|
|
243
|
+
* Proof is the topmost Authentication-Results, which the sender cannot
|
|
244
|
+
* write: `spf=pass` for a bounce address at the list's host — SPF is checked
|
|
245
|
+
* against the envelope, and a list's envelope IS the list — or `dkim=pass`
|
|
246
|
+
* for the list's domain, which Mailman 3 and Sympa add. A List-Id alone is
|
|
247
|
+
* a header anyone can type; without the server's pass it proves nothing,
|
|
248
|
+
* and `trustedLists` never counts on such a message. */
|
|
249
|
+
function listProof(input) {
|
|
250
|
+
const none = { id: "", proved: false, host: "" };
|
|
251
|
+
const raw = header(input.headerLines, "list-id");
|
|
252
|
+
if (!raw)
|
|
253
|
+
return none;
|
|
254
|
+
// `Meeting Announcements <ieee-cs.mit.edu>` or bare `<ieee-cs.mit.edu>`;
|
|
255
|
+
// the angle-bracketed token is the identifier, the rest is a comment.
|
|
256
|
+
const id = (raw.match(/<([^>]+)>/)?.[1] || raw).trim().toLowerCase();
|
|
257
|
+
if (!id)
|
|
258
|
+
return none;
|
|
259
|
+
const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
|
|
260
|
+
const hosts = [senderDomain, id].filter(Boolean);
|
|
261
|
+
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
262
|
+
for (const part of auth.split(";")) {
|
|
263
|
+
if (/\bspf=pass\b/i.test(part)) {
|
|
264
|
+
// iecc writes `spf.mailfrom=user@host`, Microsoft `smtp.mailfrom=host`.
|
|
265
|
+
const mailfrom = (part.match(/(?:spf|smtp)\.mailfrom=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
266
|
+
const domain = mailfrom.includes("@") ? mailfrom.split("@")[1] : mailfrom;
|
|
267
|
+
if (domain && hosts.some(h => sameOrg(domain, h)))
|
|
268
|
+
return { id, proved: true, host: domain };
|
|
269
|
+
}
|
|
270
|
+
if (/\bdkim=pass\b/i.test(part)) {
|
|
271
|
+
const d = (part.match(/header\.d=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
272
|
+
if (d && hosts.some(h => sameOrg(d, h)))
|
|
273
|
+
return { id, proved: true, host: d };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { id, proved: false, host: "" };
|
|
277
|
+
}
|
|
202
278
|
function dmarcProof(input) {
|
|
203
279
|
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
204
280
|
if (!auth)
|
|
@@ -275,6 +351,20 @@ function analyzeServerSpam(input) {
|
|
|
275
351
|
const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
|
|
276
352
|
const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
|
|
277
353
|
const dmarc = dmarcProof(input);
|
|
354
|
+
// A list the server proved delivered this message explains two things
|
|
355
|
+
// that otherwise read as forgery: the author's signature no longer
|
|
356
|
+
// verifying (the list tagged the subject and appended a footer), and the
|
|
357
|
+
// From domain not matching the path (the list re-sent it from its own
|
|
358
|
+
// host). Both are removed from the evidence on a PROVED list whether or
|
|
359
|
+
// not the reader trusts it — the explanation is a fact about the
|
|
360
|
+
// headers, and the trust is a separate question answered below. SPF and
|
|
361
|
+
// DMARC failures stay: SPF is checked against the list's own envelope,
|
|
362
|
+
// and a list that lets a DMARC failure through is not one to be quiet
|
|
363
|
+
// about. (2026-09-09)
|
|
364
|
+
const listFound = listProof(input);
|
|
365
|
+
const trustedLists = (input.trustedLists || []).map(l => (l || "").trim().toLowerCase());
|
|
366
|
+
const list = { ...listFound, trusted: listFound.proved && trustedLists.includes(listFound.id) };
|
|
367
|
+
const authFailures = list.proved && !dmarc.failed ? dmarc.authFailures.filter(f => f !== "DKIM") : dmarc.authFailures;
|
|
278
368
|
const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
|
|
279
369
|
// Everything up to the first lowercase key=value — SpamAssassin's trailing
|
|
280
370
|
// shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
|
|
@@ -288,7 +378,7 @@ function analyzeServerSpam(input) {
|
|
|
288
378
|
name,
|
|
289
379
|
score: found ? found.score : NaN,
|
|
290
380
|
description: found ? found.description : "",
|
|
291
|
-
cls: classifyRule(name, dmarc.failed),
|
|
381
|
+
cls: classifyRule(name, dmarc.failed, list.proved),
|
|
292
382
|
};
|
|
293
383
|
});
|
|
294
384
|
// A rule that scored zero or negative did not put this message over any
|
|
@@ -325,7 +415,7 @@ function analyzeServerSpam(input) {
|
|
|
325
415
|
// blocklist rules" — with 3.2 of its 8.3 coming from KAM_ACCOUNTPHISH,
|
|
326
416
|
// which is neither, listed directly underneath.
|
|
327
417
|
const bulkOnly = rules.every(r => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
|
|
328
|
-
const kind = (
|
|
418
|
+
const kind = (authFailures.length || present("forgery")) ? "forgery"
|
|
329
419
|
: present("association") ? "association"
|
|
330
420
|
: unknownCouldFlagAlone ? "unclassified"
|
|
331
421
|
: rules.length ? "bulk"
|
|
@@ -365,10 +455,11 @@ function analyzeServerSpam(input) {
|
|
|
365
455
|
proved: !!provedBy, provedBy, bulkOnly,
|
|
366
456
|
provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
|
|
367
457
|
bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
|
|
368
|
-
authFailures
|
|
458
|
+
authFailures,
|
|
369
459
|
reasons,
|
|
370
460
|
intermediary: provedIntermediary(input),
|
|
371
461
|
signers: dkimSigners(input),
|
|
462
|
+
list,
|
|
372
463
|
};
|
|
373
464
|
}
|
|
374
465
|
/**
|
|
@@ -423,7 +514,10 @@ function serverSpamVerdict(input) {
|
|
|
423
514
|
// A trusted INTERMEDIARY that provably handled the message answers the
|
|
424
515
|
// same question the same way — Google Calendar's invitation on Aaron's
|
|
425
516
|
// behalf is wanted because the reader said google.com is (2026-09-08).
|
|
426
|
-
|
|
517
|
+
// A trusted mailing LIST answers it for every post the list provably
|
|
518
|
+
// delivered, whoever wrote it — the From line is the list's word, not
|
|
519
|
+
// the author's, and the reader subscribed to the list (2026-09-09).
|
|
520
|
+
const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted;
|
|
427
521
|
if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
|
|
428
522
|
return null;
|
|
429
523
|
// The party that sent this on the author's behalf, when it signed: the
|
|
@@ -439,6 +533,14 @@ function serverSpamVerdict(input) {
|
|
|
439
533
|
const relayNote = relay && !a.proved
|
|
440
534
|
? `. Sent on the author's behalf by ${relay}, which signed it; "Trust ${relay} as an intermediary" stops this note for mail ${relay} signs.`
|
|
441
535
|
: "";
|
|
536
|
+
// The list that provably delivered this, named the same way, with the
|
|
537
|
+
// action that writes `trustedLists`. Offered only while the author is
|
|
538
|
+
// unproved and the list is not yet trusted: a proved author needs no
|
|
539
|
+
// list, and a trusted list returned above.
|
|
540
|
+
const listNote = a.list.proved && !a.list.trusted && !a.proved
|
|
541
|
+
? `. Delivered by the ${a.list.id} mailing list — ${a.list.host} proved it sent this — which re-sent the author's message from its own host, so nothing proves the From line; "Trust the ${a.list.id} list" stops this note for mail the list delivers.`
|
|
542
|
+
: "";
|
|
543
|
+
const viaList = listNote ? { list: a.list.id } : {};
|
|
442
544
|
const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
|
|
443
545
|
? `SpamAssassin score ${a.score} of ${a.threshold}`
|
|
444
546
|
: "flagged by SpamAssassin";
|
|
@@ -483,14 +585,16 @@ function serverSpamVerdict(input) {
|
|
|
483
585
|
id: "server-spam-verdict",
|
|
484
586
|
severity: "caution",
|
|
485
587
|
text: "This claims to be a sender you trust, but nothing proves it is.",
|
|
486
|
-
detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}`,
|
|
588
|
+
detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}${listNote}`,
|
|
589
|
+
...viaList,
|
|
487
590
|
};
|
|
488
591
|
return {
|
|
489
592
|
id: "server-spam-verdict",
|
|
490
593
|
severity: "caution",
|
|
491
594
|
text: "Your mail server classified this as spam before delivering it.",
|
|
492
|
-
detail: `${proof}${numbers}${why}${bayes}${relayNote}`,
|
|
595
|
+
detail: `${proof}${numbers}${why}${bayes}${relayNote}${listNote}`,
|
|
493
596
|
...via,
|
|
597
|
+
...viaList,
|
|
494
598
|
};
|
|
495
599
|
}
|
|
496
600
|
/**
|
|
@@ -631,6 +735,18 @@ function provedSenderDomain(input) {
|
|
|
631
735
|
*
|
|
632
736
|
* 1. The redirector and the destination are the same organisation. A site
|
|
633
737
|
* bouncing through its own hostnames is routing, not hiding.
|
|
738
|
+
* 4. The link ENDS at the proved sender (2026-09-08). Microsoft 365's
|
|
739
|
+
* Message center mail, dmarc=pass header.from=microsoft.com with
|
|
740
|
+
* policy reject, links through its click-tracker
|
|
741
|
+
* `nam.safelink.emails.azure.net` to `admin.microsoft.com`: two
|
|
742
|
+
* organisations by hostname, nobody signed for azure.net, and the
|
|
743
|
+
* sender is not the redirector — rules 1 to 3 all miss (Bob: "another
|
|
744
|
+
* safe message flagged"). But the reader lands on the site of the party
|
|
745
|
+
* that provably sent the mail, which is exactly where a plain link
|
|
746
|
+
* would have taken them; the hop hides nothing from the reader. The
|
|
747
|
+
* redirector's reputation may be borrowed, but that is the
|
|
748
|
+
* redirector's problem, not this reader's — and the sender who chose
|
|
749
|
+
* the route is the proved sender, who could have linked openly.
|
|
634
750
|
* 2. The redirector IS the proved sender. Google's account-security notice
|
|
635
751
|
* (2026-09-02, Bob: "this is valid") links through
|
|
636
752
|
* `accounts.google.com/AccountChooser?continue=https://myaccount.google.com/…`
|
|
@@ -682,6 +798,8 @@ function redirectorLink(input) {
|
|
|
682
798
|
continue;
|
|
683
799
|
if (sameOrg(target.hostname, url.hostname))
|
|
684
800
|
continue; // same site, not laundering
|
|
801
|
+
if (senderDomain && sameOrg(target.hostname, senderDomain))
|
|
802
|
+
continue; // ends at the proved sender (rule 4)
|
|
685
803
|
// Name the signer behind this redirector, if there is one, so the
|
|
686
804
|
// viewer can offer "trust google.com as an intermediary" — and
|
|
687
805
|
// only then: a redirector nobody signed for has no party to vouch
|
|
@@ -819,8 +937,11 @@ export function spamScoreOf(input) {
|
|
|
819
937
|
proved: a.proved,
|
|
820
938
|
provedBy: a.provedBy,
|
|
821
939
|
bulkOnly: a.bulkOnly,
|
|
822
|
-
trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
|
|
940
|
+
trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted) && a.kind !== "forgery" && !a.authFailures.length,
|
|
823
941
|
reasons: a.reasons,
|
|
942
|
+
listId: a.list.id,
|
|
943
|
+
listProved: a.list.proved,
|
|
944
|
+
listTrusted: a.list.trusted,
|
|
824
945
|
};
|
|
825
946
|
}
|
|
826
947
|
//# sourceMappingURL=trust.js.map
|