panel-redesign: the component kit - panel.css, partials, shell, kit page

Stage 0, steps 3-5, in one commit: the three were built together (the kit
page is what exercises the partials and the shell) and do not split into
states that build on their own. Written by Sonnet, reviewed by Opus.

  - static/panel.css: shared/brand.css + panel/theme.css, rule for rule.
    wordmark.svg and perf-edge.svg beside it; the stamp is logo.svg.
  - templates/components.html: every partial of the plan's Three layers,
    with its typed input in components.go. Wrappers are open/close pairs
    (box_open/box_close, body_open/body_close, record_open/record_close).
    Rich text is a Text value built from escaped runs - no field is
    trusted HTML. status_tag and wbr_at are template functions; copy_field
    is a partial whose button panel.js wires through data-copy.
  - templates/layout.html: the shell as build.py draws it, the menu paths
    written out literally, visibility from a typed Shell (role, feature
    flags, the user's reach); the signed-out screen as a second define.
    Not in the 2.0 menu: Inbound Log / Queue / Quarantine / Filter lists
    and Preflight.
  - GET /server/components (global role, 404 otherwise): the mockup
    components page rebuilt from fixtures (kit.go) by the real templates.
  - The old panel steps aside unchanged: panel.css -> legacy.css,
    layout.html -> layout_legacy.html; a page uses the new layout iff it
    is off legacy_pages.txt.
  - legacy_pages.txt: @kit removed - the kit is under the guards for good.

Differences from the mockup components page, for the owner:
  - the head button is Help, not "Cover sheet"; two "Example" links are
    dropped (no fixed URL), two point at /outbound/domains and /login;
  - the page sits in the Server group, so the sibling strip shows;
  - Sign out is a POST form whose button carries navbar-item: same size
    and type as the links, but without Bulma's hover tint;
  - a head's actions are always wrapped in div.buttons.

Evidence
  - docs/assets/panel-redesign/evidence/components.png: 1440 px wide, full
    height, headless Edge. Taken from the real templates served locally,
    NOT from the container (no Docker on this machine); the page reads
    only its own fixtures and embedded assets.
  - panel/audit.js on the kit page, 1440 and 1280:
    {"pages":1,"widths":[1440,1280],"textsChecked":516,"problems":[]}
  - go test ./internal/web/... (with @kit removed)
    ok  	github.com/mixeme/selfpost/internal/web
    ok  	github.com/mixeme/selfpost/internal/web/auth
    ok  	github.com/mixeme/selfpost/internal/web/handlers
    ok  	github.com/mixeme/selfpost/internal/web/validate
    ok  	github.com/mixeme/selfpost/internal/web/view
    TestRoutesFollowNavigation stays skipped behind @routes (stage 1).

Roadmap 10/27.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 17:46:13 +03:00
co-authored by Claude Sonnet 5.5
parent 92678c4157
commit f861d78114
26 changed files with 3273 additions and 1001 deletions
+180
View File
@@ -0,0 +1,180 @@
{{/* The component kit: one {{define}} per partial of docs/plans/panel-redesign.md
§ Three layers. This is the only file besides layout.html in which an sp-
component class is written; a page calls a partial and passes the struct
that components.go declares for it (a page never builds the input with
dict), and it never rebuilds the markup. The reference for every partial in
every state is the mockup components page,
docs/assets/panel-redesign/panel/src/components.html — the markup here is
that markup, element for element and class for class, and the kit page
(GET /server/components, kit.html) renders it all from fixtures.
Go templates have no slots, so a component that wraps the page's own
content is an open/close pair: box_open … box_close, body_open …
body_close, record_open … record_close. Between the two the page writes
Bulma markup (a table in its table-container, a form) or calls another
partial. */}}
{{/* ---- inline content: the text a struct field carries */}}
{{/* inline renders a Text (components.go): plain text, links, code, mono
values, strong, emphasis, tags and line breaks, all escaped. It is how a
lead, a box's end slot or a fact's value can hold a link or a code span
without any field being trusted HTML. */}}
{{define "inline"}}{{range .}}{{if .Break}}<br>{{else if .Tag}}{{template "tag" .Tag}}{{else if .Href}}<a href="{{.Href}}">{{template "inline_leaf" .}}</a>{{else}}{{template "inline_leaf" .}}{{end}}{{end}}{{end}}
{{define "inline_leaf"}}{{if .Code}}<code>{{.Text}}</code>{{else if .Mono}}<span class="sp-mono">{{.Text}}</span>{{else if .Strong}}<strong>{{.Text}}</strong>{{else if .Em}}<em>{{.Text}}</em>{{else}}{{.Text}}{{end}}{{end}}
{{/* tag is a status tag (input: Tag). The colour comes from status_tag, the one
mapping of status to Bulma classes; the label defaults to the status. */}}
{{define "tag"}}<span class="{{status_tag .Status}}">{{if .Label}}{{.Label}}{{else}}{{.Status}}{{end}}</span>{{end}}
{{/* kicker is the upper-case mono micro-label on its own (input: string).
page_head writes its own kicker as the line above the title. */}}
{{define "kicker"}}<span class="sp-kicker">{{.}}</span>{{end}}
{{/* action is a button in a page head or a box foot (input: Action): a link, or
a POST form with one submit button when Post is set — a state change is
never a link. */}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{/* ---- page head and postmark */}}
{{/* page_head opens every page (input: Head): the path or section as the
kicker, the h1, a lead or a route line, the postmark when the page has a
verdict, a note on the right, and the buttons. */}}
{{define "page_head"}}<div class="sp-head">
{{if .Postmark}}{{template "postmark" .Postmark}}
{{end}}<div class="sp-grow">
{{if .Crumbs}}<p class="sp-kicker">{{range $i, $c := .Crumbs}}{{if $i}} {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Label}}</a>{{else}}{{$c.Label}}{{end}} /{{end}}</p>
{{else if .Kicker}}<p class="sp-kicker">{{.Kicker}}</p>
{{end}}<h1 class="title {{if .Compact}}is-4{{else}}is-3{{end}}{{if .Mono}} sp-mono{{end}}">{{.Title}}{{if .Code}} <span class="sp-mono">{{.Code}}</span>{{end}}</h1>
{{if .Route}}{{template "route" .Route}}
{{else if .Lead}}<p class="sp-lead">{{template "inline" .Lead}}</p>
{{end}}</div>
{{if .Note}}<div class="sp-muted sp-small">{{range $i, $l := .Note}}{{if $i}}<br>{{end}}{{$l}}{{end}}</div>
{{end}}{{if .Actions}}<div class="buttons">{{range .Actions}}{{template "action" .}}{{end}}</div>
{{end}}</div>{{end}}
{{/* postmark is the round verdict stamp (input: Postmark). Inside page_head
only, at most one per page. */}}
{{define "postmark"}}<div class="sp-postmark{{if eq .Level "warn"}} sp-warn{{else if eq .Level "fail"}} sp-fail{{end}}"><span>{{.Top}}</span><b>{{.Word}}</b><span>{{.Bottom}}</span></div>{{end}}
{{/* route is the from → to line of a message (input: Route). */}}
{{define "route"}}<p class="sp-route">{{range .}}{{if .Arrow}}<i class="ti ti-arrow-right sp-muted"></i>{{else if .Tag}}{{template "tag" .Tag}}{{else}}<span>{{.Text}}</span>{{end}}{{end}}</p>{{end}}
{{/* flash is the result of the last action, between page_head and the first
box (input: Flash). A field's own error stays in help is-danger. */}}
{{define "flash"}}<div class="notification {{if .Error}}is-danger{{else}}is-success{{end}} is-light"><i class="ti {{if .Error}}ti-alert-circle{{else}}ti-circle-check{{end}}"></i>{{template "inline" .Text}}</div>{{end}}
{{/* ---- box */}}
{{/* box_open starts a box and writes its head (input: Box); box_close ends it.
The variants are sp-danger-zone (a destructive confirmation) and
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}}>
{{template "box_head" .}}
{{end}}
{{define "box_close"}}</div>{{end}}
{{/* body_open pads the content of a box (box_body); body_close ends it. */}}
{{define "body_open"}}<div class="sp-box-body">{{end}}
{{define "body_close"}}</div>{{end}}
{{/* box_head is the numbered mono head of a box (input: Box): the number — or an
icon when the box is not part of a sequence — the title, an end slot (a
note, a tag, a link, or a filter form) and an optional link to the
section's Help topic. */}}
{{define "box_head"}}<div class="sp-box-head"><span class="sp-no">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{else}}{{.No}}{{end}}</span><h2>{{.Title}}</h2>{{if .Filter}}{{template "filter_form" .Filter}}{{else if .End}}<div class="sp-end">{{template "inline" .End}}</div>{{end}}{{if .Help}}<a class="sp-help" href="{{.Help.Href}}" title="{{.Help.Title}}"><i class="ti ti-help-circle"></i></a>{{end}}</div>{{end}}
{{/* filter_form is the filter of a list in the end slot of its box head
(input: Filter): selects, date inputs and one button, sent with GET. */}}
{{define "filter_form"}}<form class="sp-end" method="get" action="{{.Action}}">{{range .Selects}}
<div class="select"><select name="{{.Name}}" aria-label="{{.Label}}">{{$sel := .Selected}}{{range .Options}}<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}</select></div>{{end}}{{range .Dates}}
<input class="input" type="date" name="{{.Name}}" value="{{.Value}}" aria-label="{{.Label}}">{{end}}
<button type="submit" class="button"><i class="ti ti-filter"></i>{{if .Button}}{{.Button}}{{else}}Filter{{end}}</button>
</form>{{end}}
{{/* box_foot is the line under a box's content (input: Foot): a legend, paging
with an end slot, or the one button that finishes the page. */}}
{{define "box_foot"}}<div class="sp-box-foot">{{if .Button}}{{template "action" .Button}}{{else}}{{if .Bare}}{{template "inline" .Text}}{{else}}<span>{{template "inline" .Text}}</span>{{end}}{{if .Link}}<a href="{{.Link.Href}}">{{.Link.Label}}</a>{{end}}{{if .End}}<span class="sp-end">{{template "inline" .End}}</span>{{end}}{{end}}</div>{{end}}
{{/* ---- health, records, copy */}}
{{/* health_cards is the Overview grid (input: []HealthCard); each card is
health_card (input: HealthCard) and links to its detail. */}}
{{define "health_cards"}}<div class="sp-health">{{range .}}
{{template "health_card" .}}{{end}}
</div>{{end}}
{{define "health_card"}}<a href="{{.Href}}"{{if eq .Level "warn"}} class="sp-warn"{{else if eq .Level "fail"}} class="sp-fail"{{end}}><span class="sp-h-icon"><i class="ti {{.Icon}}"></i></span><span><span class="sp-h-name">{{.Name}}</span><span class="sp-h-value">{{.Value}}</span><span class="sp-h-sub">{{.Sub}}</span></span></a>{{end}}
{{/* dns_record is one DNS record (input: Record): its status, what to publish
with a Copy button, and "In DNS now" only when it differs. */}}
{{define "dns_record"}}<div class="sp-record">
<div class="sp-record-head"><h3>{{.Name}}</h3>{{if .Status.Status}}{{template "tag" .Status}}{{end}}{{if .Note}}<small>{{template "inline" .Note}}</small>{{end}}</div>
{{if .Host}}<div class="sp-row">
{{template "copy_field" .HostField}}
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="{{.Type}}"></div></div>
</div>
{{end}}{{if .Value}}{{template "copy_field" .ValueField}}
{{end}}{{if .InDNS}}<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="{{.InDNS}}"></div>
{{if .Problem}}<p class="help is-danger">{{template "inline" .Problem}}</p>
{{end}}</div>
{{end}}</div>{{end}}
{{/* record_open starts the padded block a record sits in and record_close ends
it, for what is not a DNS record but is laid out like one: the fields of a
secret shown once, a form inside a box. */}}
{{define "record_open"}}<div class="sp-record">{{end}}
{{define "record_close"}}</div>{{end}}
{{/* copy_field is a read-only value joined to its Copy button (input:
CopyField): one line, or a textarea when Rows is set — a DKIM key. The
button is wired by panel.js through data-copy. */}}
{{define "copy_field"}}<div class="field"><label class="label">{{.Label}}</label>
<div class="field has-addons"><div class="control is-expanded">{{if .Rows}}<textarea class="textarea" rows="{{.Rows}}" readonly>{{.Value}}</textarea>{{else}}<input class="input" readonly value="{{.Value}}">{{end}}</div><div class="control"><button type="button" class="button" data-copy><i class="ti ti-copy"></i>Copy</button></div></div>
{{if .Help}}<p class="help">{{template "inline" .Help}}</p>
{{end}}</div>{{end}}
{{/* ---- facts, menu, timeline, log, states */}}
{{/* facts is a label / value grid (input: []Fact); Big is for a headline
number, with its supporting line under it. */}}
{{define "facts"}}<dl class="sp-facts">{{range .}}
<div><dt>{{.Label}}</dt><dd{{if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
</dl>{{end}}
{{/* side_menu is the Bulma menu of a detail page (input: SideMenu): icons,
count tags, and the rarely used entries in a group of their own. It
stays in view while the page scrolls. */}}
{{define "side_menu"}}<aside class="menu sp-side">{{range .Groups}}
<p class="menu-label">{{.Label}}</p>
<ul class="menu-list">{{range .Items}}
<li><a{{if .Active}} class="is-active"{{else if .Danger}} class="has-text-danger"{{end}} href="{{.Href}}"><i class="ti {{.Icon}}{{if .Danger}} has-text-danger{{end}}"></i>{{.Label}}{{if .Tag}} {{template "tag" .Tag}}{{end}}</a></li>{{end}}
</ul>{{end}}
</aside>{{end}}
{{/* timeline is what happened to a message, in order (input: []Step). */}}
{{define "timeline"}}<ol class="sp-timeline">{{range .}}
<li{{if eq .Level "warn"}} class="sp-warn"{{else if eq .Level "fail"}} class="sp-fail"{{else if eq .Level "pending"}} class="sp-pending"{{end}}><time>{{.Time}}</time>{{if .Strong}}<strong>{{.Strong}}</strong>{{end}}{{template "inline" .Text}}</li>{{end}}
</ol>{{end}}
{{/* log_pane is a dark block of log lines (input: []LogLine). The time is
dimmed, a warning amber, an error red. The newline between lines is part of
the text: the pane is a pre. */}}
{{define "log_pane"}}<pre class="sp-log">{{range $i, $l := .}}{{if $i}}
{{end}}{{if $l.Time}}<span class="sp-d">{{$l.Time}}</span> {{end}}{{if eq $l.Level "warn"}}<span class="sp-w">{{$l.Text}}</span>{{else if eq $l.Level "error"}}<span class="sp-e">{{$l.Text}}</span>{{else}}{{$l.Text}}{{end}}{{end}}</pre>{{end}}
{{/* empty_state says what is absent and why that is fine (input: EmptyState). */}}
{{define "empty_state"}}<div class="sp-empty"><i class="ti {{.Icon}}"></i>{{template "inline" .Text}}</div>{{end}}
{{/* confirm_list lists what a destructive action will take with it (input:
[]Text), inside an sp-danger-zone box above the danger button. */}}
{{define "confirm_list"}}<ul class="sp-list-check">{{range .}}
<li><i class="ti ti-x"></i>{{template "inline" .}}</li>{{end}}
</ul>{{end}}
{{/* help_topic is one topic of the Help page (input: HelpTopic): a heading and
paragraphs; topics stack inside one box. */}}
{{define "help_topic"}}<div class="sp-help-topic"{{if .ID}} id="{{.ID}}"{{end}}><h3>{{.Title}}</h3>{{range .Body}}<p>{{template "inline" .}}</p>{{end}}</div>{{end}}
+153
View File
@@ -0,0 +1,153 @@
{{/* The kit page (GET /server/components): every partial of components.html in
every state, filled from kit.go — the mockup components page rebuilt with
the real templates. Like any page it is thin: partial calls with structs,
Bulma layout classes, and the page utilities; the markup of a component is
written in components.html and nowhere else. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "flash" .Flash}}
{{template "flash" .FlashError}}
{{template "box_open" .Layouts}}
{{template "body_open"}}
{{template "facts" .LayoutFacts}}
{{template "body_close"}}
{{template "box_close"}}
<div class="columns">
<div class="column is-6">
{{template "box_open" .Postmark}}
{{template "body_open"}}
<div class="buttons">{{range .Postmarks}}
{{template "postmark" .}}{{end}}
</div>
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .StatusTag}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody>{{range .StatusRows}}
<tr><td>{{.Words}}</td><td>{{template "tag" .Tag}}</td><td class="sp-mono sp-small">{{status_tag .Tag.Status}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .BoxHead}}
{{template "body_open"}}
<p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p>
<p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p>
{{template "body_close"}}
{{template "box_foot" .BoxFoot}}
{{template "box_close"}}
{{template "box_open" .DangerZone}}
{{template "body_open"}}
<p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Credential}}
{{template "body_open"}}
<p>Only for a secret the panel will never show again.</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{template "box_open" .HealthCard}}
{{template "health_cards" .HealthCards}}
{{template "box_close"}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .DNSRecord}}
{{range .Records}}{{template "dns_record" .}}
{{end}}{{template "box_close"}}
{{template "box_open" .Tables}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table></div>
{{template "box_foot" .TablesFoot}}
{{template "box_close"}}
{{template "box_open" .LogPane}}
{{template "log_pane" .LogLines}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .Facts}}
{{template "body_open"}}
{{template "facts" .FactList}}
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Timeline}}
{{template "body_open"}}
{{template "timeline" .Steps}}
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .EmptyState}}
{{template "empty_state" .Empty}}
{{template "box_close"}}
{{template "box_open" .ConfirmList}}
{{template "body_open"}}
{{template "confirm_list" .Confirm}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
<div class="columns">
<div class="column is-3">
{{template "side_menu" .SideMenu}}
</div>
<div class="column is-9">
{{template "box_open" .FormRows}}
{{template "body_open"}}
<div class="sp-form">
<div class="sp-row">
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
</div>
<div class="sp-pair">
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
</div>
</div>
{{template "route" .Route}}
{{template "body_close"}}
{{template "help_topic" .HelpTopic}}
{{template "box_close"}}
</div>
</div>
{{template "box_open" .Shell}}
{{template "body_open"}}
{{template "facts" .ShellFacts}}
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .TypeScale}}
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td>{{template "kicker" .KickerSample}} <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
</tbody>
</table></div>
{{template "box_foot" .TypeScaleFoot}}
{{template "box_close"}}
{{end}}
+125 -122
View File
@@ -1,16 +1,32 @@
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en">
<head>
{{/* The shell of every redesigned page (docs/plans/panel-redesign.md § Three
layers): a brick navbar with the wordmark and hover menus, the stamp's
perforated edge, the strip of sibling pages, the page, the footer. It is
built exactly like the mockups' build.py builds it, and it is the only
place besides components.html where sp- classes are written. A page never
writes any of it.
layout.html is executed with a Frame (components.go): .Shell is what the
shell shows, .Page is the page's own data, handed to the page's "content"
template. Which entries are visible comes from .Shell (Engine.shell derives
it from the role, the feature flags and the user's reach); the paths are
written out here, so the menu can be read, and checked against the router
(guard_routes_test.go), without running anything. */}}
{{/* layout_head is the head of both documents; the stylesheets, in this order,
and the two scripts are all a page ever loads. */}}
{{define "layout_head"}}<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<title>{{.Title}} · SelfPost</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. */}}
the two-line wordmark, which stops resolving below 32px. 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/bulma.min.css">
<link rel="stylesheet" href="/static/tabler-icons.css">
<link rel="stylesheet" href="/static/panel.css">
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
@@ -20,126 +36,113 @@
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script>
</head>
</head>{{end}}
{{/* layout.html is the signed-in document. Engine.Render uses
layout_signed_out instead when there is no user. */}}
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en" data-theme="light">
{{template "layout_head" .Shell}}
<body>
{{if .User}}{{template "help_inputs" .}}{{end}}
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in panel.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 panel.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>
{{template "nav" .Shell}}
{{template "subnav" .Shell}}
<main>
<div class="container sp-page">
{{template "content" .Page}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the
licence, where the Corresponding Source is, and that there is no
warranty. The running version is shown signed in only: it is what a
backup manifest is checked against on restore, and must not be
advertised to the internet. */}}
<footer class="sp-foot">SelfPost {{.Shell.Version}} · {{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</footer>
</div>
</main>
</div>
{{if .User}}{{template "help_drawer" .}}{{end}}
</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 /status, the page the panel opens on — named outright rather
than as "/", which is only a redirect to it. */}}
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/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="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if and .IsGlobal .InboundEnabled}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}}
{{if .DMARCEnabled}}
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/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 eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-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="/system-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="/backup">{{template "icon-backup"}}Backup</a>{{end}}
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
{{end}}
{{/* layout_signed_out is the split brand / form screen of sign-in and setup:
the stamp on the brick, the page's own form beside it, and the legal
notice without the version. */}}
{{define "layout_signed_out"}}<!DOCTYPE html>
<html lang="en" data-theme="light">
{{template "layout_head" .Shell}}
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="/static/logo.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="session">
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
<div class="column is-half sp-signin-form">
{{template "content" .Page}}
<p class="sp-foot">{{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>{{end}}
{{/* nav is the navbar and the perforated edge under it (input: Shell). A group
with nothing in it for this user is absent; the group of the current page
carries sp-current and its current entry is-active. */}}
{{define "nav"}}<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="{{.Home}}"><img src="/static/wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
{{if .ShowOverview}} <a class="navbar-item{{if eq .Section "overview"}} sp-current{{end}}" href="/overview">Overview</a>
{{end}}{{if .ShowOutbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "outbound"}} sp-current{{end}}">
<a class="navbar-link" href="/outbound/domains">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "domains")}} is-active{{end}}" href="/outbound/domains"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "log")}} is-active{{end}}" href="/outbound/log"><i class="ti ti-list-details"></i>Log</a>
{{if .ShowQueue}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "queue")}} is-active{{end}}" href="/outbound/queue"><i class="ti ti-stack-2"></i>Queue</a>
{{end}}{{if .ShowDMARC}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "dmarc")}} is-active{{end}}" href="/outbound/dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a>
{{end}} </div>
</div>
{{end}}{{if .ShowInbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "inbound"}} sp-current{{end}}">
<a class="navbar-link" href="/inbound/domains">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
</div>
</div>
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
<a class="navbar-link" href="/server/health">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "health")}} is-active{{end}}" href="/server/health"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "log")}} is-active{{end}}" href="/server/log"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "backup")}} is-active{{end}}" href="/server/backup"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "users")}} is-active{{end}}" href="/server/users"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "settings")}} is-active{{end}}" href="/server/settings"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
{{end}} </div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable{{if eq .Section "user"}} sp-current{{end}}">
<a class="navbar-link" href="/account"><i class="ti ti-user-circle"></i>{{.User}}</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="/account"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="/help"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
{{/* Signing out is a POST (the handler answers nothing else), so it
stays a form that works without scripts. The submit button
carries navbar-item: Bulma resets a button to plain text, so
it takes the dropdown's entry styling like the links around it
(only Bulma's hover tint, which it gives to a.navbar-item
alone, is missing). */}}
<form method="post" action="/logout"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div>
</div>
</div>
</div>
</div>
</nav>
{{end}}
<div class="sp-perf" aria-hidden="true"></div>{{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 panel.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 "/users" "Back to users")}}.
Place it directly under the page <h1> and above the cards (see .back in
panel.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}}
{{/* subnav is the strip of sibling pages under the edge (input: Shell): the
pages of the current group, the current one marked. A page outside every
group (the user menu, the kit page's neighbours) has none. */}}
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a></div></div>
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
{{end}}{{end}}
@@ -0,0 +1,149 @@
{{/* 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>
{{if .User}}{{template "help_inputs" .}}{{end}}
{{/* 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>
{{if .User}}{{template "help_drawer" .}}{{end}}
</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 /status, the page the panel opens on — named outright rather
than as "/", which is only a redirect to it. */}}
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/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="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if and .IsGlobal .InboundEnabled}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}}
{{if .DMARCEnabled}}
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/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 eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-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="/system-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="/backup">{{template "icon-backup"}}Backup</a>{{end}}
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
{{end}}
</div>
<div class="session">
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{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 "/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}}