@bobfrankston/rmfmail 1.2.309 → 1.2.312

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 (62) hide show
  1. package/.commitmsg +39 -11
  2. package/.llm/trusted-lists.md +46 -0
  3. package/TODO.md +2 -2
  4. package/client/android-bootstrap.bundle.js +80 -15
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +57 -2
  7. package/client/app.bundle.js.map +2 -2
  8. package/client/components/message-viewer.js +82 -1
  9. package/client/components/message-viewer.js.map +1 -1
  10. package/client/components/message-viewer.ts +79 -2
  11. package/client/compose/compose.bundle.js +1 -1
  12. package/client/compose/compose.bundle.js.map +2 -2
  13. package/client/lib/api-client.js +1 -1
  14. package/client/lib/api-client.js.map +1 -1
  15. package/client/lib/api-client.ts +3 -3
  16. package/docs/allowlist.md +25 -2
  17. package/docs/spam-false-positives.md +23 -0
  18. package/npmchanges.md +30 -0
  19. package/package.json +5 -5
  20. package/packages/mailx-imap/package-lock.json +2 -2
  21. package/packages/mailx-imap/package.json +1 -1
  22. package/packages/mailx-service/index.d.ts +3 -1
  23. package/packages/mailx-service/index.d.ts.map +1 -1
  24. package/packages/mailx-service/index.js +4 -2
  25. package/packages/mailx-service/index.js.map +1 -1
  26. package/packages/mailx-service/index.ts +6 -4
  27. package/packages/mailx-service/package.json +1 -1
  28. package/packages/mailx-settings/docs/allowlist.md +25 -2
  29. package/packages/mailx-settings/docs/spam-false-positives.md +23 -0
  30. package/packages/mailx-settings/index.d.ts +1 -0
  31. package/packages/mailx-settings/index.d.ts.map +1 -1
  32. package/packages/mailx-settings/index.js +7 -0
  33. package/packages/mailx-settings/index.js.map +1 -1
  34. package/packages/mailx-settings/index.ts +7 -0
  35. package/packages/mailx-settings/package.json +1 -1
  36. package/packages/mailx-store/package.json +1 -1
  37. package/packages/mailx-store/store.d.ts.map +1 -1
  38. package/packages/mailx-store/store.js +3 -0
  39. package/packages/mailx-store/store.js.map +1 -1
  40. package/packages/mailx-store/store.ts +3 -0
  41. package/packages/mailx-store-web/package.json +1 -1
  42. package/packages/mailx-store-web/web-service.d.ts +3 -2
  43. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  44. package/packages/mailx-store-web/web-service.js +4 -2
  45. package/packages/mailx-store-web/web-service.js.map +1 -1
  46. package/packages/mailx-store-web/web-service.ts +6 -4
  47. package/packages/mailx-store-web/web-settings.d.ts +1 -0
  48. package/packages/mailx-store-web/web-settings.d.ts.map +1 -1
  49. package/packages/mailx-store-web/web-settings.js +2 -0
  50. package/packages/mailx-store-web/web-settings.js.map +1 -1
  51. package/packages/mailx-store-web/web-settings.ts +2 -0
  52. package/packages/mailx-types/index.d.ts +2 -2
  53. package/packages/mailx-types/index.d.ts.map +1 -1
  54. package/packages/mailx-types/index.js +1 -1
  55. package/packages/mailx-types/index.js.map +1 -1
  56. package/packages/mailx-types/index.ts +2 -2
  57. package/packages/mailx-types/package.json +1 -1
  58. package/packages/mailx-types/trust.d.ts +35 -0
  59. package/packages/mailx-types/trust.d.ts.map +1 -1
  60. package/packages/mailx-types/trust.js +129 -8
  61. package/packages/mailx-types/trust.js.map +1 -1
  62. package/packages/mailx-types/trust.ts +152 -8
