Show this Postfix's retry policy on Mail queue and delivery history.
test / test (push) Waiting to run

Numbers come from a one-shot postconf -h at panel start so a manual override is visible after restart, without inventing an attempt count.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 22:12:03 +03:00
parent 46191172d6
commit 705239568b
17 changed files with 669 additions and 30 deletions
+5
View File
@@ -7,6 +7,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added ### Added
- Mail queue and a delivery's history show this Postfix's retry policy
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
at panel start. A manual `postconf -e` override is visible after the next
panel restart. There is no attempt counter — Postfix retries on time.
- docs: **Plan checklists** in [development.md](docs/development.md) — format, - docs: **Plan checklists** in [development.md](docs/development.md) — format,
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit + `Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
CHANGELOG, version cuts `1.3.1``1.8.0` per roadmap stage. CHANGELOG, version cuts `1.3.1``1.8.0` per roadmap stage.
+5
View File
@@ -66,6 +66,10 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
// the environment describes it, with nothing bound to a port yet. // the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) { func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
// postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md).
retryPolicy := postfix.LoadRetryPolicy()
return web.New(st, ms.Domains, ms.Apps, web.Config{ return web.New(st, ms.Domains, ms.Apps, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
@@ -83,6 +87,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
+12 -3
View File
@@ -78,7 +78,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
One process, three roles: One process, three roles:
1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse 1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only. proxy only. On start it runs `postconf -h` once for the deferred-mail retry
parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
@@ -173,7 +180,7 @@ below is a summary — HTMX fragment endpoints
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
@@ -184,7 +191,9 @@ HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend `panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
session idle timeout (only non-`HX-Request` GET and mutating requests count as session idle timeout (only non-`HX-Request` GET and mutating requests count as
activity). activity). The Mail queue retry-policy card is outside that fragment: it is the
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
### Sessions ### Sessions
+11 -3
View File
@@ -319,7 +319,12 @@ the panel shows after manual edits under `/data`.
### Mail queue and System log ### Mail queue and System log
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring. trying to deliver or deferring. A card at the top 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.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and - **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
related daemon lines). The log rotates daily (14 files kept) with a related daemon lines). The log rotates daily (14 files kept) with a
`postfix reload` after each rotation; a background loop checks every six `postfix reload` after each rotation; a background loop checks every six
@@ -668,8 +673,11 @@ by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
*Details* opens that row's own page (`/deliveries/{id}`). That page carries *Details* opens that row's own page (`/deliveries/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient — and, under accepted and what Postfix later reported for the recipient. A `deferred`
both, the `mail.log` lines for its queue id: the connection to the or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
administrators see them here because they cannot open Mail queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is controlled by away; the page says so. Retention is controlled by
+10 -10
View File
@@ -1,6 +1,6 @@
# Plan: queue-retries (Postfix retry policy in the panel) # Plan: queue-retries (Postfix retry policy in the panel)
**Status:** agreed **Status:** implemented (2026-08-17); version cut `1.3.1` pending
**Date:** 2026-08-13 **Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface. **Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay. **Order:** small panel item; does not wait on inbound-relay.
@@ -145,12 +145,12 @@ lifetime runs out.
Target version cut: **`1.3.1`** (PATCH). One commit per step; see Target version cut: **`1.3.1`** (PATCH). One commit per step; see
[development.md](../development.md) § Plan checklists. [development.md](../development.md) § Plan checklists.
- [ ] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus** - [x] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus**
- [ ] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus** - [x] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus**
- [ ] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus** - [x] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus**
- [ ] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet** - [x] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet**
- [ ] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet** - [x] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet**
- [ ] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet** - [x] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet**
- [ ] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet** - [x] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet**
- [ ] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet** - [x] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet**
- [ ] `go vet`, `go test` on touched packages — **Haiku** - [x] `go vet`, `go test` on touched packages — **Haiku**
+5 -2
View File
@@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---|---| |---|---|---|---|---|
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | 0/9 | [plans/queue-retries.md](plans/queue-retries.md) | | queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | 9/9 | [plans/queue-retries.md](plans/queue-retries.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) |
| send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) | | send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) |
| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) | | domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) |
@@ -65,7 +65,10 @@ panel knobs for queue lifetime, no schema change. Domain administrators see
the intervals on `/deliveries/{id}` (they cannot open Mail queue). the intervals on `/deliveries/{id}` (they cannot open Mail queue).
**Done when:** see the criteria in **Done when:** see the criteria in
[plans/queue-retries.md](plans/queue-retries.md). [plans/queue-retries.md](plans/queue-retries.md). Implementation is on
`main` (checklist 9/9); the `1.3.1` version cut is still pending because
`[Unreleased]` also holds unrelated work (including a breaking backup-layout
change).
**Dependencies / risks:** `postconf` unavailable outside the container **Dependencies / risks:** `postconf` unavailable outside the container
(fallback + muted note). Copy must stay time-based — Postfix has no max (fallback + muted note). Copy must stay time-based — Postfix has no max
+151
View File
@@ -0,0 +1,151 @@
package postfix
import (
"fmt"
"strconv"
"strings"
"time"
"unicode"
)
// ParseDuration converts a Postfix time value to a duration. Units are those
// postconf prints and accepts: s, m, h, d, w. A bare number is seconds. Values
// may concatenate units (`1h7m`), matching Postfix's own conv_time.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("postfix: empty time value")
}
var total time.Duration
i := 0
for i < len(s) {
for i < len(s) && unicode.IsSpace(rune(s[i])) {
i++
}
if i >= len(s) {
break
}
if s[i] == '-' {
return 0, fmt.Errorf("postfix: negative time value %q", s)
}
if s[i] == '+' {
i++
}
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == start {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
n, err := strconv.ParseInt(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
unit := byte('s')
if i < len(s) && isTimeUnit(s[i]) {
unit = s[i] | 0x20 // ASCII fold to lowercase
i++
}
part, err := durationForUnit(n, unit)
if err != nil {
return 0, err
}
total += part
}
return total, nil
}
func isTimeUnit(c byte) bool {
switch c | 0x20 {
case 's', 'm', 'h', 'd', 'w':
return true
}
return false
}
func durationForUnit(n int64, unit byte) (time.Duration, error) {
var unitDur time.Duration
switch unit {
case 's':
unitDur = time.Second
case 'm':
unitDur = time.Minute
case 'h':
unitDur = time.Hour
case 'd':
unitDur = 24 * time.Hour
case 'w':
unitDur = 7 * 24 * time.Hour
default:
return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit))
}
return time.Duration(n) * unitDur, nil
}
// FormatDuration renders a Postfix interval the way the Mail queue card and
// delivery history share it, so the two cannot drift. Exact day/hour/minute/
// second values stay exact (`5 minutes`, `5 days`, `1 hour`); a remainder that
// is rounded to the nearest minute is marked `about` (`about 1 hour 7 minutes`
// for the stock 4000s backoff cap).
func FormatDuration(d time.Duration) string {
if d < 0 {
d = -d
}
sec := int64(d / time.Second)
if sec == 0 {
return "0 seconds"
}
days := sec / 86400
rem := sec % 86400
hours := rem / 3600
rem %= 3600
minutes := rem / 60
seconds := rem % 60
about := false
if days > 0 || hours > 0 {
if seconds >= 30 {
minutes++
about = true
} else if seconds > 0 {
about = true
}
seconds = 0
if minutes >= 60 {
hours++
minutes = 0
}
if hours >= 24 {
days++
hours = 0
}
}
parts := make([]string, 0, 4)
if days > 0 {
parts = append(parts, counted(days, "day"))
}
if hours > 0 {
parts = append(parts, counted(hours, "hour"))
}
if minutes > 0 {
parts = append(parts, counted(minutes, "minute"))
}
if seconds > 0 {
parts = append(parts, counted(seconds, "second"))
}
s := strings.Join(parts, " ")
if about {
return "about " + s
}
return s
}
func counted(n int64, unit string) string {
if n == 1 {
return "1 " + unit
}
return strconv.FormatInt(n, 10) + " " + unit + "s"
}
+64
View File
@@ -0,0 +1,64 @@
package postfix
import (
"testing"
"time"
)
func TestParseDuration(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"5d", 5 * 24 * time.Hour},
{"300s", 300 * time.Second},
{"4000s", 4000 * time.Second},
{"1h", time.Hour},
{"0", 0},
{"0s", 0},
{"300", 300 * time.Second},
{" 300s ", 300 * time.Second},
{"1h7m", time.Hour + 7*time.Minute},
{"1w", 7 * 24 * time.Hour},
{"2m", 2 * time.Minute},
}
for _, tc := range cases {
got, err := ParseDuration(tc.in)
if err != nil {
t.Errorf("ParseDuration(%q): %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationRejectsInvalid(t *testing.T) {
for _, in := range []string{"", "foo", "5x", "-300s", "s", "1h 5x"} {
if _, err := ParseDuration(in); err == nil {
t.Errorf("ParseDuration(%q) = nil, want error", in)
}
}
}
func TestFormatDuration(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{5 * 24 * time.Hour, "5 days"},
{300 * time.Second, "5 minutes"},
{4000 * time.Second, "about 1 hour 7 minutes"},
{time.Hour, "1 hour"},
{0, "0 seconds"},
{time.Second, "1 second"},
{2 * time.Minute, "2 minutes"},
{24 * time.Hour, "1 day"},
}
for _, tc := range cases {
if got := FormatDuration(tc.in); got != tc.want {
t.Errorf("FormatDuration(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package postfix
import (
"fmt"
"log"
"os/exec"
"strings"
"time"
)
// RetryPolicy is this Postfix's deferred-mail retry timings, as postconf
// reports them. The panel loads it once at HTTP start (architecture.md) and
// never re-reads it for a request.
type RetryPolicy struct {
QueueRunDelay time.Duration
MinimalBackoff time.Duration
MaximalBackoff time.Duration
MaximalQueueLifetime time.Duration
BounceQueueLifetime time.Duration
DelayWarningTime time.Duration
// FromDefaults is true when postconf could not be read and the compiled-in
// Postfix 3.x values were substituted. The Mail queue card shows a muted
// note in that case so an operator who overrode the parameters is not
// silently shown the stock numbers.
FromDefaults bool
}
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
// build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts.
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
QueueRunDelay: 300 * time.Second,
MinimalBackoff: 300 * time.Second,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 5 * 24 * time.Hour,
BounceQueueLifetime: 5 * 24 * time.Hour,
DelayWarningTime: 0,
FromDefaults: true,
}
}
// retryConfKeys is the fixed argv tail for `postconf -h`. Order matches the
// fields of RetryPolicy. No user input is interpolated (security.md).
var retryConfKeys = []string{
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
}
// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it
// the same way logtail stubs queueIDs.
var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) {
// Fixed argv, no user input — same pattern as Queue (security.md).
cmd := exec.Command("postconf", "-h",
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out)))
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(retryConfKeys) {
return nil, fmt.Errorf("postconf -h: got %d values, want %d", len(lines), len(retryConfKeys))
}
for i := range lines {
lines[i] = strings.TrimSpace(lines[i])
}
return lines, nil
}
// LoadRetryPolicy reads the effective Postfix retry parameters. On any failure
// it logs a warning and returns DefaultRetryPolicy; the HTTP role must not
// refuse to start because postconf is absent.
func LoadRetryPolicy() RetryPolicy {
lines, err := readRetryConf()
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
p, err := parseRetryPolicy(lines)
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
return p
}
func parseRetryPolicy(lines []string) (RetryPolicy, error) {
if len(lines) != len(retryConfKeys) {
return RetryPolicy{}, fmt.Errorf("got %d values, want %d", len(lines), len(retryConfKeys))
}
var durs [6]time.Duration
for i, line := range lines {
d, err := ParseDuration(line)
if err != nil {
return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err)
}
durs[i] = d
}
return RetryPolicy{
QueueRunDelay: durs[0],
MinimalBackoff: durs[1],
MaximalBackoff: durs[2],
MaximalQueueLifetime: durs[3],
BounceQueueLifetime: durs[4],
DelayWarningTime: durs[5],
}, nil
}
// FirstRetry is the human string for the first deferred retry (minimal
// backoff), shared by the Mail queue card and delivery history.
func (p RetryPolicy) FirstRetry() string {
return FormatDuration(p.MinimalBackoff)
}
// BackoffCap is the human string for maximal_backoff_time.
func (p RetryPolicy) BackoffCap() string {
return FormatDuration(p.MaximalBackoff)
}
// QueueLifetime is the human string for maximal_queue_lifetime.
func (p RetryPolicy) QueueLifetime() string {
return FormatDuration(p.MaximalQueueLifetime)
}
+79
View File
@@ -0,0 +1,79 @@
package postfix
import (
"errors"
"testing"
"time"
)
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "300s", "4000s", "2d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if p.FromDefaults {
t.Fatal("FromDefaults = true, want live postconf values")
}
if p.MinimalBackoff != 300*time.Second {
t.Errorf("MinimalBackoff = %v, want 300s", p.MinimalBackoff)
}
if p.MaximalBackoff != 4000*time.Second {
t.Errorf("MaximalBackoff = %v, want 4000s", p.MaximalBackoff)
}
if p.MaximalQueueLifetime != 2*24*time.Hour {
t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime)
}
if p.BounceQueueLifetime != 5*24*time.Hour {
t.Errorf("BounceQueueLifetime = %v, want 5d", p.BounceQueueLifetime)
}
if p.DelayWarningTime != 0 {
t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime)
}
}
func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return nil, errors.New("exec: not found")
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
want := DefaultRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when postconf fails")
}
if p.QueueRunDelay != want.QueueRunDelay || p.MinimalBackoff != want.MinimalBackoff ||
p.MaximalBackoff != want.MaximalBackoff || p.MaximalQueueLifetime != want.MaximalQueueLifetime {
t.Errorf("fallback = %+v, want compiled-in defaults %+v", p, want)
}
}
func TestLoadRetryPolicyFallsBackOnUnparseableValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "nope", "4000s", "5d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when a value cannot be parsed")
}
}
func TestParseRetryPolicyStock(t *testing.T) {
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "5d", "5d", "0"})
if err != nil {
t.Fatalf("parseRetryPolicy: %v", err)
}
if p.FromDefaults {
t.Error("parsed policy should not be marked FromDefaults")
}
if p.QueueRunDelay != 300*time.Second || p.MaximalQueueLifetime != 5*24*time.Hour {
t.Errorf("parsed = %+v", p)
}
}
+5
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view" "github.com/mixeme/selfpost/internal/web/view"
@@ -29,6 +30,10 @@ type Config struct {
// and used to cap domain/app level-2 ceilings (guide § Rate limiting). // and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int RateLimitMessagesPerIP int
RateLimitWindowSeconds int RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts (architecture.md). The Mail queue card and
// delivery history read it from here; they never call postconf.
RetryPolicy postfix.RetryPolicy
} }
// Handlers holds dependencies for authenticated panel routes. // Handlers holds dependencies for authenticated panel routes.
+20 -10
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
@@ -115,7 +116,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
// The status in the panel's own badge vocabulary, so the headline reads // The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel. // the same way as every other health signal in the panel.
"Level": deliveryLevel(row.Status), "Level": deliveryLevel(row.Status),
"Events": deliveryEvents(row), "Events": deliveryEvents(row, h.cfg.RetryPolicy),
// The mail.log lines for this message, and — when there are none — the // The mail.log lines for this message, and — when there are none — the
// reason, which is a normal outcome rather than a failure. // reason, which is a normal outcome rather than a failure.
"LogRows": logRows, "LogRows": logRows,
@@ -173,7 +174,9 @@ type deliveryEvent struct {
// timestamps *are* the history, and stating them as steps is what makes a row // timestamps *are* the history, and stating them as steps is what makes a row
// whose created_at and updated_at differ by six hours legible as "queued for // whose created_at and updated_at differ by six hours legible as "queued for
// six hours, then delivered" rather than as two dates in a list of fields. // six hours, then delivered" rather than as two dates in a list of fields.
func deliveryEvents(row store.SendLogRow) []deliveryEvent { // policy supplies the human intervals for deferred and bounced copy, the same
// strings the Mail queue card prints, so the two cannot drift.
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
// A rejected message has no second step, and its first one is not an // A rejected message has no second step, and its first one is not an
// acceptance: the journal-milter refused it, so Postfix never queued it. // acceptance: the journal-milter refused it, so Postfix never queued it.
if row.Status == store.StatusRejected { if row.Status == store.StatusRejected {
@@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "warn", Level: "warn",
Status: store.StatusDeferred, Status: store.StatusDeferred,
Title: "Deferred, will be retried", Title: "Deferred, will be retried",
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.", Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()),
}) })
case store.StatusBounced: case store.StatusBounced:
return append(events, deliveryEvent{ return append(events, deliveryEvent{
@@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "error", Level: "error",
Status: store.StatusBounced, Status: store.StatusBounced,
Title: "Bounced", Title: "Bounced",
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.", Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
policy.QueueLifetime()),
}) })
default: default:
// A status the log-tailer learns to write before this switch does. // A status the log-tailer learns to write before this switch does.
@@ -412,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
return return
} }
out, errText := readQueue() out, errText := readQueue()
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue", "Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "mail_queue", "Active": "mail_queue",
"IsGlobal": true, "IsGlobal": true,
"Output": out, "Output": out,
"Error": errText, "Error": errText,
"FirstRetry": policy.FirstRetry(),
"BackoffCap": policy.BackoffCap(),
"QueueLifetime": policy.QueueLifetime(),
"RetryFromDefaults": policy.FromDefaults,
}) })
} }
@@ -8,7 +8,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
) )
@@ -450,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
} }
return path return path
} }
// fixtureRetryPolicy is a distinctive policy so tests can tell the Config
// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days).
func fixtureRetryPolicy() postfix.RetryPolicy {
return postfix.RetryPolicy{
QueueRunDelay: 10 * time.Minute,
MinimalBackoff: 10 * time.Minute,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 2 * 24 * time.Hour,
BounceQueueLifetime: 2 * 24 * time.Hour,
}
}
// The retry card 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) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
">10 minutes<",
"doubling, cap about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
t.Errorf("mail queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
}
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
}
func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
"for up to 2 days",
} {
if !strings.Contains(out, want) {
t.Errorf("deferred history is missing %q:\n%s", want, out)
}
}
}
func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
}
+4
View File
@@ -663,6 +663,10 @@ h1.subject { overflow-wrap: anywhere; }
wider than the column it shares. */ wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; } .fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; } .fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
/* Retry-policy tiles carry a short phrase (`doubling, cap about 1 hour 7
minutes`) rather than a domain or a queue id, so they need a slightly
wider minimum than the delivery-page facts before wrapping to one column. */
.facts.retry-facts { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
/* A message's history: the steps down a line, each with the time it happened, /* A message's history: the steps down a line, each with the time it happened,
the status it reached and what that means. The line is the list's own left the status it reached and what that means. The line is the list's own left
border and the dots sit on top of it, so nothing has to be positioned against border and the dots sit on top of it, so nothing has to be positioned against
+32 -2
View File
@@ -1,10 +1,40 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column {{/* Mail queue is two cards in the whole column: the retry-policy snapshot
rather than the reading measure (see the "wide" block in layout.html). */}} (static, outside the HTMX poll) and the live postqueue listing. */}}
{{define "wide"}}wide{{end}} {{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
<h1>Mail queue</h1> <h1>Mail queue</h1>
<div class="card" id="retry-policy">
<h2>How delivery retries work</h2>
<p class="muted">This Postfix's policy, read once at panel start. There is
no maximum attempt count — only time. A deferred message stays in this
listing until it is delivered or the queue lifetime runs out.</p>
{{if .RetryFromDefaults}}
<p class="muted">Could not read the effective Postfix configuration;
showing compiled-in defaults. A live change is visible after the next
panel restart.</p>
{{end}}
<div class="facts retry-facts">
<div class="fact">
<span class="fact-label">First retry</span>
<span class="fact-value">{{.FirstRetry}}</span>
</div>
<div class="fact">
<span class="fact-label">Later retries</span>
<span class="fact-value">doubling, cap {{.BackoffCap}}</span>
</div>
<div class="fact">
<span class="fact-label">Kept in queue</span>
<span class="fact-value">{{.QueueLifetime}}</span>
</div>
<div class="fact">
<span class="fact-label">Then</span>
<span class="fact-value">bounced</span>
</div>
</div>
</div>
<div class="card"> <div class="card">
<h2>Pending messages</h2> <h2>Pending messages</h2>
{{template "mail_queue_body" .}} {{template "mail_queue_body" .}}
+36
View File
@@ -527,3 +527,39 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
fn(e.Name(), string(body)) fn(e.Name(), string(body))
} }
} }
// The retry-policy card is static HTML on mail_queue (outside the HTMX
// fragment), so rendering the page with a fixture must print those strings
// rather than falling back to empty template fields.
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
"FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes",
"QueueLifetime": "2 days",
}); err != nil {
t.Fatalf("execute mail_queue: %v", err)
}
out := buf.String()
for _, want := range []string{
"How delivery retries work",
"10 minutes",
"about 1 hour 7 minutes",
"2 days",
`id="retry-policy"`,
`hx-get="/mail-queue/body"`,
} {
if !strings.Contains(out, want) {
t.Errorf("mail_queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
}
}
+6
View File
@@ -13,6 +13,7 @@ import (
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/handlers" "github.com/mixeme/selfpost/internal/web/handlers"
@@ -77,6 +78,10 @@ type Config struct {
// display and to cap domain/app level-2 ceilings (guide § Rate limiting). // display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int RateLimitMessagesPerIP int
RateLimitWindowSeconds int RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts. Handlers read the cache; they never call
// postconf (architecture.md).
RetryPolicy postfix.RetryPolicy
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -115,6 +120,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
JournalSocket: cfg.JournalSocket, JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil return &Server{cfg: cfg, auth: a, handlers: h}, nil
} }