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
+26
View File
@@ -5,6 +5,32 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
### Added
- **Inbound › Queue** (`/inbound/queue`, global role, with the inbound relay
on): mail accepted on port 25 that its upstream has not yet taken, a row
per recipient with the upstream's last reply - the screen `in-queue`, drawn
for 2.0 and not built until now. Help has its topic.
### Changed
- **Outbound › Queue is the table the mockup draws** instead of
`postqueue -p`'s text: a row per recipient, oldest first - queue id, when
it arrived, sender, recipient, and the last reply of the receiving server
or why no connection was made - with *Details* to the message in the
Outbound log when it has a row there. The box head counts the messages and
their size; past 200 recipients the oldest 200 are listed and a line says
how many wait. Read from `postqueue -j` (Postfix 3.10.13,
`src/postqueue/showq_json.c`: `message_size`, and a recipient's
`delay_reason` when it has one). A recipient of an inbound domain is on
Inbound › Queue instead.
- **Overview's queue card** is *Queues*, "Both empty", "outbound and inbound"
while the inbound relay is on, as the mockup has it: Postfix keeps one queue
for both.
- `TestUnbuiltScreensAreOnTheRoadmap`: a screen the guards let pass unbuilt
must be named in `docs/roadmap.md`. The two queues had been deferred on
2026-10-09 with no roadmap row and were forgotten; development.md says so.
## [2.15.0] - 2026-10-18
The two proposals the security audit left as product choices. Migration
+1 -1
View File
@@ -863,7 +863,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Queue, Filter lists | `GET /inbound/log` · `GET /inbound/queue` · `/inbound/queue/fragment` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload`, `/server/health/test-message` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password`, `/account/email/confirm` |
| User menu | `GET /help` · `POST /logout` (public mux) |
+9
View File
@@ -451,6 +451,15 @@ that changes the mockups, a guard or the script touches nothing else under
[plans/panel-redesign.md](plans/panel-redesign.md) § The contract and
§ Enforcement have the full rules.
**A drawn screen that is not built has a row.** A screen the mockups draw and
the panel does not build, or builds otherwise (the guards' `deferredScreens`
and `outlineExempt`), is named in [roadmap.md](roadmap.md) - a row of the index,
or an idea under *Ideas* - in the same commit that exempts it.
`TestUnbuiltScreensAreOnTheRoadmap` fails otherwise. The queue as a table and
the Inbound queue were drawn for 2.0, deferred on 2026-10-09 with no row of
their own, and then forgotten for nine releases, the guards green because they
had been told to be (owner, 2026-10-18: "Фиаско"); built in `2.16.0`.
### End-to-end (container suite)
Separate Go module `test/e2e/`; **not** included in the main module's
+14 -3
View File
@@ -447,7 +447,7 @@ changes.
|---|---|---|
| Overview | Overview | `/overview` |
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
| Inbound | Domains · Log · Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/filter-lists` |
| Inbound | Domains · Log · Queue and Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/queue`, `/inbound/filter-lists` |
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
@@ -564,14 +564,25 @@ past that the page shows the last result). *Greeting* is the `220` line Postfix
### Queue and System log
- **Outbound › Queue** (`/outbound/queue`, global role) — live view of
messages Postfix is still trying to deliver, deferring or holding, as
`postqueue -p` lists them. It only shows: nothing there deletes or forces a message. A box
messages Postfix is still trying to deliver, deferring or holding, a row
per recipient, oldest first: queue id, when it arrived, sender, recipient,
and the last reply of the receiving server or why no connection was made
(from `postqueue -j`). *Details* opens the message in the Outbound log when
it has a row there; the panel's own mail and bounces have none. The box
head counts the messages and their size; past 200 recipients the oldest 200
are listed and a line says how many there are - `postqueue -p` in the
container lists them all. It only shows: nothing there deletes or forces a message. A box
states this instance's retry policy — first retry delay, later backoff cap,
how long a message stays in the queue — from `postconf -h`, read once when
the panel starts. A `postconf -e` override inside the container is visible
after the next panel (or container) restart. There is no maximum attempt
count: Postfix retries until the message is delivered or the queue lifetime
runs out.
- **Inbound › Queue** (`/inbound/queue`, global role, with the inbound relay
on) — the same Postfix queue, the recipients of inbound domains: mail
accepted on port 25 that its upstream has not yet taken, with the
upstream's last reply. A recipient belongs here when its domain is an
inbound domain, and to Outbound › Queue otherwise.
- **Server › System log** (`/server/log`) — tail of `/data/log/mail.log`
(Postfix and related daemon lines). The log rotates daily (14 files kept)
with a `postfix reload` after each rotation; a background loop checks every
+5 -4
View File
@@ -95,7 +95,7 @@ only.
| Outbound: domain, applications, settings, log, DMARC | all domains | assigned outbound domains |
| Inbound: domain (upstream, recipients), log | all domains | assigned inbound domains |
| Add / delete a domain, either direction | yes | no |
| Queues, either direction | yes | no, until the queue is a filterable table |
| Queues, either direction | yes | no — the queue is the instance's; a domain's rows for its administrator would be a decision of its own |
| Inbound › Quarantine | all domains | assigned inbound domains |
| Inbound › Filter lists, Server › Preflight | yes | no — the lists are instance-wide |
| Account, Help | yes | yes |
@@ -215,8 +215,10 @@ Rules that come with the rename:
**Needs its own roadmap decision before it is built** (the mockups show the
target; none of this is smuggled in with the restyle):
- Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`.
- Inbound queue — the same queue filtered by recipient domain.
- Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`
(built in `2.16.0`).
- Inbound queue — the same queue filtered by recipient domain (built in
`2.16.0`).
- Inbound log — rides on `inbound-antispam-panel` (`2.1.0`).
- Server › Health — a route for the tables removed from Overview, and the
home of *Reload configuration* (today a card on `/status`).
@@ -236,7 +238,6 @@ Drawn and not built (`deferredScreens` in the guard tests):
| Screen | Item | State |
|---|---|---|
| `in-queue` | [queue-table](../roadmap.md#queue-table): the queue as a table from `postqueue -j`, and an Inbound › Queue | undecided; Outbound › Queue shows postqueue's listing in a log pane (`outlineExempt`) |
| `in-quarantine`, `in-quarantine-message` | inbound-quarantine, an idea off the roadmap ([its notes](inbound-quarantine.md)) | its open questions stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
The sibling strip under the navbar holds five or six entries per group without
-20
View File
@@ -30,7 +30,6 @@ directory).
|---|---|---|---|---|
| security-audit-2026-10-17 | Stage H of the 2026-10-17 security audit: the test server's host (S1, S2, S5) and its DNS (S3) | stages 0-6 shipped `2.14.4`, the product side of S3 `2.15.0`; stage H the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) § Stage H |
| go-milter-upstream | An issue and a PR for go-milter: its header map outlives a message and is unbounded | prepared 2026-10-16, the owner posts | — | [third_party/go-milter/PATCHES.md](../third_party/go-milter/PATCHES.md) |
| queue-table | Outbound › Queue as the table the mockup draws, from `postqueue -j`; an Inbound › Queue (`in-queue`) | candidate, undecided since 2026-10-09 | — | — |
After a context reset, pick an item marked `agreed` or `in progress`, then work
the **Implementation checklist** in its linked plan. The `Progress` column above
@@ -93,25 +92,6 @@ option), `third_party/go-milter` goes and `go.mod`'s `replace` with it.
---
## queue-table
**Problem:** the 2.0 mockups draw Outbound › Queue as a table and an
Inbound › Queue (`in-queue`); the redesign kept both out as decisions of
their own (owner, 2026-10-09). Outbound › Queue shows `postqueue -p`'s own
listing in a log pane, and the mockup's `in-queue` is drawn but not built
(`deferredScreens`, `outlineExempt` in the guard tests).
**Goal:** the outbound queue as a table from `postqueue -j` - a row per
message with its sender, recipients, age and last reason - and, if the owner
wants it, the same for mail port 25 holds for an upstream.
**Recommendation:** the outbound table first, read-only - about 4 h with its
tests, since the notifier already reads `postqueue -j` for each message's
arrival time; the inbound queue and any action on a row (retry, delete) only
if the owner asks for them, each drawn mockup-first.
---
## Dropped, with the condition for return
Items the owner took off the roadmap. Each names what would bring it back;
+1 -1
View File
@@ -541,7 +541,7 @@ func (n *Notifier) ownMail(m postfix.QueuedMessage) bool {
return true
}
for _, rcpt := range m.Recipients {
if strings.EqualFold(rcpt, own) {
if strings.EqualFold(rcpt.Address, own) {
return true
}
}
+3 -3
View File
@@ -460,12 +460,12 @@ func TestQueueLeavesOutTheNotifiersOwnMail(t *testing.T) {
b := newBench(t)
old := b.now.Add(-4 * 24 * time.Hour)
b.q = []postfix.QueuedMessage{
{ID: "NOTE0001", Sender: "postmaster@mail.example.org", Recipients: []string{"gone@example.net"}, Arrival: old},
{ID: "BOUNCE01", Sender: "", Recipients: []string{"Postmaster@Mail.Example.Org"}, Arrival: old},
{ID: "NOTE0001", Sender: "postmaster@mail.example.org", Recipients: []postfix.QueuedRecipient{{Address: "gone@example.net"}}, Arrival: old},
{ID: "BOUNCE01", Sender: "", Recipients: []postfix.QueuedRecipient{{Address: "Postmaster@Mail.Example.Org"}}, Arrival: old},
}
want(t, "only its own mail is old", b.hour())
b.q = append(b.q, postfix.QueuedMessage{ID: "APP00001", Sender: "", Recipients: []string{"app@example.com"}, Arrival: old})
b.q = append(b.q, postfix.QueuedMessage{ID: "APP00001", Sender: "", Recipients: []postfix.QueuedRecipient{{Address: "app@example.com"}}, Arrival: old})
if got := b.hour(); len(got) != 1 {
t.Errorf("an application's bounce is old too: %v, want one message", got)
}
+37 -13
View File
@@ -11,7 +11,7 @@ import (
)
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
// it: where it is, and since when.
// it: where it is, since when, and what became of each recipient's last try.
//
// Postfix 3.10.13, src/postqueue/showq_json.c, format_json(): one object and a
// newline per message - "queue_name", "queue_id", "arrival_time" (seconds,
@@ -22,21 +22,31 @@ type QueuedMessage struct {
ID string
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
Sender string
Recipients []string
Recipients []QueuedRecipient
Arrival time.Time
Size int64 // bytes, "message_size"
}
// queueJSONTimeout is longer than the listing for a page gets: the notifier
// asks once an hour and can wait, and a queue large enough to be slow to list
// is the one that must not go unreported.
// QueuedRecipient is one recipient of a queued message. DelayReason is why its
// last delivery attempt did not finish - the remote server's reply, or why no
// connection was made - and empty when there was none yet (showq_json.c writes
// "delay_reason" only for a recipient that has one).
type QueuedRecipient struct {
Address string
DelayReason string
}
// queueJSONTimeout is longer than the listing for a page gets (postqueueTimeout):
// the notifier asks once an hour and can wait, and a queue large enough to be
// slow to list is the one that must not go unreported.
const queueJSONTimeout = 30 * time.Second
// readQueueJSON runs `postqueue -j`. It is a var so tests can answer without a
// running Postfix, the way readRetryConf is.
// readQueueJSON runs `postqueue -j` with a deadline. It is a var so tests can
// answer without a running Postfix, the way readRetryConf is.
var readQueueJSON = postqueueJSON
func postqueueJSON() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), queueJSONTimeout)
func postqueueJSON(timeout time.Duration) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// A fixed flag and no user input, never through a shell (security.md).
cmd := exec.CommandContext(ctx, "postqueue", "-j")
@@ -46,7 +56,7 @@ func postqueueJSON() ([]byte, error) {
cmd.Stderr = errOut
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("postqueue -j timed out after %s", queueJSONTimeout)
return nil, fmt.Errorf("postqueue -j timed out after %s", timeout)
}
if err != nil {
return nil, fmt.Errorf("postqueue -j: %w: %s", err, strings.TrimSpace(errOut.buf.String()))
@@ -63,7 +73,18 @@ func postqueueJSON() ([]byte, error) {
// for the notification about mail that has waited too long. An error means the
// queue could not be listed and says nothing about any message.
func QueueMessages() ([]QueuedMessage, error) {
out, err := readQueueJSON()
out, err := readQueueJSON(queueJSONTimeout)
if err != nil {
return nil, err
}
return parseQueueJSON(out)
}
// QueueListing is QueueMessages for a page: the Outbound and Inbound queues
// (architecture.md § Panel HTTP surface). A page waits as long as the
// `postqueue -p` listing did, not the notifier's half minute.
func QueueListing() ([]QueuedMessage, error) {
out, err := readQueueJSON(postqueueTimeout)
if err != nil {
return nil, err
}
@@ -86,9 +107,11 @@ func parseQueueJSON(out []byte) ([]QueuedMessage, error) {
QueueName string `json:"queue_name"`
QueueID string `json:"queue_id"`
ArrivalTime int64 `json:"arrival_time"`
MessageSize int64 `json:"message_size"`
Sender string `json:"sender"`
Recipients []struct {
Address string `json:"address"`
Address string `json:"address"`
DelayReason string `json:"delay_reason"`
} `json:"recipients"`
}
if err := json.Unmarshal([]byte(line), &e); err != nil || e.QueueID == "" || e.ArrivalTime <= 0 {
@@ -99,9 +122,10 @@ func parseQueueJSON(out []byte) ([]QueuedMessage, error) {
Queue: e.QueueName,
Sender: e.Sender,
Arrival: time.Unix(e.ArrivalTime, 0).UTC(),
Size: e.MessageSize,
}
for _, r := range e.Recipients {
m.Recipients = append(m.Recipients, r.Address)
m.Recipients = append(m.Recipients, QueuedRecipient{Address: r.Address, DelayReason: r.DelayReason})
}
msgs = append(msgs, m)
}
+11 -3
View File
@@ -28,10 +28,15 @@ not json at all
!got[0].Arrival.Equal(time.Unix(1791000000, 0)) {
t.Errorf("first message = %+v", got[0])
}
if len(got[0].Recipients) != 1 || got[0].Recipients[0] != "a@example.net" {
if len(got[0].Recipients) != 1 || got[0].Recipients[0] != (QueuedRecipient{"a@example.net", "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}) {
t.Errorf("first message's recipients = %v", got[0].Recipients)
}
if got[1].ID != "5F00AA12" || got[1].Queue != "active" {
if got[0].Size != 446 {
t.Errorf("first message's size = %d, want 446", got[0].Size)
}
// A recipient not tried yet has no delay_reason (showq_json.c writes it
// only when there is one).
if got[1].ID != "5F00AA12" || got[1].Queue != "active" || got[1].Recipients[0] != (QueuedRecipient{Address: "b@example.net"}) {
t.Errorf("second message = %+v", got[1])
}
if empty, err := parseQueueJSON(nil); err != nil || len(empty) != 0 {
@@ -50,9 +55,12 @@ not json at all
func TestQueueMessagesReportsAFailedListing(t *testing.T) {
old := readQueueJSON
t.Cleanup(func() { readQueueJSON = old })
readQueueJSON = func() ([]byte, error) { return nil, errors.New("postqueue: Permission denied") }
readQueueJSON = func(time.Duration) ([]byte, error) { return nil, errors.New("postqueue: Permission denied") }
if msgs, err := QueueMessages(); err == nil {
t.Fatalf("no error for a failed listing (got %v)", msgs)
}
if msgs, err := QueueListing(); err == nil {
t.Fatalf("no error for a failed listing on a page (got %v)", msgs)
}
}
+2
View File
@@ -61,6 +61,8 @@ func TestSendLogQueriesUseTheirIndexes(t *testing.T) {
[]string{"SEARCH send_log USING INDEX idx_send_log_domain_id_created_at (domain_id=? AND created_at>?)"}, true},
{"busiest hour of an application", peakPerHourQuery(appByLogin), []any{"app1", StatusRejected, since},
[]string{"SEARCH send_log USING INDEX idx_send_log_app_login_created_at (app_login=? AND created_at>?)"}, true},
{"the rows of queued messages", sendLogRefsQuery(2), []any{"4XcB7k2Jm9z1", "5F00AA12"},
[]string{"SEARCH send_log USING INDEX idx_send_log_queue_id (queue_id=?)"}, true},
{"open rows", listOpenQuery, []any{since},
[]string{"SEARCH send_log USING INDEX idx_send_log_open (created_at<?)"}, true},
{"open rows counted", countOpenQuery, []any{since},
+54
View File
@@ -621,3 +621,57 @@ func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
n, _ := res.RowsAffected()
return n, nil
}
// SendLogRef is one send-log row as Outbound › Queue needs it: the row's id,
// for the link to its page, and the recipient, to tell the rows of one
// message apart.
type SendLogRef struct {
ID int64
To string
}
// sendLogRefsChunk keeps one query's parameters well under SQLite's limit
// (SQLITE_MAX_VARIABLE_NUMBER, 32766 since 3.32.0, https://sqlite.org/limits.html).
const sendLogRefsChunk = 500
// sendLogRefsQuery reads the rows of n queue ids, through idx_send_log_queue_id
// (TestSendLogQueriesUseTheirIndexes).
func sendLogRefsQuery(n int) string {
marks := strings.TrimSuffix(strings.Repeat("?,", n), ",")
return `SELECT id, queue_id, COALESCE(to_addr, '') FROM send_log WHERE queue_id IN (` + marks + `) ORDER BY id`
}
// SendLogRefsByQueueID returns the send-log rows of each of the queue ids, in
// row order: what Outbound › Queue links a waiting recipient to. An id with no
// row - the panel's own mail, a bounce, a row past the retention - is absent.
func (s *Store) SendLogRefsByQueueID(queueIDs []string) (map[string][]SendLogRef, error) {
out := make(map[string][]SendLogRef)
for start := 0; start < len(queueIDs); start += sendLogRefsChunk {
chunk := queueIDs[start:min(start+sendLogRefsChunk, len(queueIDs))]
args := make([]any, len(chunk))
for i, id := range chunk {
args[i] = id
}
rows, err := s.db.Query(sendLogRefsQuery(len(chunk)), args...)
if err != nil {
return nil, fmt.Errorf("send-log rows by queue id: %w", err)
}
for rows.Next() {
var (
ref SendLogRef
queueID string
)
if err := rows.Scan(&ref.ID, &queueID, &ref.To); err != nil {
rows.Close()
return nil, fmt.Errorf("send-log rows by queue id: %w", err)
}
out[queueID] = append(out[queueID], ref)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, fmt.Errorf("send-log rows by queue id: %w", err)
}
}
return out, nil
}
+26
View File
@@ -560,3 +560,29 @@ func TestCloseUnmatchedTestMessages(t *testing.T) {
t.Errorf("the row with a queue id = %+v", r)
}
}
// Outbound › Queue links a waiting recipient to its row: the rows of each queue
// id asked for, in row order, and nothing for an id with none.
func TestSendLogRefsByQueueID(t *testing.T) {
s := openTestStore(t)
for _, e := range []SendLogEntry{
{QueueID: "Q1", Domain: "example.com", From: "a@example.com", To: "x@example.net"},
{QueueID: "Q1", Domain: "example.com", From: "a@example.com", To: "y@example.net"},
{QueueID: "Q2", Domain: "example.com", From: "a@example.com", To: "z@example.net"},
} {
if err := s.InsertQueued(e); err != nil {
t.Fatal(err)
}
}
refs, err := s.SendLogRefsByQueueID([]string{"Q1", "NONE"})
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || len(refs["Q1"]) != 2 || refs["Q1"][0].To != "x@example.net" || refs["Q1"][1].To != "y@example.net" ||
refs["Q1"][0].ID >= refs["Q1"][1].ID {
t.Errorf("refs = %+v", refs)
}
if refs, err := s.SendLogRefsByQueueID(nil); err != nil || len(refs) != 0 {
t.Errorf("no ids: %v, %v", refs, err)
}
}
+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
+4 -4
View File
@@ -469,18 +469,18 @@ func renderShell(t *testing.T, e *Engine, m Meta) string {
}
// The menu has the entries that are built and none of the ones that are not (plan
// § Routes): no Inbound Queue or Quarantine, no Preflight.
func TestMenuHoldsTheTwoDotZeroEntries(t *testing.T) {
// § Routes): no Quarantine, no Preflight. Inbound › Queue joined it in 2.16.0.
func TestMenuHoldsTheBuiltEntries(t *testing.T) {
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
for _, href := range []string{
"/overview", "/outbound/domains", "/outbound/log", "/outbound/queue", "/outbound/dmarc",
"/inbound/domains", "/inbound/log", "/inbound/filter-lists", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
"/inbound/domains", "/inbound/log", "/inbound/queue", "/inbound/filter-lists", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
"/account", "/help", "/license",
} {
mustContain(t, out, `href="`+href+`"`)
}
for _, gone := range []string{
"/inbound/queue", "/inbound/quarantine", "/server/preflight",
"/inbound/quarantine", "/server/preflight",
`href="/status"`, `href="/domains"`, `href="/users"`, `href="/settings"`,
} {
mustNotContain(t, out, gone)
+15 -10
View File
@@ -47,6 +47,7 @@ var pageFixtures = map[string]func() any{
"in-domain": func() any { return inDomainFixture() },
"in-domain-delete": func() any { return inDomainDeleteFixture() },
"in-log": func() any { return inLogFixture() },
"in-queue": func() any { return inQueueFixture() },
"in-filter-lists": func() any { return inFilterListsFixture() },
"out-log": func() any { return outLogFixture() },
@@ -395,16 +396,20 @@ func inDomainFixture() *InDomain {
return p
}
// outQueueFixture is the queue as Postfix prints it for the mockup's one waiting
// message, greylisted by the receiving server, and the retry policy it shows.
func outQueueFixture() *OutQueue {
listing := "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------\n" +
"4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org\n" +
"(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted, try again in 5 minutes (in reply to RCPT TO command))\n" +
" noc@example.org\n" +
"\n" +
"-- 3 Kbytes in 1 Request.\n"
return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
// outQueueFixture is the mockup's Outbound queue: one message waiting, its one
// recipient greylisted by the receiving server, and the retry policy it shows.
func outQueueFixture() *Queue {
entries := []QueueEntry{{
ID: "4XcB7k2Jm9z1", Queue: "deferred", Arrival: time.Date(2026, 9, 21, 13, 58, 7, 0, time.Local), Size: 3174,
Sender: "alerts@example.org", Recipient: "noc@example.org",
Reason: "451 4.7.1 Greylisted, try again in 5 minutes", Href: "/outbound/log/41",
}}
return NewOutQueue(admin(), entries, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
}
// inQueueFixture is the mockup's Inbound queue: nothing waiting.
func inQueueFixture() *Queue {
return NewInQueue(admin(), nil, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
}
// systemLogFixture is the mockup's System log: nine lines of mail.log, as the
+10 -4
View File
@@ -57,8 +57,9 @@ const (
HelpRecipients = "recipients"
HelpFilter = "filter"
// HelpInboundLog and HelpFilterLists: the plain "log" is the Outbound log's.
HelpInboundLog = "inbound-log"
HelpFilterLists = "filter-lists"
HelpInboundLog = "inbound-log"
HelpInboundQueue = "inbound-queue"
HelpFilterLists = "filter-lists"
// Server
HelpBackup = "backup"
HelpUsers = "users"
@@ -139,13 +140,15 @@ func NewHelp(m Meta, inbound bool) *Help {
{Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients},
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter},
{Label: "Inbound log", Icon: "ti-list-details", Href: "#" + HelpInboundLog},
{Label: "Inbound queue", Icon: "ti-stack-2", Href: "#" + HelpInboundQueue},
{Label: "Filter lists", Icon: "ti-filter", Href: "#" + HelpFilterLists},
}
// The filter lists are the whole server's and their page is 404 to
// a domain administrator (requireInboundGlobal), so their topic is
// not shown to one either.
// not shown to one either; nor is the queue's, the global role's too.
if !m.IsGlobal {
topics, menu = withoutTopic(topics, menu, HelpFilterLists)
topics, menu = withoutTopic(topics, menu, HelpInboundQueue)
}
add("Inbound", topics, menu)
}
@@ -216,7 +219,7 @@ var outboundTopics = []HelpTopic{
Code("mail.log"), ", found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."),
}},
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
Rich("The page lists, as ", Code("postqueue -p"), " prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged."),
Plain("The page lists the messages Postfix has accepted and not yet delivered, one row per recipient: mail being sent right now and mail deferred for a later attempt, with the last reply of the receiving server, or why no connection was made. Mail for an inbound domain waits under Inbound › Queue instead. Only the global role can open it, and it only shows; nothing here deletes or forces a message, and Details opens the message in the Outbound log. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering; the last reply says which."),
}},
{ID: HelpDMARC, Title: "DMARC reports", Body: []Text{
Rich("An aggregate report is the summary a receiving provider sends, usually daily, to the ", Code("rua="),
@@ -256,6 +259,9 @@ var inboundTopics = []HelpTopic{
"Accepted mail is recorded as it passes; refused mail is read from ", Code("mail.log"),
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
}},
{ID: HelpInboundQueue, Title: "Inbound queue", Body: []Text{
Plain("Mail accepted on port 25 and not yet taken by its upstream, one row per recipient, with the upstream's last reply or why it could not be reached. It is the same Postfix queue as Outbound › Queue, split by the recipient's domain, and retried the same way; an upstream that refuses or does not answer is what makes it grow. Only the global role can open it, and it only shows."),
}},
{ID: HelpFilterLists, Title: "Filter lists", Body: []Text{
Rich("Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, ",
Strong("Deny wins"), ".",
+5 -4
View File
@@ -278,13 +278,14 @@ func TestFilterListsEscapesTheEntries(t *testing.T) {
pageLacks(t, "Filter lists", out, `<img src=x>`, `<script>alert`)
}
// The menu leads to both pages, the lists for the global role only.
func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
// The menu leads to the pages, the queue and the lists for the global role only.
func TestInboundMenuHasLogQueueAndFilterLists(t *testing.T) {
out := renderSignedIn(t, "in-log", inLogFixture())
pageHas(t, "Inbound log", out,
`<a class="navbar-item is-active" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>`,
`<a class="navbar-item" href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a>`,
`<a class="navbar-item" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`,
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
pageHas(t, "Filter lists", out,
`<a class="navbar-item is-active" href="/inbound/filter-lists">`, `<a href="/inbound/filter-lists" aria-current="page">`)
@@ -292,7 +293,7 @@ func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 90, nil, InLogFilter{}).WithRows(nil, 1, 1)
out = renderSignedIn(t, "in-log", domain)
pageHas(t, "Inbound log", out, `<a class="navbar-item is-active" href="/inbound/log">`, `<a href="/inbound/log" aria-current="page">`)
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`)
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`, `/inbound/queue`)
}
// What a sheet says, from the two shapes a row's detail has: the filter's
-82
View File
@@ -1,82 +0,0 @@
package view
// The data of the Outbound queue (templates/out_queue.html, mockup
// out-queue.html): what Postfix is still holding, and the retry policy that
// decides how long it holds it. The queue box polls; the page and its fragment,
// GET /outbound/queue/fragment, render the same box
// (templates/out_queue_body.html).
//
// Until the queue is a table (docs/roadmap.md, parse postqueue -j) the box
// shows postqueue's own listing in a log pane, as the system log shows
// mail.log: the mockup's table is not drawn from data the panel has.
//
// 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 "strings"
const outboundQueue = "/outbound/queue"
// OutQueue is the data of the Outbound queue. It is for the global role.
type OutQueue struct {
Meta
Head Head
Queue Box
Lines []LogLine
// Empty is shown in place of the listing when there is none: the queue holds
// nothing, or it could not be read.
Empty EmptyState
Retries Box
Policy []Fact
}
// NewOutQueue builds the page. listing is what postqueue printed, as it was
// read, and errText is why it could not be read, empty when it could.
func NewOutQueue(m Meta, listing, errText string) *OutQueue {
m.Title, m.Section, m.Page = "Outbound queue", "outbound", "queue"
p := &OutQueue{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound queue",
Lead: Rich("What applications handed over and the receiving server has not yet taken, as ", Code("postqueue -p"), " lists it."),
},
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
Help: topicLink(HelpQueue, "What waits here and how Postfix retries"),
End: Plain("Refreshes on its own")},
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Queue is empty.")},
Retries: Box{No: "02", Title: "How retries work"},
}
if errText != "" {
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
return p
}
if strings.TrimSpace(listing) == "" {
return p
}
for _, line := range strings.Split(strings.TrimRight(listing, "\r\n"), "\n") {
p.Lines = append(p.Lines, LogLine{Text: strings.TrimRight(line, "\r")})
}
return p
}
// 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 *OutQueue) WithRetryPolicy(firstRetry, backoffCap, lifetime string, fromDefaults bool) *OutQueue {
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("no attempt counter, only time")},
}
return p
}
-60
View File
@@ -1,60 +0,0 @@
package view
import (
"strings"
"testing"
)
// ---- Outbound queue
// The page prints postqueue's listing as it is and polls from its box; the
// retry policy sits under it, outside the poll.
func TestOutQueueShowsTheListingAndPollsItsBox(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>`,
`id="out-queue" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML"`,
`class="sp-log"`, `-Queue ID- --Size-- `, `4XcB7k2Jm9z1 3174 Sun Sep 21 13:58:07 alerts@example.org`,
`-- 3 Kbytes in 1 Request.`,
`aria-current="page"><i class="ti ti-stack-2"></i>Queue</a>`,
)
// What Postfix printed is escaped, not interpreted.
pageHas(t, "Outbound queue", out, `(host mx1.example.org[192.0.2.10] said: 451 4.7.1 Greylisted`)
pageLacks(t, "Outbound queue", out, "<table", "Queue is empty.")
frag := renderFragment(t, "out_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, `class="sp-log"`) != 1 || strings.Count(frag, `class="sp-log"`) != 1 {
t.Error("the page and its fragment should each carry the listing once")
}
}
// 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(), "", "").WithRetryPolicy("10 minutes", "about 1 hour 7 minutes", "2 days", false))
pageHas(t, "Outbound queue", out,
`<h2>How retries work</h2>`, `This Postfix&#39;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(), "", "").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 TestOutQueueSaysWhyItHasNoListing(t *testing.T) {
out := renderSignedIn(t, "out-queue", NewOutQueue(admin(), "", ""))
pageHas(t, "empty queue", out, `class="sp-empty"`, "Queue is empty.")
pageLacks(t, "empty queue", out, `class="sp-log"`, "Could not read")
out = renderSignedIn(t, "out-queue", NewOutQueue(admin(), "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, `class="sp-log"`, "Queue is empty.", "ignored")
}
+221
View File
@@ -0,0 +1,221 @@
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
}
+123
View File
@@ -0,0 +1,123 @@
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`, `&lt;&gt;`, `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&#39;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`)
}
+14
View File
@@ -0,0 +1,14 @@
{{/* Inbound queue (mockup in-queue.html): what Postfix is still holding for an
inbound domain's upstream, in one box that polls (queue_body.html), and
under it the retry policy read once at panel start. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "queue_body" .}}
{{template "box_open" .Retries}}
{{template "body_open"}}
{{template "facts" .Policy}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
+4 -3
View File
@@ -115,7 +115,8 @@
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "log")}} is-active{{end}}" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>
{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
{{if .ShowQueue}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "queue")}} is-active{{end}}" href="/inbound/queue"><i class="ti ti-stack-2"></i>Queue</a>
{{end}}{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
{{end}} </div>
</div>
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
@@ -158,7 +159,7 @@
{{define "touchnav"}}<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">
{{if .ShowOverview}}<a href="/overview"{{if eq .Section "overview"}} aria-current="page"{{end}}>Overview</a>
{{end}}{{if .ShowOutbound}}<details class="sp-touchnav-group"{{if eq .Section "outbound"}} open{{end}}><summary>Outbound</summary><a href="/outbound/domains"{{if and (eq .Section "outbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if and (eq .Section "outbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if and (eq .Section "outbound") (eq .Page "queue")}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if and (eq .Section "outbound") (eq .Page "dmarc")}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</details>
{{end}}{{if .ShowInbound}}<details class="sp-touchnav-group"{{if eq .Section "inbound"}} open{{end}}><summary>Inbound</summary><a href="/inbound/domains"{{if and (eq .Section "inbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if and (eq .Section "inbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</details>
{{end}}{{if .ShowInbound}}<details class="sp-touchnav-group"{{if eq .Section "inbound"}} open{{end}}><summary>Inbound</summary><a href="/inbound/domains"{{if and (eq .Section "inbound") (eq .Page "domains")}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if and (eq .Section "inbound") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/inbound/queue"{{if and (eq .Section "inbound") (eq .Page "queue")}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</details>
{{end}}{{if .ShowServer}}<details class="sp-touchnav-group"{{if eq .Section "server"}} open{{end}}><summary>Server</summary><a href="/server/health"{{if and (eq .Section "server") (eq .Page "health")}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if and (eq .Section "server") (eq .Page "log")}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if and (eq .Section "server") (eq .Page "backup")}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if and (eq .Section "server") (eq .Page "users")}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if and (eq .Section "server") (eq .Page "settings")}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></details>
{{end}}<details class="sp-touchnav-group"{{if eq .Section "user"}} open{{end}}><summary><i class="ti ti-user-circle"></i>{{.User}}</summary><a href="/account"><i class="ti ti-key"></i>Account</a><a href="/help"><i class="ti ti-help"></i>Help</a><form method="post" action="/logout"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form></details>
</div></details>{{end}}
@@ -167,6 +168,6 @@
pages of the current group, the current one marked. A page outside every
group (the user menu, the kit page's neighbours) has none. */}}
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/inbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
{{end}}{{end}}
+4 -4
View File
@@ -1,10 +1,10 @@
{{/* Outbound queue (mockup out-queue.html): what Postfix is still holding, in one
box that polls (out_queue_body.html), and under it the retry policy read once
at panel start. The queue is postqueue's own listing until it is a table. */}}
{{/* Outbound queue (mockup out-queue.html): what Postfix is still holding for a
receiving server, in one box that polls (queue_body.html), and under it the
retry policy read once at panel start. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{template "out_queue_body" .}}
{{template "queue_body" .}}
{{template "box_open" .Retries}}
{{template "body_open"}}
@@ -1,6 +0,0 @@
{{/* The polled part of the Outbound queue, in the page and in its fragment (GET
/outbound/queue/fragment) alike: the box itself polls (.Queue.Poll), so the
response replaces it whole. With nothing to list it says why instead. */}}
{{define "out_queue_body"}}{{template "box_open" .Queue}}
{{if .Lines}}{{template "log_pane" .Lines}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
@@ -0,0 +1,13 @@
{{/* The polled part of either queue, in the page and in its fragment (GET
/outbound/queue/fragment, /inbound/queue/fragment) alike: the box itself
polls (.Queue.Poll), so the response replaces it whole. A row per recipient,
oldest first; with nothing to list it says why instead. */}}
{{define "queue_body"}}{{template "box_open" .Queue}}
{{if .Rows}}<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th>{{if .Details}}<th></th>{{end}}</tr></thead>
<tbody>{{range .Rows}}
<tr><td class="sp-mono">{{.ID}}</td><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Arrived}}</td><td>{{wbr_at .From}}</td><td>{{wbr_at .To}}</td><td class="sp-muted">{{.Reply}}</td>{{if $.Details}}<td class="sp-actions">{{if .Href}}<a href="{{.Href}}">Details</a>{{end}}</td>{{end}}</tr>{{end}}
</tbody>
</table></div>
{{with .Foot}}{{template "box_foot" .}}{{end}}{{else}}{{template "empty_state" .Empty}}{{end}}
{{template "box_close"}}{{end}}
+32
View File
@@ -0,0 +1,32 @@
package view
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A screen the mockups draw and the panel does not build, or builds otherwise,
// is one the guards were told to let pass (deferredScreens, outlineExempt). It
// must be named in docs/roadmap.md, a row or an idea, so that it is not simply
// forgotten while every test is green: the queue as a table and the Inbound
// queue were (development.md § Panel pages: the component kit and its guards).
func TestUnbuiltScreensAreOnTheRoadmap(t *testing.T) {
roadmap, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "roadmap.md"))
if err != nil {
t.Fatalf("read the roadmap: %v", err)
}
var names []string
for name := range deferredScreens {
names = append(names, name)
}
for name := range outlineExempt {
names = append(names, name)
}
for _, name := range names {
if !strings.Contains(string(roadmap), "`"+name+"`") {
t.Errorf("screen %q is drawn and not built as drawn, and docs/roadmap.md does not name it (`%s`)", name, name)
}
}
}
+3 -2
View File
@@ -58,13 +58,14 @@ var pageFiles = map[string][]string{
"in-domain": {"templates/in_domain.html"},
"in-domain-delete": {"templates/in_domain_delete.html"},
"in-log": {"templates/in_log.html"},
"in-queue": {"templates/in_queue.html", "templates/queue_body.html"},
"in-filter-lists": {"templates/in_filter_lists.html"},
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
"out-message": {"templates/out_message.html"},
"dmarc": {"templates/dmarc.html"},
"dmarc-domain": {"templates/dmarc_domain.html"},
"dmarc-report": {"templates/dmarc_report.html"},
"out-queue": {"templates/out_queue.html", "templates/out_queue_body.html"},
"out-queue": {"templates/out_queue.html", "templates/queue_body.html"},
"components": {"templates/kit.html"},
}
@@ -72,7 +73,7 @@ var pageFiles = map[string][]string{
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"out_log_rows": "templates/out_log_rows.html",
"out_queue_body": "templates/out_queue_body.html",
"queue_body": "templates/queue_body.html",
"system_log_body": "templates/system_log_body.html",
"health_body": "templates/health_body.html",
"overview_poll": "templates/overview_cards.html",
+5 -2
View File
@@ -285,9 +285,12 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
authed.HandleFunc("GET /inbound/domains/{id}/delete", h.HandleInboundDeleteConfirm)
authed.HandleFunc("POST /inbound/domains/{id}/delete", h.HandleInboundDelete)
// The log is for everyone who reaches an inbound domain (scoped to
// theirs); the lists are the instance's, so the handlers keep the
// global role's check (requireInboundGlobal) and answer 404 to the rest.
// theirs); the queue and the lists are the instance's, so the handlers
// keep the global role's check (requireInboundGlobal) and answer 404
// to the rest.
authed.HandleFunc("GET /inbound/log", h.HandleInboundLog)
authed.HandleFunc("GET /inbound/queue", h.HandleInboundQueue)
authed.HandleFunc("GET /inbound/queue/fragment", h.HandleInboundQueueBody)
authed.HandleFunc("GET /inbound/filter-lists", h.HandleFilterLists)
authed.HandleFunc("POST /inbound/filter-lists", h.HandleFilterListAdd)
authed.HandleFunc("POST /inbound/filter-lists/{id}/delete", h.HandleFilterListDelete)