panel-redesign: the queue in the new shell; the old design is removed

Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.

The queue
  - out-queue, named after its mockup: today's content - the listing of
    postqueue -p in a log pane, polled as before (GET
    /outbound/queue/fragment) - and the "How retries work" facts. The
    mockup's table of messages is the roadmap's queue-as-a-table; the
    outline guard exempts this page for exactly that reason. Routes and
    the global-only check are unchanged; the page has no actions and
    never had.
  - Inbound has no Queue entry and no route serves one.

The old design is gone
  - Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
    mail_queue.html, mail_queue_body.html.
  - view.go has one path: every page is layout.html + components.html +
    its own files; kitPages, kitFragments, the map-data branch of Render,
    the dict and back template functions and metaOf's map branch are
    removed. handlers: pageBase removed.
  - legacy_pages.txt holds its header and no entry.
  - panel.js: the old copy handler (button.copy in .code-row) is
    removed. A show-when rule now applies to every element it matches,
    and hiding adds Bulma's is-hidden beside the hidden attribute - a
    .box or a grid row has a display of its own and stayed visible.
  - The user form hides both domain lists again when the role is Global
    (Box.DomainPick, an attribute; data-global-role on the select).
  - Tests of the old layout are deleted; what they asserted about the
    shell (version only signed in, the menu follows the user's reach,
    Inbound only with the feature, Help present, the current entry
    marked) is asserted on the kit pages, two tests added for it.

For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.

