feat(panel): move a delivery's details onto its own page

The delivery log now lists what identifies a message and nothing else —
time, sender, recipient, subject, status — and links each row to
/deliveries/{id}, which carries the rest: the sending domain, the
application it was submitted under, the Postfix queue id to search the
system log for, and when the status was last reported. Domain and
application were a column each; they were the widest thing in the table
after the addresses and repeat down every filtered page, and they remain
the log's two filters. Back returns to the page and filters the row was
opened from, rebuilt from the log's own parameters only.

Subjects are now decoded for display as well as on the way in. The milter
has decoded them since d35b309, but the rows it wrote before that still
hold the raw =?utf-8?Q?...?= header, and those are the ones an operator is
most likely to still be reading. The decoder moves to internal/mailhdr,
shared by the milter and the panel; it is idempotent, so a row decoded
once passes through unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-08-07 22:42:50 +03:00
parent ef57d705d4
commit 997af18065
14 changed files with 409 additions and 59 deletions
+60
View File
@@ -4,9 +4,11 @@ import (
"errors"
"io/fs"
"net/http"
"net/url"
"strconv"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
@@ -50,6 +52,58 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
s.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.
func (s *Server) 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 := s.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
}
row.Subject = mailhdr.DecodeSubject(row.Subject)
s.render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery",
"User": currentUser(r),
"Active": "deliveries",
"Row": row,
// 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),
})
}
// 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).
@@ -69,6 +123,12 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
if err != nil {
return nil, err
}
// Decode on the way out as well as on the way in: rows the journal-milter
// wrote before it decoded subjects itself still hold the raw header, and
// they are the ones an operator is most likely to be looking at.
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
}
domains, err := s.store.ListDomains()
if err != nil {
return nil, err
+140
View File
@@ -1,8 +1,14 @@
package web
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// After log rotation renames mail.log away, Postfix takes about a second to
@@ -19,3 +25,137 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
t.Errorf("errText = %q, want empty (missing file is not an error)", errText)
}
}
// The delivery log is a list of messages, not a dump of the journal: it shows
// when, from, to, subject and status, and links each row to the page carrying
// the rest. A column added back here is one the table has no width for.
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
s, row := serverWithDelivery(t)
out := getBody(t, s.handleDeliveries, "/deliveries")
for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru",
"Проверка", ">sent<", `href="/deliveries/` + itoa(row.ID),
} {
if !strings.Contains(out, want) {
t.Errorf("delivery log is missing %q:\n%s", want, out)
}
}
// Domain and application stay available as filters; what the table must not
// carry is a column of them per row.
for _, unwanted := range []string{"<th>Domain</th>", "<th>App</th>", "Queuer3C"} {
if strings.Contains(out, unwanted) {
t.Errorf("delivery log still shows %q; that detail belongs on the delivery page", unwanted)
}
}
}
// Subjects reached the journal as RFC 2047 encoded-words before the milter
// decoded them, and those rows are still in the send log. Decoding on the way
// out is what keeps them readable, so the encoding must not survive to the page.
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
s, _ := serverWithDelivery(t)
for name, out := range map[string]string{
"log": getBody(t, s.handleDeliveries, "/deliveries"),
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
} {
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
}
if !strings.Contains(out, "Проверка") {
t.Errorf("%s does not show the decoded subject:\n%s", name, out)
}
}
}
// Everything the log dropped has to be somewhere, and that somewhere is the
// per-row page — including for a row still holding an encoded subject.
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
s, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
for _, want := range []string{
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
"noreply@bs.example.ru", "public@example.ru", "sent",
`href="/deliveries?domain=bs.example.ru&amp;p=2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("delivery page is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("delivery page shows the subject's MIME encoding instead of its text:\n%s", out)
}
}
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) {
s, _ := serverWithDelivery(t)
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
s.handleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
}
}
}
// serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "4A1B2C3D",
Domain: "bs.example.ru",
AppLogin: "Queuer3C",
From: "noreply@bs.example.ru",
To: "public@example.ru",
Subject: "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=",
}); err != nil {
t.Fatalf("insert: %v", err)
}
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
t.Fatalf("update status: %v", err)
}
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
}
// getBody runs one handler over a GET and returns the page it wrote, failing
// the test on any non-200. The path's {id} is bound by hand because these calls
// bypass the router that would otherwise fill it in.
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
+1
View File
@@ -35,6 +35,7 @@ var pageFiles = map[string][]string{
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"},
+2 -3
View File
@@ -4,18 +4,17 @@
{{if .Rows}}
<table>
<thead>
<tr><th>Time</th><th>Domain</th><th>App</th><th>From</th><th>To</th><th>Subject</th><th>Status</th></tr>
<tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Domain}}</td>
<td>{{.AppLogin}}</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td>{{.Status}}</td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
</tr>
{{end}}
</tbody>
+55
View File
@@ -0,0 +1,55 @@
{{define "content"}}
<h1>Delivery</h1>
<a class="back" href="{{.BackURL}}">&larr; Back to deliveries</a>
<div class="card">
<h2>Message</h2>
<p class="muted">One recipient of one message, as the journal-milter recorded
it when Postfix accepted it. The delivery log lists these; everything the list
has no room for is here.</p>
<label>Time</label>
<span class="code">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<label>From</label>
<span class="code">{{.Row.From}}</span>
<label>To</label>
<span class="code">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<label>Subject</label>
<span class="code">{{if .Row.Subject}}{{.Row.Subject}}{{else}}—{{end}}</span>
<label>Status</label>
<span class="code">{{.Row.Status}}</span>
</div>
<div class="card">
<h2>Origin</h2>
<p class="muted">Which sending domain and which application's SASL account the
message was submitted under — the two filters the delivery log offers.</p>
<label>Domain</label>
<span class="code">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
<label>Application</label>
<span class="code">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="card">
<h2>Handling</h2>
<p class="muted">Postfix's own identifiers for this message. The queue id is
what to search the system log for; a rejected message never got one, because
it was refused before it was queued.</p>
<label>Queue id</label>
<span class="code">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
<label>Status last reported</label>
<span class="code">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<label>Journal id</label>
<span class="code">{{.Row.ID}}</span>
</div>
{{end}}
+1
View File
@@ -198,6 +198,7 @@ func (s *Server) Handler() http.Handler {
// and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /deliveries/{id}", s.handleDelivery)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /system-log", s.handleSystemLog)