test / test (push) Has been cancelled
Review pass over the 1.4.0-1.9.1 work. Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters is always overridden on smtp/inet, to the antispam milter or to nothing. Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing outage, and the journal-milter filed inbound mail into the outbound send log under the sender's domain -- so Deliveries showed other people's mail and a forged From: consumed that domain's level-2 budget. architecture.md already described the intended behaviour; the code now matches. DMARC reports are checked before they are stored: the policy domain must be a configured sending domain, and must match the +tag of a per-domain hosted address. The rua address form is predictable, so anyone able to reach it could file reports under any domain and evict genuine ones through the retention cap. Zip attachments are now decompressed as well as gzip. The auto level-2 ceiling is scaled to the level-1 window before the multiplier is applied; any window other than 3600s shifted the allowance by window/3600. Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL FROM rate-limit count and the per-application statistics were scanning the created_at range. e2e now regenerates the Postfix config in-container for all four port-25 flag combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped in 1.4.0 and survived three releases because only the flags-off path was exercised. Migration 0009 stays as shipped -- its conversion branch only fires for a database carrying application-scope allowed_ips from before 1.9.0. The schema reference notes that the old list was permissive and the new column is restrictive; the guide documents the allow-list as a sending control on a fail-open path, not an authentication boundary. Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from internal/web/validate, export no longer swallows store errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
18 KiB
Go
502 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/logtail"
|
|
"github.com/mixeme/selfpost/internal/mailhdr"
|
|
"github.com/mixeme/selfpost/internal/postfix"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/auth"
|
|
)
|
|
|
|
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
|
// call for pagination); logTailLines bounds how much of mail.log the log view
|
|
// shows per refresh, and deliveryLogLines how many of one message's own lines
|
|
// its page shows.
|
|
const (
|
|
sendLogPageSize = 50
|
|
logTailLines = 200
|
|
deliveryLogLines = 200
|
|
)
|
|
|
|
// HandleDeliveries renders the Deliveries page over the send log: server-side
|
|
// filters by domain/application and pagination (architecture.md §
|
|
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
|
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
|
|
// refreshes never diverge.
|
|
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
data, err := h.sendLogData(r)
|
|
if err != nil {
|
|
logf("panel: send log: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data["Title"] = "SelfPost — deliveries"
|
|
for k, v := range h.pageBase(r) {
|
|
data[k] = v
|
|
}
|
|
data["Active"] = "deliveries"
|
|
h.view.Render(w, http.StatusOK, "deliveries", data)
|
|
}
|
|
|
|
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
|
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
|
// JSON).
|
|
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
|
data, err := h.sendLogData(r)
|
|
if err != nil {
|
|
logf("panel: send log rows: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
|
|
}
|
|
|
|
// HandleDelivery renders one send-log row in full. The log itself carries only
|
|
// what identifies a message at a glance — when, who to and from, what about,
|
|
// how it ended — and every remaining field (domain, application, queue id, when
|
|
// the status was last reported) lives here, one page per row, so widening the
|
|
// journal never costs the table a column.
|
|
//
|
|
// The page answers the question the log raises rather than restating it: what
|
|
// the journal recorded, in what order it happened, and what Postfix itself
|
|
// wrote about the message. So it is three blocks — the message's own facts and
|
|
// its history side by side, and the mail.log lines for its queue id under both.
|
|
// The queue id used to be printed here as something to go and search the system
|
|
// log for by hand; the search is done for the operator instead.
|
|
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
row, err := h.store.GetSendLog(id)
|
|
if err != nil {
|
|
// A row pruned on the retention window is gone, not broken.
|
|
if errors.Is(err, store.ErrSendLogNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: delivery %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
p, ok := h.principal(r)
|
|
if !ok {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !p.IsGlobal() {
|
|
allowed, err := h.assignedDomains(p)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !domainNameSet(allowed)[row.Domain] {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
}
|
|
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
|
logRows, logNote := h.deliveryLog(row)
|
|
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
|
"Title": "SelfPost — delivery",
|
|
"User": auth.CurrentUser(r),
|
|
"Active": "deliveries",
|
|
"IsGlobal": p.IsGlobal(),
|
|
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
|
"Row": row,
|
|
// The status in the panel's own badge vocabulary, so the headline reads
|
|
// the same way as every other health signal in the panel.
|
|
"Level": deliveryLevel(row.Status),
|
|
"Events": deliveryEvents(row, h.cfg.RetryPolicy),
|
|
// The mail.log lines for this message, and — when there are none — the
|
|
// reason, which is a normal outcome rather than a failure.
|
|
"LogRows": logRows,
|
|
"LogNote": logNote,
|
|
// Where the row came from, so "Back" returns to the page and filters
|
|
// the operator was looking at rather than the top of an unfiltered log.
|
|
"BackURL": deliveriesBackURL(r),
|
|
})
|
|
}
|
|
|
|
// deliveryLevel maps a send-log status onto the ok/warn/error/unknown badge
|
|
// vocabulary the status page and the DNS checks already use (see .st in
|
|
// panel.css), so a colour means the same thing on every page: delivered is the
|
|
// good outcome, deferred is not settled yet, and the two refusals are failures.
|
|
// A queued row is "unknown" rather than "warn" — nothing has gone wrong, it is
|
|
// simply that nothing has been reported.
|
|
func deliveryLevel(status string) string {
|
|
switch status {
|
|
case store.StatusSent:
|
|
return "ok"
|
|
case store.StatusDeferred:
|
|
return "warn"
|
|
case store.StatusBounced, store.StatusRejected:
|
|
return "error"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// sendLogRow is a row of the send log as the table draws it: the stored row
|
|
// plus the badge level its status maps onto. The level is carried rather than
|
|
// derived in the template because deliveryLevel is the one place that decides
|
|
// what a status means — the delivery page already reads it, and a second
|
|
// mapping written in the template or the stylesheet would be free to drift
|
|
// from it.
|
|
type sendLogRow struct {
|
|
store.SendLogRow
|
|
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
|
}
|
|
|
|
// deliveryEvent is one step of a message's history, as the timeline on the
|
|
// delivery page draws it. At is zero for the step that has not happened yet —
|
|
// the delivery report a queued message is still waiting for.
|
|
type deliveryEvent struct {
|
|
At time.Time
|
|
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
|
Status string // the send-log status value this step reached
|
|
Title string
|
|
Detail string
|
|
}
|
|
|
|
// deliveryEvents turns a row's two timestamps into the history the page shows.
|
|
// The journal keeps no event table — a row is created when the message is
|
|
// accepted and updated once when Postfix reports the attempt — so the two
|
|
// timestamps *are* the history, and stating them as steps is what makes a row
|
|
// whose created_at and updated_at differ by six hours legible as "queued for
|
|
// six hours, then delivered" rather than as two dates in a list of fields.
|
|
// policy supplies the human intervals for deferred and bounced copy, the same
|
|
// strings the Mail queue card prints, so the two cannot drift.
|
|
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
|
|
// A rejected message has no second step, and its first one is not an
|
|
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
|
if row.Status == store.StatusRejected {
|
|
return []deliveryEvent{{
|
|
At: row.CreatedAt,
|
|
Level: "error",
|
|
Status: store.StatusRejected,
|
|
Title: "Refused before queueing",
|
|
Detail: "The journal-milter refused the message under a rate limit. It was never queued, so there is no queue id and Postfix never attempted delivery.",
|
|
}}
|
|
}
|
|
|
|
events := []deliveryEvent{{
|
|
At: row.CreatedAt,
|
|
Level: "unknown",
|
|
Status: store.StatusQueued,
|
|
Title: "Accepted and queued",
|
|
Detail: "Postfix accepted the message over an authenticated submission and the journal-milter recorded it. Delivery to the recipient had not been attempted yet.",
|
|
}}
|
|
|
|
switch row.Status {
|
|
case store.StatusQueued:
|
|
// The step that has not happened. Drawn as an open dot with no time.
|
|
return append(events, deliveryEvent{
|
|
Level: "unknown",
|
|
Status: store.StatusQueued,
|
|
Title: "Waiting for a delivery report",
|
|
Detail: "Postfix has not reported an attempt for this recipient yet. The Mail queue page shows what it is still holding.",
|
|
})
|
|
case store.StatusSent:
|
|
return append(events, deliveryEvent{
|
|
At: row.UpdatedAt,
|
|
Level: "ok",
|
|
Status: store.StatusSent,
|
|
Title: "Delivered",
|
|
Detail: "The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back.",
|
|
})
|
|
case store.StatusDeferred:
|
|
return append(events, deliveryEvent{
|
|
At: row.UpdatedAt,
|
|
Level: "warn",
|
|
Status: store.StatusDeferred,
|
|
Title: "Deferred, will be retried",
|
|
Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
|
|
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()),
|
|
})
|
|
case store.StatusBounced:
|
|
return append(events, deliveryEvent{
|
|
At: row.UpdatedAt,
|
|
Level: "error",
|
|
Status: store.StatusBounced,
|
|
Title: "Bounced",
|
|
Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
|
|
policy.QueueLifetime()),
|
|
})
|
|
default:
|
|
// A status the log-tailer learns to write before this switch does.
|
|
return append(events, deliveryEvent{
|
|
At: row.UpdatedAt,
|
|
Level: deliveryLevel(row.Status),
|
|
Status: row.Status,
|
|
Title: "Status reported",
|
|
Detail: "The last state Postfix reported for this recipient.",
|
|
})
|
|
}
|
|
}
|
|
|
|
// deliveryLogRow is one mail.log line split for the table on the delivery
|
|
// page: when it was written, and what it says. Time is empty for a line whose
|
|
// head is not a timestamp the log format recognises — the line still shows, in
|
|
// full, under Message.
|
|
type deliveryLogRow struct {
|
|
Time string
|
|
Text string
|
|
}
|
|
|
|
// deliveryLog reads the mail.log lines Postfix wrote about one message and
|
|
// splits each into the two columns the page shows it in. The second return
|
|
// value is what to say when there are none: every reason for an empty result
|
|
// here is an ordinary one — the message never reached the queue, or its lines
|
|
// have aged out of the log — so none of them is an error on the page. Only a
|
|
// log that cannot be read at all is reported as a fault, and that one is
|
|
// logged for the operator as well.
|
|
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
|
if row.QueueID == "" {
|
|
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
|
}
|
|
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
logf("panel: delivery log %s: %v", row.QueueID, err)
|
|
return nil, "Could not read the mail log."
|
|
}
|
|
if len(lines) == 0 {
|
|
days := h.sendLogRetentionDays()
|
|
return nil, fmt.Sprintf("Nothing for this queue id in the current mail log. Its lines have most likely been rotated away (send-log rows are kept for %d days).", days)
|
|
}
|
|
out := make([]deliveryLogRow, len(lines))
|
|
for i, line := range lines {
|
|
stamp, rest := logtail.SplitTimestamp(line)
|
|
out[i] = deliveryLogRow{Time: stamp, Text: rest}
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// deliveriesBackURL rebuilds the delivery-log URL a detail page was opened
|
|
// from. Only the log's own parameters are carried over, and each is re-encoded
|
|
// by url.Values, so nothing a visitor appends to the link can travel back into
|
|
// the page as markup or as a different destination.
|
|
func deliveriesBackURL(r *http.Request) string {
|
|
q := r.URL.Query()
|
|
back := url.Values{}
|
|
for _, k := range []string{"domain", "app", "p"} {
|
|
if v := q.Get(k); v != "" {
|
|
back.Set(k, v)
|
|
}
|
|
}
|
|
if len(back) == 0 {
|
|
return "/deliveries"
|
|
}
|
|
return "/deliveries?" + back.Encode()
|
|
}
|
|
|
|
// sendLogData reads the domain/app filters and page number off the query
|
|
// string, queries the store, and assembles everything the template needs
|
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
|
//
|
|
// The invariant this function owes the journal: a principal who is not global
|
|
// only ever reads rows for the domains assigned to them. That scope is stated
|
|
// to the store as SendLogFilter.Domains and holds for every number of
|
|
// assignments, including none — a domain administrator whose last domain was
|
|
// deleted gets an empty log, not the whole one. The query parameters are
|
|
// filters *within* that scope and can only narrow it: both are checked against
|
|
// the assigned domains and their applications before the query runs, because a
|
|
// dropdown that offers only permitted values is a courtesy to the browser, not
|
|
// a check on the request.
|
|
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
|
p, ok := h.principal(r)
|
|
if !ok {
|
|
return nil, errors.New("no principal")
|
|
}
|
|
q := r.URL.Query()
|
|
|
|
assigned, err := h.assignedDomains(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowedNames := domainNameSet(assigned)
|
|
|
|
domainNames := make([]string, 0, len(assigned))
|
|
for _, d := range assigned {
|
|
domainNames = append(domainNames, d.Name)
|
|
}
|
|
|
|
loginSet := make(map[string]bool)
|
|
for _, d := range assigned {
|
|
apps, err := h.store.ListApplicationsByDomain(d.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, a := range apps {
|
|
loginSet[a.Login] = true
|
|
}
|
|
}
|
|
logins := make([]string, 0, len(loginSet))
|
|
for login := range loginSet {
|
|
logins = append(logins, login)
|
|
}
|
|
sort.Strings(logins)
|
|
|
|
filter := store.SendLogFilter{
|
|
Domain: q.Get("domain"),
|
|
AppLogin: q.Get("app"),
|
|
// A global administrator reads the whole journal, including rows left
|
|
// behind by a domain that has since been deleted.
|
|
Domains: domainNames,
|
|
AllDomains: p.IsGlobal(),
|
|
}
|
|
if !p.IsGlobal() {
|
|
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
|
filter.Domain = ""
|
|
}
|
|
if filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
|
filter.AppLogin = ""
|
|
}
|
|
}
|
|
|
|
page := parsePage(q.Get("p"))
|
|
|
|
total, err := h.store.CountSendLog(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view := make([]sendLogRow, len(rows))
|
|
for i := range rows {
|
|
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
|
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
|
}
|
|
|
|
lastPage := 1
|
|
if total > 0 {
|
|
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
|
}
|
|
return map[string]any{
|
|
"Rows": view,
|
|
"FilterDomains": domainNames,
|
|
"FilterApps": logins,
|
|
"FilterDomain": filter.Domain,
|
|
"FilterApp": filter.AppLogin,
|
|
"Page": page,
|
|
"PrevPage": page - 1,
|
|
"NextPage": page + 1,
|
|
"LastPage": lastPage,
|
|
"HasPrev": page > 1,
|
|
"HasNext": page < lastPage,
|
|
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
|
}, nil
|
|
}
|
|
|
|
// parsePage clamps the "p" query parameter to a valid page number, defaulting
|
|
// to 1 for anything missing or malformed rather than rejecting the request.
|
|
func parsePage(v string) int {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
|
// surface).
|
|
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
out, errText := readQueue()
|
|
policy := h.cfg.RetryPolicy
|
|
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
|
"Title": "SelfPost — mail queue",
|
|
"User": auth.CurrentUser(r),
|
|
"Active": "mail_queue",
|
|
"IsGlobal": true,
|
|
"Output": out,
|
|
"Error": errText,
|
|
"FirstRetry": policy.FirstRetry(),
|
|
"BackoffCap": policy.BackoffCap(),
|
|
"QueueLifetime": policy.QueueLifetime(),
|
|
"RetryFromDefaults": policy.FromDefaults,
|
|
})
|
|
}
|
|
|
|
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
|
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
out, errText := readQueue()
|
|
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
|
"Output": out,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
// readQueue runs postqueue -p, returning a friendly message instead of the
|
|
// error itself: a transient postqueue failure should degrade the monitoring
|
|
// view, not surface internals to the panel.
|
|
func readQueue() (string, string) {
|
|
out, err := postfix.Queue()
|
|
if err != nil {
|
|
logf("panel: postqueue -p: %v", err)
|
|
return "", "Could not read the mail queue."
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
|
// Panel HTTP surface).
|
|
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
lines, errText := h.readLogTail()
|
|
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
|
"Title": "SelfPost — system log",
|
|
"User": auth.CurrentUser(r),
|
|
"Active": "system_log",
|
|
"IsGlobal": true,
|
|
"Lines": lines,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
|
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
lines, errText := h.readLogTail()
|
|
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
|
"Lines": lines,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
func (h *Handlers) readLogTail() ([]string, string) {
|
|
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
// Rotation renamed the file away; Postfix recreates it on reload
|
|
// (within about a second), so this is a normal, brief gap rather
|
|
// than a failure worth alarming the operator about.
|
|
return nil, ""
|
|
}
|
|
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
|
|
return nil, "Could not read the mail log."
|
|
}
|
|
return lines, ""
|
|
}
|