@bobfrankston/mailx-types 0.1.88 → 0.1.92

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.88",
3
+ "version": "0.1.92",
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)
@@ -183,6 +216,30 @@ function dkimSigners(input) {
183
216
  }
184
217
  return out;
185
218
  }
219
+ /** Did the From domain's OWN organisation pass authentication at the
220
+ * receiving server — a verified signature for the From domain, or an SPF
221
+ * pass for an envelope at the From domain? This is DMARC's relaxed
222
+ * alignment computed locally, for domains that publish no DMARC policy and
223
+ * so never get a dmarc= verdict. Only the From domain's own servers can pass
224
+ * SPF as that domain or sign as it, so a forger's pass for their own domain
225
+ * does not count. Returns which proof, or "". (2026-09-09) */
226
+ function alignedPass(input) {
227
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
228
+ if (!fromDomain)
229
+ return "";
230
+ if (dkimSigners(input).some(d => sameOrg(d, fromDomain)))
231
+ return "dkim";
232
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
233
+ for (const part of auth.split(";")) {
234
+ if (!/\bspf=pass\b/i.test(part))
235
+ continue;
236
+ const mailfrom = (part.match(/(?:spf|smtp)\.mailfrom=([^\s;,]+)/i)?.[1] || "").toLowerCase();
237
+ const domain = mailfrom.includes("@") ? mailfrom.split("@")[1] : mailfrom;
238
+ if (domain && sameOrg(domain, fromDomain))
239
+ return "spf";
240
+ }
241
+ return "";
242
+ }
186
243
  /** The entry in `trustedIntermediaries` that provably handled this message,
187
244
  * or "" — proof being a verified signature from its domain, or a DMARC pass
188
245
  * when it is the author. The list alone proves nothing. (2026-09-08) */
@@ -199,6 +256,49 @@ function provedIntermediary(input) {
199
256
  return entry;
200
257
  return "";
201
258
  }
