Files
selfpost/internal/web/handlers/handlers_inbound_log.go
T
mixandClaude Opus 5.5 a618ffb0e4 send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2
count and the notifier's refusal count picked send-log rows by domain name,
so a domain deleted and re-added under its old name showed its earlier mail
to the next assignee and went on counting it.

Migration 0008 (not run on the live database yet, so extended here):
send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ...
ON DELETE SET NULL, filled once by name from the registry as it is at
migration time, each with an (id, created_at) index.

- Writes: InsertQueued / InsertRejected set domain_id to the domain of the
  row's login (loginDomainID) - the journal milter and the sender check's
  refusal rows both go through them; the inbound journal sets
  inbound_domain_id from the inbound domain's name at insert.
- Reads: SendLogFilter.DomainIDs (was Domains, by name) and
  InboundSpamFilter.DomainIDs are the scope; the global role still reads
  everything. The message page checks row.DomainID against the user's
  domains. DomainSendStats and CountMessages keep their name argument and
  select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier
  joins send_log.domain_id to the domain and its application.
- DMARC reports stay by name on purpose (a report is about the DNS name).

Tests: store - scope by ids; a re-added domain's log, level-2 count and
statistics hold its own row only, the global log both; inbound scope by
ids. Handlers - the new assignee gets 404 for the old row's page and does
not see it in the log, the global role sees both. Tests that wrote rows for
a login that is no application now register it (a row is a domain's through
its application). Schema at 0008 with the two columns and indexes.

schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 19/62.
Evidence: go vet ./... clean; go test ./... ok locally but for the two
Windows-only tests; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:37:26 +03:00

250 lines
8.6 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))
ids := make([]int64, 0, len(assigned))
offered := make(map[string]bool, len(assigned))
for _, d := range assigned {
names = append(names, d.Name)
ids = append(ids, d.ID)
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.DomainIDs = ids
}
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, Listable: inbound.Listable(store.SpamEntryAddress, row.From), 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)
}