Evidence
  - docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
    headless Edge, the real template served locally with its fixture -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on out-queue, user and backup at 1440 and 1280:
    366 texts checked, problems: [].
  - In a browser against the local fixture server: the encryption
    fields of Backup, the manual / auto limit fields and the custom
    report address of Domain settings show and hide with their control;
    all 28 fixture pages answer 200 with panel.css, /static/legacy.css
    answers 404.
  - Not exercised: polling against a real postqueue (none on Windows).
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 02:36:04 +03:00
co-authored by Claude Opus 5.5
parent fa696efa8b
commit 32c6ae98a1
32 changed files with 329 additions and 1712 deletions
-11
View File
@@ -22,17 +22,6 @@ func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.P
return p, true
}
func (h *Handlers) pageBase(r *http.Request) map[string]any {
p, _ := h.principal(r)
return map[string]any{
"User": auth.CurrentUser(r),
"IsGlobal": p.IsGlobal(),
// Which groups the menu shows this user (plan § Who sees what).
"HasOutbound": p.HasOutbound(),
"HasInbound": p.HasInbound(),
}
}
// shellMeta is what the shell of a page of the component kit needs to know
// about the person looking at it: their name and which groups of the menu they
// reach. The page's constructor adds its title and where it sits in the menu.
@@ -184,9 +184,6 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
t.Errorf("account page missing %q", want)
}
}
if strings.Contains(out, "/static/legacy.css") {
t.Error("account page loads the old stylesheet")
}
}
// A refused form is shown again with the error above the boxes and what the
@@ -50,7 +50,4 @@ func TestComponentsPageRendersTheKitForTheGlobalRole(t *testing.T) {
t.Errorf("the kit page is missing %q", want)
}
}
if strings.Contains(out, "/static/legacy.css") {
t.Error("the kit page loads the old stylesheet")
}
}
+10 -19
View File
@@ -14,7 +14,6 @@ import (
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
@@ -333,26 +332,15 @@ func parsePage(v string) int {
return n
}
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// HandleMailQueue renders the Outbound queue page (architecture.md § Panel HTTP
// surface).
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
out, errText := readQueue()
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
"FirstRetry": policy.FirstRetry(),
"BackoffCap": policy.BackoffCap(),
"QueueLifetime": policy.QueueLifetime(),
"RetryFromDefaults": policy.FromDefaults,
})
h.view.Render(w, http.StatusOK, "out-queue", h.outQueuePage(r).
WithRetryPolicy(policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime(), policy.FromDefaults))
}
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
@@ -360,11 +348,14 @@ func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.view.RenderFragment(w, http.StatusOK, "out_queue_body", h.outQueuePage(r))
}
// outQueuePage reads the queue and gives it to the view; the page and its
// fragment draw the same box from it.
func (h *Handlers) outQueuePage(r *http.Request) *view.OutQueue {
out, errText := readQueue()
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out,
"Error": errText,
})
return view.NewOutQueue(h.shellMeta(r), out, errText)
}
// readQueue runs postqueue -p, returning a friendly message instead of the
+10 -7
View File
@@ -479,17 +479,17 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
}
}
// The retry card sits on the page itself, outside the HTMX poll, and prints
// The retry policy sits on the page itself, outside the HTMX poll, and prints
// whatever policy was cached on Config — never a live postconf.
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/outbound/queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
"<h2>How retries work</h2>",
"This Postfix&#39;s policy, read at panel start",
">10 minutes<",
"doubling, cap about 1 hour 7 minutes",
"capped at about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
@@ -499,7 +499,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
if strings.Contains(out, "compiled-in defaults") {
if strings.Contains(out, "Compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
@@ -508,7 +508,10 @@ func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/outbound/queue/fragment")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
if !strings.HasPrefix(out, `<div class="box" id="out-queue" data-poll`) || strings.Contains(out, "<html") {
t.Errorf("the fragment is not the polled box alone:\n%.200s", out)
}
if strings.Contains(out, "How retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
}
@@ -517,7 +520,7 @@ func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/outbound/queue")
if !strings.Contains(out, "compiled-in defaults") {
if !strings.Contains(out, "Compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
}
@@ -11,9 +11,8 @@ import (
// The Outbound log and the page of one message, rendered from the real journal
// by the handlers. Their markup is held to the mockups by the guards in
// internal/web/view; what is checked here is the behaviour the pages keep from
// the legacy ones — the polled region, the paging, the scoping — on the new
// markup.
// internal/web/view; what is checked here is the behaviour of the pages — the
// polled region, the paging, the scoping.
// regionOf cuts the polled region (the table and the foot under it) out of a
// body: from the table's container to the end of the foot.
@@ -223,7 +223,7 @@ func TestUserFormOfTheOnlyGlobalUserSavesUntouched(t *testing.T) {
pv := map[string]string{"uid": idStr(root.ID)}
get := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String()
for _, want := range []string{`<input type="hidden" name="role" value="global">`, `<select id="role" name="role" disabled>`} {
for _, want := range []string{`<input type="hidden" name="role" value="global">`, `<select id="role" name="role" data-global-role="global" disabled>`} {
if !strings.Contains(get, want) {
t.Errorf("the form of the only global user is missing %q", want)
}
+9 -16
View File
@@ -233,7 +233,9 @@ type Flash struct {
// Poll, when set, makes the box the element that polls: it is the address of the
// fragment that answers with the box again (panel.js schedules the next request
// by the box's ID, which Poll therefore requires). Used by Overview's Server
// health box. It adds attributes only, no markup.
// health box. DomainPick marks a box as one of the lists of domains on the user
// form: panel.js hides it while the form's role is Global, which reaches every
// domain. Poll and DomainPick add attributes only, no markup.
type Box struct {
No string
Icon string
@@ -244,6 +246,8 @@ type Box struct {
Help *HelpLink
Variant string
Poll string
// DomainPick: see Box.
DomainPick bool
}
// HelpLink is a Box's link to its Help topic.
@@ -432,9 +436,7 @@ type HelpTopic struct {
}
// Meta is what a page says about itself to the shell. A page's data embeds it
// (Engine.Render reads it through ShellMeta); a handler that still passes a map
// gives the same names as keys: Title, User, IsGlobal, HasOutbound, HasInbound,
// Section and Page.
// (Engine.Render reads it through ShellMeta).
//
// Section is the group the page belongs to — overview, outbound, inbound,
// server, or user for Account and Help — and Page the entry within it
@@ -458,20 +460,11 @@ func (m Meta) ShellMeta() Meta { return m }
// shellMeta is implemented by Meta, and so by every page that embeds it.
type shellMeta interface{ ShellMeta() Meta }
// metaOf reads a page's Meta from its data: a struct that embeds Meta, or the
// map a handler builds.
// metaOf reads a page's Meta from its data, a struct that embeds it; anything
// else has none, and is shown as the signed-out screen.
func metaOf(data any) Meta {
switch d := data.(type) {
case shellMeta:
if d, ok := data.(shellMeta); ok {
return d.ShellMeta()
case map[string]any:
str := func(k string) string { s, _ := d[k].(string); return s }
flag := func(k string) bool { b, _ := d[k].(bool); return b }
return Meta{
Title: str("Title"), User: str("User"), IsGlobal: flag("IsGlobal"),
HasOutbound: flag("HasOutbound"), HasInbound: flag("HasInbound"),
Section: str("Section"), Page: str("Page"),
}
}
return Meta{}
}
+30 -47
View File
@@ -4,15 +4,14 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// Tests of the component kit's own code: the template functions, each partial
// in each of its states, the shell's visibility rules and the agreement between
// kitPages and legacy_pages.txt. The design contract itself (vocabulary, outlines,
// the stylesheet) is held by the guard tests, which this file does not repeat.
// in each of its states and the shell's visibility rules. The design contract
// itself (vocabulary, outlines, the stylesheet) is held by the guard tests,
// which this file does not repeat.
func kitEngine(t *testing.T) *Engine {
t.Helper()
@@ -503,8 +502,29 @@ func TestMenuOfADomainAdministratorLeavesNoGap(t *testing.T) {
mustNotContain(t, out, "/outbound/dmarc")
}
// Signing out is a POST, as it is in the old layout and as the handler demands;
// it has to work without scripts.
// A domain administrator who was given inbound domains only is offered Inbound
// and none of the sending pages, and the wordmark takes them to what they have.
func TestMenuOfAnInboundOnlyAdministratorLeavesNoGap(t *testing.T) {
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "ops", HasInbound: true, Section: "inbound", Page: "domains"})
mustContain(t, out, `href="/inbound/domains"`, `href="/account"`, `href="/help"`,
`<a class="navbar-item" href="/inbound/domains"><img src="/static/wordmark.svg"`)
// (The kit page's own examples link to /outbound/domains, so the menu's group
// is looked for by its link.)
mustNotContain(t, out, `href="/overview"`, `href="/server/`, `navbar-link" href="/outbound/domains"`, `href="/outbound/log"`, `href="/outbound/dmarc"`)
}
// The Inbound entry needs the feature as well as something to reach in it.
func TestInboundEntryNeedsTheFeature(t *testing.T) {
off, err := New("t")
if err != nil {
t.Fatal(err)
}
mustNotContain(t, renderShell(t, off, Meta{Title: "t", User: "admin", IsGlobal: true}), "/inbound/domains")
mustContain(t, renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true}), `href="/inbound/domains"`)
}
// Signing out is a POST, as the handler demands; it has to work without
// scripts.
func TestSignOutStaysAPostForm(t *testing.T) {
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
mustContain(t, out, `<form method="post" action="/logout"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>`)
@@ -531,59 +551,22 @@ func TestKitLayoutLoadsOnlyTheKit(t *testing.T) {
`<link rel="stylesheet" href="/static/bulma.min.css">`+"\n"+`<link rel="stylesheet" href="/static/tabler-icons.css">`+"\n"+`<link rel="stylesheet" href="/static/panel.css">`,
`<script src="/static/htmx.min.js" defer></script>`, `<script src="/static/panel.js" defer></script>`,
`<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>`)
mustNotContain(t, out, "legacy.css", "http://", "https://cdn")
mustNotContain(t, out, "http://", "https://cdn")
}
}
// Meta can come from a struct that embeds it or from the map a handler builds.
func TestMetaOfReadsAStructAndAMap(t *testing.T) {
// Meta comes from a struct that embeds it; any other data has none, which is
// the signed-out screen.
func TestMetaOfReadsAStruct(t *testing.T) {
want := Meta{Title: "t", User: "u", IsGlobal: true, HasOutbound: true, HasInbound: true, Section: "server", Page: "users"}
if got := metaOf(&Kit{Meta: want}); got != want {
t.Errorf("metaOf(struct) = %+v", got)
}
got := metaOf(map[string]any{"Title": "t", "User": "u", "IsGlobal": true, "HasOutbound": true, "HasInbound": true, "Section": "server", "Page": "users", "Other": 1})
if got != want {
t.Errorf("metaOf(map) = %+v", got)
}
if got := metaOf(42); got != (Meta{}) {
t.Errorf("metaOf(other) = %+v", got)
}
}
// A page is on the new layout exactly when legacy_pages.txt does not list it.
func TestKitPagesAreTheOnesOffTheRatchet(t *testing.T) {
b, err := os.ReadFile("legacy_pages.txt")
if err != nil {
t.Fatal(err)
}
legacy := map[string]bool{}
for _, line := range strings.Split(string(b), "\n") {
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "@") {
legacy[line] = true
}
}
for name := range pageFiles {
if kitPages[name] == legacy[name] {
t.Errorf("page %q: kitPages=%v and legacy_pages.txt lists it=%v; it must be in exactly one", name, kitPages[name], legacy[name])
}
}
for name := range kitPages {
if _, ok := pageFiles[name]; !ok {
t.Errorf("kitPages names %q, which is not a page", name)
}
}
}
// The old pages keep rendering through the old layout and stylesheet.
func TestLegacyPagesStayOnTheOldLayout(t *testing.T) {
e := kitEngine(t)
rec := httptest.NewRecorder()
e.Render(rec, http.StatusOK, "mail_queue", map[string]any{"Title": "t", "User": "admin", "IsGlobal": true})
out := rec.Body.String()
mustContain(t, out, `href="/static/legacy.css"`, `class="shell"`)
mustNotContain(t, out, "bulma.min.css", "tabler-icons.css", "/static/panel.css", "navbar")
}
// Poll, ID and OOB are optional attributes: empty, box_open and page_head write
// exactly the markup they wrote before they existed.
func TestPollAndOOBAttributesAreAbsentWhenEmpty(t *testing.T) {
+15 -2
View File
@@ -6,8 +6,8 @@ import (
)
// pageFixtures is the data each redesigned page is rendered with by the guard
// tests (guard_panel_test.go): one entry per page that has left
// legacy_pages.txt, keyed by the page's engine name. The guards render the page
// tests (guard_panel_test.go): one entry per page of the engine, keyed by the
// page's engine name. The guards render the page
// through Engine.Render with this value and hold the result to the design
// contract — its class vocabulary, its page structure and the component
// skeleton recorded for the mockup of the same name in
@@ -41,6 +41,7 @@ var pageFixtures = map[string]func() any{
"out-log": func() any { return outLogFixture() },
"out-message": func() any { return outMessageFixture() },
"out-queue": func() any { return outQueueFixture() },
"dmarc": func() any { return dmarcHubFixture() },
"dmarc-domain": func() any { return dmarcDomainFixture() },
"dmarc-report": func() any { return dmarcReportFixture() },
@@ -350,6 +351,18 @@ func inDomainFixture() *InDomain {
return p
}
// outQueueFixture is the queue as Postfix prints it for the mockup's one waiting
// message, greylisted by the receiving server, and the retry policy it shows.
func outQueueFixture() *OutQueue {
listing := "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------\n" +
"4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org\n" +
"(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted, try again in 5 minutes (in reply to RCPT TO command))\n" +
" noc@example.org\n" +
"\n" +
"-- 3 Kbytes in 1 Request.\n"
return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
}
// systemLogFixture is the mockup's System log: ten lines of mail.log, as the
// handler reads them (oldest first), with the deferrals in amber and the
// refusals and the bounce in red.
-1
View File
@@ -10,4 +10,3 @@
# components.html, components.go, layout.html, GET /server/components
# @routes the pre-2.0 route table is still in place (stage 1)
# <name> a page of the view engine that is still the old template
mail_queue
+81
View File
@@ -0,0 +1,81 @@
package view
// The data of the Outbound queue (templates/out_queue.html, mockup
// out-queue.html): what Postfix is still holding, and the retry policy that
// decides how long it holds it. The queue box polls; the page and its fragment,
// GET /outbound/queue/fragment, render the same box
// (templates/out_queue_body.html).
//
// Until the queue is a table (docs/roadmap.md, parse postqueue -j) the box
// shows postqueue's own listing in a log pane, as the system log shows
// mail.log: the mockup's table is not drawn from data the panel has.
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads Postfix, the view decides what is said and
// how it is worded.
import "strings"
const outboundQueue = "/outbound/queue"
// OutQueue is the data of the Outbound queue. It is for the global role.
type OutQueue struct {
Meta
Head Head
Queue Box
Lines []LogLine
// Empty is shown in place of the listing when there is none: the queue holds
// nothing, or it could not be read.
Empty EmptyState
Retries Box
Policy []Fact
}
// NewOutQueue builds the page. listing is what postqueue printed, as it was
// read, and errText is why it could not be read, empty when it could.
func NewOutQueue(m Meta, listing, errText string) *OutQueue {
m.Title, m.Section, m.Page = "Outbound queue", "outbound", "queue"
p := &OutQueue{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound queue",
Lead: Rich("What applications handed over and the receiving server has not yet taken, as ", Code("postqueue -p"), " lists it."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
End: Plain("Refreshes on its own")},
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Queue is empty.")},
Retries: Box{No: "02", Title: "How retries work"},
}
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
return p
}
if strings.TrimSpace(listing) == "" {
return p
}
for _, line := range strings.Split(strings.TrimRight(listing, "\r\n"), "\n") {
p.Lines = append(p.Lines, LogLine{Text: strings.TrimRight(line, "\r")})
}
return p
}
// WithRetryPolicy fills the second box: the first retry, the cap the later ones
// double up to and how long a message is kept, each as the handler words it.
// fromDefaults says the Postfix configuration could not be read and these are
// the compiled-in values.
func (p *OutQueue) WithRetryPolicy(firstRetry, backoffCap, lifetime string, fromDefaults bool) *OutQueue {
p.Retries.End = Plain("This Postfix's policy, read at panel start")
if fromDefaults {
p.Retries.End = Plain("Compiled-in defaults: the Postfix configuration could not be read")
}
p.Policy = []Fact{
{Label: "First retry", Value: Plain(firstRetry), Big: true},
{Label: "Later retries", Value: Plain("doubling"), Big: true, Note: Plain("capped at " + backoffCap)},
{Label: "Kept in queue", Value: Plain(lifetime), Big: true},
{Label: "Then", Value: Plain("bounced"), Big: true, Note: Plain("no attempt counter, only time")},
}
return p
}
+60
View File
@@ -0,0 +1,60 @@
package view
import (
"strings"
"testing"
)
// ---- Outbound queue
// The page prints postqueue's listing as it is and polls from its box; the
// retry policy sits under it, outside the poll.
func TestOutQueueShowsTheListingAndPollsItsBox(t *testing.T) {
out := renderSignedIn(t, "out-queue", outQueueFixture())
pageHas(t, "Outbound queue", out,
`<h1 class="title is-3">Outbound queue</h1>`, `<h2>Waiting</h2>`,
`id="out-queue" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
`class="sp-log"`, `-Queue ID- --Size-- `, `4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org`,
`-- 3 Kbytes in 1 Request.`,
`aria-current="page"><i class="ti ti-stack-2"></i>Queue</a>`,
)
// What Postfix printed is escaped, not interpreted.
pageHas(t, "Outbound queue", out, `(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted`)
pageLacks(t, "Outbound queue", out, "<table", "Queue is empty.")
frag := renderFragment(t, "out_queue_body", outQueueFixture())
if !strings.HasPrefix(frag, `<div class="box" id="out-queue" data-poll`) || strings.Contains(frag, "sp-head") || strings.Contains(frag, "How retries work") {
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
}
if strings.Count(out, `class="sp-log"`) != 1 || strings.Count(frag, `class="sp-log"`) != 1 {
t.Error("the page and its fragment should each carry the listing once")
}
}
// The policy is the page's, wherever it was read from: its facts, and a head
// that says so when they are the compiled-in defaults.
func TestOutQueueStatesTheRetryPolicy(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", "").WithRetryPolicy("10 minutes", "about 1 hour 7 minutes", "2 days", false))
pageHas(t, "Outbound queue", out,
`<h2>How retries work</h2>`, `This Postfix&#39;s policy, read at panel start`,
`<dd class="sp-big">10 minutes</dd>`, `<dd class="sp-big">doubling</dd>`, `capped at about 1 hour 7 minutes`,
`<dd class="sp-big">2 days</dd>`, `<dd class="sp-big">bounced</dd>`, `no attempt counter, only time`,
)
pageLacks(t, "Outbound queue", out, "Compiled-in defaults")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", true))
pageHas(t, "Outbound queue", out, "Compiled-in defaults: the Postfix configuration could not be read")
pageLacks(t, "Outbound queue", out, "read at panel start")
}
// With nothing to list the box says why instead: the queue holds nothing, or
// it could not be read — and never both.
func TestOutQueueSaysWhyItHasNoListing(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", ""))
pageHas(t, "empty queue", out, `class="sp-empty"`, "Queue is empty.")
pageLacks(t, "empty queue", out, `class="sp-log"`, "Could not read")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), "ignored", "Could not read the mail queue."))
pageHas(t, "unreadable queue", out, `class="sp-empty"`, "Could not read the mail queue.")
pageLacks(t, "unreadable queue", out, `class="sp-log"`, "Queue is empty.", "ignored")
}
+2 -2
View File
@@ -310,11 +310,11 @@ func NewUserForm(m Meta, in UserFormInput) *UserForm {
Role: in.Role,
RoleLocked: in.RoleLocked,
Outbound: Box{No: "02", Title: "Outbound domains", End: Plain("DNS records, applications, log")},
Outbound: Box{No: "02", Title: "Outbound domains", End: Plain("DNS records, applications, log"), DomainPick: true},
AllOut: in.AllOut,
OutDomains: in.OutDomains,
ShowInbound: in.ShowInbound,
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log")},
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log"), DomainPick: true},
AllIn: in.AllIn,
InDomains: in.InDomains,
}
+16 -2
View File
@@ -156,7 +156,7 @@ func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
`<input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off">`,
`name="password" type="password" autocomplete="new-password">`,
`Leave empty to keep the current one.`,
`<select id="role" name="role">`, `<option value="domain" selected>`, `<option value="global">`,
`<select id="role" name="role" data-global-role="global">`, `<option value="domain" selected>`, `<option value="global">`,
`name="all_domains" value="1" checked`, `name="domain_ids" value="1">`, `name="domain_ids" value="3">`,
`name="all_inbound_domains" value="1">`, `name="inbound_domain_ids" value="1">`, `name="inbound_domain_ids" value="2" checked`,
`>Save user</button>`, `href="/server/users">Cancel</a>`,
@@ -185,11 +185,25 @@ func TestUserFormOfTheOnlyGlobalUser(t *testing.T) {
p := NewUserForm(admin(), UserFormInput{ID: 1, Name: "admin", Username: "admin", Role: "global", RoleLocked: true,
PasswordMin: 12, ShowInbound: true})
out := renderSignedIn(t, "user", p)
pageHas(t, "User", out, `<input type="hidden" name="role" value="global">`, `<select id="role" name="role" disabled>`,
pageHas(t, "User", out, `<input type="hidden" name="role" value="global">`, `<select id="role" name="role" data-global-role="global" disabled>`,
`<option value="global" selected>`)
pageLacks(t, "User", out, `/delete`, `Delete user`)
}
// The role select names the value that reaches every domain, and each list of
// domains is marked, so panel.js can hide the lists while the role is Global.
func TestUserFormMarksTheListsTheGlobalRoleHides(t *testing.T) {
out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`)
if n := strings.Count(out, `<div class="box" data-domain-pick>`); n != 2 {
t.Errorf("%d boxes are marked data-domain-pick, want the outbound and the inbound list", n)
}
out = renderSignedIn(t, "user", NewUserForm(admin(), UserFormInput{ID: 2, Name: "shop-team", Username: "shop-team", Role: "domain", PasswordMin: 12}))
if n := strings.Count(out, "data-domain-pick"); n != 1 {
t.Errorf("%d boxes are marked without the inbound feature, want the outbound list alone", n)
}
}
// Inbound domains are offered only where the feature is on; the handler keeps
// what the user already has there when the form has no such list.
func TestUserFormHasNoInboundListWhenTheFeatureIsOff(t *testing.T) {
-862
View File
@@ -1,862 +0,0 @@
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
layout so the panel's Content-Security-Policy can be a plain
"default-src 'self'" with no inline-style exemption. Any rule
added here must therefore stay here: an inline style="..." attribute in a
template is blocked by that policy and silently does nothing.
The palette and the type below come from the mark's own approval sheet
(docs/assets/selfpost-proof.html) — brick, warm paper, IBM Plex. Before it
the panel was a default blue-on-cool-grey admin theme with the stamp pasted
onto it, and the two read as different pieces of work. */
/* The fonts ship with the panel rather than being asked for by name. Two
reasons, and the second is the one that matters. A named family resolves to
whatever the operator's machine has, so the mark — Plex converted to
outlines — would be the only Plex on the page. And ui-monospace is Consolas,
SF Mono or DejaVu Sans Mono depending on that machine, each with its own
advance width: the six-column send log and the mail.log tables would lay out
differently for every operator. Served from the panel's own origin, so the
policy above already covers them and needs no font-src of its own.
One variable file for the sans (100–700 in 46 KB, less than the four static
weights it replaces); mono has no variable release, so it is two files.
swap, so a cold load shows the system stack for a frame instead of nothing. */
@font-face {
font-family: "IBM Plex Sans"; font-style: normal; font-weight: 100 700;
font-display: swap; src: url("/static/ibm-plex-sans.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400;
font-display: swap; src: url("/static/ibm-plex-mono-400.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600;
font-display: swap; src: url("/static/ibm-plex-mono-600.woff2") format("woff2");
}
/* Colour tokens. Light values live on :root; the dark media query below
reassigns the same names rather than re-declaring every rule that uses
them, so a rule needs !important nowhere in this file — the custom
property already carries the right value for the active scheme. */
:root {
color-scheme: light dark;
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --fg: #12161c;
--muted: #6b7684; /* secondary text: .muted, th, timestamps, footers */
--card-bg: #fff;
--border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */
--control-border: #cbc8c1; /* input, select, textarea, action buttons */
--input-bg: #fff;
--code-bg: #efede9;
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
--nav-active-bg: #ede4de;
/* Brick, the mark's own colour, is the panel's accent — but as two values,
not one. A filled control carries --accent-fill with white on it; a link or
an active entry carries --accent-text, because the fill on a page
background is a colour to sit behind text, not to be text. In the light
scheme the two coincide. */
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
--on-accent: #fff;
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
--credential-bg: #fcf6e4; --credential-border: #e0c874;
--danger-fill: #b42318; --danger-fill-hover: #912018;
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
/* Brick as drawn is about 2:1 against this background — a link nobody can
read. The fill lightens just enough to keep white on it above 4.5:1
(6.9:1 as set), and the text value lightens further to clear the same bar
as text on the page. This is where the two values earn their keep. */
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
}
}
* { box-sizing: border-box; }
/* The scrollbar's width is reserved whether or not the page is long enough to
need one. Without it a short page (Account) and a long one (Status) are laid
out in viewports differing by the scrollbar's width, and everything on the
page — the navigation column included — shifts sideways on the way between
them. The panel is a set of pages an operator moves through, so that shift
read as the layout jumping. */
html { scrollbar-gutter: stable; }
body {
font: 400 15px/1.5 var(--font-sans);
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
}
/* The two columns — navigation on the left, page on the right — centred as a
pair. The navigation is a fixed width and the page keeps the measure it had:
the column is added beside the content, not taken out of it. */
.shell { display: flex; justify-content: center; align-items: flex-start; gap: 1.75rem; }
/* One column for every page. It used to be the reading measure, 48rem, which
the three pages holding data widened to 64rem — and since the shell centres
the navigation and the page as a pair, that difference moved the navigation
column and the left edge of every card on the way between two pages. The
column is now the wider of the two everywhere, so nothing moves; the reading
measure is kept inside it by the rule below rather than by the column.
No auto margins: inside the shell they would eat the free space and push the
navigation column off to the far edge, so the centring is the shell's job
(justify-content above). */
main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 64rem; }
/* 48rem is a reading measure: right for the forms and prose that make up most
of the panel, and too narrow for the pages that are tables of data. Every
page therefore keeps its content at that measure, centred in the column, and
the pages made of data opt out (main.wide below) and take the column whole.
The cap is on <main>'s children rather than on .card so a page's heading, its
back link and the version footer travel with the cards: capping the cards
alone would leave the heading spanning the column with the card it belongs to
floating in the middle — the misalignment that gave login and setup their own
narrow column (main.page-login further down). */
main > * { max-width: 48rem; margin-left: auto; margin-right: auto; }
/* The four monitoring pages are the ones that opt out: six columns of send
log, raw mail.log lines that are long by nature — which at the measure left
Subject and Status fighting over the last inch and wrapped every second log
line — and a single delivery's page, which stands two cards side by side over
a block of those same log lines. The class is declared by the page itself
(the "wide" block in layout.html) rather than derived from the page name: a
page's name is the navigation entry it belongs under, which several pages
share, and how wide a page needs to be is a property of that page alone. */
main.wide > * { max-width: none; }
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
side margins above and pin the heading to the column's edge, a measure's
worth to the left of the card under it. */
/* Light rather than bold, which is the sheet's own setting for the name and
the one thing a variable font makes free. It is the page's title, not an
alarm: the size already says which line is the heading, and at 700 it shouted
over the card under it. */
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the
compact one. It takes the column's width so its edges line up with the card
below it, capped at that column's own 24rem; height stays automatic because
the stamp's proportions are part of the mark and it is never scaled unevenly.
The subtitle stops resolving below 280px, which a 320px viewport still clears
once the body's padding is taken off. */
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
/* The radius comes down from 10px across the panel: the mark is a stamp with a
square edge and a hairline, and a card rounded like a phone widget sat in
front of it as a different kind of object. 6px on the containers, 5px on the
controls inside them. */
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
/* A narrow card as a direct child of main means the page is a short form:
every sibling in the column (heading, flash, footer) must share that 24rem
measure, or they centre on 48rem while the card centres on 24rem and the
left edges drift by half the difference (12rem / 192px at a 16px root —
measured). The column itself stays 64rem so the navigation does not move;
only the stack inside the column narrows. :has tracks the card rather than
a per-page class, so a new signed-in page that adds .card.narrow inherits
the alignment without a second stylesheet rule.
login/setup already shrink main to 24rem (below); this rule is then a no-op
relative to the parent. Do not replace that with :has alone: narrowing main
is what centres the signed-out block on the page when there is no nav. */
main:has(> .card.narrow) > * { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block
label and full-width input rules above: the box and its caption sit on one
line. Without this the caption drops under a stretched checkbox. */
label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600;
cursor: pointer;
}
label.check input { width: auto; margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
}
/* Domain assignment on the user form: a bordered group under Role, not another
stacked field label. Legend replaces the usual label; the muted line is the
hint that used to sit where a field caption would. */
fieldset {
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
border: 1px solid var(--control-border); border-radius: 5px;
}
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
fieldset > label.check:first-of-type { margin-top: 0.15rem; }
/* One vocabulary for actions. Anything that performs an action looks like a
button: a <button>, or an <a> carrying .btn/.danger where the action is a
plain navigation (the delete confirmation page, the full queue view).
Several of these used to render as bold blue text instead — a POST wrapped
in form.inline, a disclosure toggle, the delete links — which read as links
and left two appearances for the same kind of control. They all get the
button look now: filled for a card's own action, and the compact outlined
variant further down where actions cluster (table rows, the nav bar). Bare
<a> is left for links that read as part of a sentence or a list. */
button, a.btn, a.danger {
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
}
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
button:disabled, button.danger:disabled {
opacity: 0.45; cursor: not-allowed;
}
button:disabled:hover { background: var(--accent-fill); }
button.danger:disabled:hover { background: var(--danger-fill); }
select:disabled {
opacity: 0.65; cursor: not-allowed; background: var(--code-bg);
}
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: var(--muted); }
/* Links take the accent. The panel never set a colour here and ran on the
browser's own blue, which passed unnoticed while the accent was itself blue;
against brick it left every domain name and back link belonging to a palette
the panel no longer uses. The controls that are links dressed as buttons
(a.btn, a.danger, a.st) all carry a class and out-specify this. */
a { color: var(--accent-text); }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
/* form.inline only exists so a POST can sit next to other content without a
form's block layout; its button is styled like any other. */
form.inline { display: inline; margin: 0; }
/* The signed-out pages are a single card and nothing else, and .card.narrow
centres itself inside whatever holds it — so at the panel's usual width the
card floated in the middle while the mark and the heading stayed at the far
left, three alignments on a page with four elements. Narrowing the column to
the card's own width makes the three line up and puts the block as a whole in
the middle of the page.
By page name and not by "the page holds a narrow card": signed-in pages that
hold a narrow card keep the 64rem column (see main:has(> .card.narrow)
above) so the navigation stays put; only these two pages have no nav and
need the column itself narrowed. */
main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
/* RateLimitErr renders as .flash.error: a validation failure, not a success
notice. Without this rule it inherited the success surface (green) and only
.error's text colour, so a rejected rate limit read as good news in red
text. Same box, danger palette. */
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
table { width: 100%; border-collapse: collapse; }
/* A table column is at least as wide as the longest unbreakable run inside it,
and the panel's tables are full of runs with nothing to break on: email
addresses, domains, queue ids. One 40-character recipient was enough to widen
the send-log past its card and hang Status over the edge. Cells may break mid
word, so a column can always be squeezed to the width available. */
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
/* The exceptions: a timestamp broken across two lines is unreadable, and so is
a send-log status — "deferred" was being hyphenless-broken into "deferre" and
"d" whenever the row was tight. Both are short enough to never be the reason
a row does not fit. */
td.time, td.status { white-space: nowrap; }
/* Column headings are set in the mono face: they are labels for machine data
rather than prose, and the wider tracking a small monospaced capital wants
also holds them apart from the first row of values. Same treatment on
.fact-label, so a heading of that size reads as one thing wherever it
appears. */
th {
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
}
/* The controls at the end of a row are short and fixed — Details, Delete — and
the cell is the last one, so it is the one the automatic layout squeezes
first: in the send log it was breaking "Details" after "Detail". Holding it
on one line costs the row nothing and hands the width back to the status
column, which was wrapping single words for the same reason. */
td.actions { text-align: right; white-space: nowrap; }
/* Subject is the one cell whose text we do not control. Breaking mid word (the
rule above) keeps it inside the card, but a long subject would do it by
growing the row several lines tall, which buries the rows around it. So the
subject is clipped to one line instead, with the whole of it in the tooltip.
The clamp sits on an inner block box rather than the cell because max-width
on a <td> is only advisory in the automatic table layout. */
td.subject span {
display: block; max-width: 18rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
/* Semibold, not the browser's bold: a card's title sits a step under the page's
own heading, and 700 against a 300 h1 read as the louder of the two. */
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
/* The back link is a block so it takes the measure and lines up with the
heading above it and the card below: auto side margins centre a block, but
they do nothing for an inline-block, which would have stayed at the column's
edge while everything around it moved in. Its own text stays at the left of
that block — only the click target grows to the line, as it does for the
navigation entries. */
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); line-height: 1.45; }
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.version a:hover { color: var(--fg); }
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
into a ragged edge, so centre it there. */
main.page-login .version, main.page-setup .version { text-align: center; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
font-family: inherit;
}
textarea { resize: vertical; }
button.danger, a.danger { background: var(--danger-fill); }
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
/* The disclosure toggle is an action too, so it is drawn as a button (see the
compact rule below); it carries no marker because the pressed background
already shows the open state. */
.actions > label.toggle { display: inline-block; cursor: pointer; }
/* Applications are a list of blocks, not table rows. As a table it fell apart:
four columns of which the last held six controls — two of them <details>
panels with textareas — never fit the panel's default width. The controls
wrapped into a staircase, .code on the login cell grew into a slab as tall
as the row, and the two text cells sat on the baseline halfway down it. One
block per application gives the identity a line of its own and the
controls a row of their own, at the width they actually need. */
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app:last-child { padding-bottom: 0; }
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
/* Mode and rate limit open under the whole row rather than inside it. As
<details> the panel sat where its summary was, so opening one cut the row of
controls in half and pushed the rest below a block of fields — the buttons
moved every time a panel opened or closed. The panel is therefore the last
child of the row, and the checkbox that opens it is visually hidden earlier
in the row with its label drawn as the button. The checkbox stays in the tab
order and keeps its focus ring on the label, so it works from the keyboard,
and being pure CSS it also works with JavaScript blocked, as <details> did. */
.app .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
}
.app .actions > .panel { display: none; flex: 1 0 100%; }
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
.panel form { margin-top: 0.6rem; }
/* A panel's own submit is a form button, not one of the controls in the row
above, so it takes back the spacing the compact .actions rule zeroes out:
without it Save sits flush against the field it saves and against the
Remove button under it, reading as one stack of edges. */
.app .actions > .panel button { margin-top: 0.9rem; }
/* Where a panel has two of them (save the limit, remove it) they share one row
under the fields, which the row itself spaces off instead of each button.
The buttons align on their tops rather than stretching: one of the two is
wrapped in a form, and that wrapper's margin would otherwise make the row
taller than a button and stretch the unwrapped one to match it. */
.app .actions > .panel .panel-buttons {
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.4rem; margin-top: 0.9rem;
}
.app .actions > .panel .panel-buttons button,
.app .actions > .panel .panel-buttons form { margin-top: 0; }
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
.app .actions > .panel .check-col > form { margin-top: 0; }
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
.app .actions > .panel .check-col > .muted { margin: 0; }
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
.app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
margin-top: 0.45rem;
min-height: 2.55rem;
box-sizing: border-box;
}
.app .actions > .panel .check-col > .muted + form > textarea[name="auth_allowed_ips"] {
field-sizing: fixed;
overflow-y: auto;
}
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
/* A column down the left edge rather than a bar across the top. The six page
entries need about 660px and the session block another 260px, against the
738px the panel was wide, so as a bar it had to be split over two rows — and
even then it cost the top of every page. Standing it up removes that: the
entries share one left edge to scan down, the session sits at the foot where
it is out of the way. Sticky, so the list stays in view while the page
scrolls past it. The layout template lists the blocks in the order they are
drawn, so reading and tab order follow the eye without a CSS `order`. */
.nav {
position: sticky; top: 2rem; align-self: flex-start;
flex: none; width: 13.5rem;
display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;
/* A viewport shorter than the column would otherwise cut off whatever hangs
below the fold — with no page scroll left to reach it, since the column is
stuck to the viewport. */
max-height: calc(100vh - 4rem); overflow-y: auto;
}
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
/* The session is the column's last block and the only one that is not
navigation, so it is ruled off from the entries above it. */
.nav .session { padding-top: 0.75rem; border-top: 1px solid var(--border); }
/* The signed-in administrator's name: a label above the two controls, not one
of them. It may be long and there is nothing to break it on, so it is allowed
to break mid word rather than widen the column. */
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
.nav .session-user {
display: flex; align-items: center; gap: 0.5rem;
}
/* The mark is a link, but not one of the column's entries: it takes none of the
padding and rounding the entry rule below applies, so its own edge lines up
with the entries' icons rather than sitting half a step inside them. */
.nav .brand { padding: 0; }
/* The mark takes the column's full width, so its edges line up with the entries
below it rather than ending halfway across — the same way .mark fills the
card column on the pages that carry no navigation. width/height are on the
element too, so the column reserves the right height before the SVG has
loaded; height stays automatic because the stamp's proportions are part of
the mark — it is never scaled unevenly. */
.nav .brand img { display: block; width: 100%; height: auto; }
/* Each entry pairs an icon with its label, so the entry itself is a flex row
rather than a run of text — that is also why the entries centre their items
instead of aligning them on the text baseline. In the column the entry fills
the width, so the whole strip is the click target and the hover and active
backgrounds read as one stack of bars. Account is included: it is a page like
the others and would otherwise be the one bare word in the column. */
/* No underline: in a row the entries were separated by space alone and the
underline was what marked them as links, but a column of them reads as a
list of links already, and eight underlines down the left edge is a lot of
line for no added meaning. The hover background carries the affordance. */
/* The entries are page text, not accented links: a column of eight brick rows
would leave nothing for the current one to stand out from — that is what the
active entry's colour is for. */
.nav a, .nav [aria-current] {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
color: var(--fg);
}
.nav a:hover { background: var(--surface-bg); }
/* The marker moved with the bar: a rule under the entry made sense in a row,
where the entries sat side by side; standing up, the current entry is marked
down its leading edge, which is the edge every entry shares. */
.nav [aria-current] {
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
/* Sign out carries an icon too, so it needs the same row layout; its colours
come from the compact button rule further down, and its type is put back to
the column's beside it (see the rule after that one). */
.nav button { display: flex; align-items: center; gap: 0.5rem; }
/* The icons draw in the entry's own colour, so the active entry's darker text,
a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; }
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
.card[id] { scroll-margin-top: 1rem; }
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
and the 48rem measure, plus the body's padding — the measure, not the column
it sits in: a page column short of its 64rem is only a page column that has
not reached its cap, while a measure that no longer fits is a page that has),
the column lies back down
into a bar above the page — the same wrapping rows it used to be. A drawer
behind a hamburger would save more height, but it would need script to open,
and the panel's navigation is six entries: they fit. */
@media (max-width: 66rem) {
.shell { flex-direction: column; align-items: stretch; gap: 1.2rem; }
main { margin: 0 auto; }
.nav {
position: static; width: auto; max-height: none; overflow-y: visible;
flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.4rem 1.2rem;
padding-bottom: 0.6rem; border-bottom: 1px solid var(--border);
}
/* Lying down there is no column for the mark to span, and at its full width
it would take a row to itself above the entries, so it goes back to the
compact size it had — enough to read beside them. */
.nav .brand img { width: 110px; }
/* Each block keeps its own group of entries together and wraps as one; the
rules that separated the blocks vertically become the space between them. */
.nav .links, .nav .session {
flex-direction: row; flex-wrap: wrap; align-items: center;
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
}
}
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
/* Mono and squared off rather than a bold uppercase pill: the badge carries a
value the server produced (ok, warn, delivered, bounced), and the mono face
is where the panel puts machine output everywhere else. Uppercasing it was
the pill's own emphasis — dropped with the pill, since the colour already
says how loud the badge is. */
/* line-height 1 and the padding doing the centring, rather than the 1.5 the
badge would inherit from the body. IBM Plex Mono sits a shade low in its em
square, so equal padding leaves "ok" / "warn" sunk in the box (and visibly
below the heading or label it sits next to). The bottom padding is therefore
heavier than the top — enough to lift the word to the optical centre. A
top-heavy pad was tried earlier and made the sink worse. Words with a
descender (queued) hang into that bottom pad rather than growing the badge. */
.st {
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
letter-spacing: 0.02em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
/* In the domain list the badge is also the link to that domain's DNS section,
so it must not pick up the link colour and underline that would fight with
the badge's own palette. */
a.st, a.st:hover { color: inherit; text-decoration: none; }
a.st:hover { filter: brightness(1.08); }
/* Usage bars on the status page's machine card. <meter> rather than a div sized
from the reading, because the CSP forbids inline styles (see the note at the
top of this file) and a bar's length has to travel on an attribute. The
element grades itself from low/high/optimum, so the colour matches the
badges' meaning without this file restating the thresholds — and a browser
that does not render meters falls back to the percentage beside it, which is
printed either way. */
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
/* The card's own two narrow columns. Cells may break mid word by default (see
the th, td rule above), which the detail column needs and these two must not
have: the resource names and the readings are short, and the long detail
beside them would otherwise win the width and leave "Memory" broken across
two lines. */
.metric { white-space: nowrap; }
/* Copy sits at the top of the value — right for a long DKIM key. The row's
.code uses the compact button's vertical padding so a one-line value is the
same height as Copy and does not leave the button hanging under an empty
band of padding. */
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code {
flex: 1; min-width: 0; margin-top: 0;
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
/* A short field with its submit on the same row (Add domain). The button
keeps the card's usual filled look; its top margin is for stacked forms. */
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
.input-row input { flex: 1; min-width: 0; }
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
/* Two cards abreast, on the one page that has a pair of them worth reading
together (a delivery's facts beside its history). auto-fit with a minimum
rather than a media query: the pair falls into one column exactly when a
column would drop below the width its content needs, which is also what
happens inside the narrow column the navigation lies down at. */
.split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1.2rem; margin-top: 1.2rem;
}
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
grid those auto side margins absorb free space and shrink each card to its
content instead of filling the 1fr track — so cancel them here, and allow
the cell to shrink below the card's intrinsic minimum when the column is
tight. */
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
/* The gap already spaces the cards inside the grid; the general .card + .card
rule would add a second gap's worth on top of it, and only to the second
card, which in one column reads as an uneven stack. The blocks around the
grid keep their spacing from it instead. */
.split > .card + .card { margin-top: 0; }
.split + .card { margin-top: 1.2rem; }
.card + .split { margin-top: 1.2rem; }
/* Configuration sits outside the polled fragment; without this it would
touch the last .split inside #status-body (a nested .split is not a sibling). */
#status-body + .card { margin-top: 1.2rem; }
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
two; domain settings; application edit). Same auto-fit idea as .split, so a
wide card gets two columns — not three — and falls to one when narrow. */
.check-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1rem 1.2rem; margin-top: 1rem;
}
.check-col { min-width: 0; }
.check-col > label:first-child { margin-top: 0; }
.check-col-title {
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
}
.card > h2 + .check-col-title { margin-top: 0.55rem; }
/* Domain settings pairs DMARC ‖ rate limit: share four row tracks so titles,
leads, fields and action buttons line up across columns. */
@supports (grid-template-rows: subgrid) {
.check-cols-rows {
grid-template-rows: auto auto 1fr auto;
}
.check-cols-rows > .check-col {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
}
}
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
.check-cols-rows > .check-col > .muted { margin: 0; }
.check-col-fields { min-width: 0; }
.check-col-fields > form { margin: 0; }
.check-col-fields .field-pair { margin-top: 0; }
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
.check-col-actions {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
margin-top: 0;
}
.check-col-actions > button { margin-top: 1.2rem; }
.check-col-actions > form.inline { margin: 0; }
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
one column when the parent is too narrow for both. */
.field-pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0 1rem; margin-top: 0.45rem;
}
.field-pair > div { min-width: 0; }
.field-pair label { margin-top: 0.45rem; }
/* Hint under a field-pair sits tight above the form's submit button (the
global button margin-top would leave a blank line). */
.field-pair + p.muted { margin: 0.45rem 0 0; }
.field-pair + p.muted + button { margin-top: 0.45rem; }
/* Host / name beside a DNS Type that is always TXT — size the type column to
that token rather than giving it half the row. Vertical padding matches
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
.field-pair.host-type {
grid-template-columns: minmax(0, 1fr) auto;
}
.field-pair.host-type .code {
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
.field-type { width: max-content; }
.field-type .code {
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
}
/* The subject heads a delivery's page, and it is the one heading in the panel
whose text we do not control: it may be a hundred characters with nothing to
break on. It wraps to as many lines as it needs (this is the page's name, not
a table cell to clip) but never widens the column. */
h1.subject { overflow-wrap: anywhere; }
/* The line under it: sender, recipient and outcome. Wrapping rather than
clipping, since either address may be long and both are the point. The
vertical margins are longhands for the reason given at the h1 rule above —
the shorthand would re-zero the auto side margins that keep a page's blocks
centred on the measure. It sits close under the heading: the two are one
statement of what this page is about. */
.route {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
margin-top: -0.5rem; margin-bottom: 1rem;
}
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
.route .arrow { color: var(--muted); }
/* A card's short facts, as a grid of tiles rather than the label-over-value
stack the rest of the panel uses. That stack is right for the pages where a
value is a DNS record or a password to copy — one per line, full width — and
wrong for six values of a few characters each, which down a narrow column
would be a page of mostly empty rows. Same auto-fit as .split: as many
columns as fit, and one when nothing else does. */
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
.fact-label {
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
}
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
ids are all one run — so a value may break mid word rather than push its tile
wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
/* Retry-policy tiles carry a short phrase (`doubling, cap about 1 hour 7
minutes`) rather than a domain or a queue id, so they need a slightly
wider minimum than the delivery-page facts before wrapping to one column. */
.facts.retry-facts { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
/* A message's history: the steps down a line, each with the time it happened,
the status it reached and what that means. The line is the list's own left
border and the dots sit on top of it, so nothing has to be positioned against
a fixed row height — a step's text is two lines or five depending on what
there is to say about it. */
.timeline { list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border); }
.event { position: relative; min-width: 0; }
.event::before {
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
width: 0.65rem; height: 0.65rem; border-radius: 50%;
background: var(--card-bg); border: 2px solid var(--control-border);
}
/* The dot carries the step's own verdict, in the same four colours as the badge
beside it — so the shape of the history is readable down the line alone. */
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
/* The step that has not happened yet — a queued message's delivery report — is
the one hollow dot, and its text is quiet: it is what to expect, not a record
of anything. */
.event.pending { opacity: 0.7; }
.event.pending::before { background: var(--card-bg); border-style: dashed; }
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
/* A message's own mail.log lines, as a table of when and what. The time column
is sized to its content — width:1% is as narrow as the automatic layout will
take a column, which with the nowrap on td.time settles on exactly the stamp
— so everything left over goes to the text, which is the column that needs
it. */
table.log { margin-top: 1rem; }
table.log th:first-child, table.log td.time { width: 1%; }
/* The log's own text: monospace, because it is full of addresses, response
codes and identifiers that are compared character by character rather than
read as words. pre-wrap keeps the spacing a server's reply was written with
while still letting a long line wrap inside the cell instead of widening it
(the th/td rule above allows the break). */
table.log td.log-text {
font-family: var(--font-mono);
font-size: 0.8rem; white-space: pre-wrap;
}
/* Compact outlined button: same affordance as the filled one but quiet enough
that several can sit together without shouting — the Copy buttons beside a
value, the controls of a table row or of an application block. Sign out
overrides this with .danger below since signing out is a deliberate,
singular action. .actions is the shared hook: a cell that holds controls, or
the control row of an application. */
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 5px; white-space: nowrap;
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
button.copy { flex: none; }
.actions button.danger, .actions a.danger, .nav button.danger {
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
/* Sign out takes the compact button's colours but not its type. The rule above
is written for controls that cluster — a row of them beside a value or in a
table cell — where 0.8rem/600 is what keeps four of them from shouting. In
the navigation column it has no cluster to belong to: it stands under
Settings, among entries set 0.95rem/400, and being smaller and heavier than
every word around it made it read as a different kind of object rather than
as the last item of the list. Type and padding go back to the entries';
the red, the border and the background stay, and are what say it acts. */
.nav button {
padding: 0.35rem 0.6rem; font-size: 0.95rem; font-weight: 400;
}
/* The optional "encrypt this download" block on the backup and export forms.
The checkbox uses the shared label.check rule above; the fields it reveals
are indented under it to read as its consequence rather than as three more
fields of the form. panel.js hides the inner block until the box is ticked
(and empties it when unticked); without JavaScript everything stays visible,
which the server handles identically. The import form reuses the same
indented .encrypt-fields look for its password field, but reveals it by
file extension instead of a checkbox (see panel.js). */
.encrypt { margin-top: 1.2rem; }
.encrypt label.check { margin: 0; }
.encrypt-fields {
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
border-left: 2px solid var(--border);
}
.encrypt .encrypt-fields { margin-top: 0; }
.encrypt-fields label { margin-top: 0.7rem; }
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
/* Screen-reader accessible controls that drive UI state (help drawer panes). */
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.page-head {
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
gap: 0.5rem 1rem; margin-bottom: 1rem;
}
.page-head h1 { margin: 0; }
.card-head {
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
gap: 0.5rem 0.75rem; margin-bottom: 0.7rem;
}
.card-head h2 { margin: 0; }
.help-link {
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center;
justify-content: center; border-radius: 50%; border: 1px solid var(--control-border);
color: var(--muted); text-decoration: none; font-family: var(--font-mono);
font-size: 0.75rem; font-weight: 600; background: var(--card-bg); cursor: pointer; margin: 0;
}
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
/* Help drawer — CSS checkbox/radio, no script required. */
.help-drawer {
display: none; position: fixed; top: 0; right: 0; bottom: 0;
width: min(26rem, 100vw); z-index: 30;
background: var(--card-bg); border-left: 1px solid var(--border);
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
}
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
.help-pane { display: none; }
html:has(#help-index:checked) .help-pane-index,
html:has(#help-status:checked) .help-pane-status,
html:has(#help-password:checked) .help-pane-password,
html:has(#help-dns:checked) .help-pane-dns,
html:has(#help-records:checked) .help-pane-records,
html:has(#help-dmarc:checked) .help-pane-dmarc,
html:has(#help-connection:checked) .help-pane-connection,
html:has(#help-apps:checked) .help-pane-apps,
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
html:has(#help-export:checked) .help-pane-export { display: block; }
.help-scrim {
display: none; position: fixed; inset: 0; z-index: 25;
background: rgba(18, 22, 28, 0.28); cursor: pointer;
}
.help-drawer h2 { margin-top: 1.2rem; }
.help-drawer h2:first-of-type,
.help-pane h2 { margin-top: 0; }
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
.help-drawer .toc li { margin: 0.35rem 0 0; }
.help-drawer .toc label {
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
}
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
.help-drawer .more label {
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
}
.help-close {
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
}
@media (max-width: 66rem) {
.help-drawer { width: 100vw; }
}
/* DMARC pages: paired cards side by side, same layout as .split but for two
summary cards at the top of a page. */
.pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1.2rem; margin-top: 1.2rem;
}
.pair > .card { margin-left: 0; margin-right: 0; min-width: 0; }
.pair > .card + .card { margin-top: 0; }
/* Attention card: elevated border when a DMARC report has failures. */
.card.attn { border-color: var(--st-warn-fg); }
/* Responsive visibility: hide elements on narrow/wide screens. */
.desk-only { display: revert; }
.phone-only, .phone-list { display: none; }
@media (max-width: 48rem) {
.desk-only { display: none !important; }
.phone-only, .phone-list { display: revert !important; }
}
.phone-list { list-style: none; margin: 0; padding: 0; }
.phone-list li { border-bottom: 1px solid var(--border); }
.phone-list li:last-child { border-bottom: none; }
.phone-list a {
display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem;
padding: 0.7rem 0; text-decoration: none; color: inherit;
}
.phone-list .when { grid-column: 1; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.phone-list .meta { grid-column: 2; font-size: 0.85rem; }
.phone-list .subj { grid-column: 1 / -1; font-size: 0.85rem; color: var(--muted); }
/* Checkbox-label row inside a form (IP restriction toggle). */
.check-row {
display: flex; align-items: center; gap: 0.5rem;
margin: 0.5rem 0; cursor: pointer;
}
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round" />
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E" />
<path d="M61.06 66.41Q58.14 66.41 56.23 65.3Q54.33 64.2 53 62.46L53.96 61.72Q55.21 63.35 56.86 64.32Q58.51 65.29 61.1 65.29Q64.05 65.29 65.67 63.89Q67.28 62.5 67.28 59.88Q67.28 58.08 66.6 57.04Q65.92 56 64.73 55.46Q63.54 54.92 62.08 54.61L59.8 54.17Q57.46 53.73 56.12 52.83Q54.77 51.92 54.18 50.68Q53.58 49.44 53.58 47.98Q53.58 45.91 54.5 44.55Q55.42 43.19 57.08 42.52Q58.75 41.86 60.96 41.86Q63.61 41.86 65.38 42.78Q67.15 43.7 68.17 45.23L67.22 45.94Q66.2 44.48 64.67 43.73Q63.14 42.98 60.89 42.98Q58.07 42.98 56.46 44.21Q54.84 45.43 54.84 47.95Q54.84 49.61 55.54 50.61Q56.23 51.62 57.42 52.16Q58.61 52.71 60.08 53.01L62.35 53.45Q64.77 53.9 66.11 54.83Q67.45 55.77 67.98 57.04Q68.51 58.32 68.51 59.78Q68.51 61.95 67.61 63.42Q66.71 64.88 65.02 65.64Q63.34 66.41 61.06 66.41Z M78.56 66.41Q76.28 66.41 74.64 65.35Q72.99 64.3 72.09 62.29Q71.18 60.29 71.18 57.36Q71.18 54.44 72.07 52.42Q72.95 50.39 74.57 49.36Q76.18 48.32 78.46 48.32Q80.57 48.32 82.17 49.37Q83.76 50.43 84.67 52.35Q85.57 54.27 85.57 56.89V57.43H72.44V58.35Q72.44 61.51 74.06 63.43Q75.67 65.35 78.6 65.35Q80.64 65.35 82.08 64.35Q83.53 63.35 84.27 61.41L85.19 61.92Q84.44 63.96 82.74 65.18Q81.04 66.41 78.56 66.41ZM78.46 49.34Q76.56 49.34 75.21 50.21Q73.87 51.07 73.16 52.66Q72.44 54.24 72.44 56.34V56.48H84.27V56.28Q84.27 54.17 83.54 52.62Q82.81 51.07 81.5 50.21Q80.19 49.34 78.46 49.34Z M93.72 66H91.17Q90.42 66 90.06 65.63Q89.7 65.25 89.7 64.57V40.84H90.89V64.95H93.72Z M97.92 66V49.78H94.83V48.73H97.92V42.57Q97.92 41.76 98.33 41.3Q98.74 40.84 99.59 40.84H102.99V41.89H99.11V48.73H102.99V49.78H99.11V66Z M109.88 66H105.39V42.27H116.07Q118.28 42.27 119.84 43.19Q121.41 44.1 122.26 45.75Q123.11 47.4 123.11 49.61Q123.11 51.82 122.26 53.47Q121.41 55.12 119.84 56.04Q118.28 56.96 116.07 56.96H109.88ZM109.88 46.18V53.08H115.66Q116.55 53.08 117.16 52.76Q117.77 52.43 118.11 51.84Q118.45 51.24 118.45 50.39V48.83Q118.45 47.95 118.11 47.37Q117.77 46.79 117.16 46.48Q116.55 46.18 115.66 46.18Z M132.75 66.41Q130.24 66.41 128.38 65.27Q126.53 64.13 125.53 62.04Q124.53 59.95 124.53 57.09Q124.53 54.24 125.53 52.18Q126.53 50.12 128.38 48.98Q130.24 47.84 132.75 47.84Q135.3 47.84 137.16 48.98Q139.01 50.12 140.01 52.18Q141.02 54.24 141.02 57.09Q141.02 59.95 140.01 62.04Q139.01 64.13 137.16 65.27Q135.3 66.41 132.75 66.41ZM132.75 62.91Q134.49 62.91 135.47 61.83Q136.46 60.76 136.46 58.76V55.46Q136.46 53.45 135.47 52.4Q134.49 51.35 132.75 51.35Q131.09 51.35 130.08 52.4Q129.08 53.45 129.08 55.46V58.76Q129.08 60.76 130.08 61.83Q131.09 62.91 132.75 62.91Z M149.47 66.41Q146.92 66.41 145.19 65.54Q143.45 64.67 142.13 63.14L144.78 60.56Q145.77 61.68 146.94 62.33Q148.11 62.97 149.64 62.97Q151.21 62.97 151.9 62.43Q152.6 61.89 152.6 60.93Q152.6 60.15 152.11 59.69Q151.61 59.23 150.42 59.06L148.66 58.83Q146.75 58.59 145.43 57.94Q144.1 57.3 143.42 56.17Q142.74 55.05 142.74 53.45Q142.74 50.87 144.59 49.36Q146.45 47.84 149.47 47.84Q151.21 47.84 152.45 48.17Q153.69 48.49 154.64 49.12Q155.59 49.75 156.41 50.63L153.86 53.18Q153.08 52.37 151.99 51.82Q150.9 51.28 149.61 51.28Q148.25 51.28 147.58 51.77Q146.92 52.26 146.92 53.11Q146.92 54 147.45 54.46Q147.98 54.92 149.23 55.12L151.04 55.36Q153.96 55.77 155.37 57.08Q156.78 58.38 156.78 60.59Q156.78 62.29 155.88 63.62Q154.98 64.95 153.33 65.68Q151.68 66.41 149.47 66.41Z M168.09 66H164.97Q162.72 66 161.55 64.83Q160.38 63.65 160.38 61.48V51.69H157.76V48.25H159.12Q160.1 48.25 160.46 47.79Q160.82 47.33 160.82 46.42V43.39H164.73V48.25H168.37V51.69H164.73V62.53H168.09Z" fill="#F6EDE2" />
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

+13 -39
View File
@@ -4,35 +4,7 @@
(function () {
"use strict";
// --- Copy buttons on .code values ------------------------------------
// Values that get carried into another interface (a DNS panel, a mail
// client) sit in a .code-row wrapper next to a Copy button. The text is read
// from the .code element itself, so it can never drift from what is shown.
// navigator.clipboard needs a secure context (HTTPS or localhost); over plain
// HTTP in development it is simply absent, in which case the value stays
// selectable by hand.
document.addEventListener("click", function (ev) {
var button = ev.target.closest("button.copy");
if (!button) {
return;
}
var row = button.closest(".code-row");
var code = row && row.querySelector(".code");
if (!code || !navigator.clipboard) {
return;
}
navigator.clipboard.writeText(code.textContent).then(function () {
var original = button.textContent;
button.textContent = "Copied";
setTimeout(function () {
button.textContent = original;
}, 1500);
}, function () {
/* Clipboard refused (permissions, insecure context): leave the page be. */
});
});
// --- Copy buttons of the component kit ------------------------------
// --- Copy buttons ---------------------------------------------------
// copy_field joins a read-only input or textarea to a Copy button marked
// data-copy; the two sit together in one .has-addons field, and the value is
// read from the field itself, so it can never drift from what is shown. Where
@@ -143,17 +115,19 @@
if (!control.matches(rule.match)) {
continue;
}
var target = form.querySelector(rule.target);
if (!target) {
continue;
}
// A block can be several elements (the user form's two lists of domains).
var show = rule.visible(control);
target.hidden = !show;
if (!show && rule.clearWhenHidden) {
target.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
form.querySelectorAll(rule.target).forEach(function (target) {
// hidden alone does not hide an element whose own stylesheet gives it a
// display (Bulma's .box), so Bulma's is-hidden helper goes with it.
target.hidden = !show;
target.classList.toggle("is-hidden", !show);
if (!show && rule.clearWhenHidden) {
target.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
});
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
// guess, and a rebranded panel keeps serving the old mark from the tab.
func TestStaticAssetsCarryETag(t *testing.T) {
for _, name := range []string{
"favicon.png", "favicon.svg", "panel.css", "legacy.css", "panel.js", "htmx.min.js",
"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js",
// The fonts are the assets this matters most for: they are the largest
// thing the panel serves and the ones a browser is most willing to keep.
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
+1 -1
View File
@@ -72,7 +72,7 @@
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}}>
{{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}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
@@ -1,158 +0,0 @@
{{/* The layout of the pre-redesign pages: the old stylesheet (legacy.css) and the
side navigation. A page that has been restyled is rendered by layout.html
instead; Engine decides by the page's name (kitPages in view.go) and the
pair goes when the last old page does. */}}
{{define "layout_legacy.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
{{/* The tab icon is the stamp's small-size variant — the initials rather than
the two-line wordmark, which stops resolving below 32px (the thresholds are
recorded in docs/assets/selfpost-proof.html). The PNG is declared first and
the SVG second: a browser takes the last icon whose type it understands, so
vector wins wherever it is supported and the raster catches the rest. */}}
<link rel="icon" href="/static/favicon.png" sizes="32x32">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/legacy.css">
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the
Content-Security-Policy has to make an exception for, so it
is switched off here. */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script>
</head>
<body>
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in legacy.css). The signed-out pages have no navigation
and the shell holds their card alone. */}}
<div class="shell">
{{if .User}}{{template "nav" .}}{{end}}
{{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the signed-out pages are narrower than the rest
(see main.page-* in legacy.css). It is .Active, already carried for the
navigation, rather than a second field every handler would have to
remember to set. The "wide" block beside it is how a page made of data
asks for the whole column instead of the reading measure; it is the page's
own declaration rather than a name in the stylesheet, since .Active cannot
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
where the Corresponding Source is, and that there is no warranty. Shown
on every page, including login/setup — those are interactive UIs too.
The running version stays signed-in only: it is what a backup manifest is
checked against on restore, and must not be advertised to the internet. */}}
<footer class="version">
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
<a href="/license">License (AGPL-3.0)</a> ·
<a href="{{.SourceURL}}">Source</a> ·
No warranty
</footer>
</main>
</div>
</body>
</html>{{end}}
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
every authenticated page has it without the page's own template having to
remember to include it; .Active names the current page so it is highlighted
instead of linking to itself. Unauthenticated pages (login, setup) carry no
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
{{/* The order of the blocks is the order they are read in, top to bottom of
the column: the mark, the panel's pages, and the session last. The mark
goes to /overview, the page the panel opens on — named outright rather
than as "/", which is only a redirect to it. */}}
<a class="brand" href="{{if .IsGlobal}}/overview{{else if .HasOutbound}}/outbound/domains{{else}}/inbound/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links">
{{if .IsGlobal}}
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/overview">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{/* A group with nothing assigned is absent from that user's menu: the
sending pages need outbound reach, Inbound needs inbound reach. */}}
{{if or .IsGlobal .HasOutbound}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/outbound/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{end}}
{{if and .InboundEnabled (or .IsGlobal .HasInbound)}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound/domains">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}}
{{if and .DMARCEnabled (or .IsGlobal .HasOutbound)}}
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/outbound/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
{{end}}
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
{{if or .IsGlobal .HasOutbound}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/outbound/log">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/outbound/queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/server/log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/server/backup">{{template "icon-backup"}}Backup</a>{{end}}
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/server/users">{{template "icon-users"}}Users</a>{{end}}
{{end}}
</div>
<div class="session">
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
{{/* Account is every user's own; Settings is the instance's and belongs
to the global role. */}}
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/server/settings">{{template "icon-settings"}}Settings</a>{{end}}
{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</nav>
{{end}}
{{/* Whether the page takes the column whole. Empty here, so a page is held to
the reading measure unless it says otherwise; the pages that are tables of
data, raw log lines or side-by-side cards redefine this as the word "wide",
which lands in <main>'s class list (see main.wide in legacy.css). Each
page's template file is parsed after this one, so its definition replaces
the empty one. */}}
{{define "wide"}}{{end}}
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
function, e.g. {{template "back_link" (back "/server/users" "Back to users")}}.
Place it directly under the page <h1> and above the cards (see .back in
legacy.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
{{define "back_link"}}
<a class="back" href="{{.Href}}">&larr; {{.Label}}</a>
{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is
aria-hidden because the entry's own text is already the accessible name;
the icon is a landmark for the eye, not a second label. Kept as separate
templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
{{define "icon-help"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
{{/* A filing cabinet rather than a storage box: the box read as a box, while
two drawers with handles is the picture of an archive. */}}
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks
from behind; front is a full silhouette matching icon-account. Heads share
one baseline; shoulder arcs share one floor — otherwise the pair looks
lopsided at 16 px. */}}
{{define "icon-users"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{end}}
{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone
read as brightness, while the toothed rim is the picture of configuration.
viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}}
{{define "icon-settings"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
{{/* Sign out is the one icon here that marks an action rather than a page: an
open door with the arrow leading out of it. */}}
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
@@ -1,42 +0,0 @@
{{/* Mail queue is two cards in the whole column: the retry-policy snapshot
(static, outside the HTMX poll) and the live postqueue listing. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Mail queue</h1>
<div class="card" id="retry-policy">
<h2>How delivery retries work</h2>
<p class="muted">This Postfix's policy, read once at panel start. There is
no maximum attempt count — only time. A deferred message stays in this
listing until it is delivered or the queue lifetime runs out.</p>
{{if .RetryFromDefaults}}
<p class="muted">Could not read the effective Postfix configuration;
showing compiled-in defaults. A live change is visible after the next
panel restart.</p>
{{end}}
<div class="facts retry-facts">
<div class="fact">
<span class="fact-label">First retry</span>
<span class="fact-value">{{.FirstRetry}}</span>
</div>
<div class="fact">
<span class="fact-label">Later retries</span>
<span class="fact-value">doubling, cap {{.BackoffCap}}</span>
</div>
<div class="fact">
<span class="fact-label">Kept in queue</span>
<span class="fact-value">{{.QueueLifetime}}</span>
</div>
<div class="fact">
<span class="fact-label">Then</span>
<span class="fact-value">bounced</span>
</div>
</div>
</div>
<div class="card">
<h2>Pending messages</h2>
{{template "mail_queue_body" .}}
</div>
{{end}}
@@ -1,6 +0,0 @@
{{define "mail_queue_body"}}
<div id="mail-queue-body" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
{{end}}
@@ -0,0 +1,14 @@
{{/* Outbound queue (mockup out-queue.html): what Postfix is still holding, in one
box that polls (out_queue_body.html), and under it the retry policy read once
at panel start. The queue is postqueue's own listing until it is a table. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "out_queue_body" .}}
{{template "box_open" .Retries}}
{{template "body_open"}}
{{template "facts" .Policy}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
@@ -0,0 +1,6 @@
{{/* The polled part of the Outbound queue, in the page and in its fragment (GET
/outbound/queue/fragment) alike: the box itself polls (.Queue.Poll), so the
response replaces it whole. With nothing to list it says why instead. */}}
{{define "out_queue_body"}}{{template "box_open" .Queue}}
{{if .Lines}}{{template "log_pane" .Lines}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
+6 -4
View File
@@ -1,8 +1,10 @@
{{/* The user form (mockup user.html): one form that creates a panel user and
edits one. The account is the left column; on the right the domains the user
reaches, outbound and inbound as two lists, each with its All. The lists stay
in view whatever the role — the server ignores them for the global role. A
blank password keeps the current one. */}}
reaches, outbound and inbound as two lists, each with its All. panel.js hides
the lists while the role is Global (data-global-role on the select,
data-domain-pick on the boxes); without scripts they stay in view and the
server ignores them for the global role. A blank password keeps the current
one. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
@@ -19,7 +21,7 @@
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
<div class="field"><label class="label" for="role">Role</label>
{{if .RoleLocked}}<input type="hidden" name="role" value="{{.Role}}">{{end}}
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"{{if .RoleLocked}} disabled{{end}}>{{$sel := .Role}}{{range .Roles}}
<div class="control"><div class="select is-fullwidth"><select id="role" name="role" data-global-role="global"{{if .RoleLocked}} disabled{{end}}>{{$sel := .Role}}{{range .Roles}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
+21 -355
View File
@@ -1,7 +1,6 @@
package view
import (
"bytes"
"io/fs"
"net/http"
"net/http/httptest"
@@ -26,16 +25,13 @@ func TestEveryPageResolvesNav(t *testing.T) {
}
}
// The version comes from render(), not from each handler's data map, so the
// footer is only correct as long as every page composes with the layout and
// render keeps supplying the key. Both are asserted here rather than trusted.
// Appropriate Legal Notices (copyright, licence, source, no warranty) must
// appear on every page, including the signed-out ones.
// The version and the legal lines come from Render, not from each handler's
// data, so the footer is only correct as long as every page composes with the
// layout. It is asserted on every page, not trusted. Appropriate Legal Notices
// (copyright, licence, source, no warranty) appear on every page, signed out
// ones included; the running version only on the signed-in ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine := kitEngine(t) // version 9.9.9-test
legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko",
`href="/license"`,
@@ -44,204 +40,31 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
"Source",
"No warranty",
}
rendered := 0
for name := range engine.Pages() {
if kitPages[name] {
continue // the kit layout has its own tests (components_test.go)
}
var buf bytes.Buffer
err := engine.Page(name).ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
})
if err != nil {
// Pages whose content block needs more data than this cannot be
// rendered here; the footer is in the shared layout, so one page
// that does render proves it for all of them.
continue
}
rendered++
out := buf.String()
if !strings.Contains(out, "SelfPost 9.9.9-test") {
t.Errorf("page %q does not show the version in the layout footer", name)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("page %q is missing legal notice %q", name, want)
}
}
}
if rendered == 0 {
t.Fatal("no page rendered, so the footer was never actually checked")
}
// Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present.
// Both signed-out pages go through Engine.Render, which supplies the
// engine's version and the legal lines itself.
signedOut := map[string]any{
"login": NewLogin("mail.example.org", ""),
"setup": NewSetup("token", ""),
}
for name, data := range signedOut {
signedOut := map[string]bool{"login": true, "setup": true}
for name, fixture := range pageFixtures {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, data)
engine.Render(rec, http.StatusOK, name, fixture())
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
}
out := rec.Body.String()
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
if signedOut[name] {
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
}
} else if !strings.Contains(out, "SelfPost 9.9.9-test") {
t.Errorf("page %q does not show the version in the layout footer", name)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("%s page is missing legal notice %q", name, want)
}
}
}
}
func TestRenderSuppliesTheVersion(t *testing.T) {
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
data := map[string]any{"Title": "t", "User": "admin"}
engine.Render(rec, http.StatusOK, "mail_queue", data)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := data["Version"]; got != "9.9.9-test" {
t.Errorf("render did not supply Version (got %v)", got)
}
if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" {
t.Errorf("render did not supply Copyright (got %v)", got)
}
if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" {
t.Errorf("render did not supply SourceURL (got %v)", got)
}
body := rec.Body.String()
if !strings.Contains(body, "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", body)
}
if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") {
t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body)
}
}
func TestNavMarksActivePage(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
err = engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
"IsGlobal": true,
})
if err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
// The label is checked apart from the opening tag because each entry now
// carries an icon between the two.
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
t.Errorf("active page is not marked:\n%s", out)
}
if strings.Contains(out, `href="/outbound/queue"`) {
t.Errorf("active page still links to itself:\n%s", out)
}
if !strings.Contains(out, `href="/outbound/log"`) {
t.Errorf("inactive pages are not linked:\n%s", out)
}
}
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
t.Errorf("the status page is not marked active:\n%s", out)
}
if !strings.Contains(out, `href="/outbound/domains"`) {
t.Errorf("Domains does not link to /outbound/domains:\n%s", out)
}
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
t.Errorf("Status is not the first navigation entry:\n%s", out)
}
if strings.Contains(out, `href="/inbound/domains"`) || strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
}
}
func TestNavShowsInboundWhenEnabled(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
"InboundEnabled": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/inbound/domains"`) || !strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
}
dom := strings.Index(out, `href="/outbound/domains"`)
inb := strings.Index(out, `href="/inbound/domains"`)
if dom < 0 || inb < 0 || inb < dom {
t.Errorf("Inbound should follow Domains:\n%s", out)
}
}
// Whether a page takes the whole column or the reading measure is declared by
// the page's own "wide" block (see layout_legacy.html), which the layout stamps into
// <main>'s class list. A page that loses the block does not fail to render — it
// silently comes back at the measure, with its table squeezed into two thirds
// of the column — so the set is asserted here, in both directions.
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{
"mail_queue": true,
"inbound": true, "inbound_domain": true,
}
for name, page := range engine.Pages() {
if kitPages[name] {
continue // "wide" belongs to the legacy layout; the kit's width comes from its columns
}
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err)
}
got := strings.TrimSpace(buf.String())
switch {
case wide[name] && got != "wide":
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
case !wide[name] && got != "":
t.Errorf("page %q declares itself %q; only the pages that are tables of data, raw log lines or side-by-side cards take the whole column", name, got)
// The help lives on the Help page: no page carries a drawer for it.
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
if strings.Contains(out, gone) {
t.Errorf("page %q still carries the help drawer (%q)", name, gone)
}
}
}
}
@@ -268,26 +91,6 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
}
}
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"inbound_domain.html": true,
"inbound_delete.html": true,
}
forEachTemplate(t, func(name, body string) {
if !drillDown[name] {
return
}
if !strings.Contains(body, `template "back_link"`) {
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
}
backIdx := strings.Index(body, `template "back_link"`)
cardIdx := strings.Index(body, `class="card`)
if cardIdx >= 0 && backIdx > cardIdx {
t.Errorf("%s places the back link after the first card", name)
}
})
}
// Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one.
@@ -350,7 +153,7 @@ func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
// server actually serves, so a typo in a /static path is a blocked request,
// not a 404 in the page's own colours.
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
for _, file := range []string{"templates/layout_legacy.html", "templates/layout.html"} {
for _, file := range []string{"templates/layout.html"} {
body, err := fs.ReadFile(assetsFS, file)
if err != nil {
t.Fatalf("read layout: %v", err)
@@ -383,84 +186,6 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
}
}
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
"FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes",
"QueueLifetime": "2 days",
}); err != nil {
t.Fatalf("execute mail_queue: %v", err)
}
out := buf.String()
for _, want := range []string{
"How delivery retries work",
"10 minutes",
"about 1 hour 7 minutes",
"2 days",
`id="retry-policy"`,
`hx-get="/outbound/queue/fragment"`,
} {
if !strings.Contains(out, want) {
t.Errorf("mail_queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
}
}
// The help drawer is gone from the old layout as well: a topic is a section of
// the Help page, linked from the head of the box it explains, and the Help
// entry of the navigation leads there.
func TestAuthenticatedLayoutHasNoHelpDrawer(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute legacy layout: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/help"`) {
t.Error("the authenticated layout has no link to the Help page")
}
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
if strings.Contains(out, gone) {
t.Errorf("the authenticated layout still carries the help drawer (%q)", gone)
}
}
}
func TestLoginPageOmitsHelpDrawer(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
for name, data := range map[string]any{
"login": NewLogin("", ""),
"setup": NewSetup("token", ""),
} {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, data)
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "help-drawer") {
t.Errorf("%s page should not include the help drawer", name)
}
}
}
// The help drawer is gone from the pages of the kit: Overview points at the
// section of the Help page that explains its checks, from the head of its box.
func TestOverviewLinksToItsHelpTopic(t *testing.T) {
@@ -472,62 +197,3 @@ func TestOverviewLinksToItsHelpTopic(t *testing.T) {
t.Error("a page of the kit carries the old help drawer")
}
}
func TestNavIncludesHelp(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "Active": "domains", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
if !strings.Contains(buf.String(), `href="/help"`) {
t.Error("nav is missing Help link")
}
}
// A group with nothing assigned is absent from that user's menu: a domain
// administrator who was given inbound domains only sees Inbound and none of the
// sending pages, and the mark takes them to what they have.
func TestLegacyNavFollowsTheUsersReach(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
nav := func(data map[string]any) string {
t.Helper()
data["User"], data["Active"] = "ops", ""
data["InboundEnabled"], data["DMARCEnabled"] = true, true
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", data); err != nil {
t.Fatalf("execute nav: %v", err)
}
return buf.String()
}
has := func(out, href string) bool { return strings.Contains(out, `href="`+href+`"`) }
out := nav(map[string]any{"HasInbound": true})
if !has(out, "/inbound/domains") {
t.Errorf("an inbound-only administrator has no Inbound entry:\n%s", out)
}
for _, gone := range []string{"/outbound/domains", "/outbound/log", "/outbound/dmarc", "/overview", "/server/users", "/outbound/queue"} {
if has(out, gone) {
t.Errorf("an inbound-only administrator is offered %s", gone)
}
}
out = nav(map[string]any{"HasOutbound": true})
if has(out, "/inbound/domains") || !has(out, "/outbound/domains") || !has(out, "/outbound/log") || !has(out, "/outbound/dmarc") {
t.Errorf("an outbound-only administrator's menu is wrong:\n%s", out)
}
out = nav(map[string]any{"IsGlobal": true})
for _, want := range []string{"/overview", "/outbound/domains", "/inbound/domains", "/outbound/dmarc", "/outbound/log", "/server/users"} {
if !has(out, want) {
t.Errorf("the global role lost %s", want)
}
}
}
+23 -116
View File
@@ -26,13 +26,12 @@ type Engine struct {
dmarcEnabled bool
}
// pageFiles maps a logical page name to its template files. Every page
// composes with a layout — layout_legacy.html for the pages still in the old
// design, layout.html and components.html for those that have been rebuilt
// from the component kit (kitPages); pages that embed a polling fragment
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
// same {{define}} block renders both the initial page and the fragment's own
// refresh responses identically.
// pageFiles maps a logical page name to its template files. Every page is built
// from the component kit: layout.html and components.html, then the page's own
// file. Pages that embed a polling fragment (architecture.md § Panel HTTP
// surface) list that fragment's file too, so the same {{define}} block renders
// both the initial page and the fragment's own refresh responses identically.
// The guard tests read this map.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
@@ -60,63 +59,20 @@ var pageFiles = map[string][]string{
"dmarc": {"templates/dmarc.html"},
"dmarc-domain": {"templates/dmarc_domain.html"},
"dmarc-report": {"templates/dmarc_report.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"out-queue": {"templates/out_queue.html", "templates/out_queue_body.html"},
"components": {"templates/kit.html"},
}
// kitPages are the pages built from the component kit: they are rendered by
// layout.html with the partials of components.html and load panel.css. Every
// other page is still the old design — layout_legacy.html and legacy.css — and
// is listed in legacy_pages.txt; a page is in exactly one of the two, and
// restyling one means moving it from the list to here in the same commit.
// pageFiles stays a plain map of file lists because the guard tests read it.
var kitPages = map[string]bool{
"components": true,
"login": true,
"setup": true,
"overview": true,
"health": true,
"account": true,
"settings": true,
"out-domains": true,
"out-domain": true,
"out-domain-settings": true,
"out-app": true,
"out-app-created": true,
"out-domain-delete": true,
"in-domains": true,
"in-domain": true,
"in-domain-delete": true,
"out-log": true,
"out-message": true,
"dmarc": true,
"dmarc-domain": true,
"dmarc-report": true,
"system-log": true,
"backup": true,
"users": true,
"user": true,
"user-delete": true,
"help": true,
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"out_log_rows": "templates/out_log_rows.html",
"mail_queue_body": "templates/mail_queue_body.html",
"out_queue_body": "templates/out_queue_body.html",
"system_log_body": "templates/system_log_body.html",
"health_body": "templates/health_body.html",
"overview_poll": "templates/overview_cards.html",
}
// kitFragments are the fragments rendered with the component kit's partials.
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true, "out_log_rows": true, "system_log_body": true}
// New parses embedded templates. version is stamped into every page footer.
func New(version string) (*Engine, error) {
e := &Engine{
@@ -125,13 +81,10 @@ func New(version string) (*Engine, error) {
version: version,
}
for name, files := range pageFiles {
// A page parses with the layout of its design. The template is named
// after the layout file so that its content is the layout's.
layout, shared := "layout_legacy.html", []string(nil)
if kitPages[name] {
layout, shared = "layout.html", []string{"templates/components.html"}
}
patterns := append([]string{"templates/" + layout}, append(shared, files...)...)
// A page parses with the layout and the kit's partials. The template
// is named after the layout file so that its content is the layout's.
const layout = "layout.html"
patterns := append([]string{"templates/" + layout, "templates/components.html"}, files...)
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
@@ -139,12 +92,8 @@ func New(version string) (*Engine, error) {
e.pages[name] = tmpl
}
for name, file := range fragmentFiles {
// A fragment of a kit page calls the kit's partials, so it parses with
// them; the old fragments define everything they use themselves.
patterns := []string{file}
if kitFragments[name] {
patterns = []string{"templates/components.html", file}
}
// A fragment calls the kit's partials, so it parses with them.
patterns := []string{"templates/components.html", file}
tmpl, err := template.New(path.Base(file)).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
if err != nil {
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
@@ -168,25 +117,6 @@ func (e *Engine) SetDMARCEnabled(v bool) {
// templateFuncs supplies helpers shared across page templates.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"dict": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict: odd argument count")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %d is not a string", i)
}
m[key] = values[i+1]
}
return m, nil
},
// back builds the map back_link reads; keeps href and label paired at
// the call site instead of repeating the <a class="back"> markup.
"back": func(href, label string) map[string]string {
return map[string]string{"Href": href, "Label": label}
},
// status_tag and wbr_at are the adapters of the component kit
// (components.go): the one mapping of a status to Bulma's tag
// classes, and the break opportunity after the @ of an address.
@@ -214,39 +144,16 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var (
buf bytes.Buffer
layout string
frame = data
)
if kitPages[page] {
// A page of the component kit: the shell is typed data derived from the
// page's Meta (components.go), and the page's own data travels beside it.
// With no user there is no navigation — the signed-out screen.
layout = "layout.html"
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
if sh.User == "" {
layout = "layout_signed_out"
}
frame = Frame{Shell: sh, Page: data}
} else {
layout = "layout_legacy.html"
// The layout's navigation compares .Active against each item, so the key
// must exist on every authenticated page. Defaulting it here keeps a page
// that forgets it from failing to render — it simply highlights nothing.
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
// page, so no handler should have to pass them.
if m, ok := data.(map[string]any); ok {
if _, has := m["Active"]; !has {
m["Active"] = ""
}
m["Version"] = e.version
m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL
m["InboundEnabled"] = e.inboundEnabled
m["DMARCEnabled"] = e.dmarcEnabled
}
// The shell is typed data derived from the page's Meta (components.go), and
// the page's own data travels beside it. With no user there is no
// navigation — the signed-out screen.
var buf bytes.Buffer
layout := "layout.html"
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
if sh.User == "" {
layout = "layout_signed_out"
}
frame := Frame{Shell: sh, Page: data}
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
log.Printf("panel: render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)