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(), "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 { // Send-log rows outlive mail.log: retention is ninety days by default // and rotation keeps fourteen files, so an older message having nothing // left to show is the normal end state, not a fault. return nil, "Nothing for this queue id in the current mail log. Its lines have most likely been rotated away." } 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, }, 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, "" }