Files
selfpost/internal/web/view/queue.go
T
mixandClaude Opus 5.5 6db3704711 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>
2026-10-18 23:17:14 +03:00

222 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package view
// The data of the two queues, Outbound › Queue and Inbound › Queue
// (templates/out_queue.html and in_queue.html, mockups out-queue.html and
// in-queue.html): what Postfix still holds, a row per recipient, and the retry
// policy that decides how long it holds it. Both are one Postfix queue, split
// by the recipient's domain: a recipient of an inbound domain is the Inbound
// queue's, every other the Outbound queue's. The Waiting box polls; a page and
// its fragment render the same box (templates/queue_body.html).
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads Postfix, the view decides what is said and
// how it is worded.
import (
"fmt"
"sort"
"time"
)
const (
outboundQueue = "/outbound/queue"
inboundQueue = "/inbound/queue"
)
// maxQueueRows is how many recipients a queue page lists. A backlog of
// thousands is one receiver or upstream failing, and the oldest rows say which;
// the page polls, and a table of every one would be drawn again each time.
const maxQueueRows = 200
// QueueEntry is one recipient of a message Postfix holds, as the handler hands
// it over: the message's queue id, which of Postfix's queues it is in, when it
// arrived and its size, its sender and this recipient, why the recipient's
// last attempt did not finish (empty when there was none), and the page of the
// message in the Outbound log ("" when it has none, or for the Inbound queue).
type QueueEntry struct {
ID string
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
Arrival time.Time
Size int64
Sender string
Recipient string
Reason string
Href string
}
// QueueRow is one row of the Waiting table.
type QueueRow struct {
ID string
Arrived string
From string
To string
Reply string
Href string
}
// Queue is the data of either queue page. It is for the global role.
type Queue struct {
Meta
Head Head
Queue Box
Rows []QueueRow
// Details is the table's last column: a row of the Outbound queue opens
// its message in the Outbound log; the Inbound queue has no such page.
Details bool
// Empty is shown in place of the table when there is no row: the queue
// holds nothing for this direction, or it could not be read.
Empty EmptyState
// Foot says that the table is cut at maxQueueRows; nil when it is not.
Foot *Foot
Retries Box
Policy []Fact
// then is the note under "bounced": what the mockup of each page says.
then string
}
// NewOutQueue builds Outbound › Queue from the recipients waiting for a
// receiving server. errText is why the queue could not be read, empty when it
// could.
func NewOutQueue(m Meta, entries []QueueEntry, errText string) *Queue {
m.Title, m.Section, m.Page = "Outbound queue", "outbound", "queue"
p := &Queue{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound queue",
Lead: Plain("Messages accepted from applications and still waiting for the receiving server. Refreshes on its own."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
Help: topicLink(HelpQueue, "What waits here and how Postfix retries")},
Details: true,
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Nothing is waiting. Every message has reached its receiving server.")},
Retries: Box{No: "02", Title: "How retries work"},
then: "no attempt counter, only time",
}
return p.fill(entries, errText)
}
// NewInQueue builds Inbound › Queue from the recipients of inbound domains
// waiting for their upstream.
func NewInQueue(m Meta, entries []QueueEntry, errText string) *Queue {
m.Title, m.Section, m.Page = "Inbound queue", "inbound", "queue"
p := &Queue{
Meta: m,
Head: Head{
Kicker: "Inbound",
Title: "Inbound queue",
Lead: Rich("Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as ",
Link(outboundQueue, "outbound"), ", filtered by recipient domain."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "in-queue", Poll: inboundQueue + "/fragment",
Help: topicLink(HelpInboundQueue, "What waits here and why")},
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Nothing is waiting. Every accepted message has reached its upstream.")},
Retries: Box{No: "02", Title: "How retries work"},
then: "back to the original sender",
}
return p.fill(entries, errText)
}
// fill lays the rows out, oldest first - the message that has waited longest
// is the one that says what is wrong - and heads the box with the count of
// messages and their size, or with "Refreshes on its own" while there is none.
func (p *Queue) fill(entries []QueueEntry, errText string) *Queue {
p.Queue.End = Plain("Refreshes on its own")
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
return p
}
if len(entries) == 0 {
return p
}
sorted := append([]QueueEntry(nil), entries...)
sort.SliceStable(sorted, func(i, j int) bool {
if !sorted[i].Arrival.Equal(sorted[j].Arrival) {
return sorted[i].Arrival.Before(sorted[j].Arrival)
}
return sorted[i].ID < sorted[j].ID
})
sizes := map[string]int64{}
for _, e := range sorted {
sizes[e.ID] = e.Size
}
var total int64
for _, s := range sizes {
total += s
}
p.Queue.End = Plain(countOf(len(sizes), "message") + " · " + byteSize(total))
for i, e := range sorted {
if i == maxQueueRows {
p.Foot = &Foot{Text: Plain(fmt.Sprintf("The oldest %d of %d recipients waiting. postqueue -p on the server lists them all.", maxQueueRows, len(sorted)))}
break
}
from := e.Sender
if from == "" {
from = "<>" // a bounce has no sender
}
p.Rows = append(p.Rows, QueueRow{
ID: e.ID, Arrived: e.Arrival.Local().Format("01-02 15:04:05"),
From: from, To: e.Recipient, Reply: queueReply(e), Href: e.Href,
})
}
return p
}
// queueReply is the Last reply cell: the reason Postfix keeps for the
// recipient's last attempt, or, before there was one, where the message is.
func queueReply(e QueueEntry) string {
if e.Reason != "" {
return e.Reason
}
switch e.Queue {
case "active":
return "Being delivered now"
case "hold":
return "On hold: not tried until it is released"
case "incoming", "maildrop":
return "Just arrived, not tried yet"
}
return "Not tried yet"
}
// countOf is "1 message", "3 messages".
func countOf(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}
// byteSize is a size as the mockup writes it: "3.1 kB" for 3174 bytes, counted
// in 1024s as postqueue counts its Kbytes.
func byteSize(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f kB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
// WithRetryPolicy fills the second box: the first retry, the cap the later ones
// double up to and how long a message is kept, each as the handler words it.
// fromDefaults says the Postfix configuration could not be read and these are
// the compiled-in values.
func (p *Queue) WithRetryPolicy(firstRetry, backoffCap, lifetime string, fromDefaults bool) *Queue {
p.Retries.End = Plain("This Postfix's policy, read at panel start")
if fromDefaults {
p.Retries.End = Plain("Compiled-in defaults: the Postfix configuration could not be read")
}
p.Policy = []Fact{
{Label: "First retry", Value: Plain(firstRetry), Big: true},
{Label: "Later retries", Value: Plain("doubling"), Big: true, Note: Plain("capped at " + backoffCap)},
{Label: "Kept in queue", Value: Plain(lifetime), Big: true},
{Label: "Then", Value: Plain("bounced"), Big: true, Note: Plain(p.then)},
}
return p
}