from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
test / test (push) Canceled after 0s

A message's From: line is held against the application that sends it, under
the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by
that line, so until now an application could have mail signed as another
domain of the server or as the server itself.

The check (internal/milter/sender.go)
- A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in
  smtpd_milters and strict: default_action=tempfail. The journal milter keeps
  its socket and stays fail-open. postfix-wrapper waits for the new socket.
- It reads no database: what an application may send as comes from Postfix's
  own sender_login_maps, what the server signs for from OpenDKIM's
  SigningTable. Both are read again when they change; one that cannot be
  read defers mail.
- One From: field, one address, allowed by the envelope's lookup; then no
  other name of the SigningTable may appear anywhere in the raw line.
  Refused: 550 5.7.1 naming the line, reduced to printable ASCII.
- A refusal is a send-log row with reason "from" and the header address
  (migration 0006), written by another goroutine and not waited for. The
  journal's refusals record their reason too (limit, client), and the
  notifier takes only a limit's.

What it costs (docs/plans/from-header-binding.md, Statistics)
- The role marks itself alive every 2 s; at start the gap since the mark is
  downtime, counted from Postfix's start if later and not at all when
  supervisord says Postfix is not running.
- The log tailer counts the connections Postfix turned away: a warning that
  names the socket, then milter-reject "Service unavailable" from the same
  smtpd. Seen on the stand: the client is refused from CONNECT on and leaves
  at AUTH (454), so no MAIL line is ever written.
- sender_check_days, a row per UTC day, kept a year.

Pages
- Health: the card "Sender check", the socket's row, and the cards layout -
  boxes numbered by importance in two stacks read in rows; the left stack
  polls, the three readings of the right one are swapped in by id.
- Outbound log: a refused row says why in its tag (wrong From, wrong client,
  rate-limited); the message's page shows the From: line and what the
  application may send as.
- Help: topic "Sender check", word for word the mockup's.

Found by the stand, fixed here: the names for the second look came from the
sender bindings, and a domain with a key and no application yet was not
among them.

e2e: negative_journal_milter_fail_open is gone - it proved that mail passes
with the panel down, which is no longer so. In its place
sender_check_holds_the_from_line (another domain's From:, the hostname's, a
stranger's, an address off the list, another sender in the display name -
refused; own addresses - delivered) and panel_down_defers_mail (4xx with the
panel stopped, the container alive, mail again after, downtime and one
deferred message on Health, the card warning).

