Files
selfpost/docs/plans/inbound-antispam-panel.md
T
mixandClaude Opus 5.5 56c5dfee40 inbound-antispam-panel: Inbound > Log and Inbound > Filter lists
Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.

GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.

GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.

Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.

Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.

Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:50:34 +03:00

11 KiB
Raw Blame History

Plan: inbound-antispam-panel

Status: agreed
Date: 2026-08-19
Version: 2.1.0 MINOR (opt-in; no change to outbound-only or inbound-without-filter paths). Was 1.10.0; on 2026-10-09 the owner put the panel redesign first, so this ships on the 2.0 baseline and its screens are built from the redesign's component kit (panel-redesign.md § Component kit).


Goal

Give the operator a panel view of inbound anti-spam decisions and editable allow/deny lists that tune filter behaviour — without SelfPost shipping or starting an anti-spam engine (product.md out-of-scope list unchanged).

Typical journal row:

When From To Subject Decision
2026-08-19 09:14 spammer@evil.example user@inbound.example Cheap pills reject (rspamd: BAYES_SPAM +0.95)

Lists let the operator correct false positives/negatives (e.g. always allow billing@vendor.example, block 192.0.2.0/24).

Context (as-built)

  • Inbound relay ships in [1.4.0] (INBOUND_RELAY_ENABLE); optional INBOUND_ANTISPAM_MILTER attaches an external filter (documented rspamd sidecar in deploy/antispam/docker-compose.antispam.yml).
  • The inbound smtpd has no journal-milter today — only outbound 465/587 writes send_log (architecture.md).
  • Inbound UI and handlers are global-administrator only today (requireInbound → requireGlobal).

Quarantine

Not in this plan. Held-mail review/release is tracked separately as roadmap candidate inbound-quarantine. The journal may still record a quarantine decision label when the external filter reports it, without SelfPost storing the message.

DMARC p=quarantine remains unrelated (outbound policy for receivers).

Decisions (closed 2026-08-19)

Capture mechanism

Inbound journal-milter in panel only — no rspamd history API polling in v1. A separate unix socket (INBOUND_JOURNAL_MILTER_SOCKET, default /run/selfpost/inbound-journal.sock) on the inbound smtpd only; fail-open (default_action=accept).

Milter order on port 25 (when antispam is enabled):

smtpd_milters = { antispam, inbound-journal }

Antispam runs first; inbound-journal runs at end-of-message on the accept path and reads rspamd-added headers (X-Rspamd-Action, and X-Spamd-Result for the score and symbols - rspamd has no X-Rspamd-Score; the sidecar adds both through deploy/antispam/rspamd/milter_headers.conf).

Antispam rejects do not reach end-of-message on the journal milter. v1 also tails mail.log for inbound postfix/smtpd milter-reject / reject lines and inserts journal rows (client IP, envelope from/to when logged, reason text, subject empty if rejected before DATA). DMARC-ingest pipe traffic is out of scope for this tailer.

Journal without INBOUND_ANTISPAM_MILTER

Journal stays on whenever INBOUND_RELAY_ENABLE=true (not tied to the antispam hook). Without an external filter:

  • accept rows — message relayed (engine selfpost, detail empty);
  • reject / tempfail rows — from the mail.log tailer (Postfix policy, size limit, unknown recipient, etc.).

Panel copy explains that rspamd symbols appear only when the antispam milter is configured.

RBAC

Global administrator only for journal and lists — same as inbound relay. No domain-admin access in v1.

As built (2026-10-11): the lists are the global role's; the log follows the delegation of Inbound that 2.0 introduced after this was written - a user with the domain role sees the rows of the inbound domains assigned to them and nothing else, without the Allow / Deny sender links. That is what the accepted in-log mockup draws (no "global role" in its head). The owner was told and can turn it back into a global-only page.

Retention

Mirror send-log retention:

  • Settings key inbound_spam_log_retention_days on /server/settings (global admin).
  • Default 90 days; validation 7–365; seed from env INBOUND_SPAM_LOG_RETENTION_DAYS when the setting is missing.
  • Prune every 6 hours (same cadence as send-log retention).
  • Hard cap 10 000 rows after age prune (drop oldest) — busier than DMARC reports, lighter than unbounded growth.

Allow/deny lists

Topic Decision
Entry types sender address, sender domain (@domain suffix), client IP / CIDR
Precedence deny overrides allow
Scope instance-wide (not per inbound domain in v1)
Where applied rspamd map files under /data/antispam/, one per list and entry type (allow_ip.map, allow_from.map, allow_domain.map, deny_…) - an IP map and a sender map are different map types to rspamd; documented compose fragment mounts them into the sidecar. Deny is a prefilter reject, allow a weight of -100 (as built: two prefilters do not give "deny overrides allow")
SelfPost pre-check out of v1 — lists live in rspamd only
Edit access global administrator only
When inactive lists editable in the panel but UI warns that sync applies only when INBOUND_ANTISPAM_MILTER is set; postfix reload / rspamd reload documented in guide

List change → atomic map rewrite (same pattern as Postfix maps). No reload: rspamd rereads a changed map file by itself (about ten seconds on 3.11).

Activation

