diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1e84c..11804bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [1.8.0] - 2026-08-18 + +Built-in operator documentation in the panel: a CSS-only help drawer and Help +page so Status checks and domain controls are explained without leaving the +panel. + +### Added + +- **Help** navigation entry and `/help` index page. +- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine, + TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records, + DMARC, connection, applications, settings, export). +- «?» entry points on Status and domain card heads open the matching drawer + topic. + +### Changed + +- [guide.md](docs/guide.md) documents the boundary: in-panel help for card + meaning; the repository guide for installation, env vars, and operations. + ## [1.7.0] - 2026-08-18 Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e5d100a..6c9ecb1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.7.0 + image: ghcr.io/mixeme/selfpost:1.8.0 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/guide.md b/docs/guide.md index d534add..9e0b4ec 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -311,6 +311,18 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals. After sign-in the panel opens on **Status** — the place to answer "is the service healthy and will mail be accepted?" +### In-panel help + +The panel carries short operator notes so day-to-day work does not require +opening this guide. **Help** in the navigation opens an index; each Status or +domain card's «?» opens the same text in a side drawer (CSS only, no extra +script). That content explains what a check or control *means* — kernel +counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate +limits, and similar. It is not a second copy of this document: installation, +reverse-proxy setup, backup/restore, environment variables, and security +assumptions stay here in `docs/guide.md` (linked from the drawer and Help +page). + ### Status `/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS diff --git a/docs/plans/panel-docs.md b/docs/plans/panel-docs.md index dac8014..3250079 100644 --- a/docs/plans/panel-docs.md +++ b/docs/plans/panel-docs.md @@ -1,6 +1,6 @@ # Plan: panel-docs (in-panel operator documentation) -**Status:** candidate +**Status:** agreed **Date:** 2026-08-17 **Version:** `1.x` MINOR; `candidate` until explicitly agreed. @@ -46,9 +46,9 @@ Target version cut: **`1.8.0`** (MINOR). One commit per step; code only after roadmap status is **agreed**. See [development.md](../development.md) § Plan checklists. -- [ ] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet** -- [ ] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet** -- [ ] «?» entry points on domain cards — **Sonnet** -- [ ] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet** -- [ ] Template tests — **Sonnet** -- [ ] `go vet`, `go test` on touched packages — **Haiku** +- [x] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet** +- [x] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet** +- [x] «?» entry points on domain cards — **Sonnet** +- [x] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet** +- [x] Template tests — **Sonnet** +- [x] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/roadmap.md b/docs/roadmap.md index 6f58a0f..4f85c3f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,7 +29,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Progress | Plan | |---|---|---|---|---| | contributing | `CONTRIBUTING.md` | candidate | — | — | -| panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) | +| panel-docs | In-panel operator documentation | agreed | 6/6 | [plans/panel-docs.md](plans/panel-docs.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | **Recommended order** (not binding): the next feature is **panel-docs** once diff --git a/internal/web/handlers/handlers_help.go b/internal/web/handlers/handlers_help.go new file mode 100644 index 0000000..d8c8dbf --- /dev/null +++ b/internal/web/handlers/handlers_help.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "net/http" + + "github.com/mixeme/selfpost/internal/web/auth" +) + +func (h *Handlers) HandleHelp(w http.ResponseWriter, r *http.Request) { + p, ok := h.principal(r) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + data := map[string]any{ + "Title": "SelfPost — help", + "User": auth.CurrentUser(r), + "Active": "help", + "IsGlobal": p.IsGlobal(), + } + h.view.Render(w, http.StatusOK, "help", data) +} diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 65b1747..cfae068 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -756,3 +756,71 @@ button.copy { flex: none; } .encrypt .encrypt-fields { margin-top: 0; } .encrypt-fields label { margin-top: 0.7rem; } .encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; } + +/* Screen-reader accessible controls that drive UI state (help drawer panes). */ +.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + +.page-head { + display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; + gap: 0.5rem 1rem; margin-bottom: 1rem; +} +.page-head h1 { margin: 0; } + +.card-head { + display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; + gap: 0.5rem 0.75rem; margin-bottom: 0.7rem; +} +.card-head h2 { margin: 0; } + +.help-link { + flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; + justify-content: center; border-radius: 50%; border: 1px solid var(--control-border); + color: var(--muted); text-decoration: none; font-family: var(--font-mono); + font-size: 0.75rem; font-weight: 600; background: var(--card-bg); cursor: pointer; margin: 0; +} +.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); } + +/* Help drawer — CSS checkbox/radio, no script required. */ +.help-drawer { + display: none; position: fixed; top: 0; right: 0; bottom: 0; + width: min(26rem, 100vw); z-index: 30; + background: var(--card-bg); border-left: 1px solid var(--border); + padding: 1.25rem 1.35rem 2rem; overflow-y: auto; +} +html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer, +html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; } +.help-pane { display: none; } +html:has(#help-index:checked) .help-pane-index, +html:has(#help-status:checked) .help-pane-status, +html:has(#help-password:checked) .help-pane-password, +html:has(#help-dns:checked) .help-pane-dns, +html:has(#help-records:checked) .help-pane-records, +html:has(#help-dmarc:checked) .help-pane-dmarc, +html:has(#help-connection:checked) .help-pane-connection, +html:has(#help-apps:checked) .help-pane-apps, +html:has(#help-domain-settings:checked) .help-pane-domain-settings, +html:has(#help-export:checked) .help-pane-export { display: block; } +.help-scrim { + display: none; position: fixed; inset: 0; z-index: 25; + background: rgba(18, 22, 28, 0.28); cursor: pointer; +} +.help-drawer h2 { margin-top: 1.2rem; } +.help-drawer h2:first-of-type, +.help-pane h2 { margin-top: 0; } +.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; } +.help-drawer .toc li { margin: 0.35rem 0 0; } +.help-drawer .toc label { + margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer; +} +.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; } +.help-drawer .more label { + display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer; +} +.help-close { + position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem; + font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg); + border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer; +} +@media (max-width: 66rem) { + .help-drawer { width: 100vw; } +} diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index d911bb3..e422802 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -67,7 +67,10 @@ {{if .NewCred}}
-

