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,63 @@
|
||||
{{/* Inbound > Filter lists (mockup in-filter-lists.html): the allow and deny
|
||||
lists that correct the spam filter by hand, for the whole instance, and
|
||||
where they go. Global role only. The form adds an entry; each row of a list
|
||||
removes its own. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
{{template "box_open" .Add}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="/inbound/filter-lists">
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="list">List</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type">{{$list := .List}}{{range .ListOptions}}
|
||||
<option value="{{.Value}}"{{if eq .Value $list}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type">{{$kind := .Kind}}{{range .KindOptions}}
|
||||
<option value="{{.Value}}"{{if eq .Value $kind}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></div></div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field"><label class="label" for="value">Value</label>
|
||||
<div class="control"><input class="input sp-mono" id="value" name="value" value="{{.Value}}" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label class="label" for="note">Note</label>
|
||||
<div class="control"><input class="input" id="note" name="note" value="{{.Note}}" placeholder="Why — for whoever reads this list next year"></div></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
{{template "filter_list" .Deny}}
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
{{template "filter_list" .Allow}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "box_open" .Where}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .WhereFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
|
||||
{{/* filter_list is one of the two boxes of entries (input: FilterList): the
|
||||
table, or what is absent when the list is empty. */}}
|
||||
{{define "filter_list"}}{{template "box_open" .Box}}
|
||||
{{if .Rows}}<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-small">{{.Value}} {{template "tag" .Kind}}</td>{{if .Note}}<td class="sp-muted">{{.Note}}</td>{{else}}<td class="sp-muted">—</td>{{end}}<td class="sp-muted sp-nowrap sp-small">{{.Added}}</td><td class="sp-actions">{{template "row_action" .Remove}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else}}{{template "empty_state" .Empty}}
|
||||
{{end}}{{template "box_close"}}{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{/* Inbound log (mockup in-log.html): what port 25 decided about each message
|
||||
offered for an inbound domain, one row per recipient, newest first,
|
||||
filtered by domain, decision and day. It does not poll. The last cell is
|
||||
the way to correct a decision in Filter lists, and only the global role,
|
||||
who alone edits the lists, is given it. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
{{template "box_open" .Messages}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "tag" .Decision}}</td><td class="sp-muted sp-small">{{.Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_foot" .Foot}}
|
||||
{{else}}
|
||||
{{template "empty_state" .Empty}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
@@ -104,7 +104,9 @@
|
||||
<a class="navbar-link" href="/inbound/domains">Inbound</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
|
||||
</div>
|
||||
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "log")}} is-active{{end}}" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>
|
||||
{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
|
||||
{{end}} </div>
|
||||
</div>
|
||||
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
|
||||
<a class="navbar-link" href="/server/health">Server</a>
|
||||
@@ -143,6 +145,6 @@
|
||||
pages of the current group, the current one marked. A page outside every
|
||||
group (the user menu, the kit page's neighbours) has none. */}}
|
||||
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
|
||||
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a></div></div>
|
||||
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
|
||||
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||
{{end}}{{end}}
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<div class="field"><label class="label" for="send_log_retention_days">Keep outbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="send_log_retention_days" name="send_log_retention_days" type="number" min="7" max="365" step="1" value="{{.RetentionDays}}" required></div>
|
||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||
{{if .Inbound}}<div class="field"><label class="label" for="inbound_spam_log_retention_days">Keep inbound log rows, days</label>
|
||||
<div class="control"><input class="input" id="inbound_spam_log_retention_days" name="inbound_spam_log_retention_days" type="number" min="7" max="365" step="1" value="{{.InboundRetentionDays}}" required></div>
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
{{end}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user