259
+ /** The mailing list this message came through, and whether the receiving
260
+ * server proved the list sent it. (2026-09-09)
261
+ *
262
+ * Identity is the List-Id (RFC 2919), the one header that is the same on
263
+ * every post to the list whoever wrote it — `<ieee-cs.mit.edu>` — so it is
264
+ * what `trustedLists` is keyed on. The list's HOST is the Sender: domain
265
+ * (Mailman sets `Sender: list-bounces@host`) or the List-Id's own domain.
266
+ *
267
+ * Proof is the topmost Authentication-Results, which the sender cannot
268
+ * write: `spf=pass` for a bounce address at the list's host — SPF is checked
269
+ * against the envelope, and a list's envelope IS the list — or `dkim=pass`
270
+ * for the list's domain, which Mailman 3 and Sympa add. A List-Id alone is
271
+ * a header anyone can type; without the server's pass it proves nothing,
272
+ * and `trustedLists` never counts on such a message. */
273
+ function listProof(input) {
274
+ const none = { id: "", proved: false, host: "" };
275
+ const raw = header(input.headerLines, "list-id");
276
+ if (!raw)
277
+ return none;
278
+ // `Meeting Announcements <ieee-cs.mit.edu>` or bare `<ieee-cs.mit.edu>`;
279
+ // the angle-bracketed token is the identifier, the rest is a comment.
280
+ const id = (raw.match(/<([^>]+)>/)?.[1] || raw).trim().toLowerCase();
281
+ if (!id)
282
+ return none;
283
+ const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
284
+ const hosts = [senderDomain, id].filter(Boolean);
285
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
286
+ for (const part of auth.split(";")) {
287
+ if (/\bspf=pass\b/i.test(part)) {
288
+ // iecc writes `spf.mailfrom=user@host`, Microsoft `smtp.mailfrom=host`.
289
+ const mailfrom = (part.match(/(?:spf|smtp)\.mailfrom=([^\s;,]+)/i)?.[1] || "").toLowerCase();
290
+ const domain = mailfrom.includes("@") ? mailfrom.split("@")[1] : mailfrom;
291
+ if (domain && hosts.some(h => sameOrg(domain, h)))
292
+ return { id, proved: true, host: domain };
293
+ }
294
+ if (/\bdkim=pass\b/i.test(part)) {
295
+ const d = (part.match(/header\.d=([^\s;,]+)/i)?.[1] || "").toLowerCase();
296
+ if (d && hosts.some(h => sameOrg(d, h)))
297
+ return { id, proved: true, host: d };
298
+ }
299
+ }
300
+ return { id, proved: false, host: "" };
301
+ }
202
302
  function dmarcProof(input) {
203
303
  const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
204
304
  if (!auth)
@@ -275,6 +375,20 @@ function analyzeServerSpam(input) {
275
375
  const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
276
376
  const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
277
377
  const dmarc = dmarcProof(input);
378
+ // A list the server proved delivered this message explains two things
379
+ // that otherwise read as forgery: the author's signature no longer
380
+ // verifying (the list tagged the subject and appended a footer), and the
381
+ // From domain not matching the path (the list re-sent it from its own
382
+ // host). Both are removed from the evidence on a PROVED list whether or
383
+ // not the reader trusts it — the explanation is a fact about the
384
+ // headers, and the trust is a separate question answered below. SPF and
385
+ // DMARC failures stay: SPF is checked against the list's own envelope,
386
+ // and a list that lets a DMARC failure through is not one to be quiet
387
+ // about. (2026-09-09)
388
+ const listFound = listProof(input);
389
+ const trustedLists = (input.trustedLists || []).map(l => (l || "").trim().toLowerCase());
390
+ const list = { ...listFound, trusted: listFound.proved && trustedLists.includes(listFound.id) };
391
+ const authFailures = list.proved && !dmarc.failed ? dmarc.authFailures.filter(f => f !== "DKIM") : dmarc.authFailures;
278
392
  const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
279
393
  // Everything up to the first lowercase key=value — SpamAssassin's trailing
280
394
  // shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
@@ -288,7 +402,7 @@ function analyzeServerSpam(input) {
288
402
  name,
289
403
  score: found ? found.score : NaN,
290
404
  description: found ? found.description : "",
291
- cls: classifyRule(name, dmarc.failed),
405
+ cls: classifyRule(name, dmarc.failed, list.proved),
292
406
  };
293
407
  });
294
408
  // A rule that scored zero or negative did not put this message over any