New application password

+
+

New application password

+ {{template "card_help" (dict "ID" "password" "Title" "Why this is shown once")}} +

Shown once only and not stored. Copy it now — if it is lost, regenerate a new one.

@@ -85,7 +88,10 @@ {{/* Two rows of two checks (.check-cols). */}}
-

DNS status {{.DNS.Overall}}

+
+

DNS status {{.DNS.Overall}}

+ {{template "card_help" (dict "ID" "dns" "Title" "What these checks mean")}} +

Cached a few minutes — use Re-check after publishing.

@@ -141,7 +147,10 @@
-

DKIM and SPF records

+
+

DKIM and SPF records

+ {{template "card_help" (dict "ID" "records" "Title" "What to publish")}} +

DKIM

{{template "host_type_copy" .Record.Name}} @@ -159,7 +168,10 @@
-

DMARC record

+
+

DMARC record

+ {{template "card_help" (dict "ID" "dmarc" "Title" "What this record does")}} +
{{template "host_type_copy" .DMARCName}} @@ -191,7 +203,10 @@
-

Connection settings

+
+

Connection settings

+ {{template "card_help" (dict "ID" "connection" "Title" "How to connect")}} +

Same for every domain. Authenticate with an application login from below.

@@ -262,7 +277,10 @@
-

Applications

+
+

Applications

+ {{template "card_help" (dict "ID" "apps" "Title" "What an application is")}} +

SASL logins for this domain — wildcard (*@domain) or a fixed address list.

@@ -376,7 +394,10 @@
-

Domain settings

+
+

Domain settings

+ {{template "card_help" (dict "ID" "domain-settings" "Title" "Reports and rate limits")}} +
@@ -464,7 +485,10 @@
-

Export domain

+
+

Export domain

+ {{template "card_help" (dict "ID" "export" "Title" "What the file contains")}} +

Secret file — transfer securely, or encrypt below as .spde.

{{if .ExportErr}}

{{.ExportErr}}

{{end}} diff --git a/internal/web/view/templates/help.html b/internal/web/view/templates/help.html new file mode 100644 index 0000000..a586de6 --- /dev/null +++ b/internal/web/view/templates/help.html @@ -0,0 +1,62 @@ +{{define "content"}} +

