from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
test / test (push) Canceled after 0s
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:
@@ -28,6 +28,7 @@ type Config struct {
|
||||
TLSCertFile string
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
SenderSocket string
|
||||
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||
RateLimitMessagesPerIP int
|
||||
|
||||
@@ -25,7 +25,8 @@ func socketsServer(t *testing.T) *Handlers {
|
||||
dir := t.TempDir()
|
||||
h.cfg.OpenDKIMSocket = filepath.Join(dir, "opendkim.sock")
|
||||
h.cfg.JournalSocket = filepath.Join(dir, "journal.sock")
|
||||
for _, path := range []string{h.cfg.OpenDKIMSocket, h.cfg.JournalSocket} {
|
||||
h.cfg.SenderSocket = filepath.Join(dir, "sender.sock")
|
||||
for _, path := range []string{h.cfg.OpenDKIMSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
@@ -55,19 +56,19 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
h.cfg.InboundEnabled = true
|
||||
h.cfg.InboundAntispamMilter = "" // no filter attached
|
||||
if got := socketsOf(t, h); len(got) != 2 {
|
||||
t.Fatalf("%d sockets with the filter off, want 2: %+v", len(got), got)
|
||||
if got := socketsOf(t, h); len(got) != 3 {
|
||||
t.Fatalf("%d sockets with the filter off, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// A milter address with the inbound relay off is not a filter either.
|
||||
h.cfg.InboundEnabled = false
|
||||
h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1"
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("%d sockets with the inbound relay off, want 2: %+v", len(got), got)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%d sockets with the inbound relay off, want 3: %+v", len(got), got)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "2 of 2 answering" || card.Sub != "OpenDKIM, send-log" || card.Level != "" {
|
||||
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, sender-check, send-log" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
|
||||
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 3 || got[2].Name != "Spam filter" || got[2].Note != "inbound" || got[2].Path != "inet:"+l.Addr().String() {
|
||||
if len(got) != 4 || got[3].Name != "Spam filter" || got[3].Note != "inbound" || got[3].Path != "inet:"+l.Addr().String() {
|
||||
t.Fatalf("sockets = %+v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
@@ -92,7 +93,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "3 of 3 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
if card.Value != "4 of 4 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
|
||||
@@ -132,10 +133,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
} {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("action %q: %d sockets, want 3", tt.action, len(got))
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("action %q: %d sockets, want 4", tt.action, len(got))
|
||||
}
|
||||
f := got[2]
|
||||
f := got[3]
|
||||
refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail
|
||||
if f.Present || f.Status != tt.status || f.Detail != refused {
|
||||
t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused)
|
||||
@@ -146,7 +147,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c["SocketStatus"], tt.status)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "2 of 3 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
t.Errorf("action %q: card = %+v", tt.action, card)
|
||||
}
|
||||
|
||||
@@ -160,7 +161,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
|
||||
page := rec.Body.String()
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (2 of 3 answering)`} {
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (3 of 4 answering)`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("action %q: Overview is missing %q", tt.action, want)
|
||||
}
|
||||
|
||||
@@ -95,13 +95,13 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
|
||||
if strings.Contains(out, "Listening") {
|
||||
t.Error("a socket that answers repeats its detail beside the tag")
|
||||
}
|
||||
// The reload control stays outside the polled rows.
|
||||
body, conf := strings.Index(out, `id="health-machine"`), strings.Index(out, `id="configuration"`)
|
||||
if body < 0 || conf < body {
|
||||
t.Fatal("polled rows or the Configuration box missing or out of order")
|
||||
// The reload control stays outside the polled stack, in the other one.
|
||||
body, right, conf := strings.Index(out, `id="health-stack"`), strings.Index(out, `id="sockets"`), strings.Index(out, `id="configuration"`)
|
||||
if body < 0 || right < body || conf < right {
|
||||
t.Fatal("the polled stack or the Configuration box missing or out of order")
|
||||
}
|
||||
if frag := out[body:conf]; strings.Contains(frag, `action="/server/health/reload"`) || !strings.Contains(frag, `id="hostname"`) {
|
||||
t.Error("the hostname box is outside the polled rows, or the reload control inside them")
|
||||
if frag := out[body:right]; strings.Contains(frag, `action="/server/health/reload"`) || !strings.Contains(frag, `id="hostname"`) || !strings.Contains(frag, `id="sender-check"`) {
|
||||
t.Error("the hostname or sender check box is outside the polled stack, or the reload control inside it")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +163,9 @@ func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
|
||||
t.Fatalf("GET /server/health/fragment = %d:\n%s", rec.Code, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`id="health-machine" data-poll`, `hx-get="/server/health/fragment"`,
|
||||
`id="health-checks" hx-swap-oob="true"`, `id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
||||
`id="health-stack" data-poll`, `hx-get="/server/health/fragment"`,
|
||||
`id="sockets" hx-swap-oob="true"`, `id="certificate" hx-swap-oob="true"`, `id="notification-mail" hx-swap-oob="true"`,
|
||||
`id="machine"`, `id="processes"`, `id="sender-check"`, `id="hostname"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("health fragment is missing %q", want)
|
||||
|
||||
@@ -104,6 +104,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
in := view.MessageInput{
|
||||
ID: row.ID, QueueID: row.QueueID, Domain: row.Domain, App: row.AppLogin,
|
||||
From: row.From, To: row.To, Subject: row.Subject, Status: row.Status,
|
||||
Reason: row.Reason, HeaderFrom: row.HeaderFrom,
|
||||
Accepted: row.CreatedAt, Reported: row.UpdatedAt,
|
||||
// Where the row came from, so the way back returns to the page and filters
|
||||
// the operator was looking at rather than the top of an unfiltered log.
|
||||
@@ -122,13 +123,31 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
page := view.NewOutMessage(h.shellMeta(r), in).
|
||||
WithHistory(deliveryEvents(row, h.cfg.RetryPolicy)).
|
||||
WithHistory(deliveryEvents(row, h.cfg.RetryPolicy, h.sendsAs(row.AppLogin))).
|
||||
// The mail.log lines for this message, and — when there are none — the
|
||||
// reason, which is a normal outcome rather than a failure.
|
||||
WithDeliveryLog(logLines, logNote)
|
||||
h.view.Render(w, http.StatusOK, "out-message", page)
|
||||
}
|
||||
|
||||
// sendsAs says what an application may send as, in the words of a refusal:
|
||||
// any address of its domain, or the addresses of its list. Empty when the
|
||||
// login is no application's any more.
|
||||
func (h *Handlers) sendsAs(login string) view.Text {
|
||||
a, err := h.store.GetApplicationByLogin(login)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if a.AddressMode == store.AddressModeList {
|
||||
return view.Plain("the addresses of its list")
|
||||
}
|
||||
d, err := h.store.GetDomain(a.DomainID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return view.Rich("any address of ", view.Mono(d.Name))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -137,7 +156,10 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
// 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) []view.Step {
|
||||
//
|
||||
// sendsAs is what the row's application may send as, for the step of a message
|
||||
// refused for its From: line; empty when the application is gone.
|
||||
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy, sendsAs view.Text) []view.Step {
|
||||
// step states what happened and when; outcome is ok, warn, error or "" for a
|
||||
// step that reports nothing either way, and a zero time is a step that has
|
||||
// not happened yet.
|
||||
@@ -153,6 +175,22 @@ func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []view.Ste
|
||||
// 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 {
|
||||
switch row.Reason {
|
||||
case store.ReasonFrom:
|
||||
// The one step that names things: which line, whose, and what that
|
||||
// application may write instead.
|
||||
text := view.Rich(" — ", view.Code("From: "+row.HeaderFrom), " is not an address of application ", view.Mono(row.AppLogin))
|
||||
if len(sendsAs) > 0 {
|
||||
text = append(append(text, view.Inline{Text: ", which sends as "}), sendsAs...)
|
||||
}
|
||||
text = append(text, view.Rich(". The application was told ", view.Code("550 5.7.1"), " and will not try again.")...)
|
||||
s := step(row.CreatedAt, "error", "Refused before queueing", "")
|
||||
s.Text = text
|
||||
return []view.Step{s}
|
||||
case store.ReasonClient:
|
||||
return []view.Step{step(row.CreatedAt, "error", "Refused before queueing",
|
||||
"The application accepts mail only from its listed client addresses, and this message came from another. It was never queued, so there is no queue id and Postfix never attempted delivery.")}
|
||||
}
|
||||
return []view.Step{step(row.CreatedAt, "error", "Refused before queueing",
|
||||
"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.")}
|
||||
}
|
||||
@@ -336,7 +374,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
|
||||
for i, row := range rows {
|
||||
table[i] = view.OutLogRow{
|
||||
Time: view.FormatLogTime(row.CreatedAt), From: row.From, To: row.To,
|
||||
Subject: mailhdr.DecodeSubject(row.Subject), Status: view.Tag{Status: row.Status},
|
||||
Subject: mailhdr.DecodeSubject(row.Subject), Status: view.SendLogTag(row.Status, row.Reason),
|
||||
Href: out.DetailHref(row.ID, page),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func TestHealthNotificationMailCard(t *testing.T) {
|
||||
}
|
||||
|
||||
// With both records in place the card is green. The card is part of the polled
|
||||
// fragment, swapped in by its id like the second row.
|
||||
// fragment, swapped in by its id.
|
||||
func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
publishMail(zone, mail)
|
||||
@@ -279,7 +279,7 @@ func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
|
||||
h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
|
||||
out := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`id="health-mail" hx-swap-oob="true"`,
|
||||
`id="notification-mail" hx-swap-oob="true"`,
|
||||
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div>`,
|
||||
`<td><span class="tag is-success is-light">published</span></td>`,
|
||||
} {
|
||||
@@ -287,7 +287,7 @@ func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
|
||||
t.Errorf("health fragment is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out[strings.Index(out, `id="health-mail"`):], "is-warning") {
|
||||
if strings.Contains(out[strings.Index(out, `id="notification-mail"`):], "is-warning") {
|
||||
t.Error("the card warns while both records are published")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -118,15 +119,13 @@ func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
h.view.Render(w, http.StatusOK, "health", h.healthView(r, statusFlash(r)))
|
||||
}
|
||||
|
||||
// HandleHealthFragment is the polled part of Health: the same two rows of
|
||||
// boxes the page holds, read again.
|
||||
// HandleHealthFragment is the polled part of Health: the boxes the page holds,
|
||||
// all but Configuration, read again.
|
||||
func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
page := h.healthView(r, "")
|
||||
page.Refresh = true
|
||||
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
|
||||
h.view.RenderFragment(w, http.StatusOK, "health_body", h.healthView(r, "").AsFragment())
|
||||
}
|
||||
|
||||
// HandleHealthRecheck re-runs the DNS checks of the server ignoring the cache.
|
||||
@@ -144,7 +143,31 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handlers) healthView(r *http.Request, flash string) *view.Health {
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), flash)
|
||||
mail := h.serverMail(false)
|
||||
return page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
stats, err := h.store.SenderCheckStats(time.Now())
|
||||
if err != nil {
|
||||
logf("panel: health: sender check statistics: %v", err)
|
||||
}
|
||||
rows, since, status := senderCheckCard(stats, time.Now())
|
||||
return page.WithSenderCheck(rows, since, status)
|
||||
}
|
||||
|
||||
// senderCheckCard is the Sender check card from its statistics. It warns when
|
||||
// a message was deferred in the last 24 hours: that is mail an application had
|
||||
// to send again. Downtime that deferred nothing is only shown.
|
||||
func senderCheckCard(s store.SenderCheckStats, now time.Time) (rows []view.SenderCheckRow, since, status string) {
|
||||
row := func(period string, t store.SenderCheckTotals) view.SenderCheckRow {
|
||||
return view.SenderCheckRow{Period: period, Down: view.Downtime(t.Down), Deferred: strconv.FormatInt(t.Deferred, 10)}
|
||||
}
|
||||
rows = []view.SenderCheckRow{row("Today", s.Today), row("Last 30 days", s.Month), row("Last 12 months", s.Year)}
|
||||
if !s.Since.IsZero() {
|
||||
since = s.Since.Format("2006-01-02")
|
||||
}
|
||||
status = string(health.StatusOK)
|
||||
if !s.DeferredAt.IsZero() && now.Sub(s.DeferredAt) < 24*time.Hour {
|
||||
status = string(health.StatusWarn)
|
||||
}
|
||||
return rows, since, status
|
||||
}
|
||||
|
||||
// healthPage turns one reading of the checks (healthChecks) into the Health
|
||||
@@ -397,6 +420,7 @@ func (h *Handlers) healthChecks() map[string]any {
|
||||
cert := health.CheckCertificate(h.cfg.TLSCertFile)
|
||||
sockets := []health.Socket{
|
||||
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
|
||||
health.CheckSocket("sender-check", h.cfg.SenderSocket, true),
|
||||
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
|
||||
}
|
||||
if on, milter, action := h.inboundFilter(); on {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, "")} }
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"))},
|
||||
},
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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&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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -67,6 +67,8 @@ type Config struct {
|
||||
// only for the send log (the journal-milter fails open).
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
// SenderSocket is the sender check's socket, attached strictly.
|
||||
SenderSocket string
|
||||
// SessionIdleDays is the sliding inactivity window after which a login
|
||||
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
|
||||
// falls back to the 7-day default.
|
||||
@@ -138,6 +140,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
TLSCertFile: cfg.TLSCertFile,
|
||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||
JournalSocket: cfg.JournalSocket,
|
||||
SenderSocket: cfg.SenderSocket,
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
|
||||
Reference in New Issue
Block a user