@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.86",
3
+ "version": "0.1.90",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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
- function classifyRule(name, dmarcFailed) {
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 = (dmarc.authFailures.length || present("forgery")) ? "forgery"
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: dmarc.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
- const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary;
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