diff --git a/CHANGELOG.md b/CHANGELOG.md
index 580cc3e..bb96c0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -113,6 +113,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the help drawer is gone. The user form sets the user's e-mail. The backup
download is encrypted unless the box is unticked. Only the queue page is
left on the ratchet.
+- panel-redesign (stage 2): the **Outbound queue** page is in the new shell
+ with its content unchanged - the listing of `postqueue -p` and how
+ retries work. With it every page is built from the component kit: the old
+ stylesheet, the old layout and the engine's second rendering path are
+ deleted, and the ratchet is empty.
### Changed
diff --git a/docs/assets/panel-redesign/evidence/out-queue.png b/docs/assets/panel-redesign/evidence/out-queue.png
new file mode 100644
index 0000000..d574963
Binary files /dev/null and b/docs/assets/panel-redesign/evidence/out-queue.png differ
diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md
index 3118c8c..6098cbd 100644
--- a/docs/plans/panel-redesign.md
+++ b/docs/plans/panel-redesign.md
@@ -519,8 +519,8 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [x] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
- [x] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
-- [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
-- [ ] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
+- [x] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
+- [x] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
**Stage 3 — reviews, docs, 2.0.0**
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 02ea499..d646782 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
-| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 21/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 23/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
diff --git a/internal/web/handlers/authz.go b/internal/web/handlers/authz.go
index a2d088e..4d3c337 100644
--- a/internal/web/handlers/authz.go
+++ b/internal/web/handlers/authz.go
@@ -22,17 +22,6 @@ func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.P
return p, true
}
-func (h *Handlers) pageBase(r *http.Request) map[string]any {
- p, _ := h.principal(r)
- return map[string]any{
- "User": auth.CurrentUser(r),
- "IsGlobal": p.IsGlobal(),
- // Which groups the menu shows this user (plan § Who sees what).
- "HasOutbound": p.HasOutbound(),
- "HasInbound": p.HasInbound(),
- }
-}
-
// shellMeta is what the shell of a page of the component kit needs to know
// about the person looking at it: their name and which groups of the menu they
// reach. The page's constructor adds its title and where it sits in the menu.
diff --git a/internal/web/handlers/handlers_account_test.go b/internal/web/handlers/handlers_account_test.go
index 1f2ba28..f266f02 100644
--- a/internal/web/handlers/handlers_account_test.go
+++ b/internal/web/handlers/handlers_account_test.go
@@ -184,9 +184,6 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
t.Errorf("account page missing %q", want)
}
}
- if strings.Contains(out, "/static/legacy.css") {
- t.Error("account page loads the old stylesheet")
- }
}
// A refused form is shown again with the error above the boxes and what the
diff --git a/internal/web/handlers/handlers_components_test.go b/internal/web/handlers/handlers_components_test.go
index 8dadd3e..6ac9c94 100644
--- a/internal/web/handlers/handlers_components_test.go
+++ b/internal/web/handlers/handlers_components_test.go
@@ -50,7 +50,4 @@ func TestComponentsPageRendersTheKitForTheGlobalRole(t *testing.T) {
t.Errorf("the kit page is missing %q", want)
}
}
- if strings.Contains(out, "/static/legacy.css") {
- t.Error("the kit page loads the old stylesheet")
- }
}
diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go
index 6deebfc..5d68b66 100644
--- a/internal/web/handlers/handlers_monitor.go
+++ b/internal/web/handlers/handlers_monitor.go
@@ -14,7 +14,6 @@ import (
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
- "github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
@@ -333,26 +332,15 @@ func parsePage(v string) int {
return n
}
-// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
+// HandleMailQueue renders the Outbound queue page (architecture.md § Panel HTTP
// surface).
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
- out, errText := readQueue()
policy := h.cfg.RetryPolicy
- h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
- "Title": "SelfPost — mail queue",
- "User": auth.CurrentUser(r),
- "Active": "mail_queue",
- "IsGlobal": true,
- "Output": out,
- "Error": errText,
- "FirstRetry": policy.FirstRetry(),
- "BackoffCap": policy.BackoffCap(),
- "QueueLifetime": policy.QueueLifetime(),
- "RetryFromDefaults": policy.FromDefaults,
- })
+ h.view.Render(w, http.StatusOK, "out-queue", h.outQueuePage(r).
+ WithRetryPolicy(policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime(), policy.FromDefaults))
}
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
@@ -360,11 +348,14 @@ func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
+ h.view.RenderFragment(w, http.StatusOK, "out_queue_body", h.outQueuePage(r))
+}
+
+// outQueuePage reads the queue and gives it to the view; the page and its
+// fragment draw the same box from it.
+func (h *Handlers) outQueuePage(r *http.Request) *view.OutQueue {
out, errText := readQueue()
- h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
- "Output": out,
- "Error": errText,
- })
+ return view.NewOutQueue(h.shellMeta(r), out, errText)
}
// readQueue runs postqueue -p, returning a friendly message instead of the
diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go
index d000320..0a390c6 100644
--- a/internal/web/handlers/handlers_monitor_test.go
+++ b/internal/web/handlers/handlers_monitor_test.go
@@ -479,17 +479,17 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
}
}
-// The retry card sits on the page itself, outside the HTMX poll, and prints
+// The retry policy sits on the page itself, outside the HTMX poll, and prints
// whatever policy was cached on Config — never a live postconf.
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/outbound/queue")
for _, want := range []string{
- "How delivery retries work",
- "id=\"retry-policy\"",
+ "
How retries work
",
+ "This Postfix's policy, read at panel start",
">10 minutes<",
- "doubling, cap about 1 hour 7 minutes",
+ "capped at about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
@@ -499,7 +499,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
- if strings.Contains(out, "compiled-in defaults") {
+ if strings.Contains(out, "Compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
@@ -508,7 +508,10 @@ func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/outbound/queue/fragment")
- if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
+ if !strings.HasPrefix(out, `