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>
124 lines
7.1 KiB
Go
124 lines
7.1 KiB
Go
package view
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ---- Outbound queue
|
|
|
|
// The page is the mockup's table, a row per recipient with its last reply and
|
|
// the link to the message, and it polls from its box; the retry policy sits
|
|
// under it, outside the poll.
|
|
func TestOutQueueShowsTheTableAndPollsItsBox(t *testing.T) {
|
|
out := renderSignedIn(t, "out-queue", outQueueFixture())
|
|
pageHas(t, "Outbound queue", out,
|
|
`<h1 class="title is-3">Outbound queue</h1>`, `<h2>Waiting</h2>`,
|
|
`Messages accepted from applications and still waiting for the receiving server. Refreshes on its own.`,
|
|
`id="out-queue" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
|
|
`1 message · 3.1 kB`, `href="/help#queue"`,
|
|
`<th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th><th></th>`,
|
|
`<td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td>`,
|
|
`<td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td>`,
|
|
`<td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="/outbound/log/41">Details</a></td>`,
|
|
`aria-current="page"><i class="ti ti-stack-2"></i>Queue</a>`,
|
|
)
|
|
pageLacks(t, "Outbound queue", out, `class="sp-log"`, "Nothing is waiting", "postqueue -p")
|
|
|
|
frag := renderFragment(t, "queue_body", outQueueFixture())
|
|
if !strings.HasPrefix(frag, `<div class="box" id="out-queue" data-poll`) || strings.Contains(frag, "sp-head") || strings.Contains(frag, "How retries work") {
|
|
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
|
|
}
|
|
if strings.Count(out, "4XcB7k2Jm9z1") != 1 || strings.Count(frag, "4XcB7k2Jm9z1") != 1 {
|
|
t.Error("the page and its fragment should each carry the row once")
|
|
}
|
|
}
|
|
|
|
// Rows come oldest first, a recipient each; the head counts messages and their
|
|
// size once however many recipients a message has; a recipient without a
|
|
// reason says where its message is, a bounce shows its empty sender, and a
|
|
// recipient whose message has no send-log row has no Details link.
|
|
func TestOutQueueRowsAndHead(t *testing.T) {
|
|
t0 := time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local)
|
|
entries := []QueueEntry{
|
|
{ID: "NEWER", Queue: "active", Arrival: t0.Add(time.Hour), Size: 2048, Sender: "a@example.com", Recipient: "x@example.net", Href: "/outbound/log/2"},
|
|
{ID: "OLDER", Queue: "deferred", Arrival: t0, Size: 1000, Sender: "", Recipient: "b@example.net", Reason: "connect to mx.example.net[203.0.113.9]:25: Connection timed out"},
|
|
{ID: "OLDER", Queue: "deferred", Arrival: t0, Size: 1000, Sender: "", Recipient: "c@example.net", Reason: "connect to mx.example.net[203.0.113.9]:25: Connection timed out"},
|
|
{ID: "HELD", Queue: "hold", Arrival: t0.Add(2 * time.Hour), Size: 100, Sender: "a@example.com", Recipient: "y@example.net", Href: "/outbound/log/3"},
|
|
}
|
|
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), entries, ""))
|
|
pageHas(t, "queue", out, `3 messages · 3.1 kB`, `<>`, `Being delivered now`, `On hold: not tried until it is released`)
|
|
if i, j, k := strings.Index(out, "OLDER"), strings.Index(out, "NEWER"), strings.Index(out, "HELD"); i < 0 || !(i < j && j < k) {
|
|
t.Errorf("rows are not oldest first: OLDER %d, NEWER %d, HELD %d", i, j, k)
|
|
}
|
|
if strings.Count(out, `<td class="sp-actions"></td>`) != 2 {
|
|
t.Error("the two recipients of a message without a send-log row should have an empty Details cell")
|
|
}
|
|
}
|
|
|
|
// A backlog is cut at maxQueueRows, oldest kept, and the foot says how many
|
|
// there are.
|
|
func TestOutQueueCutsALongBacklog(t *testing.T) {
|
|
t0 := time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local)
|
|
var entries []QueueEntry
|
|
for i := 0; i < maxQueueRows+5; i++ {
|
|
entries = append(entries, QueueEntry{ID: fmt.Sprintf("Q%04d", i), Queue: "deferred", Arrival: t0.Add(time.Duration(i) * time.Minute), Recipient: "r@example.net"})
|
|
}
|
|
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), entries, ""))
|
|
pageHas(t, "long queue", out, "Q0000", fmt.Sprintf("Q%04d", maxQueueRows-1),
|
|
fmt.Sprintf("The oldest %d of %d recipients waiting.", maxQueueRows, maxQueueRows+5), `class="sp-box-foot"`)
|
|
pageLacks(t, "long queue", out, fmt.Sprintf("Q%04d", maxQueueRows))
|
|
}
|
|
|
|
// The policy is the page's, wherever it was read from: its facts, and a head
|
|
// that says so when they are the compiled-in defaults.
|
|
func TestOutQueueStatesTheRetryPolicy(t *testing.T) {
|
|
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, "").WithRetryPolicy("10 minutes", "about 1 hour 7 minutes", "2 days", false))
|
|
pageHas(t, "Outbound queue", out,
|
|
`<h2>How retries work</h2>`, `This Postfix's policy, read at panel start`,
|
|
`<dd class="sp-big">10 minutes</dd>`, `<dd class="sp-big">doubling</dd>`, `capped at about 1 hour 7 minutes`,
|
|
`<dd class="sp-big">2 days</dd>`, `<dd class="sp-big">bounced</dd>`, `no attempt counter, only time`,
|
|
)
|
|
pageLacks(t, "Outbound queue", out, "Compiled-in defaults")
|
|
|
|
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", true))
|
|
pageHas(t, "Outbound queue", out, "Compiled-in defaults: the Postfix configuration could not be read")
|
|
pageLacks(t, "Outbound queue", out, "read at panel start")
|
|
}
|
|
|
|
// With nothing to list the box says why instead: the queue holds nothing, or
|
|
// it could not be read — and never both.
|
|
func TestOutQueueSaysWhyItHasNoRows(t *testing.T) {
|
|
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), nil, ""))
|
|
pageHas(t, "empty queue", out, `class="sp-empty"`, "Nothing is waiting. Every message has reached its receiving server.", "Refreshes on its own")
|
|
pageLacks(t, "empty queue", out, "<table", "Could not read")
|
|
|
|
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), []QueueEntry{{ID: "IGNORED"}}, "Could not read the mail queue."))
|
|
pageHas(t, "unreadable queue", out, `class="sp-empty"`, "Could not read the mail queue.")
|
|
pageLacks(t, "unreadable queue", out, "<table", "Nothing is waiting", "IGNORED")
|
|
}
|
|
|
|
// ---- Inbound queue
|
|
|
|
// The mockup's Inbound queue: nothing waiting, the help link, and the lead
|
|
// that links the Outbound queue; with rows, the same table without Details,
|
|
// since an inbound message has no page of its own.
|
|
func TestInQueue(t *testing.T) {
|
|
out := renderSignedIn(t, "in-queue", inQueueFixture())
|
|
pageHas(t, "Inbound queue", out,
|
|
`<h1 class="title is-3">Inbound queue</h1>`,
|
|
`The same Postfix queue as <a href="/outbound/queue">outbound</a>, filtered by recipient domain.`,
|
|
`id="in-queue" data-poll aria-live="polite" hx-get="/inbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
|
|
`Nothing is waiting. Every accepted message has reached its upstream.`, `href="/help#inbound-queue"`,
|
|
`back to the original sender`,
|
|
)
|
|
rows := []QueueEntry{{ID: "IN0001", Queue: "deferred", Arrival: time.Date(2026, 10, 18, 9, 0, 0, 0, time.Local), Size: 5000,
|
|
Sender: "someone@example.net", Recipient: "staff@lists.example.com", Reason: "451 4.7.1 Greylisted"}}
|
|
out = renderSignedIn(t, "in-queue", NewInQueue(admin(), rows, ""))
|
|
pageHas(t, "Inbound queue", out, `1 message · 4.9 kB`, `<td class="sp-mono">IN0001</td>`, `staff@<wbr>lists.example.com`, `451 4.7.1 Greylisted`,
|
|
`<th>Last reply</th></tr>`)
|
|
pageLacks(t, "Inbound queue", out, "Details", `sp-actions`)
|
|
}
|