Condition Behaviour
INBOUND_RELAY_ENABLE=false no inbound journal milter, no UI, no tables written
inbound on, antispam empty journal + UI; lists visible but marked inactive for filter sync
inbound on + antispam set full journal (symbols on accept) + list sync

No separate feature flag beyond inbound relay + existing antispam env vars.

UI

The journal is Inbound › Log and list management is a new Inbound › Filter lists page (global-only: the lists are instance-wide) — see panel-redesign.md § Room for roadmap items. The journal table has filters (decision, domain, date range). Mockups are drawn first, in docs/assets/panel-redesign/panel/src/ under that plan's design contract — not in the superseded docs/assets/panel-ui/.

Schema (migration 0002_inbound_spam_log.sql, the first after the 2.0 baseline)

inbound_spam_log

Column Type Notes
id INTEGER PK
inbound_domain TEXT NOT NULL recipient domain (from relay_domains)
client_ip TEXT NOT NULL
from_addr TEXT NOT NULL envelope from
to_addr TEXT NOT NULL envelope to (one row per recipient)
subject TEXT NOT NULL truncated with mailhdr.SubjectMaxRunes (200)
decision TEXT NOT NULL accept, reject, tempfail, quarantine
engine TEXT NOT NULL rspamd, postfix, selfpost
detail TEXT NOT NULL symbol/score summary or log-line reason; may be empty
created_at TEXT NOT NULL RFC3339 UTC

Index on (created_at), (inbound_domain, created_at).

inbound_spam_list

Column Type Notes
id INTEGER PK
list_type TEXT NOT NULL allow or deny
entry_type TEXT NOT NULL address, domain, ip
value TEXT NOT NULL normalized entry
note TEXT NOT NULL operator comment; default ''
created_at TEXT NOT NULL

Unique on (list_type, entry_type, value).

Scope

In:

  • Schema, store CRUD, retention loop, mail.log tailer extension for inbound rejects.
  • Inbound journal-milter (fail-open) + Postfix postfix-config.sh wiring.
  • rspamd map sync under /data/antispam/.
  • Panel journal + list UI (global admin).
  • Full backup includes /data/antispam/ and new SQLite tables.
  • Tests; guide.md; architecture.md.

Out:

  • Shipping rspamd, ClamAV, or any filter binary inside the SelfPost image.
  • Outbound spam filtering.
  • inbound-quarantine.md storage/release.
  • rspamd rule editing, Bayes training UI, antivirus.
  • MIME replay from the panel (metadata only).
  • Per-domain lists; domain-admin RBAC.
  • rspamd history API polling.

Architecture

flowchart LR
  internet["Internet :25"] --> inbound["Postfix inbound smtpd"]
  inbound --> antispam["External antispam milter optional"]
  antispam --> journalM["inbound-journal milter fail-open"]
  journalM --> upstream["smtp upstream transport"]
  journalM --> sqlite["inbound_spam_log"]
  mailog["mail.log tailer"] --> sqlite
  listsUI["Panel allow/deny lists"] --> maps["/data/antispam/*.map"]
  maps --> antispam
  sqlite --> journalUI["Panel inbound spam journal"]

Security

  • Same validation whitelists as inbound relay (security.md).
  • Journal rows contain PII — retention and global-admin-only access.
  • Inbound journal milter fail-open must not block mail (backup-MX role).
  • List map writes injection-safe; deny wins over allow.

Done when

  • With inbound relay + antispam sidecar, panel shows accept rows with symbols and reject rows from mail.log tailing.
  • Operator can add/remove list entries; a test message reflects allow/deny.
  • With antispam hook off, journal still shows Postfix-level decisions; list UI shows inactive sync notice.
  • go vet, go test, image build green; guide and architecture updated.

Risks

  • rspamd header/symbol format drift — thin parser, pin sidecar tag in compose.
  • Journal volume — retention + 10k cap.
  • mail.log parse brittleness — unit tests on sample lines; inbound smtpd only.

Dependencies

  • Inbound relay, shipped in [1.4.0] — as built in architecture.md § Mail path. Its plan file was removed once it shipped.

Model routing (this plan)

Role Model
All implementation (code, tests, docs) Composer
Technical review (reviewer ≠ author) Opus
Security review Fable

Composer ships each step; Opus reviews the accumulated diff once implementation steps are done (fix loop: Composer addresses Opus findings, Opus re-checks). Fable runs after Opus sign-off.

Implementation checklist

Target version cut: 2.1.0 (MINOR), after 2.0.0. One commit per step; development.md § Plan checklists.

  • Agree journal fields, retention, and RBAC (this plan § Decisions) — Composer
  • Migration 0002_inbound_spam_log.sql on the 2.0 baseline — Composer
  • Inbound journal-milter + Postfix wiring — Composer
  • mail.log tailer: inbound reject rows — Composer
  • List CRUD + validation + atomic rspamd map sync — Composer
  • Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups in-log / in-filter-lists (add client IP and engine columns mockup-first), evidence pairs — Composer
  • Backup includes /data/antispam/ — Composer
  • Unit + handler tests — Composer
  • guide.md, architecture.md — Composer
  • go vet, go test, e2e if applicable — Composer
  • Technical review of inbound antispam changes — Opus
  • Security review inbound antispam path — Fable