test / test (push) Waiting to run
The owner, 2026-10-16: the Inbound log lacks the upstream's answer - "Не понятна судьба письма: принято, отклонено, хост не доступен"; the result in the row only, a page per message later if needed. Mockups5348330and46b90d2, plan295d72e. - Migration 0012: inbound_spam_log gains queue_id, upstream_status ('', queued, held, sent, deferred, bounced; CHECK), upstream_relay, upstream_reply, upstream_at, and a partial index on queue_id. Schema 12. - The inbound journal milter writes an accepted row with the queue id of end-of-message and "queued" ("held" when the filter had it held), the recipient as Postfix logs it (recipientAsLogged), after detaching older rows of a queue id Postfix uses again; no queue id, no result. - The tailer: a delivery line no send-log row took goes to the journal - status, relay= ("" for none), the reply after the first status=, cut to 255 printable bytes, the line's time; queued/held/deferred rows only, sent and bounced final. qmgr's expiry makes them bounced, keeping the last reply. cleanup's "removed (" detaches rows of a message never queued. Addresses in delivery lines are read by Postfix's quoting (loggedAddress): a quoted RCPT TO cannot end one early. - The page: an Upstream column - a plain tag for queued and held, else a tag opening the host, the reply, when and what follows; "—" for refused mail and older rows. Help (word for word with the mockup), guide, architecture, security, schema-migrations, CHANGELOG. Reviewed before the commit (technical and security, 2026-10-16); the plan lists each finding and what it became. Tests: store (results, finality, expiry, held, a reused id, the queue-id index in every plan), milter (queue id, held, quoted recipient, no {i}), logtail (relay and reply, a forged quoted recipient, Run hands the journal what the send log did not take, cleanup's removal), view (the column, held, null sender), e2e (both relayed messages sent with the sink's 250; an upstream refusing connections deferred with the reason and no host, then expired to bounced; the refused row has none). go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
8.7 KiB
Go
251 lines
8.7 KiB
Go
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,
|
||
Upstream: row.Upstream, UpstreamRelay: row.UpstreamRelay, UpstreamReply: row.UpstreamReply, UpstreamAt: row.UpstreamAt,
|
||
})
|
||
}
|
||
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)
|
||
}
|