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