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`, ``, ``, `class="tag is-success is-light" popovertarget="why-`, `class="tag is-primary is-light" popovertarget="why-`, `203.0.113.9`, `Decided byrspamdVerdictdetail 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, `