package/.commitmsg CHANGED
@@ -1,11 +1,39 @@
1
- Trust banner names the intermediary and the action that trusts it
2
-
3
- "Is there a 'trust this intermediary' operation or do I need to edit it
4
- manually? If so the banner should tell which and how" (Bob 2026-09-08). The
5
- button existed since v1.2.308 but the finding text did not mention it. The
6
- spam-verdict note now ends with: Sent on the author's behalf by google.com,
7
- which signed it; "Trust google.com as an intermediary" stops this note for
8
- mail google.com signs. The redirector warning ends the same way, naming the
9
- signer — or, when nobody signed for the redirector host, says plainly that
10
- there is no intermediary to trust and only the sender could vouch for the
11
- link. mailx-types 0.1.85.
1
+ Trusted mailing lists: "how do I say that this is a valid mailing list message"
2
+
3
+ Bob 2026-09-09, on an IEEE-CS meeting announcement through MIT's Mailman.
4
+ The list keeps the poster's own From (gmail.com publishes no DMARC policy,
5
+ so Mailman does not rewrite it), re-sends from its own host with its own
6
+ bounce address, and tags the subject and appends a footer which breaks
7
+ the gmail.com and mitprod signatures. iecc.com recorded both as dkim=fail,
8
+ SpamAssassin fired POSSIBLE_GMAIL_PHISHER / FREEMAIL_FORGED_FROMDOMAIN /
9
+ DKIM_ADSP_CUSTOM_MED, and the chip went red on a talk announcement. The
10
+ author can never be proved on list mail, so per-sender trust had nothing
11
+ to hold on to and the banner (correctly) offered no button.
12
+
13
+ The LIST is proved: spf=pass for the mit.edu bounce address, the same host
14
+ as Sender:. New allowlist.jsonc `trustedLists`, keyed on List-Id — the one
15
+ header identical on every post whoever wrote it. An entry counts only on a
16
+ message the topmost Authentication-Results proves the list host sent
17
+ (spf=pass for the Sender:/bounce domain, or dkim=pass for the list domain,
18
+ which ISOC's Mailman adds). On a proved list, trusted or not, the author's
19
+ DKIM failure and the From-vs-path rules (LIST_RELAY_RULES in trust.ts) are
20
+ set aside as the list's doing; trusted + proved silences the verdict and
21
+ hides the chip, exactly as a trusted intermediary does. SPF and DMARC
22
+ failures and URL/phishing listings are never silenced — a list member can
23
+ post a bad link like anyone else.
24
+
25
+ Actions: "Trust the ‹List-Id› list" under a spam-verdict banner; "Trust
26
+ the ‹List-Id› mailing list" in the From address's right-click menu, which
27
+ the chip's tooltip now points to (an unflagged post shows only the chip).
28
+ trustSenderOrDomain gains type "list"; TRUST_LIST_KEYS in mailx-types is
29
+ now the single table mapping trust type → allowlist key for the desktop
30
+ service and the Android web-service, which each carried their own ternary.
31
+ Android web-settings DEFAULT_ALLOWLIST gains trustedLists.
32
+
33
+ Tests: IEEE-CS headers verbatim (chip kind no longer forgery; trusted hides
34
+ it; flagged variant cautions naming the list and the action, then goes
35
+ quiet), ISOC (DKIM-proved list), an unproved List-Id (just a header), a
36
+ phish listing and an SPF failure on a trusted list (never silenced).
37
+ mailx-types 0.1.89, mailx-settings 0.1.82, mailx-store 0.1.119,
38
+ mailx-service 0.1.35, mailx-store-web 0.1.111. docs/allowlist.md and
39
+ docs/spam-false-positives.md updated; TODO C165.
@@ -0,0 +1,46 @@
1
+ # Trusted mailing lists (session note, Claude Code 2026-09-09)
2
+
3
+ Bob: "how do i say that this is a valid mailing list message" → "implement it".
4
+
5
+ ## Problem
6
+ IEEE-CS list (MIT Mailman, List-Id ieee-cs.mit.edu) leaves the poster's own From
7
+ (petmager@gmail.com; gmail has no DMARC policy so no From rewrite). Mailman's subject tag
8
+ + footer break both DKIM signatures (gmail.com, mitprod.onmicrosoft.com) → iecc records
9
+ dkim=fail; SA fires POSSIBLE_GMAIL_PHISHER, FREEMAIL_FORGED_FROMDOMAIN etc. → mailx kind
10
+ "forgery", red chip; sender is unproved so no trust button and an allowlist sender entry is
11
+ ignored by design. Only stable, verifiable identity = the LIST: spf=pass for the mit.edu
12
+ bounce address (Sender:/Return-Path) + List-Id.
13
+
14
+ ## Design
15
+ - allowlist.jsonc `trustedLists: string[]` keyed on List-Id value (e.g. "ieee-cs.mit.edu").
16
+ - trust.ts: listProof(input) → { id, proved, host }. Proof = topmost Authentication-Results
17
+ has spf=pass whose mailfrom domain is sameOrg with Sender: domain or the List-Id, OR
18
+ dkim=pass header.d sameOrg with either. List alone proves nothing.
19
+ - On a PROVED list (trusted or not): author-side DKIM failure is the list's doing → dropped
20
+ from authFailures (SPF/DMARC failures stay vetoes); LIST_RELAY_RULES (rules that test the
21
+ From domain against the transport, which a list breaks by design: POSSIBLE_GMAIL_PHISHER,
22
+ FREEMAIL_FORGED_FROMDOMAIN, DKIM_ADSP_*, NML_ADSP_*, FORGED_<provider>_RCVD) → neutral.
23
+ URL/phish listings stay forgery.
24
+ - Trusted + proved list → server-spam-verdict silenced + chip hidden (same as intermediary),
25
+ unless kind is still forgery / SPF or DMARC failed.
26
+ - Proved, untrusted → caution note names the list and the button; finding.list = List-Id;
27
+ SpamScore.listId/listProved/listTrusted for the chip + From-address context menu item.
28
+ - IPC: trustSenderOrDomain(type "list") → trustedLists. Files: mailx-service/index.ts,
29
+ web-service.ts (android), api-client.ts type, viewer buttons. jsonrpc/mailxapi/bootstrap
30
+ pass `type` through untouched.
31
+ - Tests: tests/trust.test.ts with the IEEE-CS headers verbatim (trimmed).
32
+
33
+ ## Files to touch
34
+ packages/mailx-types/trust.ts (+bump), mailx-settings/index.ts DEFAULT_ALLOWLIST (+bump),
35
+ mailx-store/store.ts trustInput (+bump), mailx-service/index.ts (+bump), mailx-store-web
36
+ web-service.ts + web-settings.ts DEFAULT_ALLOWLIST (+bump), client/lib/api-client.ts,
37
+ client/components/message-viewer.ts, docs/allowlist.md (+ mailx-settings/docs copy),
38
+ tests/trust.test.ts, .commitmsg, TODO.md C165, root version 1.2.311. Then rebuild.cmd.
39
+
40
+ ## Status
41
+ - [x] trust.ts
42
+ - [x] settings/store/service/web
43
+ - [x] client (banner button, From-menu item, chip tooltip)
44
+ - [x] tests pass (tests/trust.test.ts 40/40)
45
+ - [x] docs/TODO/.commitmsg/versions (v1.2.311)
46
+ - [ ] rebuild.cmd — running next; app must be restarted to pick up the new bundle
package/TODO.md CHANGED
@@ -144,7 +144,7 @@ None of these are truly showstoppers — mailx is usable — but they're the nex
144
144
 
145
145
  | # | Status | Item |
146
146
  |---|---|---|
147
- | **C165** | PARTIAL | Spam banner fires on subscribed newsletters (Constant Contact / Adobe / Mokin). **Rule classification + DMARC veto shipped v1.2.286** — severity now comes from what the `tests=` rules test, not from the score; red is reserved for forgery. **Still owed: sender trust** via `allowlist.jsonc` + an inline trust action on the banner. **Do not tune the `threshold/2` chip floor.** [`docs/spam-false-positives.md`](spam-false-positives.md). [S] |
147
+ | **C165** | PARTIAL | Spam banner fires on subscribed newsletters (Constant Contact / Adobe / Mokin). **Rule classification + DMARC veto shipped v1.2.286** — severity now comes from what the `tests=` rules test, not from the score; red is reserved for forgery. **Still owed: sender trust** via `allowlist.jsonc` + an inline trust action on the banner. **Do not tune the `threshold/2` chip floor.** **Trusted mailing lists shipped v1.2.311** (`trustedLists` by List-Id, proof = server SPF/DKIM pass for the list host). [`docs/spam-false-positives.md`](spam-false-positives.md). [S] |
148
148
  | [**S56**](#ext56) | OPEN | Row-objects own the preview pane — the last open slice of the old "S1 local-first refactor" umbrella. All S1 sub-shipments (tombstones, opaque UUIDs, stable row UUID, Message-ID move-detection, pink rows, viewer-reacts-to-list-replace, unified-inbox pink) are in Done under their version tags; this is what remains. ✓ (can start the `focus()`/`unfocus()` seam; full migration is more than one session) |
149
149
  | [**S9**](#ext9) | PARTIAL | Predownloading gaps — 60s prefetch landed v1.0.321; per-account prefetch guard already in mailx-imap (`prefetchingAccounts` Set). Remaining: batch body prefetch (C24) for 10–50× speedup. |
150
150
  | [**S51**](#ext51) ❓ | PARTIAL | Calendar Thunderbird-style sidebar. Sidebar UI + visible-by-default + 4-column grid reflow shipped v1.0.375/376. **Still owed**: Google Calendar live fetch via service-side proxy. Open Q: keep the full-screen calendar modal, or retire in favor of sidebar-only? |
@@ -230,7 +230,7 @@ Items blocked on a design decision. Short-form question here; full context in th
230
230
  | **C129** | OPEN | **Android prod/dev split via filename/URL channel** — User 2026-05-09 (revised): no separate MAUI project. Same source tree builds; output filenames distinguish channels: Phase 1 (start now) writes `rmfmaildev.apk` alongside today's `mailx-maui.apk`. Phase 2 (when a release is declared) switches to `rmfmail-<version>.apk` versioned + `rmfmaildev.apk` for latest, with promotion = re-pointing `rmfmail.apk` at a chosen version. Single APK contents per build; channel is runtime setting in AppUpdater (Settings toggle for which URL to poll). Side-by-side install on one device NOT supported by this design (single Android package id) — that's a separate need. See `docs/prod-android.md` for full plan. Phase 1 ~30 min; full flow ~2.5 hours when activated. |
231
231
  | **C130** | OPEN | **Reset prefetch error budget after fresh sync** — Android: `errors`/`failedThisSession` accumulate within a prefetch session. Already resets on next syncAll cycle (since each `prefetchBodies(account)` call is fresh). Verify this works in practice via logit; if a long sync session burns through budget and stays stuck, add an explicit reset on `bodyAvailable` events that fire while prefetch is still running. |
232
232
  | **C164** | RESEARCHED | **Thundermail / JMAP provider** — full protocol survey at [`docs/thundermail-jmap.md`](thundermail-jmap.md), probed live 2026-08-17. Thundermail is Stalwart, so there is no proprietary API: IMAP 993 / SMTP 465 work **today with zero code** (an `accounts.jsonc` entry and nothing else), CardDAV `/dav/card` + CalDAV `/dav/cal` are http-basic, and JMAP is live at `https://mail.thundermail.com/jmap/` advertising core / mail / submission / calendars / contacts / filenode / sieve / quota plus **WebSocket push** (`supportsPush: true`). JMAP is the attractive bet: it kills the IDLE lane, the 5-lane connection semaphore and the Dovecot 20-conn ceiling, needs no transport injection (one path for Android and desktop, cf. C125), and `Email/changes` is native set-difference sync. **From address: resolved — good.** The 15-alias cap is `@thundermail.com`/`@tb.pro` only; custom domains get unlimited aliases plus a catch-all (empty-name alias), and with catch-all on you can send from any prefix unregistered — their own example is the per-vendor pattern already used on `bob.ma`. mailx needs ~nothing here (per-message From identity shipped v1.2.201); only Stalwart-side `MAIL FROM`/DKIM validation for an unregistered prefix is untested. **Still blocking/staged:** JMAP-calendars and `filenode` are unratified drafts; OAuth `scopes_supported` covers mail only (no calendars/contacts scope); invite-only beta so there is nothing to test against; and **no Drive equivalent** — Thunderbird Send is one-shot file *sharing*, not a listable revisable store, so mailx's shared-config dependency (`accounts.jsonc` / `contacts.jsonc` / preferences / allowlist on GDrive) has no home. **C161 is therefore the prerequisite, not JMAP** — the `_rmfmail` hidden-IMAP-folder state channel removes the Drive dependency for every account type at once and keeps working unchanged if JMAP lands later. Until C161 ships, adopting Thundermail is a half-migration that trades a Google dependency for a Thunderbird one. |
233
- | **C165** | PARTIAL | **Spam-banner false positives — trust the sender, do not tune the threshold** — full diagnosis and a "What shipped" section at [`docs/spam-false-positives.md`](spam-false-positives.md). Bob 2026-08-27: the red "This message is not what it says it is" banner fired on Constant Contact / Adobe MAX / Mokin newsletters he subscribed to, because `serverSpamVerdict()` consumed SpamAssassin’s **bottom line** (a delivery decision) to answer a different question (is this forged) and never parsed the `tests=` list in the same header. **Shipped v1.2.286–289:** `tests=` is classified into forgery / association / bulk / neutral / other; `X-Spam-Report` supplies each rule’s score and description so findings name their evidence; the **topmost** `Authentication-Results` is read for a DMARC-aligned pass with `header.from=` checked against the real From. Severity follows the rules — danger only on a forgery rule or a server-recorded SPF/DKIM/DMARC failure (phishing/malware URL listings count); a new neutral `info` severity (no headline, no "do not open" footer, grey chip) when the sender is proved and every scoring rule is classifiable, or the score is all bulk; caution otherwise, **including a proved sender whose rules are unclassified** — unknown is never a reason to go quiet (326 messages in the store). `spamScoreOf()` and the banner now come from one analysis so they cannot disagree. Measured over 20,291 messages: of 1,131 flagged, 294 stay red, 672 caution, 165 quiet; the 2026-08-25 phishes stay red. Tests: all four samples verbatim + both ways the DMARC proof could be gamed. Follow-up v1.2.288: proof of the From line accepts `DKIM_VALID_AU` and `ALL_TRUSTED` as well as DMARC (`provedBy` names which, in banner and chip tooltip) — Bob's OWN mail carries no `Authentication-Results` at all and was getting a caution banner over a URL it quoted. **John Levine 2026-08-27 confirms the direction:** DCC means bulk, "you have to whitelist the senders … I haven't tried to do a global one because everyone's different" — bulk is a fact about the mail, wanted is a fact about the reader. Server-side whitelisting is out for Bob (he whitelists `@bob.ma` and "that gets abused"), so the client list must key on evidence (DKIM-verified domain, Delivered-To tag), not the From line. **Still owed:** sender trust — `allowlist.jsonc` already is the approved-sender store (`getAllowlist()` at `mailx-service/index.ts:1980`) and the viewer already has “Trust all senders at ‹domain›”, but nothing consults it when computing `trust`/`spamScore` (`store.ts:586`) and the banner has no inline trust action. Open Qs unchanged: trust key granularity (shared ESP domain vs Reply-To org vs Delivered-To tag), whether trust suppresses the whole banner or only the bulk/association half (leaning: never suppress forgery evidence), and auto-trust on reply/move-out-of-Junk. |
233
+ | **C165** | PARTIAL | **Spam-banner false positives — trust the sender, do not tune the threshold** — full diagnosis and a "What shipped" section at [`docs/spam-false-positives.md`](spam-false-positives.md). Bob 2026-08-27: the red "This message is not what it says it is" banner fired on Constant Contact / Adobe MAX / Mokin newsletters he subscribed to, because `serverSpamVerdict()` consumed SpamAssassin’s **bottom line** (a delivery decision) to answer a different question (is this forged) and never parsed the `tests=` list in the same header. **Shipped v1.2.286–289:** `tests=` is classified into forgery / association / bulk / neutral / other; `X-Spam-Report` supplies each rule’s score and description so findings name their evidence; the **topmost** `Authentication-Results` is read for a DMARC-aligned pass with `header.from=` checked against the real From. Severity follows the rules — danger only on a forgery rule or a server-recorded SPF/DKIM/DMARC failure (phishing/malware URL listings count); a new neutral `info` severity (no headline, no "do not open" footer, grey chip) when the sender is proved and every scoring rule is classifiable, or the score is all bulk; caution otherwise, **including a proved sender whose rules are unclassified** — unknown is never a reason to go quiet (326 messages in the store). `spamScoreOf()` and the banner now come from one analysis so they cannot disagree. Measured over 20,291 messages: of 1,131 flagged, 294 stay red, 672 caution, 165 quiet; the 2026-08-25 phishes stay red. Tests: all four samples verbatim + both ways the DMARC proof could be gamed. Follow-up v1.2.288: proof of the From line accepts `DKIM_VALID_AU` and `ALL_TRUSTED` as well as DMARC (`provedBy` names which, in banner and chip tooltip) — Bob's OWN mail carries no `Authentication-Results` at all and was getting a caution banner over a URL it quoted. **John Levine 2026-08-27 confirms the direction:** DCC means bulk, "you have to whitelist the senders … I haven't tried to do a global one because everyone's different" — bulk is a fact about the mail, wanted is a fact about the reader. Server-side whitelisting is out for Bob (he whitelists `@bob.ma` and "that gets abused"), so the client list must key on evidence (DKIM-verified domain, Delivered-To tag), not the From line. **Still owed:** sender trust — `allowlist.jsonc` already is the approved-sender store (`getAllowlist()` at `mailx-service/index.ts:1980`) and the viewer already has “Trust all senders at ‹domain›”, but nothing consults it when computing `trust`/`spamScore` (`store.ts:586`) and the banner has no inline trust action. Open Qs unchanged: trust key granularity (shared ESP domain vs Reply-To org vs Delivered-To tag), whether trust suppresses the whole banner or only the bulk/association half (leaning: never suppress forgery evidence), and auto-trust on reply/move-out-of-Junk. **v1.2.311 — trusted mailing lists.** Bob 2026-09-09, on an IEEE-CS announcement (MIT Mailman, poster's own gmail.com From kept, both signatures broken by the subject tag + footer, `POSSIBLE_GMAIL_PHISHER` + `dkim=fail` → red chip, author unprovable so no trust button by design): "how do I say that this is a valid mailing list message". New `allowlist.jsonc` `trustedLists` keyed on **List-Id**; counts only when the topmost `Authentication-Results` proves the list host (`spf=pass` for the Sender:/bounce domain, or `dkim=pass` for the list domain). On a proved list the author's DKIM failure and the From-vs-path rules (`LIST_RELAY_RULES` in trust.ts) are set aside as the list's doing; trusted + proved silences the verdict and the chip; SPF/DMARC failures and URL/phish listings never. Buttons: "Trust the ‹List-Id› list" on the banner, "Trust the ‹List-Id› mailing list" in the From address menu (chip tooltip points there). `TRUST_LIST_KEYS` in mailx-types is now the one table mapping trust type → allowlist key for desktop and Android. Tests: IEEE-CS headers verbatim + ISOC (DKIM-proved list) + unproved List-Id + phish listing + SPF fail. |
234
234
 
235
235
  <a id="concerns"></a>
236
236
 
@@ -2233,7 +2233,12 @@ function sameOrg(a, b) {
2233
2233
  return false;
2234
2234
  return x === y || x.endsWith("." + y) || y.endsWith("." + x);
2235
2235
  }
2236
- function classifyRule(name, dmarcFailed) {
2236
+ function classifyRule(name, dmarcFailed, listRelayed = false) {
2237
+ if (listRelayed) {
2238
+ for (const re of LIST_RELAY_RULES)
2239
+ if (re.test(name))
2240
+ return "neutral";
2241
+ }
2237
2242
  if (dmarcFailed && name === "DKIM_INVALID")
2238
2243
  return "forgery";
2239
2244
  for (const re of FORGERY_RULES)
@@ -2275,6 +2280,32 @@ function provedIntermediary(input) {
2275
2280
  return entry;
2276
2281
  return "";
2277
2282
  }
2283
+ function listProof(input) {
2284
+ const none = { id: "", proved: false, host: "" };
2285
+ const raw = header(input.headerLines, "list-id");
2286
+ if (!raw)
2287
+ return none;
2288
+ const id = (raw.match(/<([^>]+)>/)?.[1] || raw).trim().toLowerCase();
2289
+ if (!id)
2290
+ return none;
2291
+ const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
2292
+ const hosts = [senderDomain, id].filter(Boolean);
2293
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
2294
+ for (const part of auth.split(";")) {
2295
+ if (/\bspf=pass\b/i.test(part)) {
2296
+ const mailfrom = (part.match(/(?:spf|smtp)\.mailfrom=([^\s;,]+)/i)?.[1] || "").toLowerCase();
2297
+ const domain = mailfrom.includes("@") ? mailfrom.split("@")[1] : mailfrom;
2298
+ if (domain && hosts.some((h) => sameOrg(domain, h)))
2299
+ return { id, proved: true, host: domain };
2300
+ }
2301
+ if (/\bdkim=pass\b/i.test(part)) {
2302
+ const d = (part.match(/header\.d=([^\s;,]+)/i)?.[1] || "").toLowerCase();
2303
+ if (d && hosts.some((h) => sameOrg(d, h)))
2304
+ return { id, proved: true, host: d };
2305
+ }
2306
+ }
2307
+ return { id, proved: false, host: "" };
2308
+ }
2278
2309
  function dmarcProof(input) {
2279
2310
  const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
2280
2311
  if (!auth)
@@ -2327,6 +2358,10 @@ function analyzeServerSpam(input) {
2327
2358
  const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
2328
2359
  const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
2329
2360
  const dmarc = dmarcProof(input);
2361
+ const listFound = listProof(input);
2362
+ const trustedLists = (input.trustedLists || []).map((l) => (l || "").trim().toLowerCase());
2363
+ const list = { ...listFound, trusted: listFound.proved && trustedLists.includes(listFound.id) };
2364
+ const authFailures = list.proved && !dmarc.failed ? dmarc.authFailures.filter((f) => f !== "DKIM") : dmarc.authFailures;
2330
2365
  const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
2331
2366
  const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "").replace(/\s+/g, "").split(",").map((n) => n.toUpperCase()).filter(Boolean);
2332
2367
  const rules = names.map((name) => {
@@ -2335,7 +2370,7 @@ function analyzeServerSpam(input) {
2335
2370
  name,
2336
2371
  score: found ? found.score : NaN,
2337
2372
  description: found ? found.description : "",
2338
- cls: classifyRule(name, dmarc.failed)
2373
+ cls: classifyRule(name, dmarc.failed, list.proved)
2339
2374
  };
2340
2375
  });
2341
2376
  const scored = (r) => !(r.score <= 0);
@@ -2344,7 +2379,7 @@ function analyzeServerSpam(input) {
2344
2379
  const unknownWeight = unknown.reduce((sum, r) => sum + r.score, 0);
2345
2380
  const unknownCouldFlagAlone = !unknown.length ? false : !Number.isFinite(unknownWeight) || !Number.isFinite(threshold) ? true : unknownWeight >= threshold;
2346
2381
  const bulkOnly = rules.every((r) => !scored(r) || r.cls === "bulk" || r.cls === "neutral");
2347
- const kind = dmarc.authFailures.length || present("forgery") ? "forgery" : present("association") ? "association" : unknownCouldFlagAlone ? "unclassified" : rules.length ? "bulk" : "unclassified";
2382
+ const kind = authFailures.length || present("forgery") ? "forgery" : present("association") ? "association" : unknownCouldFlagAlone ? "unclassified" : rules.length ? "bulk" : "unclassified";
2348
2383
  const has = (name) => rules.some((r) => r.name === name);
2349
2384
  const provedBy = dmarc.pass ? "dmarc" : has("DKIM_VALID_AU") ? "dkim" : has("ALL_TRUSTED") ? "trusted" : "";
2350
2385
  const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
@@ -2364,10 +2399,11 @@ function analyzeServerSpam(input) {
2364
2399
  provedDomain: dmarc.domain || fromDomain,
2365
2400
  provedPolicy: dmarc.policy,
2366
2401
  bayesHam: rules.some((r) => r.name === "BAYES_00" || r.name === "BAYES_01"),
2367
- authFailures: dmarc.authFailures,
2402
+ authFailures,
2368
2403
  reasons,
2369
2404
  intermediary: provedIntermediary(input),
2370
- signers: dkimSigners(input)
2405
+ signers: dkimSigners(input),
2406
+ list
2371
2407
  };
2372
2408
  }
2373
2409
  function provedNote(a) {
@@ -2386,13 +2422,15 @@ function serverSpamVerdict(input) {
2386
2422
  const a = analyzeServerSpam(input);
2387
2423
  if (!a || !a.flagged)
2388
2424
  return null;
2389
- const trustedAndProved = input.senderTrusted && a.proved || !!a.intermediary;
2425
+ const trustedAndProved = input.senderTrusted && a.proved || !!a.intermediary || a.list.trusted;
2390
2426
  if (trustedAndProved && a.kind !== "forgery" && !a.authFailures.length)
2391
2427
  return null;
2392
2428
  const senderDomain = (bare(header(input.headerLines, "sender")).split("@")[1] || "").toLowerCase();
2393
2429
  const relay = senderDomain ? a.signers.find((s) => sameOrg(s, senderDomain)) : "";
2394
2430
  const via = relay && !a.proved ? { via: relay } : {};
2395
2431
  const relayNote = relay && !a.proved ? `. Sent on the author's behalf by ${relay}, which signed it; "Trust ${relay} as an intermediary" stops this note for mail ${relay} signs.` : "";
2432
+ const listNote = a.list.proved && !a.list.trusted && !a.proved ? `. Delivered by the ${a.list.id} mailing list \u2014 ${a.list.host} proved it sent this \u2014 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.` : "";
2433
+ const viaList = listNote ? { list: a.list.id } : {};
2396
2434
  const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold) ? `SpamAssassin score ${a.score} of ${a.threshold}` : "flagged by SpamAssassin";
2397
2435
  const why = a.reasons.length ? ` \u2014 ${a.reasons.join("; ")}` : "";
2398
2436
  const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
@@ -2423,14 +2461,16 @@ function serverSpamVerdict(input) {
2423
2461
  id: "server-spam-verdict",
2424
2462
  severity: "caution",
2425
2463
  text: "This claims to be a sender you trust, but nothing proves it is.",
2426
- detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}`
2464
+ detail: `No DMARC pass, no valid signature from the sending domain, and it passed through hosts your server does not trust. ${numbers}${why}${listNote}`,
2465
+ ...viaList
2427
2466
  };
2428
2467
  return {
2429
2468
  id: "server-spam-verdict",
2430
2469
  severity: "caution",
2431
2470
  text: "Your mail server classified this as spam before delivering it.",
2432
- detail: `${proof}${numbers}${why}${bayes}${relayNote}`,
2433
- ...via
2471
+ detail: `${proof}${numbers}${why}${bayes}${relayNote}${listNote}`,
2472
+ ...via,
2473
+ ...viaList
2434
2474
  };
2435
2475
  }
2436
2476
  function selfSpoof(input) {
@@ -2533,6 +2573,8 @@ function redirectorLink(input) {
2533
2573
  continue;
2534
2574
  if (sameOrg(target.hostname, url.hostname))
2535
2575
  continue;
2576
+ if (senderDomain && sameOrg(target.hostname, senderDomain))
2577
+ continue;
2536
2578
  const signer = signers.find((s) => sameOrg(s, url.hostname));
2537
2579
  const how = signer ? ` ${signer} signed this message; "Trust ${signer} as an intermediary" stops this warning for links through ${signer} in mail it signs.` : ` Nobody signed this message for ${url.hostname}, so there is no intermediary to trust; only the sender could vouch for the link.`;
2538
2580
  return {
@@ -2603,14 +2645,23 @@ function spamScoreOf(input) {
2603
2645
  proved: a.proved,
2604
2646
  provedBy: a.provedBy,
2605
2647
  bulkOnly: a.bulkOnly,
2606
- trusted: (!!input.senderTrusted && a.proved || !!a.intermediary) && a.kind !== "forgery" && !a.authFailures.length,
2607
- reasons: a.reasons
2648
+ trusted: (!!input.senderTrusted && a.proved || !!a.intermediary || a.list.trusted) && a.kind !== "forgery" && !a.authFailures.length,
2649
+ reasons: a.reasons,
2650
+ listId: a.list.id,
2651
+ listProved: a.list.proved,
2652
+ listTrusted: a.list.trusted
2608
2653
  };
2609
2654
  }
2610
- var FORGERY_RULES, ASSOCIATION_RULES, BULK_RULES, NEUTRAL_RULES, OVERLAY_MIN_EDGE_PX, RELAY_IDENTITY_HEADERS, ZERO_WIDTH_MIN_OCCURRENCES;
2655
+ var TRUST_LIST_KEYS, FORGERY_RULES, ASSOCIATION_RULES, BULK_RULES, NEUTRAL_RULES, LIST_RELAY_RULES, OVERLAY_MIN_EDGE_PX, RELAY_IDENTITY_HEADERS, ZERO_WIDTH_MIN_OCCURRENCES;
2611
2656
  var init_trust = __esm({
2612
2657
  "packages/mailx-types/trust.js"() {
2613
2658
  "use strict";
2659
+ TRUST_LIST_KEYS = {
2660
+ sender: "trustedSenders",
2661
+ domain: "trustedDomains",
2662
+ intermediary: "trustedIntermediaries",
2663
+ list: "trustedLists"
2664
+ };
2614
2665
  FORGERY_RULES = [
2615
2666
  /^SPF_(HELO_)?(FAIL|SOFTFAIL)$/,
2616
2667
  /^(KAM_)?DMARC_(FAIL|REJECT|QUAR)/,
@@ -2686,6 +2737,16 @@ var init_trust = __esm({
2686
2737
  /^NO_RELAYS$/,
2687
2738
  /^ALL_TRUSTED$/
2688
2739
  ];
2740
+ LIST_RELAY_RULES = [
2741
+ /^POSSIBLE_GMAIL_PHISHER$/,
2742
+ // From gmail.com, not sent through Google
2743
+ /^FREEMAIL_FORGED_FROMDOMAIN$/,
2744
+ // From and envelope are different freemail domains
2745
+ /^(NML_|DKIM_)ADSP_/,
2746
+ // author domain did not sign what arrived
2747
+ /^FORGED_[A-Z]+_RCVD$/
2748
+ // From ‹provider› without ‹provider›'s Received
2749
+ ];
2689
2750
  OVERLAY_MIN_EDGE_PX = 2e3;
2690
2751
  RELAY_IDENTITY_HEADERS = [
2691
2752
  "x-source-auth",
@@ -3089,6 +3150,7 @@ var mailx_types_exports = {};
3089
3150
  __export(mailx_types_exports, {
3090
3151
  CONTACT_RULES: () => CONTACT_RULES,
3091
3152
  REMINDER_STATE_FILE: () => REMINDER_STATE_FILE,
3153
+ TRUST_LIST_KEYS: () => TRUST_LIST_KEYS,
3092
3154
  addContactsDenylistEntry: () => addContactsDenylistEntry,
3093
3155
  addContactsPreferredEntry: () => addContactsPreferredEntry,
3094
3156
  answeredOf: () => answeredOf,
@@ -4208,7 +4270,9 @@ var init_web_settings = __esm({
4208
4270
  flaggedSenders: [],
4209
4271
  flaggedDomains: [],
4210
4272
  trustedSenders: [],
4211
- trustedDomains: []
4273
+ trustedDomains: [],
4274
+ // Mailing lists by List-Id — mirrors mailx-settings DEFAULT_ALLOWLIST (2026-09-09).
4275
+ trustedLists: []
4212
4276
  };
4213
4277
  PREV_KEEP = 5;
4214
4278
  SHARED_REFRESH_MS = 5 * 6e4;
@@ -7138,7 +7202,7 @@ var WebMailxService = class _WebMailxService {
7138
7202
  return { trusted: false };
7139
7203
  let trusted = false;
7140
7204
  await updateAllowlist((list) => {
7141
- const key = type === "sender" ? "trustedSenders" : type === "intermediary" ? "trustedIntermediaries" : "trustedDomains";
7205
+ const key = TRUST_LIST_KEYS[type];
7142
7206
  const arr = Array.isArray(list[key]) ? list[key] : [];
7143
7207
  const idx = arr.findIndex((x) => (x || "").toLowerCase() === v);
7144
7208
  if (idx >= 0) {
@@ -7172,7 +7236,8 @@ var WebMailxService = class _WebMailxService {
7172
7236
  flaggedDomains: list.flaggedDomains || [],
7173
7237
  trustedSenders: list.trustedSenders || [],
7174
7238
  trustedDomains: list.trustedDomains || [],
7175
- trustedIntermediaries: list.trustedIntermediaries || []
7239
+ trustedIntermediaries: list.trustedIntermediaries || [],
7240
+ trustedLists: list.trustedLists || []
7176
7241
  };
7177
7242
  }
7178
7243
  async allowRemoteContent(type, value) {