panel-redesign: the queue in the new shell; the old design is removed
Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.
The queue
- out-queue, named after its mockup: today's content - the listing of
postqueue -p in a log pane, polled as before (GET
/outbound/queue/fragment) - and the "How retries work" facts. The
mockup's table of messages is the roadmap's queue-as-a-table; the
outline guard exempts this page for exactly that reason. Routes and
the global-only check are unchanged; the page has no actions and
never had.
- Inbound has no Queue entry and no route serves one.
The old design is gone
- Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
mail_queue.html, mail_queue_body.html.
- view.go has one path: every page is layout.html + components.html +
its own files; kitPages, kitFragments, the map-data branch of Render,
the dict and back template functions and metaOf's map branch are
removed. handlers: pageBase removed.
- legacy_pages.txt holds its header and no entry.
- panel.js: the old copy handler (button.copy in .code-row) is
removed. A show-when rule now applies to every element it matches,
and hiding adds Bulma's is-hidden beside the hidden attribute - a
.box or a grid row has a display of its own and stayed visible.
- The user form hides both domain lists again when the role is Global
(Box.DomainPick, an attribute; data-global-role on the select).
- Tests of the old layout are deleted; what they asserted about the
shell (version only signed in, the menu follows the user's reach,
Inbound only with the feature, Help present, the current entry
marked) is asserted on the kit pages, two tests added for it.
For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.
Evidence
- docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
headless Edge, the real template served locally with its fixture -
NOT from the container (no Docker on this machine).
- panel/audit.js on out-queue, user and backup at 1440 and 1280:
366 texts checked, problems: [].
- In a browser against the local fixture server: the encryption
fields of Backup, the manual / auto limit fields and the custom
report address of Domain settings show and hide with their control;
all 28 fixture pages answer 200 with panel.css, /static/legacy.css
answers 404.
- Not exercised: polling against a real postqueue (none on Windows).
- go test -count=1 ./internal/web/... ./cmd/panel
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
ok cmd/panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -26,16 +25,13 @@ func TestEveryPageResolvesNav(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The version comes from render(), not from each handler's data map, so the
|
||||
// footer is only correct as long as every page composes with the layout and
|
||||
// render keeps supplying the key. Both are asserted here rather than trusted.
|
||||
// Appropriate Legal Notices (copyright, licence, source, no warranty) must
|
||||
// appear on every page, including the signed-out ones.
|
||||
// The version and the legal lines come from Render, not from each handler's
|
||||
// data, so the footer is only correct as long as every page composes with the
|
||||
// layout. It is asserted on every page, not trusted. Appropriate Legal Notices
|
||||
// (copyright, licence, source, no warranty) appear on every page, signed out
|
||||
// ones included; the running version only on the signed-in ones.
|
||||
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
engine, err := New("9.9.9-test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
engine := kitEngine(t) // version 9.9.9-test
|
||||
legalBits := []string{
|
||||
"Copyright © 2026 Mikhail Yenuchenko",
|
||||
`href="/license"`,
|
||||
@@ -44,204 +40,31 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
"Source",
|
||||
"No warranty",
|
||||
}
|
||||
rendered := 0
|
||||
for name := range engine.Pages() {
|
||||
if kitPages[name] {
|
||||
continue // the kit layout has its own tests (components_test.go)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := engine.Page(name).ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
})
|
||||
if err != nil {
|
||||
// Pages whose content block needs more data than this cannot be
|
||||
// rendered here; the footer is in the shared layout, so one page
|
||||
// that does render proves it for all of them.
|
||||
continue
|
||||
}
|
||||
rendered++
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SelfPost 9.9.9-test") {
|
||||
t.Errorf("page %q does not show the version in the layout footer", name)
|
||||
}
|
||||
for _, want := range legalBits {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("page %q is missing legal notice %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rendered == 0 {
|
||||
t.Fatal("no page rendered, so the footer was never actually checked")
|
||||
}
|
||||
|
||||
// Signed out (login, setup) the version must not be advertised, but the
|
||||
// Appropriate Legal Notices must still be present.
|
||||
// Both signed-out pages go through Engine.Render, which supplies the
|
||||
// engine's version and the legal lines itself.
|
||||
signedOut := map[string]any{
|
||||
"login": NewLogin("mail.example.org", ""),
|
||||
"setup": NewSetup("token", ""),
|
||||
}
|
||||
for name, data := range signedOut {
|
||||
signedOut := map[string]bool{"login": true, "setup": true}
|
||||
for name, fixture := range pageFixtures {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, name, data)
|
||||
engine.Render(rec, http.StatusOK, name, fixture())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
out := rec.Body.String()
|
||||
if strings.Contains(out, "9.9.9-test") {
|
||||
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
|
||||
if signedOut[name] {
|
||||
if strings.Contains(out, "9.9.9-test") {
|
||||
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
|
||||
}
|
||||
} else if !strings.Contains(out, "SelfPost 9.9.9-test") {
|
||||
t.Errorf("page %q does not show the version in the layout footer", name)
|
||||
}
|
||||
for _, want := range legalBits {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s page is missing legal notice %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSuppliesTheVersion(t *testing.T) {
|
||||
engine, err := New("9.9.9-test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
data := map[string]any{"Title": "t", "User": "admin"}
|
||||
engine.Render(rec, http.StatusOK, "mail_queue", data)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := data["Version"]; got != "9.9.9-test" {
|
||||
t.Errorf("render did not supply Version (got %v)", got)
|
||||
}
|
||||
if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" {
|
||||
t.Errorf("render did not supply Copyright (got %v)", got)
|
||||
}
|
||||
if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" {
|
||||
t.Errorf("render did not supply SourceURL (got %v)", got)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "SelfPost 9.9.9-test") {
|
||||
t.Errorf("rendered page does not show the version:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") {
|
||||
t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavMarksActivePage(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// The label is checked apart from the opening tag because each entry now
|
||||
// carries an icon between the two.
|
||||
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
|
||||
t.Errorf("active page is not marked:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `href="/outbound/queue"`) {
|
||||
t.Errorf("active page still links to itself:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/outbound/log"`) {
|
||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
|
||||
t.Errorf("the status page is not marked active:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/outbound/domains"`) {
|
||||
t.Errorf("Domains does not link to /outbound/domains:\n%s", out)
|
||||
}
|
||||
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
||||
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `href="/inbound/domains"`) || strings.Contains(out, "Inbound") {
|
||||
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavShowsInboundWhenEnabled(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
engine.SetInboundEnabled(true)
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
"InboundEnabled": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `href="/inbound/domains"`) || !strings.Contains(out, "Inbound") {
|
||||
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
|
||||
}
|
||||
dom := strings.Index(out, `href="/outbound/domains"`)
|
||||
inb := strings.Index(out, `href="/inbound/domains"`)
|
||||
if dom < 0 || inb < 0 || inb < dom {
|
||||
t.Errorf("Inbound should follow Domains:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Whether a page takes the whole column or the reading measure is declared by
|
||||
// the page's own "wide" block (see layout_legacy.html), which the layout stamps into
|
||||
// <main>'s class list. A page that loses the block does not fail to render — it
|
||||
// silently comes back at the measure, with its table squeezed into two thirds
|
||||
// of the column — so the set is asserted here, in both directions.
|
||||
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
wide := map[string]bool{
|
||||
"mail_queue": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
if kitPages[name] {
|
||||
continue // "wide" belongs to the legacy layout; the kit's width comes from its columns
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
t.Fatalf("execute the wide block of %s: %v", name, err)
|
||||
}
|
||||
got := strings.TrimSpace(buf.String())
|
||||
switch {
|
||||
case wide[name] && got != "wide":
|
||||
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
|
||||
case !wide[name] && got != "":
|
||||
t.Errorf("page %q declares itself %q; only the pages that are tables of data, raw log lines or side-by-side cards take the whole column", name, got)
|
||||
// The help lives on the Help page: no page carries a drawer for it.
|
||||
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("page %q still carries the help drawer (%q)", name, gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,26 +91,6 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if !drillDown[name] {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(body, `template "back_link"`) {
|
||||
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
|
||||
}
|
||||
backIdx := strings.Index(body, `template "back_link"`)
|
||||
cardIdx := strings.Index(body, `class="card`)
|
||||
if cardIdx >= 0 && backIdx > cardIdx {
|
||||
t.Errorf("%s places the back link after the first card", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Since the panel root redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one.
|
||||
@@ -350,7 +153,7 @@ func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
|
||||
// server actually serves, so a typo in a /static path is a blocked request,
|
||||
// not a 404 in the page's own colours.
|
||||
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
|
||||
for _, file := range []string{"templates/layout_legacy.html", "templates/layout.html"} {
|
||||
for _, file := range []string{"templates/layout.html"} {
|
||||
body, err := fs.ReadFile(assetsFS, file)
|
||||
if err != nil {
|
||||
t.Fatalf("read layout: %v", err)
|
||||
@@ -383,84 +186,6 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
|
||||
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
|
||||
"SourceURL": "https://github.com/mixeme/selfpost",
|
||||
"FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes",
|
||||
"QueueLifetime": "2 days",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute mail_queue: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"How delivery retries work",
|
||||
"10 minutes",
|
||||
"about 1 hour 7 minutes",
|
||||
"2 days",
|
||||
`id="retry-policy"`,
|
||||
`hx-get="/outbound/queue/fragment"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("mail_queue is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "compiled-in defaults") {
|
||||
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
|
||||
}
|
||||
}
|
||||
|
||||
// The help drawer is gone from the old layout as well: a topic is a section of
|
||||
// the Help page, linked from the head of the box it explains, and the Help
|
||||
// entry of the navigation leads there.
|
||||
func TestAuthenticatedLayoutHasNoHelpDrawer(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "mail_queue", "IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute legacy layout: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `href="/help"`) {
|
||||
t.Error("the authenticated layout has no link to the Help page")
|
||||
}
|
||||
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("the authenticated layout still carries the help drawer (%q)", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPageOmitsHelpDrawer(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
for name, data := range map[string]any{
|
||||
"login": NewLogin("", ""),
|
||||
"setup": NewSetup("token", ""),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, name, data)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "help-drawer") {
|
||||
t.Errorf("%s page should not include the help drawer", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The help drawer is gone from the pages of the kit: Overview points at the
|
||||
// section of the Help page that explains its checks, from the head of its box.
|
||||
func TestOverviewLinksToItsHelpTopic(t *testing.T) {
|
||||
@@ -472,62 +197,3 @@ func TestOverviewLinksToItsHelpTopic(t *testing.T) {
|
||||
t.Error("a page of the kit carries the old help drawer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavIncludesHelp(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin", "Active": "domains", "IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `href="/help"`) {
|
||||
t.Error("nav is missing Help link")
|
||||
}
|
||||
}
|
||||
|
||||
// A group with nothing assigned is absent from that user's menu: a domain
|
||||
// administrator who was given inbound domains only sees Inbound and none of the
|
||||
// sending pages, and the mark takes them to what they have.
|
||||
func TestLegacyNavFollowsTheUsersReach(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
nav := func(data map[string]any) string {
|
||||
t.Helper()
|
||||
data["User"], data["Active"] = "ops", ""
|
||||
data["InboundEnabled"], data["DMARCEnabled"] = true, true
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", data); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
has := func(out, href string) bool { return strings.Contains(out, `href="`+href+`"`) }
|
||||
|
||||
out := nav(map[string]any{"HasInbound": true})
|
||||
if !has(out, "/inbound/domains") {
|
||||
t.Errorf("an inbound-only administrator has no Inbound entry:\n%s", out)
|
||||
}
|
||||
for _, gone := range []string{"/outbound/domains", "/outbound/log", "/outbound/dmarc", "/overview", "/server/users", "/outbound/queue"} {
|
||||
if has(out, gone) {
|
||||
t.Errorf("an inbound-only administrator is offered %s", gone)
|
||||
}
|
||||
}
|
||||
|
||||
out = nav(map[string]any{"HasOutbound": true})
|
||||
if has(out, "/inbound/domains") || !has(out, "/outbound/domains") || !has(out, "/outbound/log") || !has(out, "/outbound/dmarc") {
|
||||
t.Errorf("an outbound-only administrator's menu is wrong:\n%s", out)
|
||||
}
|
||||
|
||||
out = nav(map[string]any{"IsGlobal": true})
|
||||
for _, want := range []string{"/overview", "/outbound/domains", "/inbound/domains", "/outbound/dmarc", "/outbound/log", "/server/users"} {
|
||||
if !has(out, want) {
|
||||
t.Errorf("the global role lost %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user