Files
selfpost/internal/web/handlers/handlers_inbound_log.go
T
mixandClaude Opus 5.5 7192c3861e inbound-antispam-panel: Filter lists, kind column, matches, note on a sheet
Follows the design commit before it (owner's words quoted there).

The lists' tables are Value, Kind, Matched and Remove. The kind tag
opens the entry's sheet: note, day added, how often it decided a
message in the days the log keeps, and the last match.

Matches are read off the journal on each view, nothing is stored
(inbound.Lists.Usage, store.ListSpamListHits). A row counts for an
entry when the list decided the message - a rejected row whose reply is
the deny rule's message, an accepted row whose verdict carries the
allow rule's symbol for that kind - and the entry fits the row's sender
or client address the way rspamd matches it: an address whole, a domain
as the sender's domain, a network containing the client (an
IPv4-mapped address as IPv4). A failure to count shows the lists with
"never" and logs why.

For that the journal milter keeps the lists' own symbols ahead of its
six-symbol cap: rspamd writes symbols heaviest first, so an allow
entry's -100 came last and was cut behind any six others.

The plan gains "Filter list usage": what counting from the journal
implies, and - the owner asked for it in writing - the conditions under
which a counter kept with each entry becomes worth a migration (the
journal cut by its row cap and not by age; a short retention; counts
that must outlive the journal; a slow page). multimap.conf says which
of its texts SelfPost reads back.

Tests: Usage over every kind on both lists and the rows that are
nobody's; the milter's symbol order; the store query; the page and its
sheets; a handler test from journal rows to the numbers on the page.

Seen: the fixture page with a sheet open, headless Edge at 1360 px -
the same as the accepted drawing.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Not seen in a real run: a count above zero. It needs an entry added in
the panel, which I cannot sign in to, and then mail that the list
decides; in particular the deny rule's message arriving in mail.log as
the reply of a milter-reject line is what the trial with rspamc showed,
not yet what Postfix logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:08:26 +03:00

248 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <input
// type="date">.
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{
ID: row.ID, 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
}
// How often each entry decided a message is counted from the journal. A
// failure there is not a reason to hide the lists: they are shown without
// the counts' worth - every entry "never" - and the log says why.
usage, err := h.lists.Usage()
if err != nil {
logf("panel: filter lists: usage: %v", err)
}
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,
Hits: usage[e.ID].Hits, LastHit: usage[e.ID].Last}
}
page := view.NewFilterLists(h.shellMeta(r), view.FilterListsInput{
FilterOn: on, LastWritten: h.lists.LastWritten(), RetentionDays: h.inboundSpamLogRetentionDays()}).
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)
}