test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
571 lines
18 KiB
Go
571 lines
18 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"
|
|
"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.
|
|
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",
|
|
|
|
"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.
|
|
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.
|
|
OOB bool
|
|
// DomainPick: see Box.
|
|
DomainPick bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Facts is a shorthand for plain label / value pairs: Facts("Domain",
|
|
// "example.org", "Port", "587").
|
|
func Facts(pairs ...string) []Fact {
|
|
var out []Fact
|
|
for i := 0; i+1 < len(pairs); i += 2 {
|
|
out = append(out, Fact{Label: pairs[i], Value: Plain(pairs[i+1])})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
}
|