Files
selfpost/internal/web/view/components.go
T
mixandClaude Opus 5.5 a8fd96c752 health-setup-checks: the certificate's name, Postfix's greeting, the proxy
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16; mockups b2dcefc, cc33890, 0feaa55 (his: the greeting on a row
of its own, the name check a tag beside Names).

- The certificate: health.Certificate keeps its names (DNS names, or the
  CN without any) and ForHost holds it to SELFPOST_HOSTNAME as a TLS
  client does - VerifyHostname over the DNS names, the CN only without
  any, as OpenSSL's X509_check_host and so a sending Postfix still take it
  (src/tls/tls_client.c:439, 513). A certificate for another name fails;
  the tag beside Names says matches / not for the hostname.
- The greeting: postconf -x -h smtpd_banner, read once at the panel's
  start like the served certificate's path (main.cf is not rewritten
  after), is what smtpd writes after "220 " (src/smtpd/smtpd.c:5979). A
  first word other than the hostname warns, with what to set. A fact may
  now take the whole row (Fact.Wide, sp-wide) and carry a tag beside its
  label (Fact.LabelTag); both on the kit page.
- The proxy: auth.Module.Seen says the address the panel counts sign-ins
  against, the peer, and whether X-Forwarded-For was read or left unread
  for an untrusted peer; the Configuration card shows "Your address" and,
  in the second case, warns that every visitor shares one sign-in limit
  and names the /32 (or /128) to put in TRUSTED_PROXY_CIDR.

Tests: TestCertificateForHost (CN-only, DNS names, a wildcard, the CN
ignored beside DNS names), TestSeenAddress, TestHealthSaysWhereItSeesTheViewer,
TestHealthGreetingAndCertificateName; e2e reads the stand's right state on
Health (matches, "220 mail.e2e.test ESMTP", a direct client). go vet + go
test (26 packages) and make e2e on the test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
CHANGELOG [Unreleased] § Added, roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:51:42 +03:00

612 lines
20 KiB
Go

