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
+19
View File
@@ -7,6 +7,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added ### Added
- A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
every send-log row. It carries what the log itself no longer shows — the
sending domain, the application the message was submitted under, the Postfix
queue id to search the system log for, when the status was last reported —
so the journal can grow fields without the table having to find columns for
them. *Back* returns to the page and filters the row was opened from, rebuilt
from the log's own parameters only.
- A **DNS** badge in the domain list, one per row, carrying the same - A **DNS** badge in the domain list, one per row, carrying the same
ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that
domain's DKIM, SPF and DMARC checks, so a domain whose records were never domain's DKIM, SPF and DMARC checks, so a domain whose records were never
@@ -44,6 +52,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- The delivery log lists what identifies a message and nothing else: time,
sender, recipient, subject, status. Domain and application, which were a
column each, remain the log's two filters and now appear per message on the
delivery page. The two dropped columns were the widest thing in the table
after the addresses, and both repeat down the page whenever a filter is set.
- Subjects are decoded for display as well as on the way in, so the rows the
journal-milter recorded before it decoded them itself — the ones an operator
is most likely to still be reading — show their text rather than
`=?utf-8?Q?…?=`. The decoder moved to `internal/mailhdr` and is shared by the
milter and the panel; it is idempotent, so a row decoded once passes through
unchanged.
- The panel's navigation is a column down the left edge instead of a bar across - The panel's navigation is a column down the left edge instead of a bar across
the top. As a bar it did not fit on one row — six page entries and the the top. As a bar it did not fit on one row — six page entries and the
session block against the panel's width — and had to be split into two, session block against the panel's width — and had to be split into two,
+6 -2
View File
@@ -207,9 +207,13 @@ service healthy and will mail be accepted?"
(level 2) are configured here. *Export domain* writes a single-domain archive; (level 2) are configured here. *Export domain* writes a single-domain archive;
*Import a domain* on the Backup page reads one back in. *Import a domain* on the Backup page reads one back in.
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters - **Deliveries** (`/deliveries`) — searchable send log with server-side filters
by domain and application. Each row shows status `queued` (accepted, not yet by domain and application. A row identifies its message and nothing more —
time, sender, recipient, subject and status `queued` (accepted, not yet
delivered), `sent` (handed off successfully), or `rejected` (refused — for delivered), `sent` (handed off successfully), or `rejected` (refused — for
example by a level-2 rate limit). Retention is controlled by example by a level-2 rate limit); *Details* opens that row's own page
(`/deliveries/{id}`) with 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. Retention is controlled by
`SEND_LOG_RETENTION_DAYS`. `SEND_LOG_RETENTION_DAYS`.
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring. trying to deliver or deferring.
+1
View File
@@ -136,6 +136,7 @@ unless noted.
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | | `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | | `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
| `/deliveries` | Send log with filters | | `/deliveries` | Send log with filters |
| `/deliveries/{id}` | One send-log row in full |
| `/mail-queue` | Postfix queue view | | `/mail-queue` | Postfix queue view |
| `/system-log` | `mail.log` tail | | `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps | | `/reload` | Reload OpenDKIM + Postfix maps |
+38
View File
@@ -0,0 +1,38 @@
// Package mailhdr turns raw mail header values into display text. It sits
// between the journal-milter, which reads headers off the wire, and the panel,
// which shows them: both need the same answer for the same header, and the
// panel needs it for rows the milter wrote before this decoding existed.
package mailhdr
import (
"mime"
"strings"
)
// SubjectMaxRunes caps what the journal keeps of a subject. A Subject header
// may legally run to hundreds of characters; the log only needs enough to
// recognise the message, and the panel shows one row per recipient.
const SubjectMaxRunes = 200
// DecodeSubject turns a raw Subject header into display text. Anything
// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the
// panel would otherwise show verbatim: unreadable, and — being one unbreakable
// run — wide enough to push the send-log table out of its card. Go's decoder
// covers the UTF-8 and ASCII charsets senders use in practice; for anything
// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no
// worse than not decoding at all. Truncation is applied after decoding so the
// cap counts characters of the subject, not bytes of its encoding.
//
// It is idempotent: already-decoded text contains no encoded-words, so a second
// pass returns it unchanged. That is what lets the panel decode on the way out
// as well as the milter on the way in.
func DecodeSubject(v string) string {
if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil {
v = dec
}
v = strings.TrimSpace(v)
if r := []rune(v); len(r) > SubjectMaxRunes {
v = string(r[:SubjectMaxRunes]) + "…"
}
return v
}
+38
View File
@@ -0,0 +1,38 @@
package mailhdr
import (
"strings"
"testing"
)
func TestDecodeSubject(t *testing.T) {
long := strings.Repeat("я", SubjectMaxRunes+10)
for _, tc := range []struct {
name, raw, want string
}{
{"plain", "Hello there", "Hello there"},
{"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"},
{"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"},
// No decoder for the legacy single-byte charsets: keep the header as
// sent rather than losing the subject entirely.
{"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="},
{"too long", long, strings.Repeat("я", SubjectMaxRunes) + "…"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := DecodeSubject(tc.raw); got != tc.want {
t.Fatalf("DecodeSubject(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// The panel decodes on the way out as well as the milter on the way in, so
// every already-decoded row in the send log passes through a second time.
func TestDecodeSubjectIsIdempotent(t *testing.T) {
for _, s := range []string{"Проверка", "Hello there", "", "=?windows-1251?B?z/Do4uXy?="} {
if got := DecodeSubject(DecodeSubject(s)); got != DecodeSubject(s) {
t.Fatalf("second pass over %q changed it to %q", s, got)
}
}
}
+2 -26
View File
@@ -14,7 +14,6 @@ package milter
import ( import (
"context" "context"
"log" "log"
"mime"
"net" "net"
"net/textproto" "net/textproto"
"strings" "strings"
@@ -22,6 +21,7 @@ import (
"github.com/emersion/go-milter" "github.com/emersion/go-milter"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
@@ -101,35 +101,11 @@ func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, erro
// Header captures the Subject. Only the first Subject header is kept. // Header captures the Subject. Only the first Subject header is kept.
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) { func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" { if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
s.subject = decodeSubject(value) s.subject = mailhdr.DecodeSubject(value)
} }
return milter.RespContinue, nil return milter.RespContinue, nil
} }
// subjectMaxRunes caps what the journal keeps of a subject. A Subject header
// may legally run to hundreds of characters; the log only needs enough to
// recognise the message, and the panel shows one row per recipient.
const subjectMaxRunes = 200
// decodeSubject turns the raw Subject header into display text. Anything
// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the
// panel would otherwise show verbatim: unreadable, and — being one unbreakable
// run — wide enough to push the send-log table out of its card. Go's decoder
// covers the UTF-8 and ASCII charsets senders use in practice; for anything
// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no
// worse than before. Truncation is applied after decoding so the cap counts
// characters of the subject, not bytes of its encoding.
func decodeSubject(v string) string {
if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil {
v = dec
}
v = strings.TrimSpace(v)
if r := []rune(v); len(r) > subjectMaxRunes {
v = string(r[:subjectMaxRunes]) + "…"
}
return v
}
// Body fires at end-of-message, when the queue-id macro {i} is set and the // Body fires at end-of-message, when the queue-id macro {i} is set and the
// message is about to be committed to the queue. This is where the "queued" // message is about to be committed to the queue. This is where the "queued"
// rows are written. We accept (this milter is done) without ever rejecting. // rows are written. We accept (this milter is done) without ever rejecting.
+8 -24
View File
@@ -3,7 +3,6 @@ package milter
import ( import (
"errors" "errors"
"net" "net"
"strings"
"testing" "testing"
"time" "time"
@@ -115,30 +114,15 @@ func TestSessionRecordsRowPerRecipient(t *testing.T) {
} }
// A subject in any non-ASCII alphabet reaches the milter as RFC 2047 // A subject in any non-ASCII alphabet reaches the milter as RFC 2047
// encoded-words; the journal stores the text, not the encoding. // encoded-words; the journal stores the text, not the encoding. The decoding
// itself is mailhdr's; what this checks is that Header runs it.
func TestHeaderDecodesEncodedSubject(t *testing.T) { func TestHeaderDecodesEncodedSubject(t *testing.T) {
long := strings.Repeat("я", subjectMaxRunes+10) s := &session{rec: &fakeRecorder{}}
if _, err := s.Header("Subject", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", mods(nil)); err != nil {
for _, tc := range []struct { t.Fatalf("Header: %v", err)
name, raw, want string }
}{ if s.subject != "Проверка" {
{"plain", "Hello there", "Hello there"}, t.Fatalf("subject = %q, want %q", s.subject, "Проверка")
{"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"},
{"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"},
// No decoder for the legacy single-byte charsets: keep the header as
// sent rather than losing the subject entirely.
{"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="},
{"too long", long, strings.Repeat("я", subjectMaxRunes) + "…"},
} {
t.Run(tc.name, func(t *testing.T) {
s := &session{rec: &fakeRecorder{}}
if _, err := s.Header("Subject", tc.raw, mods(nil)); err != nil {
t.Fatalf("Header: %v", err)
}
if s.subject != tc.want {
t.Fatalf("subject = %q, want %q", s.subject, tc.want)
}
})
} }
} }
+38 -4
View File
@@ -1,6 +1,8 @@
package store package store
import ( import (
"database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -104,6 +106,37 @@ type SendLogRow struct {
Subject string Subject string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
// UpdatedAt is when the status last changed — the log-tailer's report of
// the delivery attempt. It equals CreatedAt while a row is still queued.
UpdatedAt time.Time
}
// ErrSendLogNotFound is returned by GetSendLog for an id that no longer exists.
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// disappearing is expected, not a fault.
var ErrSendLogNotFound = errors.New("send-log entry not found")
// GetSendLog returns a single send-log row by id, for the delivery detail page
// the log links each row to.
func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
var (
row SendLogRow
createdAt, updatedAt string
)
err := s.db.QueryRow(
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at
FROM send_log WHERE id = ?`, id,
).Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin, &row.From, &row.To,
&row.Subject, &row.Status, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return SendLogRow{}, ErrSendLogNotFound
}
if err != nil {
return SendLogRow{}, fmt.Errorf("get send_log %d: %w", id, err)
}
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
row.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return row, nil
} }
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or // SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
@@ -119,7 +152,7 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
where, args := sendLogWhere(filter) where, args := sendLogWhere(filter)
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at `SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at
FROM send_log`+where+` FROM send_log`+where+`
ORDER BY id DESC ORDER BY id DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
@@ -133,14 +166,15 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
var out []SendLogRow var out []SendLogRow
for rows.Next() { for rows.Next() {
var ( var (
row SendLogRow row SendLogRow
createdAt string createdAt, updatedAt string
) )
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin, if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil { &row.From, &row.To, &row.Subject, &row.Status, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan send_log row: %w", err) return nil, fmt.Errorf("scan send_log row: %w", err)
} }
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
row.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
out = append(out, row) out = append(out, row)
} }
return out, rows.Err() return out, rows.Err()
+60
View File
@@ -4,9 +4,11 @@ import (
"errors" "errors"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"github.com/mixeme/selfpost/internal/logtail" "github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "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) 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 // sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs // string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination). // (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 { if err != nil {
return nil, err 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() domains, err := s.store.ListDomains()
if err != nil { if err != nil {
return nil, err return nil, err
+140
View File
@@ -1,8 +1,14 @@
package web package web
import ( import (
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"strconv"
"strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/store"
) )
// After log rotation renames mail.log away, Postfix takes about a second to // 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) 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_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"}, "system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"}, "status": {"templates/status.html", "templates/status_body.html"},
+2 -3
View File
@@ -4,18 +4,17 @@
{{if .Rows}} {{if .Rows}}
<table> <table>
<thead> <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> </thead>
<tbody> <tbody>
{{range .Rows}} {{range .Rows}}
<tr> <tr>
<td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td> <td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.Domain}}</td>
<td>{{.AppLogin}}</td>
<td>{{.From}}</td> <td>{{.From}}</td>
<td>{{.To}}</td> <td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td> <td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td>{{.Status}}</td> <td>{{.Status}}</td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </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). // and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /deliveries", s.handleDeliveries) authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) 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", s.handleMailQueue)
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody) authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /system-log", s.handleSystemLog) authed.HandleFunc("GET /system-log", s.handleSystemLog)