queue-table: Outbound › Queue as the drawn table, Inbound › Queue built
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это допустили? Исправляем!" The mockups out-queue and in-queue (design629de5e, guards8525e8e). - 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user