From 56c5dfee406a74c3eb0f5a7b6dd3bad2268df51b Mon Sep 17 00:00:00 2001 From: Mix Date: Sun, 11 Oct 2026 23:50:34 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 16 +- cmd/panel/httpserver.go | 4 +- docs/guide.md | 7 + docs/plans/inbound-antispam-panel.md | 9 +- docs/roadmap.md | 2 +- internal/inbound/lists.go | 14 + internal/inbound/lists_test.go | 41 ++ internal/store/inboundspam.go | 126 ++++ internal/store/inboundspam_test.go | 94 +++ internal/web/handlers/handlers.go | 6 + internal/web/handlers/handlers_inbound_log.go | 238 +++++++ .../web/handlers/handlers_inbound_log_test.go | 637 ++++++++++++++++++ internal/web/handlers/handlers_settings.go | 58 +- internal/web/view/account.go | 12 + internal/web/view/components.go | 9 +- internal/web/view/components_test.go | 13 +- internal/web/view/fixtures_test.go | 48 +- internal/web/view/help.go | 25 +- internal/web/view/help_test.go | 27 + internal/web/view/inbound_domain_test.go | 2 +- internal/web/view/inbound_lists.go | 176 +++++ internal/web/view/inbound_log.go | 205 ++++++ internal/web/view/inbound_log_test.go | 256 +++++++ internal/web/view/kit.go | 6 +- internal/web/view/pages_test.go | 20 + .../web/view/templates/in_filter_lists.html | 63 ++ internal/web/view/templates/in_log.html | 22 + internal/web/view/templates/layout.html | 6 +- internal/web/view/templates/settings.html | 4 + internal/web/view/view.go | 2 + internal/web/web.go | 16 +- test/e2e/port25_features_test.go | 127 +++- 32 files changed, 2241 insertions(+), 50 deletions(-) create mode 100644 internal/web/handlers/handlers_inbound_log.go create mode 100644 internal/web/handlers/handlers_inbound_log_test.go create mode 100644 internal/web/view/inbound_lists.go create mode 100644 internal/web/view/inbound_log.go create mode 100644 internal/web/view/inbound_log_test.go create mode 100644 internal/web/view/templates/in_filter_lists.html create mode 100644 internal/web/view/templates/in_log.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d99a4a..bb7a428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version of a message that was accepted for an inbound domain: client address, envelope, subject and - with a spam filter attached - the filter's action, score and heaviest symbols. It only watches: it refuses nothing and Postfix - accepts mail when it is down. There is no page for the journal yet. + accepts mail when it is down. - **Refused inbound mail is recorded too**, from the mail log: a recipient Postfix refused at an inbound domain (unknown user, sender domain not found), and a message the spam filter rejected, deferred, held or @@ -42,8 +42,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version They are written as six files under `data/antispam/`, which the fragment mounts into the sidecar together with `antispam/rspamd/multimap.conf`; rspamd rereads a changed file by itself. Deny wins over allow. The files - are there from the first start with the inbound relay on; the page that - edits the lists comes next. + are there from the first start with the inbound relay on. +- **Inbound › Log**: the journal as a page - time, sender and client address, + recipient, subject, decision and why, filtered by domain, decision and day. + A user with the *domain* role sees the rows of the inbound domains assigned + to them. For the global role each row links to the filter lists with the + sender filled in (*Deny sender* / *Allow sender*). +- **Inbound › Filter lists** (global role): add and remove allow and deny + entries; the page says where the lists go and whether the filter is on to + read them. +- **Server › Settings**: *Keep inbound log rows, days*, shown with the inbound + relay on. +- Help: *Inbound log* and *Filter lists*. - New setting `ANTISPAM_DIR` (default `/data/antispam`). - New setting `INBOUND_JOURNAL_MILTER_SOCKET` (default `/run/selfpost/inbound-journal.sock`). diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 0078fe5..13e416f 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -98,7 +98,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { // effective config, including a manual override; the panel keeps this // snapshot for the process lifetime (architecture.md). retryPolicy := postfix.LoadRetryPolicy() - return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{ + return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, SubmissionEnabled: cfg.submissionEnabled, @@ -121,6 +121,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { InboundAntispamAction: cfg.inboundAntispamAction, DMARCEnabled: cfg.dmarcEnabled, SendLogRetentionEnvDefault: cfg.retentionDays, + + InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays, }, cfg.setupTokenPath) } diff --git a/docs/guide.md b/docs/guide.md index 59c286e..833f253 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -864,6 +864,13 @@ spam score outweighs, so it is accepted - unless a deny entry matches the same message: **deny wins**, whichever entries they are. The files are readable by anyone on the host; they hold addresses, not secrets. +In the panel the lists are **Inbound › Filter lists** (global role), and what +port 25 decided about each message is **Inbound › Log**: accepted mail with the +filter's verdict, refused mail with the reply it was given. A row of the log +leads to the lists with its sender filled in. A user with the *domain* role +sees the log of the inbound domains assigned to them, and not the lists. How +long the log keeps a row is set under **Server › Settings**. + The panel only reports this setting; it does not change it. Every inbound domain page has a **Spam filter** box — on or off, what happens to mail while the filter is down, and that the filter belongs to the server, not to the diff --git a/docs/plans/inbound-antispam-panel.md b/docs/plans/inbound-antispam-panel.md index ee21ce7..ac5d5f2 100644 --- a/docs/plans/inbound-antispam-panel.md +++ b/docs/plans/inbound-antispam-panel.md @@ -84,6 +84,13 @@ configured. **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: @@ -248,7 +255,7 @@ Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step; - [x] Inbound journal-milter + Postfix wiring — **Composer** - [x] mail.log tailer: inbound reject rows — **Composer** - [x] 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** +- [x] 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](../guide.md), [architecture.md](../architecture.md) — **Composer** diff --git a/docs/roadmap.md b/docs/roadmap.md index 2710b30..2bfd14f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). |---|---|---|---|---| | preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) | | contributing | `CONTRIBUTING.md` | candidate | — | — | -| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 5/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) | +| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 6/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) | | inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) | | csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — | | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — | diff --git a/internal/inbound/lists.go b/internal/inbound/lists.go index 3f6b281..0f8d84b 100644 --- a/internal/inbound/lists.go +++ b/internal/inbound/lists.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sort" "strings" + "time" "unicode/utf8" "github.com/mixeme/selfpost/internal/atomicfile" @@ -140,6 +141,19 @@ func (l *Lists) Sync() error { return nil } +// LastWritten is when the maps were last written: the newest modification +// time of the six files, the zero time while none exists (the filter lists +// have never been touched, or the directory is gone). +func (l *Lists) LastWritten() time.Time { + var newest time.Time + for _, name := range mapFiles { + if fi, err := os.Stat(filepath.Join(l.dir, name)); err == nil && fi.ModTime().After(newest) { + newest = fi.ModTime() + } + } + return newest +} + // normalizeListValue validates one entry and returns the form it is stored and // written in: lower case for an address or a domain, the canonical text of an // address or a network for an IP. diff --git a/internal/inbound/lists_test.go b/internal/inbound/lists_test.go index b6579de..58bbc2d 100644 --- a/internal/inbound/lists_test.go +++ b/internal/inbound/lists_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/mixeme/selfpost/internal/store" ) @@ -235,3 +236,43 @@ func TestListsSyncWritesEmptyMaps(t *testing.T) { } } } + +func TestListsLastWritten(t *testing.T) { + l, _, dir := testLists(t) + if got := l.LastWritten(); !got.IsZero() { + t.Fatalf("LastWritten before anything was written = %v", got) + } + if err := l.Sync(); err != nil { + t.Fatal(err) + } + if got := l.LastWritten(); got.IsZero() || time.Since(got) > time.Minute { + t.Errorf("LastWritten after a sync = %v", got) + } + + // The newest of the six counts, whichever it is. + old := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for _, name := range mapFiles { + if err := os.Chtimes(filepath.Join(dir, name), old, old); err != nil { + t.Fatal(err) + } + } + newest := old.Add(48 * time.Hour) + if err := os.Chtimes(filepath.Join(dir, "deny_domain.map"), newest, newest); err != nil { + t.Fatal(err) + } + if got := l.LastWritten(); !got.Equal(newest) { + t.Errorf("LastWritten = %v, want %v", got, newest) + } + + // A file that is not one of the six does not count. + other := newest.Add(time.Hour) + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(dir, "notes.txt"), other, other); err != nil { + t.Fatal(err) + } + if got := l.LastWritten(); !got.Equal(newest) { + t.Errorf("LastWritten counts a stray file: %v", got) + } +} diff --git a/internal/store/inboundspam.go b/internal/store/inboundspam.go index 4357e3b..51689bd 100644 --- a/internal/store/inboundspam.go +++ b/internal/store/inboundspam.go @@ -81,6 +81,132 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) { return n + m, nil } +// InboundSpamRow is one row of the inbound antispam journal as it is read +// back for the panel: the entry as it was written, with its id and when it +// happened. +type InboundSpamRow struct { + ID int64 + InboundDomain string + ClientIP string + From string + To string + Subject string + Decision string + Engine string + Detail string + CreatedAt time.Time +} + +// InboundSpamFilter narrows QueryInboundSpam and CountInboundSpam. Like +// SendLogFilter it carries two kinds of narrowing that behave in opposite ways +// on purpose. +// +// Domain, Decision, Since and Until are the operator's own filters, chosen in +// the UI: an empty field matches everything. Since and Until are days, both +// inclusive and read as UTC days whatever the time of day they carry. +// +// Domains is the authorization scope, which no query parameter may widen: the +// exhaustive set of inbound domain names the caller may read, applied as an IN +// constraint. nil lifts it (the global role reads the whole journal, including +// rows of a domain deleted since); an empty set is a caller who reaches +// nothing and gets nothing. Unlike SendLogFilter, forgetting the scope is +// therefore the permissive case - the caller states "all" by leaving it out - +// so every caller that serves someone other than the global role must set it. +type InboundSpamFilter struct { + Domain string + Decision string + Since time.Time + Until time.Time + Domains []string +} + +// QueryInboundSpam returns journal rows matching filter, newest first, for +// the Inbound log's server-side pagination. A refusal is dated by the mail.log +// line the tailer read it from, which can be older than rows written since, so +// the order is by time and not by id. +func (s *Store) QueryInboundSpam(filter InboundSpamFilter, limit, offset int) ([]InboundSpamRow, error) { + where, args := inboundSpamWhere(filter) + args = append(args, limit, offset) + rows, err := s.db.Query( + `SELECT id, inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at + FROM inbound_spam_log`+where+` + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ?`, + args..., + ) + if err != nil { + return nil, fmt.Errorf("query inbound_spam_log: %w", err) + } + defer rows.Close() + + var out []InboundSpamRow + for rows.Next() { + var ( + row InboundSpamRow + created string + ) + if err := rows.Scan(&row.ID, &row.InboundDomain, &row.ClientIP, &row.From, &row.To, + &row.Subject, &row.Decision, &row.Engine, &row.Detail, &created); err != nil { + return nil, fmt.Errorf("scan inbound_spam_log row: %w", err) + } + row.CreatedAt, _ = time.Parse(time.RFC3339, created) + out = append(out, row) + } + return out, rows.Err() +} + +// CountInboundSpam returns how many journal rows match filter, for the page +// numbers under the Inbound log. +func (s *Store) CountInboundSpam(filter InboundSpamFilter) (int64, error) { + where, args := inboundSpamWhere(filter) + var n int64 + if err := s.db.QueryRow(`SELECT COUNT(*) FROM inbound_spam_log`+where, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("count inbound_spam_log: %w", err) + } + return n, nil +} + +func inboundSpamWhere(f InboundSpamFilter) (string, []any) { + var clauses []string + var args []any + if f.Domains != nil { + if len(f.Domains) == 0 { + return " WHERE 1 = 0", nil + } + marks := make([]string, len(f.Domains)) + for i, name := range f.Domains { + marks[i] = "?" + args = append(args, name) + } + clauses = append(clauses, "inbound_domain IN ("+strings.Join(marks, ", ")+")") + } + if f.Domain != "" { + clauses = append(clauses, "inbound_domain = ?") + args = append(args, f.Domain) + } + if f.Decision != "" { + clauses = append(clauses, "decision = ?") + args = append(args, f.Decision) + } + // created_at is RFC3339 UTC, so comparing it as text against the same + // format is comparing moments: from midnight of the first day up to, not + // including, midnight after the last. + if !f.Since.IsZero() { + y, m, d := f.Since.UTC().Date() + clauses = append(clauses, "created_at >= ?") + args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)) + } + if !f.Until.IsZero() { + y, m, d := f.Until.UTC().Date() + clauses = append(clauses, "created_at < ?") + args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)) + } + if len(clauses) == 0 { + return "", nil + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + // IsInboundDomain reports whether name is a configured inbound domain. Port 25 // also takes mail that is not the relay's - DMARC reports for the server's own // name - and the journal keeps rows for inbound domains only. diff --git a/internal/store/inboundspam_test.go b/internal/store/inboundspam_test.go index 9b269da..cd34a5f 100644 --- a/internal/store/inboundspam_test.go +++ b/internal/store/inboundspam_test.go @@ -158,3 +158,97 @@ func TestInboundSpamLogRetentionDays(t *testing.T) { t.Errorf("after Set(365): %d", days) } } + +func TestQueryInboundSpamFilters(t *testing.T) { + st := openTestStore(t) + day := func(d, h int) time.Time { return time.Date(2026, 9, d, h, 0, 0, 0, time.UTC) } + add := func(domain, to, decision string, at time.Time) { + t.Helper() + err := st.InsertInboundSpam(InboundSpamEntry{ + InboundDomain: domain, ClientIP: "203.0.113.7", From: "s@example.net", To: to, + Subject: "S " + to, Decision: decision, Engine: SpamEngineRspamd, Detail: "d", At: at, + }) + if err != nil { + t.Fatalf("InsertInboundSpam: %v", err) + } + } + add("a.example", "1@a.example", SpamDecisionAccept, day(20, 23)) + add("a.example", "2@a.example", SpamDecisionReject, day(21, 0)) + add("b.example", "3@b.example", SpamDecisionAccept, day(21, 12)) + add("b.example", "4@b.example", SpamDecisionTempfail, day(22, 0)) + // Written last but dated earliest: the tailer reads a refusal after the fact. + add("a.example", "5@a.example", SpamDecisionReject, day(19, 8)) + + to := func(f InboundSpamFilter) string { + t.Helper() + rows, err := st.QueryInboundSpam(f, 50, 0) + if err != nil { + t.Fatalf("QueryInboundSpam(%+v): %v", f, err) + } + n, err := st.CountInboundSpam(f) + if err != nil || n != int64(len(rows)) { + t.Fatalf("CountInboundSpam(%+v) = %d, %v; the query returned %d", f, n, err, len(rows)) + } + var out string + for _, r := range rows { + out += r.To[:1] + } + return out + } + + // Newest first by time, not by the order of writing. + if got := to(InboundSpamFilter{}); got != "43215" { + t.Errorf("no filter = %q, want 43215", got) + } + if got := to(InboundSpamFilter{Domain: "a.example"}); got != "215" { + t.Errorf("domain = %q, want 215", got) + } + if got := to(InboundSpamFilter{Decision: SpamDecisionReject}); got != "25" { + t.Errorf("decision = %q, want 25", got) + } + // Days are whole UTC days and both ends are in: 21 st is 00:00 up to 23:59. + if got := to(InboundSpamFilter{Since: day(21, 15), Until: day(21, 3)}); got != "32" { + t.Errorf("one day = %q, want 32", got) + } + if got := to(InboundSpamFilter{Since: day(22, 0)}); got != "4" { + t.Errorf("since = %q, want 4", got) + } + if got := to(InboundSpamFilter{Until: day(20, 0)}); got != "15" { + t.Errorf("until = %q, want 15", got) + } + // A time in another zone is read as the UTC day it falls on. + msk := time.FixedZone("msk", 3*3600) + if got := to(InboundSpamFilter{Since: time.Date(2026, 9, 22, 2, 0, 0, 0, msk)}); got != "432" { + t.Errorf("since in another zone = %q, want 432 (it is the 21st in UTC)", got) + } + if got := to(InboundSpamFilter{Domain: "b.example", Decision: SpamDecisionAccept, Since: day(21, 0), Until: day(21, 0)}); got != "3" { + t.Errorf("all filters = %q, want 3", got) + } + + // The scope: nil is everything, a set is its domains, an empty set nothing, + // and a chosen domain outside the set is nothing too. + if got := to(InboundSpamFilter{Domains: []string{"b.example"}}); got != "43" { + t.Errorf("scope = %q, want 43", got) + } + if got := to(InboundSpamFilter{Domains: []string{}}); got != "" { + t.Errorf("empty scope = %q, want nothing", got) + } + if got := to(InboundSpamFilter{Domains: []string{"b.example"}, Domain: "a.example"}); got != "" { + t.Errorf("a chosen domain outside the scope = %q, want nothing", got) + } + + // Paging. + rows, err := st.QueryInboundSpam(InboundSpamFilter{}, 2, 2) + if err != nil || len(rows) != 2 || rows[0].To != "2@a.example" || rows[1].To != "1@a.example" { + t.Errorf("page 2 of 2 rows = %+v, %v", rows, err) + } + if rows[0].ID == 0 || !rows[0].CreatedAt.Equal(day(21, 0)) || rows[0].Subject != "S 2@a.example" || + rows[0].Engine != SpamEngineRspamd || rows[0].Detail != "d" || rows[0].ClientIP != "203.0.113.7" || rows[0].From != "s@example.net" { + t.Errorf("a row read back = %+v", rows[0]) + } + + // A value is data, not SQL. + if got := to(InboundSpamFilter{Domain: "a.example' OR '1'='1"}); got != "" { + t.Errorf("injected domain = %q", got) + } +} diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 286e0d9..c7470f7 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -50,6 +50,9 @@ type Config struct { // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used // as bootstrap and fallback when the settings row is missing or invalid. SendLogRetentionEnvDefault int + // InboundSpamLogRetentionEnvDefault is INBOUND_SPAM_LOG_RETENTION_DAYS at + // panel start, the same bootstrap and fallback for the inbound journal. + InboundSpamLogRetentionEnvDefault int } // Handlers holds dependencies for authenticated panel routes. @@ -58,6 +61,7 @@ type Handlers struct { domains *domain.Service apps *app.Service inbound *inbound.Service + lists *inbound.Lists dmarc *dmarc.Service cfg Config view *view.Engine @@ -72,6 +76,7 @@ func New( domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, + lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, v *view.Engine, @@ -84,6 +89,7 @@ func New( domains: domains, apps: apps, inbound: inboundSvc, + lists: lists, dmarc: dmarcSvc, cfg: cfg, view: v, diff --git a/internal/web/handlers/handlers_inbound_log.go b/internal/web/handlers/handlers_inbound_log.go new file mode 100644 index 0000000..3e8430d --- /dev/null +++ b/internal/web/handlers/handlers_inbound_log.go @@ -0,0 +1,238 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/mixeme/selfpost/internal/inbound" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/view" +) + +// Inbound › Log and Inbound › Filter lists. The log is for everyone who reaches +// an inbound domain, with the rows of their domains only (requireInbound); the +// lists are instance-wide and so the global role's (requireInboundGlobal), and +// answer 404 to anyone else, like the rest of what only that role may do. + +const inboundLogPageSize = 50 + +// logDay is the form of a day in the log's date filters and in . +const logDay = "2006-01-02" + +// HandleInboundLog renders the Inbound log: the journal of port 25's decisions, +// filtered server-side by domain, decision and day, a page at a time. A filter +// value that is not one the page offers is dropped rather than refused, and the +// domains a domain administrator may read are decided here, before the query +// runs - the dropdown only offers them. +func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) { + p, ok := h.requireInbound(w, r) + if !ok { + return + } + assigned, err := h.assignedInboundDomains(p) + if err != nil { + logf("panel: inbound log: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + names := make([]string, 0, len(assigned)) + offered := make(map[string]bool, len(assigned)) + for _, d := range assigned { + names = append(names, d.Name) + offered[d.Name] = true + } + + q := r.URL.Query() + form := view.InLogFilter{} + query := store.InboundSpamFilter{} + if d := q.Get("domain"); offered[d] { + form.Domain, query.Domain = d, d + } + switch d := q.Get("decision"); d { + case store.SpamDecisionAccept, store.SpamDecisionReject, store.SpamDecisionTempfail, store.SpamDecisionQuarantine: + form.Decision, query.Decision = d, d + } + if t, err := time.Parse(logDay, q.Get("from")); err == nil { + form.From, query.Since = t.Format(logDay), t + } + if t, err := time.Parse(logDay, q.Get("to")); err == nil { + form.To, query.Until = t.Format(logDay), t + } + // A global administrator reads the whole journal, including rows left + // behind by a domain that has since been deleted; anyone else only what is + // assigned to them, however empty that is. + if !p.IsGlobal() { + query.Domains = names + } + + total, err := h.store.CountInboundSpam(query) + if err != nil { + logf("panel: inbound log: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + lastPage := 1 + if total > 0 { + lastPage = int((total + inboundLogPageSize - 1) / inboundLogPageSize) + } + page := min(parsePage(q.Get("p")), lastPage) + rows, err := h.store.QueryInboundSpam(query, inboundLogPageSize, (page-1)*inboundLogPageSize) + if err != nil { + logf("panel: inbound log: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + out := view.NewInLog(h.shellMeta(r), h.inboundSpamLogRetentionDays(), names, form) + table := make([]view.InLogRow, len(rows)) + for i, row := range rows { + table[i] = out.NewRow(view.InLogRowInput{ + At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject, + Decision: row.Decision, Engine: row.Engine, Detail: row.Detail, + }) + } + h.view.Render(w, http.StatusOK, "in-log", out.WithRows(table, page, lastPage)) +} + +// inboundSpamLogRetentionDays returns the effective inbound journal retention +// window. +func (h *Handlers) inboundSpamLogRetentionDays() int { + days, err := h.store.GetInboundSpamLogRetentionDays(h.cfg.InboundSpamLogRetentionEnvDefault) + if err != nil { + logf("panel: inbound log retention: %v", err) + if err := store.ValidateInboundSpamLogRetentionDays(h.cfg.InboundSpamLogRetentionEnvDefault); err == nil { + return h.cfg.InboundSpamLogRetentionEnvDefault + } + return store.InboundSpamLogRetentionDaysDefault + } + return days +} + +// ---- Filter lists + +func (h *Handlers) requireFilterLists(w http.ResponseWriter, r *http.Request) bool { + if _, ok := h.requireInboundGlobal(w, r); !ok { + return false + } + if h.lists == nil { + http.NotFound(w, r) + return false + } + return true +} + +// filterListForm is what the add form shows: the list and kind it offers (only +// values it knows are kept) and the text of the two fields. +type filterListForm struct { + list, kind, value, note string +} + +// HandleFilterLists shows the lists. The query fills the add form in - the +// Inbound log's Allow / Deny sender links lead here - and only a list and a +// kind the form offers are taken; the value is just put in the field. +func (h *Handlers) HandleFilterLists(w http.ResponseWriter, r *http.Request) { + if !h.requireFilterLists(w, r) { + return + } + q := r.URL.Query() + form := filterListForm{value: q.Get("value")} + switch l := q.Get("list"); l { + case store.SpamListAllow, store.SpamListDeny: + form.list = l + } + switch k := q.Get("kind"); k { + case store.SpamEntryAddress, store.SpamEntryDomain, store.SpamEntryIP: + form.kind = k + } + h.renderFilterLists(w, r, http.StatusOK, filterListsFlash(r), "", form) +} + +func filterListsFlash(r *http.Request) string { + switch { + case r.URL.Query().Has("added"): + return "Entry added." + case r.URL.Query().Has("removed"): + return "Entry removed." + } + return "" +} + +func (h *Handlers) renderFilterLists(w http.ResponseWriter, r *http.Request, status int, flash, formErr string, form filterListForm) { + entries, err := h.lists.Entries() + if err != nil { + logf("panel: filter lists: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + on, _, _ := h.inboundFilter() + in := make([]view.FilterEntryInput, len(entries)) + for i, e := range entries { + in[i] = view.FilterEntryInput{ID: e.ID, List: e.List, Kind: e.Kind, Value: e.Value, Note: e.Note, Added: e.CreatedAt} + } + page := view.NewFilterLists(h.shellMeta(r), view.FilterListsInput{FilterOn: on, LastWritten: h.lists.LastWritten()}). + WithEntries(in). + WithResult(flash, formErr) + // A blank list or kind leaves the form on its first choice. + if form.list == "" { + form.list = store.SpamListDeny + } + if form.kind == "" { + form.kind = store.SpamEntryAddress + } + h.view.Render(w, status, "in-filter-lists", page.WithForm(form.list, form.kind, form.value, form.note)) +} + +// HandleFilterListAdd adds an entry and rewrites the map files. A refusal - an +// entry the lists do not take, or one already on the list - shows the page again +// with the message and what was typed. +func (h *Handlers) HandleFilterListAdd(w http.ResponseWriter, r *http.Request) { + if !h.requireFilterLists(w, r) { + return + } + if err := r.ParseForm(); err != nil { + h.renderFilterLists(w, r, http.StatusBadRequest, "", "Invalid form submission.", filterListForm{}) + return + } + form := filterListForm{ + list: r.PostFormValue("list_type"), kind: r.PostFormValue("entry_type"), + value: r.PostFormValue("value"), note: r.PostFormValue("note"), + } + _, err := h.lists.Add(form.list, form.kind, form.value, form.note) + var refused *inbound.ValidationError + switch { + case err == nil: + http.Redirect(w, r, "/inbound/filter-lists?added=1", http.StatusSeeOther) + case errors.Is(err, store.ErrSpamListEntryExists): + h.renderFilterLists(w, r, http.StatusUnprocessableEntity, "", "That entry is already on the list.", form) + case errors.As(err, &refused): + h.renderFilterLists(w, r, http.StatusUnprocessableEntity, "", refused.Error(), form) + default: + logf("panel: add filter list entry: %v", err) + h.renderFilterLists(w, r, http.StatusInternalServerError, "", "Could not add the entry. Check the logs and try again.", form) + } +} + +// HandleFilterListDelete removes an entry and rewrites the map files. +func (h *Handlers) HandleFilterListDelete(w http.ResponseWriter, r *http.Request) { + if !h.requireFilterLists(w, r) { + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + if err := h.lists.Delete(id); err != nil { + if errors.Is(err, store.ErrSpamListEntryNotFound) { + http.NotFound(w, r) + return + } + logf("panel: delete filter list entry %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/inbound/filter-lists?removed=1", http.StatusSeeOther) +} diff --git a/internal/web/handlers/handlers_inbound_log_test.go b/internal/web/handlers/handlers_inbound_log_test.go new file mode 100644 index 0000000..1c52389 --- /dev/null +++ b/internal/web/handlers/handlers_inbound_log_test.go @@ -0,0 +1,637 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mixeme/selfpost/internal/inbound" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" +) + +// Inbound › Log and Inbound › Filter lists, rendered from the real journal and +// lists by the handlers. Their markup is held to the mockups by the guards in +// internal/web/view; what is checked here is what the handlers read and who may. + +// inboundLogStand is an inbound relay with two domains, a journal row for each +// and the lists over a directory the test can read the map files from. +type inboundLogStand struct { + h *Handlers + st *store.Store + dir string + a, b store.InboundDomain +} + +func newInboundLogStand(t *testing.T) inboundLogStand { + t.Helper() + h, st := inboundHandlers(t) + s := inboundLogStand{h: h, st: st, dir: filepath.Join(t.TempDir(), "antispam")} + h.lists = inbound.NewLists(st, s.dir) + var err error + if s.a, err = st.AddInboundDomain("a.example.com"); err != nil { + t.Fatal(err) + } + if s.b, err = st.AddInboundDomain("b.example.com"); err != nil { + t.Fatal(err) + } + s.row("a.example.com", "alice@a.example.com", "Hello A", store.SpamDecisionAccept, store.SpamEngineRspamd) + s.row("b.example.com", "bob@b.example.com", "Hello B", store.SpamDecisionAccept, store.SpamEngineRspamd) + return s +} + +func (s inboundLogStand) row(domain, to, subject, decision, engine string) { + s.rowAt(domain, to, subject, decision, engine, time.Time{}) +} + +func (s inboundLogStand) rowAt(domain, to, subject, decision, engine string, at time.Time) { + err := s.st.InsertInboundSpam(store.InboundSpamEntry{ + InboundDomain: domain, ClientIP: "203.0.113.9", From: "sender@example.net", To: to, Subject: subject, + Decision: decision, Engine: engine, Detail: "detail of " + subject, At: at, + }) + if err != nil { + panic(err) + } +} + +// get runs a handler over a GET as p. +func (s inboundLogStand) get(h http.HandlerFunc, target string, p auth.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h(rec, auth.RequestWithPrincipal(httptest.NewRequest(http.MethodGet, target, nil), p)) + return rec +} + +func (s inboundLogStand) owner(t *testing.T, username string, domains ...store.InboundDomain) auth.Principal { + t.Helper() + var ids []int64 + for _, d := range domains { + ids = append(ids, d.ID) + } + return userWith(t, s.st, username, store.RoleDomain, store.Reach{InboundDomainIDs: ids}) +} + +// The global role reads the whole journal, is offered every domain, and is given +// the way to correct a decision and to change how long rows are kept. +func TestInboundLogForTheGlobalRole(t *testing.T) { + s := newInboundLogStand(t) + s.row("a.example.com", "carol@a.example.com", "Quarantined", store.SpamDecisionQuarantine, store.SpamEngineRspamd) + if err := s.st.SetInboundSpamLogRetentionDays(30); err != nil { + t.Fatal(err) + } + rec := s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal) + if rec.Code != http.StatusOK { + t.Fatalf("GET /inbound/log = %d:\n%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + has(t, "log", body, + `

Inbound log

`, + `Rows older than 30 days are deleted — change that in Settings.`, + `alice@a.example.com`, `bob@b.example.com`, `Hello A`, `Hello B`, + ``, ``, + `accept`, `quarantine`, + `203.0.113.9`, + `rspamd · detail of Hello A`, + `Deny sender`, + `Allow sender`, + `Page 1 of 1`, `Allow / Deny sender opens Filter lists`, + ) + // Newest first: the last row written is the first on the page. + if strings.Index(body, `Quarantined`) > strings.Index(body, `Hello B`) || strings.Index(body, `Hello B`) > strings.Index(body, `Hello A`) { + t.Error("the rows are not newest first") + } + lacks(t, "log", body, `data-poll`, `hx-get`) +} + +// A domain administrator reads the rows of the inbound domains assigned to them, +// is offered those domains alone, and is not given the lists or the setting. +func TestInboundLogForADomainAdministrator(t *testing.T) { + s := newInboundLogStand(t) + p := s.owner(t, "owns-a", s.a) + body := s.get(s.h.HandleInboundLog, "/inbound/log", p).Body.String() + has(t, "log", body, `Hello A`, ``, `Rows older than 90 days are deleted.`) + lacks(t, "log", body, `Hello B`, `b.example.com`, `href="/server/settings"`, `/inbound/filter-lists`, + `Deny sender`, `Allow sender`, `Allow / Deny`) + + // A domain filter outside their reach is dropped, not honoured. + body = s.get(s.h.HandleInboundLog, "/inbound/log?domain=b.example.com", p).Body.String() + has(t, "log", body, `Hello A`) + lacks(t, "log", body, `Hello B`, `b.example.com`) + + // One with All sees every domain, one with none sees no log at all. + all := userWith(t, s.st, "owns-all", store.RoleDomain, store.Reach{AllInbound: true}) + body = s.get(s.h.HandleInboundLog, "/inbound/log", all).Body.String() + has(t, "log", body, `Hello A`, `Hello B`) + lacks(t, "log", body, `Deny sender`) + + outboundOnly := userWith(t, s.st, "outbound-only", store.RoleDomain, store.Reach{AllDomains: true}) + if rec := s.get(s.h.HandleInboundLog, "/inbound/log", outboundOnly); rec.Code != http.StatusNotFound { + t.Errorf("an outbound-only administrator got %d, want 404", rec.Code) + } +} + +// A domain administrator whose assigned domain has been deleted since has nothing +// to read, not everything. +func TestInboundLogOfADeletedDomainIsEmptyForItsAdministrator(t *testing.T) { + s := newInboundLogStand(t) + p := s.owner(t, "owns-a", s.a) + both := s.owner(t, "owns-both", s.a, s.b) + if err := s.st.DeleteInboundDomain(s.a.ID); err != nil { + t.Fatal(err) + } + body := s.get(s.h.HandleInboundLog, "/inbound/log", p).Body.String() + has(t, "log", body, `No messages logged yet.`) + lacks(t, "log", body, `Hello A`, `Hello B`) + body = s.get(s.h.HandleInboundLog, "/inbound/log", both).Body.String() + has(t, "log", body, `Hello B`) + lacks(t, "log", body, `Hello A`) + // The global role still reads what the deleted domain left in the journal. + has(t, "log", s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String(), `Hello A`, `Hello B`) +} + +func TestInboundLogFilters(t *testing.T) { + s := newInboundLogStand(t) + day := func(d, h int) time.Time { return time.Date(2026, 9, d, h, 0, 0, 0, time.UTC) } + s.rowAt("a.example.com", "r1@a.example.com", "Rejected on the 10th", store.SpamDecisionReject, store.SpamEnginePostfix, day(10, 23)) + s.rowAt("a.example.com", "r2@a.example.com", "Rejected on the 11th", store.SpamDecisionReject, store.SpamEnginePostfix, day(11, 0)) + s.rowAt("b.example.com", "r3@b.example.com", "Tempfail on the 11th", store.SpamDecisionTempfail, store.SpamEngineRspamd, day(11, 23)) + s.rowAt("a.example.com", "r4@a.example.com", "Accepted on the 12th", store.SpamDecisionAccept, store.SpamEngineRspamd, day(12, 0)) + + rows := func(query string) string { + t.Helper() + rec := s.get(s.h.HandleInboundLog, "/inbound/log"+query, globalPrincipal) + if rec.Code != http.StatusOK { + t.Fatalf("GET /inbound/log%s = %d", query, rec.Code) + } + var got []string + body := rec.Body.String() + for _, n := range []string{"10th", "11th", "12th"} { + for _, kind := range []string{"Rejected", "Tempfail", "Accepted"} { + if strings.Contains(body, kind+" on the "+n) { + got = append(got, kind[:1]+n[:2]) + } + } + } + return strings.Join(got, " ") + } + for query, want := range map[string]string{ + "?decision=reject": "R10 R11", + "?domain=b.example.com": "T11", + "?from=2026-09-11&to=2026-09-11": "R11 T11", + "?from=2026-09-12": "A12", + "?to=2026-09-10": "R10", + "?domain=a.example.com&decision=reject&from=2026-09-11": "R11", + // A value that is not one the page offers is ignored. + "?decision=bounce": "R10 R11 T11 A12", + "?from=yesterday&to=2026-13-45": "R10 R11 T11 A12", + "?domain=nobody.example.com": "R10 R11 T11 A12", + "?decision=reject%27+OR+%271%27%3D%271": "R10 R11 T11 A12", + "?domain=a.example.com&domain=b.example.com": "R10 R11 A12", + } { + if got := rows(query); got != want { + t.Errorf("GET /inbound/log%s shows %q, want %q", query, got, want) + } + } + + // The choice made is the one shown, and the page leads on with it. + body := s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&decision=reject&from=2026-09-11&to=2026-09-12", globalPrincipal).Body.String() + has(t, "log", body, `