release: 0.3.0: PTR fix via public resolvers; monitoring URLs and nav icons

panel: the PTR (reverse DNS) check no longer reports a correctly published
Fixed:
  - panel: the PTR (reverse DNS) check no longer reports a correctly published
Changed:
  - panel: the three monitoring pages now live at URLs that match their nav
  - panel: each entry in the navigation bar now carries an icon beside its label,
  - panel: the navigation bar is laid out as two rows on purpose — the signed-in

Full detail: CHANGELOG.md (0.3.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 981b95718e
commit af9f89b628
25 changed files with 412 additions and 84 deletions
+27 -26
View File
@@ -19,33 +19,34 @@ const (
logTailLines = 200
)
// handleSendLog renders the send-log monitoring page: server-side filters by
// domain/application and pagination (spec 7.3.3). The row table itself is the
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
// initial page and its HTMX-polled refreshes never diverge.
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
// handleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (spec 7.3.3). The row table
// itself is the "deliveries_rows" fragment, shared verbatim with
// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
// diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["Title"] = "SelfPost — send log"
data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r)
data["Active"] = "sendlog"
s.render(w, http.StatusOK, "sendlog", data)
data["Active"] = "deliveries"
s.render(w, http.StatusOK, "deliveries", data)
}
// handleSendLogRows serves the HTMX polling fragment for the send-log table
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (spec 7.1: fragment endpoints return HTML, not JSON).
func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderFragment(w, http.StatusOK, "sendlog_rows", data)
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
}
// sendLogData reads the domain/app filters and page number off the query
@@ -109,22 +110,22 @@ func parsePage(v string) int {
return n
}
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
// handleMailQueue renders the Mail queue page (spec 7.2.11).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.render(w, http.StatusOK, "queue", map[string]any{
s.render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": currentUser(r),
"Active": "queue",
"Active": "mail_queue",
"Output": out,
"Error": errText,
})
}
// handleQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.renderFragment(w, http.StatusOK, "queue_body", map[string]any{
s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out,
"Error": errText,
})
@@ -142,22 +143,22 @@ func readQueue() (string, string) {
return out, ""
}
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
// handleSystemLog renders the System log page over mail.log (spec 7.2.13).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "logtail", map[string]any{
"Title": "SelfPost — mail log",
s.render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log",
"User": currentUser(r),
"Active": "logtail",
"Active": "system_log",
"Lines": lines,
"Error": errText,
})
}
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines,
"Error": errText,
})
+2 -1
View File
@@ -15,7 +15,8 @@ const usernameKey ctxKey = 0
//
// It also extends the sliding session (plan B.1) on activity, defined as
// everything except a GET request carrying HX-Request: the four monitoring
// fragments (/status/fragment, /queue/body, /logtail/body, /sendlog/rows)
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
// /deliveries/rows)
// poll every 5s regardless of whether anyone is looking at the tab, so
// counting those as activity would make "N days idle" mean "N days since a
// browser tab was last open" instead.
+34 -6
View File
@@ -88,20 +88,48 @@ details form { margin-top: 0.6rem; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
/* Two deliberate rows, not one. The six page entries need about 660px and the
session block another 260px, against the 738px the panel is wide — so the bar
was wrapping on its own, and the session landed left-aligned directly under
the entries, reading as a third row of navigation. Shortening labels would buy
the ~200px back but undo the point of naming each entry after its page. Rows
rather than columns: a two-column grid of entries came out a third taller
(107px against 83px) and no easier to read.
The session sits on top, at the right edge, and the page entries below — the
usual arrangement, and the layout template puts the session first so the
reading and tab order follow what the eye sees rather than being flipped by a
CSS `order`. The cost is that Sign out is the bar's first tab stop; nothing
activates on focus, so that is a reordering, not a hazard. */
.nav {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
display: flex; flex-direction: column; align-items: stretch;
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
}
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
.nav .links [aria-current] {
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
/* Pushed to the far edge so the two rows read as two blocks rather than as one
ragged list that happened to wrap. */
.nav .session { justify-content: flex-end; }
/* Each entry pairs an icon with its label, so the entry itself is a flex row
rather than a run of text — that is also why the bar centres its items
instead of aligning them on the text baseline. Account is included: it is a
page like the others and would otherwise be the one bare word in the bar. */
.nav a, .nav [aria-current] {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.nav [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
.nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
}
/* Sign out carries an icon too, so it needs the same row layout; its padding
and colours come from the compact button rule further down. */
.nav button { display: inline-flex; align-items: center; gap: 0.4rem; }
/* The icons draw in the entry's own colour, so the active entry's darker text,
a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; }
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
+7 -7
View File
@@ -30,19 +30,19 @@ var pageFiles = map[string][]string{
"backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.html"},
"domain_delete": {"templates/domain_delete.html"},
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"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"},
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"sendlog_rows": "templates/sendlog_rows.html",
"queue_body": "templates/queue_body.html",
"logtail_body": "templates/logtail_body.html",
"status_body": "templates/status_body.html",
"deliveries_rows": "templates/deliveries_rows.html",
"mail_queue_body": "templates/mail_queue_body.html",
"system_log_body": "templates/system_log_body.html",
"status_body": "templates/status_body.html",
}
func loadTemplates() (*templates, error) {
@@ -3,7 +3,7 @@
<div class="card">
<h2>Filter</h2>
<form method="get" action="/sendlog">
<form method="get" action="/deliveries">
<label for="domain">Domain</label>
<select id="domain" name="domain">
<option value="">All domains</option>
@@ -19,6 +19,6 @@
</div>
<div class="card">
{{template "sendlog_rows" .}}
{{template "deliveries_rows" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "sendlog_rows"}}
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
{{define "deliveries_rows"}}
<div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Rows}}
<table>
@@ -22,8 +22,8 @@
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
+32 -10
View File
@@ -35,20 +35,42 @@
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
{{if eq .Active "sendlog"}}<span aria-current="page">Deliveries</span>{{else}}<a href="/sendlog">Deliveries</a>{{end}}
{{if eq .Active "queue"}}<span aria-current="page">Mail queue</span>{{else}}<a href="/queue">Mail queue</a>{{end}}
{{if eq .Active "logtail"}}<span aria-current="page">System log</span>{{else}}<a href="/logtail">System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
</div>
{{/* The session block is first here because it is drawn first: it is the top
row of the bar (see .nav in panel.css), and putting it first in the
document keeps the tab order matching the visual one. */}}
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">Sign out</button>
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
</div>
</nav>
{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is
aria-hidden because the entry's own text is already the accessible name;
the icon is a landmark for the eye, not a second label. Kept as separate
templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
{{/* A filing cabinet rather than a storage box: the box read as a box, while
two drawers with handles is the picture of an archive. */}}
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
{{/* Sign out is the one icon here that marks an action rather than a page: an
open door with the arrow leading out of it. */}}
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
@@ -3,6 +3,6 @@
<div class="card">
<h2>Pending messages</h2>
{{template "queue_body" .}}
{{template "mail_queue_body" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "queue_body"}}
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{define "mail_queue_body"}}
<div id="mail-queue-body" hx-get="/mail-queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
<p class="error">{{.QueueError}}</p>
{{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
<a class="btn" href="/queue">Full queue</a>
<a class="btn" href="/mail-queue">Full queue</a>
{{end}}
</div>
@@ -1,8 +1,8 @@
{{define "content"}}
<h1>Mail log</h1>
<h1>System log</h1>
<div class="card">
<h2>Recent log entries</h2>
{{template "logtail_body" .}}
{{template "system_log_body" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "logtail_body"}}
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{define "system_log_body"}}
<div id="system-log-body" hx-get="/system-log/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
+7 -5
View File
@@ -99,19 +99,21 @@ func TestNavMarksActivePage(t *testing.T) {
var buf bytes.Buffer
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "queue",
"Active": "mail_queue",
})
if err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Mail queue</span>`) {
// 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="/queue"`) {
if strings.Contains(out, `href="/mail-queue"`) {
t.Errorf("active page still links to itself:\n%s", out)
}
if !strings.Contains(out, `href="/sendlog"`) {
if !strings.Contains(out, `href="/deliveries"`) {
t.Errorf("inactive pages are not linked:\n%s", out)
}
}
@@ -129,7 +131,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Status</span>`) {
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="/domains"`) {
+12 -7
View File
@@ -63,6 +63,11 @@ type Config struct {
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
// falls back to the 7-day default.
SessionIdleDays int
// DNSResolvers are the recursive resolvers the deliverability checks query
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
// checks must not go through the system resolver — see dnscheck's
// externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string
}
// Server is the panel HTTP application.
@@ -105,7 +110,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
// Published-DNS checks for the status page and the domain pages. The
// checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13).
dns: dnscheck.New(),
dns: dnscheck.New(cfg.DNSResolvers),
// Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1).
setupLimiter: newRateLimiter(10, time.Minute),
@@ -180,12 +185,12 @@ func (s *Server) Handler() http.Handler {
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /sendlog", s.handleSendLog)
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows)
authed.HandleFunc("GET /queue", s.handleQueue)
authed.HandleFunc("GET /queue/body", s.handleQueueBody)
authed.HandleFunc("GET /logtail", s.handleLogTail)
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody)
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /system-log", s.handleSystemLog)
authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)
mux.Handle("/", s.requireAuth(authed))