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>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- The log
|
||||
|
||||
// The log is the whole of what the journal holds about the viewer's inbound mail:
|
||||
// the filters offer what the viewer reaches, the head says how long rows live and
|
||||
// where to change it, and the global role is given the way to correct a decision.
|
||||
func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
out := renderSignedIn(t, "in-log", inLogFixture())
|
||||
pageHas(t, "Inbound log", out,
|
||||
`<h1 class="title is-3">Inbound log</h1>`,
|
||||
`refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
|
||||
`<form class="sp-end" method="get" action="/inbound/log">`,
|
||||
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="lists.example.org">lists.example.org</option><option value="acme.io">acme.io</option></select>`,
|
||||
`<select name="decision" aria-label="Decision"><option value="" selected>Any decision</option><option value="accept">accept</option><option value="reject">reject</option><option value="tempfail">tempfail</option><option value="quarantine">quarantine</option></select>`,
|
||||
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
|
||||
`href="/help#inbound-log" title="What the decisions mean"`,
|
||||
`<th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th>`,
|
||||
`09-21 14:00:18`,
|
||||
`t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span>`,
|
||||
`dev@<wbr>lists.example.org`, `title="Re: [dev] patch series v3"`,
|
||||
`<td class="sp-clip sp-muted">—</td>`,
|
||||
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-danger is-light">reject</span>`,
|
||||
`<span class="tag is-warning is-light">tempfail</span>`,
|
||||
`rspamd · no action, score 0.40 / 15.00`,
|
||||
`postfix · 550 5.1.1 <sales@lists.example.org>: Recipient address rejected`,
|
||||
`Page 1 of 38`, `<a href="/inbound/log?from=2026-09-14&p=2&to=2026-09-21">Older →</a>`,
|
||||
`Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
|
||||
)
|
||||
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`)
|
||||
|
||||
// Without the global role there is nowhere to change the retention, and
|
||||
// nowhere to send a sender.
|
||||
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 7, []string{"acme.io"}, InLogFilter{})
|
||||
out = renderSignedIn(t, "in-log", domain.WithRows([]InLogRow{domain.NewRow(InLogRowInput{
|
||||
At: time.Date(2026, 9, 21, 14, 0, 0, 0, time.UTC), From: "a@example.net", To: "b@acme.io",
|
||||
Decision: "accept", Engine: "rspamd"})}, 1, 1))
|
||||
pageHas(t, "Inbound log", out, `Rows older than 7 days are deleted.`, `rspamd</td>`)
|
||||
pageLacks(t, "Inbound log", out, `/server/settings`, `/inbound/filter-lists`, `Deny sender`, `Allow sender`, `Allow / Deny`)
|
||||
}
|
||||
|
||||
// The lead's day count reads right for one day too, though the setting's range
|
||||
// starts at seven: the word follows the number, not the other way round.
|
||||
func TestInLogLeadCountsDays(t *testing.T) {
|
||||
out := renderSignedIn(t, "in-log", NewInLog(Meta{User: "ops", HasInbound: true}, 1, nil, InLogFilter{}))
|
||||
pageHas(t, "Inbound log", out, `Rows older than 1 day are deleted.`)
|
||||
}
|
||||
|
||||
// The filter in force is the one selected, and the paging and the empty state
|
||||
// carry and name it.
|
||||
func TestInLogCarriesItsFilters(t *testing.T) {
|
||||
f := InLogFilter{Domain: "acme.io", Decision: "reject", From: "2026-09-01", To: "2026-09-30"}
|
||||
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"}, f)
|
||||
p.WithRows([]InLogRow{p.NewRow(InLogRowInput{At: time.Date(2026, 9, 2, 1, 2, 3, 0, time.UTC),
|
||||
From: "a@example.net", To: "b@acme.io", Decision: "reject", Engine: "postfix"})}, 3, 5)
|
||||
out := renderSignedIn(t, "in-log", p)
|
||||
pageHas(t, "Inbound log", out,
|
||||
`<option value="acme.io" selected>acme.io</option>`, `<option value="reject" selected>reject</option>`,
|
||||
`name="from" value="2026-09-01"`, `name="to" value="2026-09-30"`,
|
||||
`Page 3 of 5 · <a href="/inbound/log?decision=reject&domain=acme.io&from=2026-09-01&p=2&to=2026-09-30">← Newer</a>`,
|
||||
`<a href="/inbound/log?decision=reject&domain=acme.io&from=2026-09-01&p=4&to=2026-09-30">Older →</a>`,
|
||||
)
|
||||
if got := InLogHref(InLogFilter{}, 1); got != "/inbound/log" {
|
||||
t.Errorf("the first unfiltered page is %q", got)
|
||||
}
|
||||
if got := InLogHref(InLogFilter{Domain: "a b&c"}, 2); got != "/inbound/log?domain=a+b%26c&p=2" {
|
||||
t.Errorf("a filter is not encoded: %q", got)
|
||||
}
|
||||
|
||||
out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, f).WithRows(nil, 1, 1))
|
||||
pageHas(t, "Inbound log", out, `No messages match these filters.`)
|
||||
out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, InLogFilter{}).WithRows(nil, 1, 1))
|
||||
pageHas(t, "Inbound log", out, `No messages logged yet.`)
|
||||
pageLacks(t, "Inbound log", out, `<table`, `Page 1`, `sp-box-foot`)
|
||||
}
|
||||
|
||||
// The last page has nothing older.
|
||||
func TestInLogLastPage(t *testing.T) {
|
||||
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||
row := p.NewRow(InLogRowInput{At: time.Now(), From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
|
||||
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 2, 2))
|
||||
pageHas(t, "Inbound log", out, `Page 2 of 2`, `← Newer</a>`)
|
||||
pageLacks(t, "Inbound log", out, `Older →`)
|
||||
}
|
||||
|
||||
// A row says who decided, and what they said when they said something; a message
|
||||
// refused before its sender was known has no sender to show or to list.
|
||||
func TestInLogRowWording(t *testing.T) {
|
||||
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||
at := time.Date(2026, 9, 21, 14, 0, 18, 0, time.UTC)
|
||||
r := p.NewRow(InLogRowInput{At: at, From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
|
||||
if r.Why != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
|
||||
t.Errorf("row = %+v", r)
|
||||
}
|
||||
r = p.NewRow(InLogRowInput{At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
|
||||
if r.Why != "postfix · 550 5.1.1 no" || r.From != "—" || r.Action != nil {
|
||||
t.Errorf("row = %+v", r)
|
||||
}
|
||||
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{r}, 1, 1))
|
||||
pageHas(t, "Inbound log", out, `<td>—</td>`)
|
||||
pageLacks(t, "Inbound log", out, `<br><span class="sp-mono sp-muted sp-small"></span>`)
|
||||
}
|
||||
|
||||
// Which decisions lead to which list, for the global role: the sender of mail
|
||||
// that got through can be denied, the sender of mail the filter turned away or
|
||||
// put off can be allowed, and a refusal that is Postfix's own can only be denied.
|
||||
func TestInLogSenderAction(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
decision, engine, from, label, href string
|
||||
}{
|
||||
{"accept", "rspamd", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||
{"accept", "selfpost", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||
{"reject", "rspamd", "a+b@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%2Bb%40example.net"},
|
||||
{"tempfail", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||
{"quarantine", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||
{"reject", "postfix", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||
{"tempfail", "postfix", "a@example.net", "", ""},
|
||||
{"reject", "selfpost", "a@example.net", "", ""},
|
||||
{"accept", "rspamd", "", "", ""},
|
||||
{"reject", "rspamd", "", "", ""},
|
||||
} {
|
||||
got := senderAction(c.decision, c.engine, c.from)
|
||||
switch {
|
||||
case c.label == "" && got != nil:
|
||||
t.Errorf("%s by %s from %q is offered %+v", c.decision, c.engine, c.from, *got)
|
||||
case c.label != "" && (got == nil || got.Label != c.label || got.Href != c.href):
|
||||
t.Errorf("%s by %s from %q is offered %+v, want %s -> %s", c.decision, c.engine, c.from, got, c.label, c.href)
|
||||
}
|
||||
}
|
||||
|
||||
// The address in the link is data: it cannot leave the attribute.
|
||||
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||
row := p.NewRow(InLogRowInput{At: time.Now(), From: `"><script>1</script>@x`, To: "b@acme.io", Decision: "accept", Engine: "rspamd"})
|
||||
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1))
|
||||
pageLacks(t, "Inbound log", out, `<script>1`)
|
||||
pageHas(t, "Inbound log", out, `value=%22%3E%3Cscript%3E1%3C%2Fscript%3E%40x`)
|
||||
}
|
||||
|
||||
// What a sender wrote is text on the page: an address, a subject or a reply from
|
||||
// a server is never markup.
|
||||
func TestInLogEscapesWhatSendersWrote(t *testing.T) {
|
||||
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||
row := p.NewRow(InLogRowInput{At: time.Now(), From: `<img src=x onerror=alert(1)>@e.example`, Client: `<b>1</b>`,
|
||||
To: `<script>alert(2)</script>@acme.io`, Subject: `<script>alert(3)</script> "quoted"`,
|
||||
Decision: "reject", Engine: "rspamd", Detail: `<i>5.7.1</i>`})
|
||||
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1))
|
||||
pageLacks(t, "Inbound log", out, `<script>alert`, `<img src=x`, `<b>1</b>`, `<i>5.7.1</i>`)
|
||||
pageHas(t, "Inbound log", out, `<script>alert(3)</script>`, `<i>5.7.1</i>`)
|
||||
}
|
||||
|
||||
// ---- Filter lists
|
||||
|
||||
// The page is the add form, the two lists with their counts, and where the lists
|
||||
// go - drawn as the mockup draws it.
|
||||
func TestFilterListsShowsTheFormTheListsAndWhereTheyGo(t *testing.T) {
|
||||
out := renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
|
||||
pageHas(t, "Filter lists", out,
|
||||
`<p class="sp-kicker">Inbound · global role</p>`, `<h1 class="title is-3">Filter lists</h1>`,
|
||||
`<form class="sp-form" method="post" action="/inbound/filter-lists">`,
|
||||
`<select id="list" name="list_type">`, `<option value="deny" selected>Deny — always turn away</option>`,
|
||||
`<option value="allow">Allow — always let through</option>`,
|
||||
`<select id="etype" name="entry_type">`, `<option value="address" selected>a sender address</option>`,
|
||||
`<option value="domain">a sender domain</option>`, `<option value="ip">a client address or network</option>`,
|
||||
`<input class="input sp-mono" id="value" name="value" value="" placeholder="promo@bulk.example"`,
|
||||
`<input class="input" id="note" name="note" value=""`, `Add entry`,
|
||||
`href="/help#filter-lists" title="How the lists are applied"`,
|
||||
`<h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div>`,
|
||||
`<h2>Allow</h2><div class="sp-end">2 entries</div>`,
|
||||
`<td class="sp-mono sp-small">bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td>`,
|
||||
`198.51.100.0/24 <span class="tag is-light">ip</span>`,
|
||||
`win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td>`,
|
||||
`<form method="post" action="/inbound/filter-lists/3/delete"><button type="submit" class="has-text-danger">Remove</button></form>`,
|
||||
`<h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div>`,
|
||||
`rspamd, from map files it rereads within seconds of a change`,
|
||||
`<span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry`,
|
||||
`2026-09-11 08:40 UTC`, `Deny wins, whichever entries they are.`,
|
||||
)
|
||||
if got := strings.Count(out, `<table`); got != 2 {
|
||||
t.Errorf("%d tables, want the two lists", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no filter attached the lists are still the operator's to edit, and the
|
||||
// box says nothing reads them; a list with no entries says so in its box.
|
||||
func TestFilterListsOffAndEmpty(t *testing.T) {
|
||||
p := NewFilterLists(admin(), FilterListsInput{}).WithEntries([]FilterEntryInput{
|
||||
{ID: 9, List: "allow", Kind: "ip", Value: "192.0.2.1", Added: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)},
|
||||
})
|
||||
out := renderSignedIn(t, "in-filter-lists", p)
|
||||
pageHas(t, "Filter lists", out,
|
||||
`<span class="tag is-light">not applied</span>`,
|
||||
`Nothing — the spam filter is off. The lists are kept and can be edited; nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.`,
|
||||
`<dt>Last written</dt><dd>never</dd>`,
|
||||
`<h2>Deny</h2><div class="sp-end">0 entries · wins over Allow</div>`,
|
||||
`<h2>Allow</h2><div class="sp-end">1 entry</div>`,
|
||||
`No sender is turned away by hand.`,
|
||||
)
|
||||
pageLacks(t, "Filter lists", out, `rspamd, from map files`, `is-success is-light">applied`, `No sender is let through by hand.`)
|
||||
if got := strings.Count(out, `<table`); got != 1 {
|
||||
t.Errorf("%d tables, want the one list that has entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The form shows what it is told to: prefilled from the log, or what was typed
|
||||
// into a form that was refused, with the refusal between the head and the form.
|
||||
func TestFilterListsFormAndRefusal(t *testing.T) {
|
||||
p := inFilterListsFixture().
|
||||
WithForm("allow", "domain", `x"><script>1</script>`, `a "note"`).
|
||||
WithResult("", "That entry is already on the list.")
|
||||
out := renderSignedIn(t, "in-filter-lists", p)
|
||||
pageHas(t, "Filter lists", out,
|
||||
`<option value="allow" selected>Allow — always let through</option>`, `<option value="domain" selected>a sender domain</option>`,
|
||||
`value="x"><script>1</script>"`, `value="a "note""`,
|
||||
`<div class="notification is-danger is-light">`, `That entry is already on the list.`)
|
||||
pageLacks(t, "Filter lists", out, `<script>1`, `<option value="deny" selected>`)
|
||||
if strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) ||
|
||||
strings.Index(out, `class="notification`) > strings.Index(out, `<form class="sp-form"`) {
|
||||
t.Error("the refusal is not between the head and the form")
|
||||
}
|
||||
|
||||
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture().WithResult("Entry added.", ""))
|
||||
pageHas(t, "Filter lists", out, `<div class="notification is-success is-light">`, `Entry added.`)
|
||||
}
|
||||
|
||||
// What a person typed into a list is text on the page.
|
||||
func TestFilterListsEscapesTheEntries(t *testing.T) {
|
||||
p := NewFilterLists(admin(), FilterListsInput{FilterOn: true}).WithEntries([]FilterEntryInput{
|
||||
{ID: 1, List: "deny", Kind: "address", Value: `<img src=x>@a.example`, Note: `<script>alert(1)</script>`, Added: time.Now()},
|
||||
})
|
||||
out := renderSignedIn(t, "in-filter-lists", p)
|
||||
pageLacks(t, "Filter lists", out, `<img src=x>`, `<script>alert`)
|
||||
}
|
||||
|
||||
// The menu leads to both pages, the lists for the global role only.
|
||||
func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
|
||||
out := renderSignedIn(t, "in-log", inLogFixture())
|
||||
pageHas(t, "Inbound log", out,
|
||||
`<a class="navbar-item is-active" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>`,
|
||||
`<a class="navbar-item" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`,
|
||||
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
|
||||
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
|
||||
pageHas(t, "Filter lists", out,
|
||||
`<a class="navbar-item is-active" href="/inbound/filter-lists">`, `<a href="/inbound/filter-lists" aria-current="page">`)
|
||||
|
||||
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 90, nil, InLogFilter{}).WithRows(nil, 1, 1)
|
||||
out = renderSignedIn(t, "in-log", domain)
|
||||
pageHas(t, "Inbound log", out, `<a class="navbar-item is-active" href="/inbound/log">`, `<a href="/inbound/log" aria-current="page">`)
|
||||
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`)
|
||||
}
|
||||
Reference in New Issue
Block a user