Help

+ +

Short operator notes inside the panel — not a second copy of +the full guide. The cards keep their readings and controls; this page (and the +drawer opened from each card's «?») holds the explanations that do not belong +on the card itself.

+ +
+

On this panel

+ {{if .IsGlobal}} +
    +
  • — Machine, TLS, PTR, + queue
  • +
  • Mail queue retries — time-based, no attempt + budget (on the queue page)
  • + {{if .InboundEnabled}} +
  • Inbound — not mailboxes; listed recipients or any + address at the domain
  • + {{end}} +
+ {{end}} +

Domain page

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+

Topics marked as labels open the same text in the help + drawer. For installation, backup, and environment variables see the + operator + guide in the repository.

+
+ +{{if .IsGlobal}} +
+

Machine

+

CPU and memory are the container's own readings, not the host's spare + capacity. Network is a short window, not a daily total. High CPU with an empty + queue usually means something else on the box — not SelfPost being slow to + send.

+
+ +
+

TLS certificate

+

Port 465 presents the certificate the reverse proxy (or the image) + mounted. The panel does not issue certificates. A warn here is expires soon; + an error is missing or unreadable, and clients will refuse submission.

+
+ +
+

Hostname / reverse DNS

+

Forward-confirmed reverse DNS: the A/AAAA for + SELFPOST_HOSTNAME must reverse to that same name. PTR is set at + the hosting provider, not in this panel.

+
+{{end}} +{{end}} diff --git a/internal/web/view/templates/help_drawer.html b/internal/web/view/templates/help_drawer.html new file mode 100644 index 0000000..92c2a87 --- /dev/null +++ b/internal/web/view/templates/help_drawer.html @@ -0,0 +1,157 @@ +{{/* Hidden radios drive which help pane is visible — CSS :has(), no script. */}} +{{define "help_inputs"}} + + + + + + + + + + + +{{end}} + +{{define "help_more"}}

{{if .}} · Help page{{end}}

{{end}} + +{{/* card_help — «?» on a card head; .ID is the topic slug (dns, records, …). */}} +{{define "card_help"}} + +{{end}} + +{{define "help_drawer"}} + + +{{end}} diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index 5ce8f93..769c7d1 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -22,6 +22,7 @@ +{{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. */}} @@ -50,6 +51,7 @@
+{{if .User}}{{template "help_drawer" .}}{{end}} {{end}} @@ -76,6 +78,7 @@ {{if .DMARCEnabled}} {{if eq .Active "dmarc"}}{{template "icon-dmarc"}}DMARC{{else}}{{template "icon-dmarc"}}DMARC{{end}} {{end}} + {{if eq .Active "help"}}{{template "icon-help"}}Help{{else}}{{template "icon-help"}}Help{{end}} {{if eq .Active "deliveries"}}{{template "icon-deliveries"}}Deliveries{{else}}{{template "icon-deliveries"}}Deliveries{{end}} {{if .IsGlobal}} {{if eq .Active "mail_queue"}}{{template "icon-mail-queue"}}Mail queue{{else}}{{template "icon-mail-queue"}}Mail queue{{end}} @@ -120,6 +123,7 @@ {{define "icon-domains"}}{{end}} {{define "icon-inbound"}}{{end}} {{define "icon-dmarc"}}{{end}} +{{define "icon-help"}}{{end}} {{define "icon-deliveries"}}{{end}} {{define "icon-mail-queue"}}{{end}} {{define "icon-system-log"}}{{end}} diff --git a/internal/web/view/templates/status.html b/internal/web/view/templates/status.html index 109b14f..70b82cc 100644 --- a/internal/web/view/templates/status.html +++ b/internal/web/view/templates/status.html @@ -3,7 +3,10 @@ {{define "wide"}}wide{{end}} {{define "content"}} -

Status

+
+

Status

+ +
{{if .Flash}}
{{.Flash}}
{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 6d38041..db84552 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -569,9 +569,6 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) { } } -// The retry-policy card is static HTML on mail_queue (outside the HTMX -// fragment), so rendering the page with a fixture must print those strings -// rather than falling back to empty template fields. func TestMailQueuePageRendersRetryPolicy(t *testing.T) { engine, err := New("test") if err != nil { @@ -604,3 +601,81 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) { t.Error("RetryFromDefaults was unset; the fallback note should stay off") } } + +func TestAuthenticatedLayoutIncludesHelpDrawer(t *testing.T) { + engine, err := New("test") + if err != nil { + t.Fatalf("New: %v", err) + } + var buf bytes.Buffer + if err := engine.Page("help").ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "User": "admin", "Active": "help", "IsGlobal": true, + }); err != nil { + t.Fatalf("execute help layout: %v", err) + } + out := buf.String() + for _, want := range []string{ + `id="help-off"`, `class="help-drawer"`, `help-pane-status`, + `for="help-dns"`, `href="/help"`, + } { + if !strings.Contains(out, want) { + t.Errorf("authenticated layout missing %q", want) + } + } +} + +func TestLoginPageOmitsHelpDrawer(t *testing.T) { + engine, err := New("test") + if err != nil { + t.Fatalf("New: %v", err) + } + var buf bytes.Buffer + if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "Active": "", + }); err != nil { + t.Fatalf("execute login: %v", err) + } + if strings.Contains(buf.String(), "help-drawer") { + t.Error("login page should not include the help drawer") + } +} + +func TestStatusPageHasHelpEntry(t *testing.T) { + out := renderStatusPage(t, statusPageData()) + if !strings.Contains(out, `for="help-status"`) { + t.Error("status page is missing the help entry point") + } +} + +func TestDomainDetailHasHelpOnCards(t *testing.T) { + body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html") + if err != nil { + t.Fatalf("read domain_detail: %v", err) + } + src := string(body) + for _, want := range []string{ + `"ID" "dns"`, `"ID" "records"`, `"ID" "dmarc"`, `"ID" "connection"`, + `"ID" "apps"`, `"ID" "domain-settings"`, `"ID" "export"`, + `card-head`, + } { + if !strings.Contains(src, want) { + t.Errorf("domain_detail missing %q", want) + } + } +} + +func TestNavIncludesHelp(t *testing.T) { + engine, err := New("test") + if err != nil { + t.Fatalf("New: %v", err) + } + var buf bytes.Buffer + if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{ + "User": "admin", "Active": "domains", "IsGlobal": true, + }); err != nil { + t.Fatalf("execute nav: %v", err) + } + if !strings.Contains(buf.String(), `href="/help"`) { + t.Error("nav is missing Help link") + } +} diff --git a/internal/web/view/view.go b/internal/web/view/view.go index a14c62a..1fb742a 100644 --- a/internal/web/view/view.go +++ b/internal/web/view/view.go @@ -54,6 +54,7 @@ var pageFiles = map[string][]string{ "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, "system_log": {"templates/system_log.html", "templates/system_log_body.html"}, "status": {"templates/status.html", "templates/status_body.html"}, + "help": {"templates/help.html"}, } // fragmentFiles maps a fragment name (also its {{define}} block name) to its @@ -73,7 +74,7 @@ func New(version string) (*Engine, error) { version: version, } for name, files := range pageFiles { - patterns := append([]string{"templates/layout.html"}, files...) + patterns := append([]string{"templates/layout.html", "templates/help_drawer.html"}, files...) tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...) if err != nil { return nil, fmt.Errorf("parse template %s: %w", name, err) @@ -104,6 +105,20 @@ func (e *Engine) SetDMARCEnabled(v bool) { // templateFuncs supplies helpers shared across page templates. func templateFuncs() template.FuncMap { return template.FuncMap{ + "dict": func(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict: odd argument count") + } + m := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %d is not a string", i) + } + m[key] = values[i+1] + } + return m, nil + }, // back builds the map back_link reads; keeps href and label paired at // the call site instead of repeating the markup. "back": func(href, label string) map[string]string { diff --git a/internal/web/web.go b/internal/web/web.go index 3e70f2e..787e3f8 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -159,6 +159,8 @@ func (s *Server) Handler() http.Handler { authed := http.NewServeMux() authed.HandleFunc("GET /{$}", redirectHome) + authed.HandleFunc("GET /help", h.HandleHelp) + authed.HandleFunc("GET /status", h.HandleStatus) authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment) authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)