package view
// The typed input of every partial in templates/components.html, and of the
// shell in templates/layout.html (docs/plans/panel-redesign.md § Component kit).
// A partial's input is part of its definition: components.html documents the
// look, this file the data. A page template passes one of these structs to its
// partial — it never assembles the input with dict — and the handler (or a
// fixture) fills it. Anything a person could type is a plain string and is
// escaped; markup a field can carry — a link, a code span — is a Text, never
// trusted HTML.
import (
"fmt"
"html/template"
"strconv"
"strings"
)
// Levels a postmark, a health card or a timeline step can have. The empty
// string is the default look (ok); "pending" is a timeline step that has not
// happened yet.
const (
LevelOK = "ok"
LevelWarn = "warn"
LevelFail = "fail"
LevelPending = "pending"
)
// Box variants.
const (
// BoxDanger is the sp-danger-zone box: a destructive confirmation or the
// "delete" entry of a settings page.
BoxDanger = "danger"
// BoxCredential is the sp-credential box: a secret the panel will never
// show again.
BoxCredential = "credential"
)
// Inline is one piece of a Text. Exactly one kind applies, in this order: a
// line break, a tag, then text — which a link wraps when Href is set, and which
// is code, a mono value, strong or emphasised when the matching flag is.
type Inline struct {
Text string
Href string
Code bool
Mono bool
Strong bool
Em bool
Tag *Tag
Break bool
}
// Text is a run of inline content: what a lead, a box's end slot, a note, a
// fact's value or a help paragraph holds when it is more than one plain string.
// It is rendered by the "inline" partial. Build it with Rich.
type Text []Inline
// Rich builds a Text from strings (plain text), Inlines (Code, Link, …) and
// other Texts. Anything else is printed as text, so a number can be passed.
func Rich(parts ...any) Text {
var t Text
for _, p := range parts {
switch v := p.(type) {
case string:
t = append(t, Inline{Text: v})
case Inline:
t = append(t, v)
case Text:
t = append(t, v...)
default:
t = append(t, Inline{Text: fmt.Sprint(v)})
}
}
return t
}
// Plain is a Text of one plain string — the common case.
func Plain(s string) Text { return Text{{Text: s}} }
// Code is a <code> span.
func Code(s string) Inline { return Inline{Text: s, Code: true} }
// Mono is a value set in the mono face.
func Mono(s string) Inline { return Inline{Text: s, Mono: true} }
// Strong is a <strong> span.
func Strong(s string) Inline { return Inline{Text: s, Strong: true} }
// Em is an <em> span.
func Em(s string) Inline { return Inline{Text: s, Em: true} }
// Link is a link with plain text.
func Link(href, label string) Inline { return Inline{Text: label, Href: href} }
// Br is a line break.
func Br() Inline { return Inline{Break: true} }
// TagOf is a status tag inside a Text.
func TagOf(status, label string) Inline { return Inline{Tag: &Tag{Status: status, Label: label}} }
// Tag is a status tag (partial "tag", function status_tag): the colour comes
// from Status, the text from Label — the status itself when Label is empty. It
// is the small coloured word in a table cell, a box's end slot or a menu entry.
type Tag struct {
Status string
Label string
}
// Sheet is what does not fit a table row, opened over the page from the row's
// tag (partials "sheet_tag" and "sheet"; used by the Inbound log, where it says
// why a message was decided the way it was). It is the browser's own popover:
// the tag is a button that names the sheet by ID, so ID is unique on the page.
// Its content is a table of name and value.
//
// HTML Living Standard, popover.html: popovertarget on a button shows or hides
// (popovertargetaction="hide") the element with that id, with no script; a
// browser without the popover attribute (before Chrome 114, Firefox 125,
// Safari 17 - MDN browser-compat-data 6.0.0, html.global_attributes.popover)
// does not open it.
type Sheet struct {
ID string
Tag Tag
Title string
Rows []SheetRow
}
// SheetRow is one name and its value in a sheet. A value of several lines is
// one line each; Mono is for what is read as written - a reply, a list of
// symbols.
type SheetRow struct {
Label string
Lines []string
Mono bool
}
// statusClass is the one mapping of status to Bulma tag classes — box 03 of the
// kit page. A page passes the status it knows (a DKIM check says ok, a message
// says deferred) and never picks a colour.
var statusClass = map[string]string{
"ok": "tag is-success is-light", "published": "tag is-success is-light",
"running": "tag is-success is-light", "sent": "tag is-success is-light",
"accept": "tag is-success is-light",
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
"quiet": "tag is-warning is-light", "tempfail": "tag is-warning is-light",
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
"reject": "tag is-danger is-light",
// A message refused under a level-2 limit, beside its two siblings that
// are red (audit 2026-10-12, L18).
"rate-limited": "tag is-danger is-light",
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
"global": "tag is-primary is-light",
}
// neutralTag is a status without a colour: unknown, off, a plain count.
const neutralTag = "tag is-light"
// StatusTag returns the Bulma classes of a status tag (template function
// status_tag). A status the table does not know — "off", "unknown", a count, a
// word a page invented — is neutral rather than an error: a tag that is merely
// grey is a visible prompt to add the word to statusClass (and to the kit page),
// where a failing render would take the whole page down for it. Case and
// surrounding space do not matter.
func StatusTag(status string) string {
if c, ok := statusClass[strings.ToLower(strings.TrimSpace(status))]; ok {
return c
}
return neutralTag
}
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
// an address in a table cell breaks where it reads naturally, so the column can
// shrink without splitting a name mid-word. Go 1.26, text/template funcs.go
// HTMLEscape(): HTMLEscapeString escapes < > & ' " and NUL, so the only markup
// in the result is the <wbr> added here.
func wbrAt(s string) template.HTML {
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
}
// Head is the input of page_head, which opens every page: the line above the
// title (a section name or the path to this page), the title, a lead or a route,
// the postmark when the page gives a verdict, a note on the right (when it was
// updated) and the page's buttons.
type Head struct {
Postmark *Postmark
// Kicker is a plain word or two above the title ("Outbound"); Crumbs
// replace it with the trail to this page.
Kicker string
Crumbs []Crumb
Title string
// Code follows the title in the mono face ("New password for prod-server");
// Mono sets the whole title in it; Compact is the smaller h1 of a page
// whose title is data (a subject line).
Code string
Mono bool
Compact bool
// Lead is the sentence under the title. A Route takes its place on the page
// of one message.
Lead Text
Route Route
Note []string // right-hand note, one line each
Actions []Action
// ID and OOB make the head replaceable by a polled fragment: ID is the
// element's id, and OOB (set on the fragment's copy only, never on the page)
// marks it to be swapped into the page by that id. Used by Overview, whose
// stamp and sentence follow its cards. Both are attributes, not markup.
ID string
OOB bool
}
// Crumb is one step of a Head's trail; a step without Href is plain text (the
// level that has no page of its own).
type Crumb struct {
Href string
Label string
}
// Action is a button in a Head or a Foot: a link to Href, or a POST form
// submitting to Post. Icon is a Tabler class ("ti-refresh"). Confirm, set on a
// POST that deletes something or ends a working password, is the question
// panel.js asks before the form is sent (data-confirm); it adds an attribute and
// no markup.
type Action struct {
Label string
Icon string
Href string
Post string
Confirm string
Primary bool
Danger bool
}
// RowAction is an action that changes something, in the last cell of a table
// row (partial row_action; used by the Applications table of a domain). A link
// cannot POST, so it is a form with one button that the stylesheet makes read as
// the links beside it: Post is the address it submits to, Confirm the question
// panel.js asks first (data-confirm), Danger the red of a delete.
type RowAction struct {
Label string
Post string
Confirm string
Danger bool
}
// Postmark is the round verdict stamp inside a Head: three lines of imprint
// and the Level (ok, warn or fail) that colours it.
type Postmark struct {
Top string
Word string
Bottom string
Level string
}
// RoutePart is one piece of a Route: a plain value, an arrow, or a tag.
type RoutePart struct {
Text string
Arrow bool
Tag *Tag
}
// Route is the from → to line under the title of a message, with its status;
// partial "route", used in a Head and in the body of a box.
type Route []RoutePart
// Flash is the input of flash: the result of the last action, between the page
// head and the first box. Error makes it the red one; Text may open with a Strong.
type Flash struct {
Error bool
Text Text
}
// Box is the input of box_open and box_head: a box and its numbered mono head.
// No is "01"; Icon replaces it when the box is not part of a sequence. End is
// the slot at the right of the head (a note, a tag, a link) and Filter takes it
// instead as a filter form. Help links the section's topic on the Help page.
// ID is the anchor a side menu points at. Variant is BoxDanger or BoxCredential.
// 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. 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
Title string
ID string
End Text
Filter *Filter
Help *HelpLink
Variant string
Poll string
// OOB marks the box to be swapped into the page by its ID when it comes
// in a polled fragment beside the element that asked for the fragment.
// htmx 2.0.4, src/htmx.js swap(): the response replaces only the target;
// findAndSwapOobElements() takes every hx-swap-oob element out of it,
// nested ones too (allowNestedOobSwaps defaults true), and oobSwap()
// with "true" swaps it outerHTML over the element of its id.
OOB bool
// DomainPick: see Box.
DomainPick bool
// InCards is a box of the cards layout, which orders its boxes on a
// phone by their numbers (Order).
InCards bool
}
// Order is a cards box's number as the layout orders it on a phone
// (data-order, panel.css: the boxes come in the order of their numbers
// whichever stack they stand in): "3" for "03", empty for a box with no
// number or outside the layout. Before, the stylesheet ordered by a box's place in its stack and
// knew four to a stack; a fifth landed out of place with nothing failing
// (owner, 2026-10-13).
func (b Box) Order() string {
if !b.InCards {
return ""
}
n, err := strconv.Atoi(b.No)
if err != nil || n < 1 {
return ""
}
return strconv.Itoa(n)
}
// HelpLink is a Box's link to its Help topic.
type HelpLink struct {
Href string
Title string
}
// Filter is the input of filter_form, the filter of a list in a box head: it is
// sent with GET to Action.
type Filter struct {
Action string
Selects []Select
Dates []DateInput
Button string // "Filter" when empty
}
// Select is one select of a Filter. Label is its accessible name.
type Select struct {
Name string
Label string
Options []Option
Selected string
}
// Option is one choice of a Select; an empty Value means "any".
type Option struct {
Value string
Label string
}
// DateInput is one date field of a Filter.
type DateInput struct {
Name string
Label string
Value string
}
// Foot is the input of box_foot, the line under a box's content: Text (a legend
// or "Page 1 of 9"), a Link beside it ("Older →"), an End slot at the right, or
// instead of all of it one Button that finishes the page. Bare puts Text
// directly in the foot instead of in a span, as the legends under tables do.
// ID and OOB make the foot replaceable by a polled fragment, as they do on Head:
// ID is the element's id, and OOB (set on the fragment's copy only, never on the
// page) marks it to be swapped into the page by that id. Used by the Outbound
// log, whose page count follows its rows. Both are attributes, not markup.
type Foot struct {
Text Text
Bare bool
Link *Anchor
End Text
Button *Action
ID string
OOB bool
}
// Anchor is a plain link: where it goes and what it says.
type Anchor struct {
Href string
Label string
}
// HealthCard is the input of health_card and, as a slice, of health_cards: one
// tile of the Overview grid. Name is the check, Value its verdict in words, Sub
// one supporting fact; Level is ok, warn or fail; Href is its detail.
type HealthCard struct {
Name string
Value string
Sub string
Icon string
Href string
Level string
}
// Record is the input of dns_record: one DNS record. Host and Type say where
// and what, Value is what to publish (a textarea of ValueRows lines when it is
// long, as a DKIM key is), InDNS what the resolver returned — set only on a
// mismatch, with Problem saying what is wrong and the fix.
type Record struct {
Name string
Status Tag
Note Text
Host string
Type string
Value string
ValueRows int
ValueHelp Text
InDNS string
Problem Text
}
// Folded says the record is drawn folded to its head: DNS holds it as shown,
// so its values are of use only to copy again. A record that is missing,
// weak, wrong or not checked stays open.
func (r Record) Folded() bool { return r.Status.Status == "published" && r.InDNS == "" }
// HostField is the Copy field of the record's host.
func (r Record) HostField() CopyField { return CopyField{Label: "Host / name", Value: r.Host} }
// ValueField is the Copy field of what to publish.
func (r Record) ValueField() CopyField {
return CopyField{Label: "Value to publish", Value: r.Value, Rows: r.ValueRows, Help: r.ValueHelp}
}
// CopyField is the input of copy_field: a read-only value with its Copy button.
// Rows makes it a textarea; Help sits under it. Used in a dns_record and, in a
// record_open block, for a secret shown once.
type CopyField struct {
Label string
Value string
Rows int
Help Text
// Secret marks a value shown once: panel.js empties the field when the
// page is left, so a page the browser restores from its back/forward
// cache does not show it again.
Secret bool
}
// Fact is the input of facts (as a slice): a label and its value. Mono sets the
// value in the mono face; Big is a headline number, with Note as the line under it.
type Fact struct {
Label string
Value Text
Mono bool
// Small sets a mono value in the supporting size, for long identifiers and
// addresses that would otherwise wrap in a narrow column (the report id and
// rua of a DMARC report).
Small bool
Big bool
Note Text
// Wide takes the whole row of the grid (sp-wide): a line a server wrote,
// read as one - Postfix's greeting.
Wide bool
// LabelTag is a verdict on the value, beside the label: the
// certificate's names against the hostname.
LabelTag *Tag
}
// SideMenu is the input of side_menu, the left column of a detail page: groups
// of entries with icons and count tags.
type SideMenu struct {
Groups []MenuGroup
}
// MenuGroup is a labelled list of a SideMenu.
type MenuGroup struct {
Label string
Items []MenuItem
}
// MenuItem is one entry of a SideMenu. Active marks the section on screen;
// Danger is the entry that deletes; Tag is a count.
type MenuItem struct {
Label string
Icon string
Href string
Active bool
Danger bool
Tag *Tag
}
// Step is one entry of a timeline (as a slice): a time, a strong word and the
// rest of the sentence. Level is "", warn, fail or pending.
type Step struct {
Time string
Strong string
Text Text
Level string
}
// LogLine is one line of a log_pane (as a slice). Level is "", warn or error;
// Time is the dimmed prefix when the line has one.
type LogLine struct {
Time string
Text string
Level string
}
// EmptyState is the input of empty_state: what is absent and why that is fine.
type EmptyState struct {
Icon string
Text Text
}
// HelpTopic is the input of help_topic: one topic of the Help page, a heading
// and its paragraphs. ID is the anchor the boxes' help links point at.
type HelpTopic struct {
ID string
Title string
Body []Text
}
// Meta is what a page says about itself to the shell. A page's data embeds it
// (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
// (domains, log, queue, dmarc, health, backup, users, settings…): the first
// highlights the menu, the second the entry and the sibling strip. A domain
// administrator's reach is HasOutbound and HasInbound: whether anything of that
// direction is assigned to them (ignored for the global role, who has it all).
type Meta struct {
Title string
User string
IsGlobal bool
HasOutbound bool
HasInbound bool
Section string
Page string
}
// ShellMeta lets a page's data that embeds Meta be read by the engine.
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 it; anything
// else has none, and is shown as the signed-out screen.
func metaOf(data any) Meta {
if d, ok := data.(shellMeta); ok {
return d.ShellMeta()
}
return Meta{}
}
// Shell is the input of layout.html: what the navbar, the sibling strip and the
// footer show. The menu's paths are in the template; whether an entry is there
// is decided here, once, from the role and the feature flags (plan § Who sees
// what): Overview and Server only for the global role, Queue likewise (a queue
// is not filtered by domain), Inbound only when the feature is on and there is
// something in it for this user, DMARC only when it is on.
type Shell struct {
Title string
User string // empty: signed out, no navigation
Section string
Page string
Home string // where the wordmark leads
Version string
Copyright string
SourceURL string
ShowOverview bool
ShowOutbound bool
ShowQueue bool
ShowDMARC bool
ShowInbound bool
ShowServer bool
// ShowFilterLists is the Filter lists entry of Inbound: the lists are the
// instance's, so only the global role has them.
ShowFilterLists bool
}
// Frame is what layout.html is executed with: the shell and the page's own
// data, which the page's "content" template receives.
type Frame struct {
Shell Shell
Page any
}
// shell derives the Shell for a page from its Meta and the engine's flags.
func (e *Engine) shell(m Meta, copyright, sourceURL string) Shell {
s := Shell{
Title: m.Title, User: m.User, Section: m.Section, Page: m.Page,
Version: e.version, Copyright: copyright, SourceURL: sourceURL,
ShowOverview: m.IsGlobal,
ShowServer: m.IsGlobal,
ShowQueue: m.IsGlobal,
ShowOutbound: m.IsGlobal || m.HasOutbound,
ShowInbound: e.inboundEnabled && (m.IsGlobal || m.HasInbound),
ShowFilterLists: e.inboundEnabled && m.IsGlobal,
}
s.ShowDMARC = e.dmarcEnabled && s.ShowOutbound
switch {
case s.ShowOverview:
s.Home = "/overview"
case s.ShowOutbound:
s.Home = "/outbound/domains"
case s.ShowInbound:
s.Home = "/inbound/domains"
default:
s.Home = "/account"
}
return s
}