queue-table: Outbound › Queue as the drawn table, Inbound › Queue built

Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The mockups out-queue and in-queue (design 629de5e,
guards 8525e8e).

- postfix: postqueue -j gives each message's size and each recipient's
  delay_reason (src/postqueue/showq_json.c, format_json(): "delay_reason"
  only when LEN(why) > 0); QueueListing reads it with the page's 5 s,
  QueueMessages keeps the notifier's 30 s.
- view: one Queue page type for both directions (queue.go), one polled box
  (queue_body.html): a row per recipient, oldest first, cut at 200 with a
  foot; the head "N messages · size", "Refreshes on its own" while empty;
  Last reply is the delay reason, or where the message is (active, hold,
  incoming) before there was one. Inbound has no Details: an inbound
  message has no page of its own.
- handlers: one read of the queue, split by the recipient's domain against
  the inbound domains; an outbound recipient is linked to its send-log row
  (store.SendLogRefsByQueueID, through idx_send_log_queue_id, plan pinned in
  TestSendLogQueriesUseTheirIndexes), matched without regard to case, else
  the message's first row. Both pages are the global role's; the inbound
  one 404s with the relay off. Routes /inbound/queue (+ /fragment), menu,
  touch menu and sibling strip.
- Overview: Queues · Both empty · outbound and inbound with the relay on.
- Help: the queue topic rewritten, an Inbound queue topic (word for word with
  the mockup; domain administrators do not see it, as with the lists).
- TestUnbuiltScreensAreOnTheRoadmap and its rule in development.md: an
  exempted screen must be named in roadmap.md; queue-table leaves the
  roadmap; guide, architecture, panel-redesign.md updated.

