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>
This commit is contained in:
@@ -304,3 +304,48 @@ func (s *Store) DeleteSpamListEntry(id int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpamListHit is what the journal holds about one message a filter list
|
||||
// decided: who it was from, which server offered it, when, and the detail that
|
||||
// says which list it was.
|
||||
type SpamListHit struct {
|
||||
ClientIP string
|
||||
From string
|
||||
Decision string
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ListSpamListHits returns the journal rows of the filter in whose detail one
|
||||
// of the marks occurs - the marks the filter lists leave (internal/inbound).
|
||||
// Which entry a row belongs to is for the caller to work out; here the rows
|
||||
// are only narrowed, so that a page view does not read the whole journal.
|
||||
func (s *Store) ListSpamListHits(marks ...string) ([]SpamListHit, error) {
|
||||
if len(marks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
likes := make([]string, len(marks))
|
||||
args := []any{SpamEngineRspamd}
|
||||
for i, m := range marks {
|
||||
likes[i] = "instr(detail, ?) > 0"
|
||||
args = append(args, m)
|
||||
}
|
||||
rows, err := s.db.Query(
|
||||
`SELECT client_ip, from_addr, decision, detail, created_at FROM inbound_spam_log
|
||||
WHERE engine = ? AND (`+strings.Join(likes, " OR ")+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query filter list hits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SpamListHit
|
||||
for rows.Next() {
|
||||
var h SpamListHit
|
||||
var created string
|
||||
if err := rows.Scan(&h.ClientIP, &h.From, &h.Decision, &h.Detail, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan filter list hit: %w", err)
|
||||
}
|
||||
h.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -252,3 +252,35 @@ func TestQueryInboundSpamFilters(t *testing.T) {
|
||||
t.Errorf("injected domain = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSpamListHits(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
add := func(engine, decision, detail string) {
|
||||
t.Helper()
|
||||
err := st.InsertInboundSpam(InboundSpamEntry{
|
||||
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", From: "a@example.net", To: "a@in.example.com",
|
||||
Decision: decision, Engine: engine, Detail: detail, At: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
add(SpamEngineRspamd, SpamDecisionReject, "5.7.1 Refused by this server's deny list")
|
||||
add(SpamEngineRspamd, SpamDecisionAccept, "no action, score -99.00 / 15.00; SELFPOST_ALLOW_IP(-100.00)")
|
||||
add(SpamEngineRspamd, SpamDecisionReject, "5.7.1 Spam message rejected")
|
||||
add(SpamEnginePostfix, SpamDecisionReject, "550 5.1.1 this server's deny list")
|
||||
// a mark with the characters LIKE would treat as wildcards is text
|
||||
add(SpamEngineRspamd, SpamDecisionAccept, "no action; SELFPOSTXALLOWX(1.00)")
|
||||
|
||||
hits, err := st.ListSpamListHits("this server's deny list", "SELFPOST_ALLOW_")
|
||||
if err != nil || len(hits) != 2 {
|
||||
t.Fatalf("hits = %+v, %v; want the two rows a list decided", hits, err)
|
||||
}
|
||||
h := hits[0]
|
||||
if h.From != "a@example.net" || h.ClientIP != "203.0.113.7" || h.Decision != SpamDecisionReject || h.CreatedAt.Hour() != 9 {
|
||||
t.Errorf("hit = %+v", h)
|
||||
}
|
||||
if hits, err := st.ListSpamListHits(); err != nil || hits != nil {
|
||||
t.Errorf("no marks: %v, %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user