Test server, this tree before the last cosmetic edits (a fixture row, a
fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s).
Locally after them: internal/web/view ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 19:35:43 +03:00
co-authored by Claude Opus 5.5
parent 8542e673a8
commit c171fb5bc7
54 changed files with 2237 additions and 336 deletions
+3
View File
@@ -281,6 +281,9 @@ type Box struct {
Help *HelpLink
Variant string
Poll string
// OOB marks the box to be swapped into the page by its ID when it comes
// in a polled fragment beside the element that asked for the fragment.
OOB bool
// DomainPick: see Box.
DomainPick bool
}
+14 -3
View File
@@ -92,7 +92,14 @@ func overviewFixture() *Overview {
// record. refresh renders it as the polled fragment.
func healthFixture(refresh bool) *Health {
h := NewHealth(admin(), "")
h.Refresh = refresh
if refresh {
h.AsFragment()
}
h.WithSenderCheck([]SenderCheckRow{
{Period: "Today", Down: "0 s", Deferred: "0"},
{Period: "Last 30 days", Down: "4 s", Deferred: "0"},
{Period: "Last 12 months", Down: "41 s", Deferred: "3"},
}, "2026-10-13", "ok")
h.Machine.End = Verdict("ok")
h.MachineRows = []MachineRow{
{Resource: "CPU", Gauge: &Gauge{Percent: 12, Text: "12%", Level: LevelOK}, Detail: []string{"2 cores · 2 threads"}},
@@ -113,6 +120,7 @@ func healthFixture(refresh bool) *Health {
h.Sockets.End = Verdict("ok")
h.SocketRows = []SocketRow{
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}},
{Name: "sender-check", Path: "unix:/run/selfpost/sender.sock", State: Tag{Status: "ok"}},
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
}
h.Hostname.End = Verdict("ok")
@@ -245,8 +253,10 @@ func outDomainDeleteFixture() *OutDomainDelete {
func outLogFixture() *OutLog {
p := NewOutLog(admin(), 30, true,
[]string{"example.org", "shop.example.org", "notify.acme.io"}, []string{"prod-server", "alerts"}, "", "")
// status is a row's status, or for a refused row "rejected:" and its reason.
row := func(id int64, at, from, to, subject, status string) OutLogRow {
return OutLogRow{Time: at, From: from, To: to, Subject: subject, Status: Tag{Status: status},
status, reason, _ := strings.Cut(status, ":")
return OutLogRow{Time: at, From: from, To: to, Subject: subject, Status: SendLogTag(status, reason),
Href: p.DetailHref(id, 1)}
}
return p.WithRows([]OutLogRow{
@@ -254,7 +264,8 @@ func outLogFixture() *OutLog {
row(184222, "09-21 14:01:50", "shop@shop.example.org", "anna.lind@outlook.com", "Your order #48212 has shipped", "sent"),
row(184220, "09-21 13:58:07", "alerts@example.org", "noc@example.org", "[FIRING] disk usage above 90 % on db-2", "deferred"),
row(184211, "09-21 13:41:19", "no-reply@notify.acme.io", "j.doe@nonexistent.example", "Reset your password", "bounced"),
row(184209, "09-21 13:40:02", "prod@example.org", "billing@partner.example", "Invoice 2026-0917", "rejected"),
row(184231, "09-21 13:40:55", "shop@shop.example.org", "k.berg@web.de", "A message from the director", "rejected:from"),
row(184209, "09-21 13:40:02", "prod@example.org", "billing@partner.example", "Invoice 2026-0917", "rejected:limit"),
row(184198, "09-21 13:12:44", "shop@shop.example.org", "p.novak@seznam.cz", "We received your return request", "sent"),
}, 1, 412)
}
+71 -20
View File
@@ -1,30 +1,38 @@
package view
import (
"strconv"
"time"
)
// The data of Server › Health (templates/health.html, mockup health.html):
// every check behind the cards of Overview, as tables, and the home of the two
// server actions, Re-check DNS and Reload configuration.
//
// Two rows of the page are polled (templates/health_body.html): the handler
// builds the same Health for the page and for its fragment, and the fragment
// carries Refresh so that its second row is swapped in out of band.
// The page is two stacks of boxes numbered by importance (the `cards` layout),
// and all but the Configuration box are polled (templates/health_body.html):
// the handler builds the same Health for the page and for its fragment, and
// AsFragment marks what the fragment swaps in out of band.
// Health is the data of the Health page.
type Health struct {
Meta
Head Head
Flash *Flash
// Refresh is set on the polled fragment only. The fragment answers with two
// sibling rows; the first replaces the element that polls, and the second
// is marked to be swapped into place by its id.
Refresh bool
Machine Box
MachineRows []MachineRow
Head Head
Flash *Flash
Processes Box
ProcessRows []ProcessRow
ProcessError bool
// SenderCheck is what the strict check of the From: line has cost: for how
// long the panel did not answer while Postfix was running, and how many
// messages were deferred for it. SenderNote is the line under its table.
SenderCheck Box
SenderRows []SenderCheckRow
SenderNote Text
Machine Box
MachineRows []MachineRow
Certificate Box
CertFacts []Fact
CertDetail string
@@ -37,8 +45,7 @@ type Health struct {
// NotifyMail is the card of the DNS records notifications are signed and
// authorised with (their values are under Server › Settings); NotifyNote
// is the line under its table. It is polled with the rest, so Refresh swaps
// it into place on its own.
// is the line under its table.
NotifyMail Box
NotifyRows []MailRow
NotifyNote Text
@@ -47,6 +54,13 @@ type Health struct {
Reload Action
}
// SenderCheckRow is one period of the Sender check table.
type SenderCheckRow struct {
Period string
Down string
Deferred string
}
// MachineRow is one line of the Machine table. A row that is a level (CPU,
// memory) carries a Gauge; one that is a rate (network) carries Usage text; a
// reading that could not be taken carries neither and shows a dash. Detail is
@@ -122,14 +136,17 @@ func NewHealth(m Meta, flash string) *Health {
Lead: Plain("Everything behind the cards on Overview. Refreshes automatically."),
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
},
Machine: Box{No: "01", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
Processes: Box{No: "02", Title: "Processes", ID: "processes"},
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"},
// Numbered by importance; the odd ones are the left stack.
Processes: Box{No: "01", Title: "Processes", ID: "processes"},
Sockets: Box{No: "02", Title: "Milter sockets", ID: "sockets"},
SenderCheck: Box{No: "03", Title: "Sender check", ID: "sender-check",
Help: topicLink(HelpSenderCheck, "What is checked and what these numbers are for")},
Certificate: Box{No: "04", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
Configuration: Box{No: "07", Title: "Configuration", ID: "configuration",
Machine: Box{No: "07", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
Configuration: Box{No: "08", Title: "Configuration", ID: "configuration",
End: Plain("Safe to run at any time")},
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
}
@@ -150,5 +167,39 @@ func (h *Health) WithNotificationMail(from string, rows []MailRow, status string
return h
}
// WithSenderCheck fills the Sender check card: its three periods, the first
// day counted ("" before any was) and the card's verdict.
func (h *Health) WithSenderCheck(rows []SenderCheckRow, since, status string) *Health {
h.SenderCheck.End = Verdict(status)
h.SenderRows = rows
h.SenderNote = Rich("The panel holds each message's ", Code("From:"),
" against the application that sends it. While the panel does not answer, Postfix defers mail instead of accepting it unchecked. Time is counted only while Postfix itself was running.")
if since != "" {
h.SenderNote = append(h.SenderNote, Rich(" Counted since ", Mono(since), ".")...)
}
return h
}
// AsFragment makes the page its polled fragment: the left stack replaces the
// element that polls, and the three readings of the right stack are marked to
// be swapped into place by their ids.
func (h *Health) AsFragment() *Health {
h.Sockets.OOB, h.Certificate.OOB, h.NotifyMail.OOB = true, true, true
return h
}
// Downtime prints a length of time the way the Sender check table does: whole
// seconds while they say something, then minutes, then hours.
func Downtime(d time.Duration) string {
secs := int64(d / time.Second)
switch {
case secs < 60:
return strconv.FormatInt(secs, 10) + " s"
case secs < 3600:
return strconv.FormatInt(secs/60, 10) + " min " + strconv.FormatInt(secs%60, 10) + " s"
}
return strconv.FormatInt(secs/3600, 10) + " h " + strconv.FormatInt(secs%3600/60, 10) + " min"
}
// Verdict puts a check's status in the end slot of its box, as the mockup does.
func Verdict(status string) Text { return Text{TagOf(status, "")} }
+19 -7
View File
@@ -41,12 +41,15 @@ const (
HelpCertificate = "certificate"
HelpRDNS = "rdns"
// Outbound
HelpDNS = "dns"
HelpApps = "apps"
HelpLimits = "limits"
HelpLog = "log"
HelpQueue = "queue"
HelpDMARC = "dmarc"
HelpDNS = "dns"
HelpApps = "apps"
// HelpSenderCheck is what an application may write in From: and what the
// strict check costs; Health and a refused message's page link to it.
HelpSenderCheck = "sender-check"
HelpLimits = "limits"
HelpLog = "log"
HelpQueue = "queue"
HelpDMARC = "dmarc"
// Inbound
HelpInbound = "inbound"
HelpUpstream = "upstream"
@@ -103,6 +106,7 @@ func NewHelp(m Meta, inbound bool) *Help {
add("Outbound", outboundTopics, []MenuItem{
{Label: "DNS records", Icon: "ti-world-check", Href: "#" + HelpDNS},
{Label: "Applications", Icon: "ti-apps", Href: "#" + HelpApps},
{Label: "Sender check", Icon: "ti-shield-check", Href: "#" + HelpSenderCheck},
{Label: "Rate limits", Icon: "ti-gauge", Href: "#" + HelpLimits},
{Label: "Outbound log", Icon: "ti-list-details", Href: "#" + HelpLog},
{Label: "Queue and retries", Icon: "ti-stack-2", Href: "#" + HelpQueue},
@@ -161,11 +165,19 @@ var outboundTopics = []HelpTopic{
{ID: HelpApps, Title: "Applications", Body: []Text{
Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."),
}},
{ID: HelpSenderCheck, Title: "Sender check", Body: []Text{
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
Code("From:"), " line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
Code("550 5.7.1"), ". It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
Br(), Br(),
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running, and how many messages were deferred because of it. The card warns when a message was deferred in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
}},
{ID: HelpLimits, Title: "Rate limits", Body: []Text{
Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."),
}},
{ID: HelpLog, Title: "Outbound log", Body: []Text{
Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in ",
Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ",
Code("mail.log"), ", found by queue id. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."),
}},
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
+2
View File
@@ -80,6 +80,8 @@ func KitPage() *Kit {
LayoutFacts: []Fact{
{Label: "list", Value: Rich("page_head, then full-width boxes: the add form first, the table second. ", Link("/outbound/domains", "Example"))},
{Label: "detail", Value: Rich("page_head, then ", Code("columns"), ": ", Code("is-3"), " side menu + ", Code("is-9"), " boxes.")},
{Label: "cards", Value: Rich("page_head, then ", Code("columns sp-cards"), ": two ", Code("is-6"),
" stacks of numbered boxes read in rows, odd on the left and even on the right, each stack closed up. On a phone the boxes come in the order of their numbers. The two stacks under this box are laid out so.")},
{Label: "form", Value: Rich("page_head, then boxes in equal ", Code("columns"), ", one submit row under them.")},
{Label: "signed out", Value: Rich("The split brand / form screen, no navbar. ", Link("/login", "Example"))},
},
+48 -11
View File
@@ -168,6 +168,10 @@ type MessageInput struct {
To string
Subject string
Status string
// Reason and HeaderFrom are a refused row's: why it was refused
// (store.Reason*) and, for its From: line, what that line said.
Reason string
HeaderFrom string
Accepted time.Time
Reported time.Time
// BackHref is the log the message was opened from, filters and page kept.
@@ -193,6 +197,24 @@ type OutMessage struct {
LogEmpty EmptyState
}
// SendLogTag is the tag of a send-log row. A refused row says why in the tag,
// so that the log answers it without the message being opened: the reasons
// are store.Reason*, and a row from before they were recorded is "rejected".
func SendLogTag(status, reason string) Tag {
if status != "rejected" {
return Tag{Status: status}
}
switch reason {
case "from":
return Tag{Status: "reject", Label: "wrong From"}
case "client":
return Tag{Status: "reject", Label: "wrong client"}
case "limit":
return Tag{Status: "rate-limited"}
}
return Tag{Status: status}
}
// dash is the value of a field a row does not have.
const dash = "—"
@@ -218,6 +240,28 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
}
return Rich(Link(href, text))
}
tag := SendLogTag(in.Status, in.Reason)
facts := []Fact{
{Label: "Domain", Value: value(in.Domain, in.DomainHref)},
{Label: "Application", Value: value(in.App, in.AppHref), Mono: true},
}
history := Box{No: "02", Title: "History", Help: topicLink(HelpLog, "What the statuses mean")}
if in.Status == "rejected" {
// A refused message was never accepted and never queued: its one date
// is the refusal, and it has no queue id to look anything up by.
facts = append(facts, Fact{Label: "Refused", Value: Plain(formatStamp(in.Accepted))})
if in.Reason == "from" {
facts = append(facts, Fact{Label: "From: line", Value: Plain(orDash(in.HeaderFrom)), Mono: true, Small: true})
history.Help = topicLink(HelpSenderCheck, "What an application may write in From:")
}
facts = append(facts, Fact{Label: "Queue id", Value: Plain("none - never queued")})
} else {
facts = append(facts,
Fact{Label: "Accepted", Value: Plain(formatStamp(in.Accepted))},
Fact{Label: "Status reported", Value: Plain(formatStamp(in.Reported))},
Fact{Label: "Queue id", Value: Plain(orDash(in.QueueID)), Mono: true})
}
facts = append(facts, Fact{Label: "Journal id", Value: Plain(strconv.FormatInt(in.ID, 10)), Mono: true})
return &OutMessage{
Meta: m,
Head: Head{
@@ -226,19 +270,12 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
Compact: true,
Route: Route{
{Text: orDash(in.From)}, {Arrow: true}, {Text: orDash(in.To)},
{Tag: &Tag{Status: in.Status}},
{Tag: &tag},
},
},
Message: Box{No: "01", Title: "Message"},
Facts: []Fact{
{Label: "Domain", Value: value(in.Domain, in.DomainHref)},
{Label: "Application", Value: value(in.App, in.AppHref), Mono: true},
{Label: "Accepted", Value: Plain(formatStamp(in.Accepted))},
{Label: "Status reported", Value: Plain(formatStamp(in.Reported))},
{Label: "Queue id", Value: Plain(orDash(in.QueueID)), Mono: true},
{Label: "Journal id", Value: Plain(strconv.FormatInt(in.ID, 10)), Mono: true},
},
History: Box{No: "02", Title: "History", Help: topicLink(HelpLog, "What the statuses mean")},
Message: Box{No: "01", Title: "Message"},
Facts: facts,
History: history,
Delivery: Box{No: "03", Title: "Delivery log", End: Rich("Lines from ", Code("mail.log"), " with this queue id")},
LogEmpty: EmptyState{Icon: "ti-file-text"},
}
+36 -4
View File
@@ -22,7 +22,7 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
`<th>Time, UTC</th>`, `09-21 14:01:52`,
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-light">rejected</span>`,
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
`href="/outbound/log/184220">Details</a>`,
`Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`,
)
@@ -114,7 +114,8 @@ func TestOutMessageShowsWhatTheJournalRecorded(t *testing.T) {
}
// A message without a subject is named for it, and a row with nothing to show
// for a field shows a dash.
// for a field shows a dash. A refused row has no queue id and says so; one
// from before the reason was recorded is plainly "rejected".
func TestOutMessageWithoutSubjectOrRecipient(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 9, Domain: "gone.example", App: "old", Status: "rejected", Accepted: at, Reported: at,
@@ -123,9 +124,40 @@ func TestOutMessageWithoutSubjectOrRecipient(t *testing.T) {
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<h1 class="title is-4">(no subject)</h1>`, `<span>—</span><i class="ti ti-arrow-right sp-muted"></i><span>—</span>`,
`<span class="tag is-light">rejected</span>`, `href="/outbound/log?domain=gone.example&amp;p=2"`,
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dd class="sp-mono">—</dd>`,
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dt>Refused</dt>`, `<dd>none - never queued</dd>`,
`never reached the queue`)
pageLacks(t, "Message", out, `<pre`, `<a href="/outbound/domains/`)
pageLacks(t, "Message", out, `<pre`, `<a href="/outbound/domains/`, `Status reported`, `From: line`)
}
// A row refused for its From: line says so in its tag, shows what the line
// said, and its History links to the topic that explains the rule.
func TestOutMessageRefusedForItsFromLine(t *testing.T) {
at := time.Date(2026, 9, 21, 13, 40, 55, 0, time.UTC)
p := NewOutMessage(admin(), MessageInput{ID: 184231, Domain: "shop.example.org", App: "shop",
From: "shop@shop.example.org", To: "k.berg@web.de", Subject: "A message from the director",
Status: "rejected", Reason: "from", HeaderFrom: "director@example.org", Accepted: at, Reported: at}).
WithDeliveryLog(nil, "This message never reached the queue, so Postfix wrote no delivery lines for it.")
out := renderSignedIn(t, "out-message", p)
pageHas(t, "Message", out, `<span class="tag is-danger is-light">wrong From</span>`,
`<dt>From: line</dt><dd class="sp-mono sp-small">director@example.org</dd>`, `<dt>Refused</dt>`,
`<dd>none - never queued</dd>`, `href="/help#sender-check"`)
}
func TestSendLogTag(t *testing.T) {
for _, c := range []struct {
status, reason string
want Tag
}{
{"sent", "", Tag{Status: "sent"}},
{"rejected", "", Tag{Status: "rejected"}},
{"rejected", "limit", Tag{Status: "rate-limited"}},
{"rejected", "from", Tag{Status: "reject", Label: "wrong From"}},
{"rejected", "client", Tag{Status: "reject", Label: "wrong client"}},
} {
if got := SendLogTag(c.status, c.reason); got != c.want {
t.Errorf("SendLogTag(%q, %q) = %+v, want %+v", c.status, c.reason, got, c.want)
}
}
}
func TestOutMessageEscapesWhatSendersWrote(t *testing.T) {
+41 -32
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
@@ -158,47 +159,55 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
`hx-get="/server/health/fragment"`,
)
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
if got := strings.Count(out, `class="columns"`); got != 3 {
t.Errorf("Health has %d rows of boxes, want 3", got)
if got := strings.Count(out, `class="columns sp-cards"`); got != 1 {
t.Errorf("Health has %d sets of cards, want 1", got)
}
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
strings.Contains(out[strings.Index(out, `id="health-machine"`):strings.Index(out, `id="configuration"`)], `action="/server/health/reload"`) {
t.Error("the reload control is inside the polled rows")
// The boxes are numbered by importance and read in rows: the odd ones are
// the left stack, the even ones the right.
order := []string{`id="processes"`, `id="sender-check"`, `id="hostname"`, `id="machine"`,
`id="sockets"`, `id="certificate"`, `id="notification-mail"`, `id="configuration"`}
for i := 1; i < len(order); i++ {
if strings.Index(out, order[i-1]) < 0 || strings.Index(out, order[i]) < strings.Index(out, order[i-1]) {
t.Errorf("%s does not come after %s", order[i], order[i-1])
}
}
for no, title := range []string{"Processes", "Milter sockets", "Sender check", "TLS certificate",
"Hostname and reverse DNS", "Notification mail", "Machine", "Configuration"} {
if want := `<span class="sp-no">0` + strconv.Itoa(no+1) + `</span><h2>` + title + `</h2>`; !strings.Contains(out, want) {
t.Errorf("Health is missing %s", want)
}
}
pageHas(t, "Health", out, `<tr><td>Last 12 months</td><td>41 s</td><td>3</td></tr>`,
`Counted since <span class="sp-mono">2026-10-13</span>.`, `href="/help#sender-check"`)
// The Configuration box is outside the polled stack: a refresh must not re-render a control.
if strings.Contains(out[strings.Index(out, `id="health-stack"`):strings.Index(out, `id="sockets"`)], `action="/server/health/reload"`) {
t.Error("the reload control is inside the polled stack")
}
}
// The fragment is the polled part of the page and nothing else: the same rows,
// the second one marked to be swapped in by its id, and the Notification mail
// card, a column of the last row, swapped in the same way. No shell and no
// actions.
func TestHealthFragmentIsThePolledRows(t *testing.T) {
// The fragment is the polled part of the page and nothing else: the left
// stack as it is, and the three readings of the right stack, each marked to be
// swapped in by its id. No shell and no actions.
func TestHealthFragmentIsThePolledBoxes(t *testing.T) {
page := renderSignedIn(t, "health", healthFixture(false))
frag := renderFragment(t, "health_body", healthFixture(true))
const card = `<div class="column is-6" id="health-mail"`
// The part of s before the card, and the card itself up to what follows it
// on the page (the Configuration box).
rows := func(s string) string {
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
}
mail := func(s string) string {
a := strings.Index(s, card)
if b := strings.Index(s, `id="configuration"`); b > a {
return s[a:b]
// The polled boxes of s: from the left stack to the Configuration box, or
// to the end of a fragment. On the page the right stack's column opens
// between them; the fragment has no such line.
polled := func(s string) string {
s = s[strings.Index(s, `<div class="column is-6" id="health-stack"`):]
if b := strings.Index(s, `<div class="box" id="configuration"`); b > 0 {
s = s[:b]
}
return s[a:]
s = strings.ReplaceAll(s, ` hx-swap-oob="true"`, "")
return strings.Join(strings.Fields(strings.Replace(s, `<div class="column is-6">`, "", 1)), " ")
}
// Same markup, apart from the out-of-band marks and what follows the card on the page.
oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") }
if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) {
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
if got, want := polled(frag), polled(page); got != want {
t.Errorf("the fragment is not the page's polled boxes\nfragment: %.600s\npage: %.600s", got, want)
}
if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) {
t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want)
}
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`,
`id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`)
pageHas(t, "fragment", frag, `id="health-stack" data-poll`, `id="sockets" hx-swap-oob="true"`,
`id="certificate" hx-swap-oob="true"`, `id="notification-mail" hx-swap-oob="true"`, `id="sender-check"`)
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
}
@@ -257,7 +266,7 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
`<div class="notification is-success is-light">`, `DNS re-checked.`,
)
// the flash sits between the head and the first row, as the kit puts it
if strings.Index(out, `class="notification`) > strings.Index(out, `id="health-machine"`) ||
if strings.Index(out, `class="notification`) > strings.Index(out, `id="health-stack"`) ||
strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) {
t.Error("the flash is not between page_head and the first box")
}
+16
View File
@@ -326,8 +326,24 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-record-head { flex-wrap: wrap; }
.sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; }
.sp-postmark { display: none; }
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
.sp-cards > .column { display: contents; }
.sp-cards .box { margin-bottom: 1.5rem; }
.sp-cards > .column:first-child > .box:nth-child(1) { order: 1; }
.sp-cards > .column:first-child > .box:nth-child(2) { order: 3; }
.sp-cards > .column:first-child > .box:nth-child(3) { order: 5; }
.sp-cards > .column:first-child > .box:nth-child(4) { order: 7; }
.sp-cards > .column:last-child > .box:nth-child(1) { order: 2; }
.sp-cards > .column:last-child > .box:nth-child(2) { order: 4; }
.sp-cards > .column:last-child > .box:nth-child(3) { order: 6; }
.sp-cards > .column:last-child > .box:nth-child(4) { order: 8; }
}
/* cards: two stacks of boxes that are read in rows - the odd ones on the left,
the even ones on the right, each stack closed up so a short box leaves no
gap beside a tall one. On a phone the stacks dissolve and the boxes come in
the order of their numbers (the rules above). */
/* sheet: what does not fit a table row, opened over the page from the row's
tag. It is the browser's own popover (the tag is a button with
popovertarget), so it needs no script: Esc, a click outside and the close
+9 -1
View File
@@ -84,11 +84,19 @@
sp-credential (a secret shown once). A box foot, a table in its
table-container, a run of records, a log or an empty state sit directly
inside; anything else goes in body_open … body_close. */}}
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}>
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
{{/* cards_open starts the `cards` layout and cards_close ends it: two
`column is-6` stacks of numbered boxes that are read in rows, the odd ones
on the left and the even ones on the right, each stack closed up so that a
short box leaves no gap beside a tall one. On a phone the stacks dissolve
and the boxes come in the order of their numbers (panel.css). */}}
{{define "cards_open"}}<div class="columns sp-cards">{{end}}
{{define "cards_close"}}</div>{{end}}
{{/* body_open pads the content of a box (box_body); body_close ends it. */}}
{{define "body_open"}}<div class="sp-box-body">{{end}}
{{define "body_close"}}</div>{{end}}
+10 -9
View File
@@ -1,17 +1,18 @@
{{/* Server > Health (mockup health.html): every check behind the cards of
Overview, and the one action that is not about DNS, Reload configuration. The
two rows of tables are the polled part (health_body.html), and so is the
Notification mail card (health_mail), which sits in the last row beside the
static Configuration box. */}}
Overview, and the one action that is not about DNS, Reload configuration.
The page is the `cards` layout: two stacks of boxes numbered by importance,
the odd ones on the left and the even ones on the right. Everything but
the Configuration box is polled (health_body.html). */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{template "health_body" .}}
<div class="columns">
{{template "health_mail" .}}
{{template "cards_open"}}
{{template "health_stack" .}}
<div class="column is-6">
{{template "health_sockets" .}}
{{template "health_certificate" .}}
{{template "health_mail" .}}
{{template "box_open" .Configuration}}
{{template "body_open"}}
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
@@ -19,5 +20,5 @@
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{template "cards_close"}}
{{end}}
+75 -72
View File
@@ -1,80 +1,83 @@
{{/* The polled part of Health: two rows of boxes, in the page and in its
fragment (GET /server/health/fragment) alike. panel.js polls the first row
(data-poll, outerHTML) and htmx swaps the second into place by its id when
the fragment marks it out of band (.Refresh) — the two rows are siblings, and
a fragment can replace only the element that asked for it. The Notification
mail card is the third thing the fragment swaps in by id: it sits in a row of
the page with the static Configuration box, so it is a column of its own. */}}
{{define "health_body"}}<div class="columns" id="health-machine" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="column is-6">
{{template "box_open" .Machine}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>{{range .MachineRows}}
<tr><td>{{.Resource}}</td><td>{{if .Gauge}}<progress class="progress is-small {{.Gauge.Bar}} mb-1" value="{{.Gauge.Percent}}" max="100"></progress>{{.Gauge.Text}}{{else if .Usage}}<span class="sp-nowrap">{{.Usage}}</span>{{else}}<span class="sp-muted">—</span>{{end}}</td><td class="sp-muted">{{range .Detail}}<div>{{.}}</div>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Processes}}
{{if .ProcessError}}
{{template "body_open"}}
<p class="has-text-danger">Could not ask supervisord for the process list.</p>
{{template "body_close"}}
{{else}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>{{range .ProcessRows}}
<tr><td>{{.Name}}</td><td>{{template "tag" .State}}</td><td class="sp-muted">{{.Detail}}</td></tr>{{end}}
</tbody>
</table></div>
{{end}}
{{template "box_close"}}
</div>
</div>
{{/* The polled part of Health, in the page and in its fragment
(GET /server/health/fragment) alike. The left stack is the element that
polls (data-poll, outerHTML): all four of its boxes are readings. The right
stack ends in the static Configuration box, so its three readings are
swapped into place one by one, each box by its own id, when the fragment
marks them out of band (Health.AsFragment) - a fragment can replace only
the element that asked for it. */}}
{{define "health_body"}}{{template "health_stack" .}}
{{template "health_sockets" .}}
{{template "health_certificate" .}}
{{template "health_mail" .}}{{end}}
<div class="columns" id="health-checks"{{if .Refresh}} hx-swap-oob="true"{{end}}>
<div class="column is-4">
{{template "box_open" .Certificate}}
{{template "body_open"}}
{{if .CertFacts}}{{template "facts" .CertFacts}}{{end}}
<p class="mt-4 {{if .CertProblem}}has-text-danger{{else}}sp-muted{{end}}">{{.CertDetail}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Sockets}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>{{range .SocketRows}}
<tr><td>{{.Name}}{{if .Note}} <span class="sp-muted">· {{.Note}}</span>{{end}}<br><span class="sp-muted sp-mono sp-small">{{.Path}}</span>{{if .Detail}}<br><span class="sp-muted sp-small">{{.Detail}}</span>{{end}}</td><td>{{template "tag" .State}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Hostname}}
{{template "body_open"}}
{{template "facts" .HostFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>{{if .Refresh}}
{{template "health_mail" .}}{{end}}{{end}}
{{/* health_mail is the Notification mail card (the page calls it inside its own
row; the fragment sends it out of band). */}}
{{define "health_mail"}}<div class="column is-6" id="health-mail"{{if .Refresh}} hx-swap-oob="true"{{end}}>
{{template "box_open" .NotifyMail}}
{{/* health_stack is the left stack: Processes, Sender check, Hostname and
reverse DNS, Machine. */}}
{{define "health_stack"}}<div class="column is-6" id="health-stack" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
{{template "box_open" .Processes}}
{{if .ProcessError}}
{{template "body_open"}}
<p class="has-text-danger">Could not ask supervisord for the process list.</p>
{{template "body_close"}}
{{else}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Record</th><th>State</th></tr></thead>
<tbody>{{range .NotifyRows}}
<tr><td>{{.Name}}<br><span class="sp-muted sp-mono sp-small">{{.Host}}</span></td><td>{{template "tag" .State}}</td></tr>{{end}}
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>{{range .ProcessRows}}
<tr><td>{{.Name}}</td><td>{{template "tag" .State}}</td><td class="sp-muted">{{.Detail}}</td></tr>{{end}}
</tbody>
</table></div>
{{end}}
{{template "box_close"}}
{{template "box_open" .SenderCheck}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Period</th><th>Panel not answering</th><th>Messages deferred</th></tr></thead>
<tbody>{{range .SenderRows}}
<tr><td>{{.Period}}</td><td>{{.Down}}</td><td>{{.Deferred}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "body_open"}}
<p class="sp-muted">{{template "inline" .NotifyNote}}</p>
<p class="sp-muted">{{template "inline" .SenderNote}}</p>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Hostname}}
{{template "body_open"}}
{{template "facts" .HostFacts}}
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Machine}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>{{range .MachineRows}}
<tr><td>{{.Resource}}</td><td>{{if .Gauge}}<progress class="progress is-small {{.Gauge.Bar}} mb-1" value="{{.Gauge.Percent}}" max="100"></progress>{{.Gauge.Text}}{{else if .Usage}}<span class="sp-nowrap">{{.Usage}}</span>{{else}}<span class="sp-muted">—</span>{{end}}</td><td class="sp-muted">{{range .Detail}}<div>{{.}}</div>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>{{end}}
{{define "health_sockets"}}{{template "box_open" .Sockets}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>{{range .SocketRows}}
<tr><td>{{.Name}}{{if .Note}} <span class="sp-muted">· {{.Note}}</span>{{end}}<br><span class="sp-muted sp-mono sp-small">{{.Path}}</span>{{if .Detail}}<br><span class="sp-muted sp-small">{{.Detail}}</span>{{end}}</td><td>{{template "tag" .State}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}{{end}}
{{define "health_certificate"}}{{template "box_open" .Certificate}}
{{template "body_open"}}
{{if .CertFacts}}{{template "facts" .CertFacts}}{{end}}
<p class="mt-4 {{if .CertProblem}}has-text-danger{{else}}sp-muted{{end}}">{{.CertDetail}}</p>
{{template "body_close"}}
{{template "box_close"}}{{end}}
{{/* health_mail is the Notification mail card. */}}
{{define "health_mail"}}{{template "box_open" .NotifyMail}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Record</th><th>State</th></tr></thead>
<tbody>{{range .NotifyRows}}
<tr><td>{{.Name}}<br><span class="sp-muted sp-mono sp-small">{{.Host}}</span></td><td>{{template "tag" .State}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "body_open"}}
<p class="sp-muted">{{template "inline" .NotifyNote}}</p>
{{template "body_close"}}
{{template "box_close"}}{{end}}
+2 -2
View File
@@ -15,7 +15,7 @@
{{template "body_close"}}
{{template "box_close"}}
<div class="columns">
{{template "cards_open"}}
<div class="column is-6">
{{template "box_open" .Postmark}}
{{template "body_open"}}
@@ -58,7 +58,7 @@
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{template "cards_close"}}
{{template "box_open" .HealthCard}}
{{template "health_cards" .HealthCards}}