@@ -325,7 +439,7 @@ function analyzeServerSpam(input) {
325
439
  // blocklist rules" — with 3.2 of its 8.3 coming from KAM_ACCOUNTPHISH,
326
440
  // which is neither, listed directly underneath.
327
441
  const bulkOnly = rules.every(r => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
328
- const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
442
+ const kind = (authFailures.length || present("forgery")) ? "forgery"
329
443
  : present("association") ? "association"
330
444
  : unknownCouldFlagAlone ? "unclassified"
331
445
  : rules.length ? "bulk"
@@ -365,10 +479,11 @@ function analyzeServerSpam(input) {
365
479
  proved: !!provedBy, provedBy, bulkOnly,
366
480
  provedDomain: dmarc.domain || fromDomain, provedPolicy: dmarc.policy,
367
481
  bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
368
- authFailures: dmarc.authFailures,
482
+ authFailures,
369
483
  reasons,
370
484
  intermediary: provedIntermediary(input),
371
485
  signers: dkimSigners(input),
486
+ list,
372
487
  };
373
488
  }
374
489
  /**
@@ -423,7 +538,10 @@ function serverSpamVerdict(input) {
423
538
  // A trusted INTERMEDIARY that provably handled the message answers the
424
539
  // same question the same way — Google Calendar's invitation on Aaron's
425
540
  // behalf is wanted because the reader said google.com is (2026-09-08).
426
- const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary;
541
+ // A trusted mailing LIST answers it for every post the list provably
542
+ // delivered, whoever wrote it — the From line is the list's word, not
543
+ // the author's, and the reader subscribed to the list (2026-09-09).
544
+ const trustedAndProved = (input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted;
427
545
  if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
428
546
  return null;
429
547
  // The party that sent this on the author's behalf, when it signed: the
@@ -439,6 +557,14 @@ function serverSpamVerdict(input) {
439
557
  const relayNote = relay && !a.proved
440
558
  ? `. Sent on the author's behalf by ${relay}, which signed it; "Trust ${relay} as an intermediary" stops this note for mail ${relay} signs.`
441
559
  : "";
560
+ // The list that provably delivered this, named the same way, with the
561
+ // action that writes `trustedLists`. Offered only while the author is
562
+ // unproved and the list is not yet trusted: a proved author needs no
563
+ // list, and a trusted list returned above.
564
+ const listNote = a.list.proved && !a.list.trusted && !a.proved
565
+ ? `. 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.`
566
+ : "";
567
+ const viaList = listNote ? { list: a.list.id } : {};
442
568
  const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
443
569
  ? `SpamAssassin score ${a.score} of ${a.threshold}`
444
570
  : "flagged by SpamAssassin";
@@ -483,14 +609,16 @@ function serverSpamVerdict(input) {
483
609
  id: "server-spam-verdict",
484
610
  severity: "caution",
485
611
  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}`,
612
+ detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}${listNote}`,
613
+ ...viaList,
487
614
  };
488
615
  return {
489
616
  id: "server-spam-verdict",
490
617
  severity: "caution",
491
618
  text: "Your mail server classified this as spam before delivering it.",
492
- detail: `${proof}${numbers}${why}${bayes}${relayNote}`,
619
+ detail: `${proof}${numbers}${why}${bayes}${relayNote}${listNote}`,
493
620
  ...via,
621
+ ...viaList,
494
622
  };
495
623
  }
496
624
  /**
@@ -529,6 +657,27 @@ function selfSpoof(input) {
529
657
  const auth = headerAll(input.headerLines, "authentication-results").join(" ; ");
530
658
  if (!auth)
531
659
  return null;
660
+ // The From domain passed DMARC, aligned, at the receiving server: the
661
+ // message provably came from that domain, which is the opposite of a
662
+ // spoof. Checked BEFORE the failure scan because the scan reads every
663
+ // Authentication-Results and every signature — an ISOC list post
664
+ // (2026-09-09) carried dkim=pass d=elists.isoc.org, dmarc=pass
665
+ // header.from=elists.isoc.org, AND dkim=fail for the original author's
666
+ // standardstrack.com signature the list had broken, and that one fail
667
+ // painted the whole message red as "did not come from your account"
668
+ // (the list address was the Delivered-To, so it counted as Bob's own).
669
+ // One failed signature on a message with an aligned pass is not
670
+ // evidence about the From line; the pass is.
671
+ //
672
+ // The same proof without the dmarc= verdict: a From domain that publishes
673
+ // no DMARC policy gets no dmarc=pass, but its own SPF pass or its own
674
+ // verified signature is the very evidence DMARC would have aligned on.
675
+ // The AMW list (berglist.com, 2026-09-09): spf=pass for
676
+ // amw-bounces@berglist.com, dkim=pass d=berglist.com, and dkim=fail for
677
+ // the gmail.com author signature the list broke — flagged as a spoof of
678
+ // the list's own address.
679
+ if (dmarcProof(input).pass || alignedPass(input))
680
+ return null;
532
681
  const failed = [
533
682
  /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
534
683
  /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
@@ -833,8 +982,11 @@ export function spamScoreOf(input) {
833
982
  proved: a.proved,
834
983
  provedBy: a.provedBy,
835
984
  bulkOnly: a.bulkOnly,
836
- trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
985
+ trusted: ((!!input.senderTrusted && a.proved) || !!a.intermediary || a.list.trusted) && a.kind !== "forgery" && !a.authFailures.length,
837
986
  reasons: a.reasons,
987
+ listId: a.list.id,
988
+ listProved: a.list.proved,
989
+ listTrusted: a.list.trusted,
838
990
  };
839
991
  }
840
992
  //# sourceMappingURL=trust.js.map