Tests: go vet; go test ./internal/web/... ./internal/store ./internal/postfix
./internal/notify pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 23:17:14 +03:00
co-authored by Claude Opus 5.5
parent 8525e8ee8d
commit 6db3704711
34 changed files with 893 additions and 246 deletions
+111 -9
View File
@@ -9,6 +9,7 @@ import (
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/logtail"
@@ -461,9 +462,8 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "out-queue", h.outQueuePage(r).
WithRetryPolicy(policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime(), policy.FromDefaults))
out, _, errText := h.queueEntries()
h.view.Render(w, http.StatusOK, "out-queue", h.withRetryPolicy(view.NewOutQueue(h.shellMeta(r), out, errText)))
}
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
@@ -471,14 +471,116 @@ 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))
out, _, errText := h.queueEntries()
h.view.RenderFragment(w, http.StatusOK, "queue_body", view.NewOutQueue(h.shellMeta(r), out, errText))
}
// 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()
return view.NewOutQueue(h.shellMeta(r), out, errText)
// HandleInboundQueue renders the Inbound queue page: the recipients of inbound
// domains still waiting for their upstream. Like the Outbound queue it is the
// global role's (requireInboundGlobal), and 404 while the relay is off.
func (h *Handlers) HandleInboundQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInboundGlobal(w, r); !ok {
return
}
_, in, errText := h.queueEntries()
h.view.Render(w, http.StatusOK, "in-queue", h.withRetryPolicy(view.NewInQueue(h.shellMeta(r), in, errText)))
}
// HandleInboundQueueBody serves the HTMX polling fragment of the Inbound queue.
func (h *Handlers) HandleInboundQueueBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInboundGlobal(w, r); !ok {
return
}
_, in, errText := h.queueEntries()
h.view.RenderFragment(w, http.StatusOK, "queue_body", view.NewInQueue(h.shellMeta(r), in, errText))
}
func (h *Handlers) withRetryPolicy(p *view.Queue) *view.Queue {
policy := h.cfg.RetryPolicy
return p.WithRetryPolicy(policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime(), policy.FromDefaults)
}
// queueListing reads what Postfix holds. It is a var so tests can answer
// without a running Postfix.
var queueListing = postfix.QueueListing
// queueEntries reads the queue once and splits it by the recipient's domain:
// a recipient of an inbound domain waits for its upstream (Inbound › Queue),
// every other one for a receiving server (Outbound › Queue) - the panel's own
// mail and bounces included. A recipient of the Outbound queue is linked to
// its send-log row when it has one. errText is what the page says when the
// queue cannot be read; the reason goes to the log, not to the page.
func (h *Handlers) queueEntries() (out, in []view.QueueEntry, errText string) {
const unreadable = "Could not read the mail queue."
msgs, err := queueListing()
if err != nil {
logf("panel: postqueue -j: %v", err)
return nil, nil, unreadable
}
inbound := map[string]bool{}
if h.cfg.InboundEnabled {
domains, err := h.store.ListInboundDomains()
if err != nil {
// Without the list, a recipient cannot be told to be inbound;
// putting it under Outbound would show it on the wrong page.
logf("panel: queue: inbound domains: %v", err)
return nil, nil, unreadable
}
for _, d := range domains {
inbound[strings.ToLower(d.Name)] = true
}
}
ids := make([]string, 0, len(msgs))
for _, m := range msgs {
ids = append(ids, m.ID)
}
refs, err := h.store.SendLogRefsByQueueID(ids)
if err != nil {
logf("panel: queue: send-log rows: %v", err) // the rows still show, without Details
}
for _, m := range msgs {
for _, rcpt := range m.Recipients {
e := view.QueueEntry{
ID: m.ID, Queue: m.Queue, Arrival: m.Arrival, Size: m.Size,
Sender: m.Sender, Recipient: rcpt.Address, Reason: rcpt.DelayReason,
}
if inbound[recipientDomain(rcpt.Address)] {
in = append(in, e)
continue
}
e.Href = sendLogHref(refs[m.ID], rcpt.Address)
out = append(out, e)
}
}
return out, in, ""
}
// recipientDomain is the domain of an address, lower-cased: what Postfix
// matches relay_domains against.
func recipientDomain(addr string) string {
at := strings.LastIndexByte(addr, '@')
if at < 0 {
return ""
}
return strings.ToLower(addr[at+1:])
}
// sendLogHref is the page of the send-log row of a waiting recipient: the row
// of that recipient, or, when the queue names it otherwise than the row does
// (a canonical map rewrote it; the row holds the RCPT TO as sent), the
// message's first row. "" when the message has no row.
func sendLogHref(refs []store.SendLogRef, rcpt string) string {
if len(refs) == 0 {
return ""
}
ref := refs[0]
for _, r := range refs {
if strings.EqualFold(r.To, rcpt) {
ref = r
break
}
}
return "/outbound/log/" + strconv.FormatInt(ref.ID, 10)
}
// readQueue runs postqueue -p, returning a friendly message instead of the
@@ -510,6 +510,7 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
// 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) {
stubQueue(t, nil, nil)
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/outbound/queue")
@@ -533,6 +534,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
}
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
stubQueue(t, nil, nil)
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/outbound/queue/fragment")
@@ -545,6 +547,7 @@ func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
}
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
stubQueue(t, nil, nil)
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/outbound/queue")
@@ -0,0 +1,110 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// The two queues are one Postfix queue split by the recipient's domain. Their
// markup is held to the mockups by the guards in internal/web/view; what is
// checked here is the split, the link to the send-log row, and who may look.
// stubQueue answers queueListing with msgs (or err) for the test.
func stubQueue(t *testing.T, msgs []postfix.QueuedMessage, err error) {
t.Helper()
old := queueListing
t.Cleanup(func() { queueListing = old })
queueListing = func() ([]postfix.QueuedMessage, error) { return msgs, err }
}
func TestQueuesSplitByRecipientDomain(t *testing.T) {
s := newInboundLogStand(t) // inbound domains a.example.com, b.example.com
if err := s.st.InsertQueued(store.SendLogEntry{QueueID: "OUT00001", Domain: "shop.example.org", AppLogin: "shop",
From: "alerts@shop.example.org", To: "noc@example.net", Subject: "Hi"}); err != nil {
t.Fatal(err)
}
rows, err := s.st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC)
stubQueue(t, []postfix.QueuedMessage{
{ID: "OUT00001", Queue: "deferred", Sender: "alerts@shop.example.org", Arrival: at, Size: 3174,
Recipients: []postfix.QueuedRecipient{{Address: "NOC@example.net", DelayReason: "451 4.7.1 Greylisted"}}},
{ID: "IN000001", Queue: "deferred", Sender: "someone@example.net", Arrival: at, Size: 5000,
Recipients: []postfix.QueuedRecipient{{Address: "alice@A.example.com", DelayReason: "connect to upstream.example.com[192.0.2.7]:25: Connection refused"}}},
{ID: "NOTE0001", Queue: "active", Sender: "postmaster@mail.example.org", Arrival: at, Size: 900,
Recipients: []postfix.QueuedRecipient{{Address: "admin@example.net"}}},
}, nil)
out := s.get(s.h.HandleMailQueue, "/outbound/queue", globalPrincipal).Body.String()
has(t, "Outbound queue", out, "OUT00001", "451 4.7.1 Greylisted",
`<a href="/outbound/log/`+strconv.FormatInt(rows[0].ID, 10)+`">Details</a>`, // matched without regard to case
"NOTE0001", "Being delivered now", "2 messages")
if strings.Contains(out, "IN000001") {
t.Error("a recipient of an inbound domain is on the Outbound queue")
}
if strings.Count(out, ">Details</a>") != 1 {
t.Error("the panel's own message has no send-log row and should have no Details link")
}
in := s.get(s.h.HandleInboundQueue, "/inbound/queue", globalPrincipal).Body.String()
has(t, "Inbound queue", in, "IN000001", "Connection refused", "1 message · 4.9 kB")
for _, other := range []string{"OUT00001", "NOTE0001", ">Details</a>"} {
if strings.Contains(in, other) {
t.Errorf("the Inbound queue shows %q", other)
}
}
frag := s.get(s.h.HandleInboundQueueBody, "/inbound/queue/fragment", globalPrincipal).Body.String()
if !strings.HasPrefix(frag, `<div class="box" id="in-queue" data-poll`) || !strings.Contains(frag, "IN000001") {
t.Errorf("the Inbound queue's fragment is not its polled box:\n%.300s", frag)
}
}
// A queue that cannot be read says so on both pages, and never reads as empty.
func TestQueuesSayWhenTheyCannotBeRead(t *testing.T) {
s := newInboundLogStand(t)
stubQueue(t, nil, errors.New("postqueue: Permission denied"))
for _, page := range []struct {
name string
h http.HandlerFunc
}{{"/outbound/queue", s.h.HandleMailQueue}, {"/inbound/queue", s.h.HandleInboundQueue}} {
body := s.get(page.h, page.name, globalPrincipal).Body.String()
has(t, page.name, body, "Could not read the mail queue.")
if strings.Contains(body, "Nothing is waiting") || strings.Contains(body, "Permission denied") {
t.Errorf("%s reads as empty or shows the error itself", page.name)
}
}
}
// Both queues are the global role's: a domain administrator is answered 404,
// whatever they reach; with the relay off the Inbound queue does not exist.
func TestQueuesAreForTheGlobalRoleOnly(t *testing.T) {
s := newInboundLogStand(t)
stubQueue(t, nil, nil)
ops := s.owner(t, "owns-a", s.a)
all := userWith(t, s.st, "owns-all", store.RoleDomain, store.Reach{AllInbound: true, AllDomains: true})
for _, p := range []auth.Principal{ops, all} {
for target, h := range map[string]http.HandlerFunc{
"/inbound/queue": s.h.HandleInboundQueue, "/inbound/queue/fragment": s.h.HandleInboundQueueBody,
"/outbound/queue": s.h.HandleMailQueue, "/outbound/queue/fragment": s.h.HandleMailQueueBody,
} {
if rec := s.get(h, target, p); rec.Code != http.StatusNotFound {
t.Errorf("GET %s as %s = %d, want 404", target, p.Username, rec.Code)
}
}
}
s.h.cfg.InboundEnabled = false
if rec := s.get(s.h.HandleInboundQueue, "/inbound/queue", globalPrincipal); rec.Code != http.StatusNotFound {
t.Errorf("GET /inbound/queue with the relay off = %d, want 404", rec.Code)
}
}
+20 -8
View File
@@ -447,7 +447,15 @@ func healthCards(c healthReadings) []view.HealthCard {
certValue = fmt.Sprintf("Expires in %d days", cert.DaysLeft)
}
queueValue := queueCardValue(c.QueueSummary)
// One Postfix queue, split into two pages when the inbound relay is on
// (mockup overview: "Queues · Both empty · outbound and inbound").
queueName, queueValue, queueSub := "Queue", queueCardValue(c.QueueSummary), "outbound"
if c.Inbound {
queueName, queueSub = "Queues", "outbound and inbound"
if queueValue == "Empty" {
queueValue = "Both empty"
}
}
if e := c.QueueError; e != "" {
queueValue = "Could not be read"
}
@@ -492,7 +500,7 @@ func healthCards(c healthReadings) []view.HealthCard {
card("Machine", "ti-cpu", "/server/health", machine.Status, cpu, ram),
card("Processes", "ti-server-cog", "/server/health", c.ProcessStatus, procValue, procSub),
card("TLS certificate", "ti-certificate", "/server/health", cert.Status, certValue, cert.Subject),
card("Queue", "ti-stack-2", "/outbound/queue", c.QueueStatus, queueValue, "outbound"),
card(queueName, "ti-stack-2", "/outbound/queue", c.QueueStatus, queueValue, queueSub),
card("Milter sockets", "ti-plug-connected", "/server/health", c.SocketStatus,
fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub),
card("Reverse DNS", "ti-arrows-exchange", "/server/health", ptr.Status, ptrValue, c.Hostname),
@@ -539,12 +547,15 @@ type healthReadings struct {
QueueSummary string
QueueError string
QueueStatus health.Status
Machine health.Machine
Cert health.Certificate
Sockets []health.Socket
SocketStatus health.Status
Hostname string
PTR dnscheck.Result
// Inbound is the relay on port 25: Postfix's one queue then holds mail of
// both directions, and the Queues card says so.
Inbound bool
Machine health.Machine
Cert health.Certificate
Sockets []health.Socket
SocketStatus health.Status
Hostname string
PTR dnscheck.Result
// Banner is Postfix's smtpd_banner, expanded: the greeting after "220".
Banner string
}
@@ -597,6 +608,7 @@ func (h *Handlers) healthChecks() healthReadings {
QueueSummary: queueSummary(queueText),
QueueError: queueErr,
QueueStatus: queueStatus,
Inbound: h.cfg.InboundEnabled,
Machine: machine,
Cert: cert,
Sockets: sockets,
@@ -56,6 +56,13 @@ func TestHealthCards(t *testing.T) {
}
}
// With the inbound relay on, the one queue is both directions'.
in := checksFixture()
in.Inbound = true
if q := healthCards(in)[3]; q.Name != "Queues" || q.Value != "Both empty" || q.Sub != "outbound and inbound" {
t.Errorf("queue card with the relay on = %+v", q)
}
// A check that could not run must not look fine.
c := checksFixture()
c.ProcessError = true