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, `