diff --git a/CHANGELOG.md b/CHANGELOG.md index 050af13..e536d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### 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, `Progress` column in [roadmap.md](docs/roadmap.md), per-step commit + CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage. diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 6f9a18b..7c44b49 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -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. func newPanel(cfg config, st *store.Store) (*web.Server, error) { 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{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, @@ -83,6 +87,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { DNSResolvers: cfg.dnsResolvers, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, + RetryPolicy: retryPolicy, }, cfg.setupTokenPath) } diff --git a/docs/architecture.md b/docs/architecture.md index fc141fc..daeeab6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`. One process, three roles: 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/ Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** (`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/import` | **Global.** Domain import (`POST`; form on the Backup page) | | `/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 | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | | `/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 `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 -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 diff --git a/docs/guide.md b/docs/guide.md index f4d3761..10657f2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -319,7 +319,12 @@ the panel shows after manual edits under `/data`. ### Mail queue and System log - **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 related daemon lines). The log rotates daily (14 files kept) with a `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 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 -accepted and what Postfix later reported for the recipient — and, under -both, the `mail.log` lines for its queue id: the connection to the +accepted and what Postfix later reported for the recipient. A `deferred` +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 as. Rows outlive `mail.log`, so an older message's lines may have rotated away; the page says so. Retention is controlled by diff --git a/docs/plans/queue-retries.md b/docs/plans/queue-retries.md index 0dc17f0..6aa6b7c 100644 --- a/docs/plans/queue-retries.md +++ b/docs/plans/queue-retries.md @@ -1,6 +1,6 @@ # 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 **Version:** patch; no schema, no configuration surface. **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 [development.md](../development.md) § Plan checklists. -- [ ] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus** -- [ ] `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** -- [ ] 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** -- [ ] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet** -- [ ] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet** -- [ ] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet** -- [ ] `go vet`, `go test` on touched packages — **Haiku** +- [x] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus** +- [x] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus** +- [x] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus** +- [x] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet** +- [x] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet** +- [x] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet** +- [x] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet** +- [x] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet** +- [x] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/roadmap.md b/docs/roadmap.md index edd6be2..2ffd4ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | 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) | | 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) | @@ -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). **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 (fallback + muted note). Copy must stay time-based — Postfix has no max diff --git a/internal/postfix/duration.go b/internal/postfix/duration.go new file mode 100644 index 0000000..286b282 --- /dev/null +++ b/internal/postfix/duration.go @@ -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" +} diff --git a/internal/postfix/duration_test.go b/internal/postfix/duration_test.go new file mode 100644 index 0000000..f65579f --- /dev/null +++ b/internal/postfix/duration_test.go @@ -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) + } + } +} diff --git a/internal/postfix/retry.go b/internal/postfix/retry.go new file mode 100644 index 0000000..662ace4 --- /dev/null +++ b/internal/postfix/retry.go @@ -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) +} diff --git a/internal/postfix/retry_test.go b/internal/postfix/retry_test.go new file mode 100644 index 0000000..747cc5d --- /dev/null +++ b/internal/postfix/retry_test.go @@ -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) + } +} diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index 60305b8..33bf795 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -8,6 +8,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "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). RateLimitMessagesPerIP 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. diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index 98dc3b9..31e56ee 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "fmt" "io/fs" "net/http" "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 same way as every other health signal in the panel. "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 // reason, which is a normal outcome rather than a failure. "LogRows": logRows, @@ -173,7 +174,9 @@ type deliveryEvent struct { // 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 // 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 // acceptance: the journal-milter refused it, so Postfix never queued it. if row.Status == store.StatusRejected { @@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent { Level: "warn", Status: store.StatusDeferred, 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: return append(events, deliveryEvent{ @@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent { Level: "error", Status: store.StatusBounced, 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: // 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 } out, errText := readQueue() + policy := h.cfg.RetryPolicy h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ - "Title": "SelfPost — mail queue", - "User": auth.CurrentUser(r), - "Active": "mail_queue", - "IsGlobal": true, - "Output": out, - "Error": errText, + "Title": "SelfPost — mail queue", + "User": auth.CurrentUser(r), + "Active": "mail_queue", + "IsGlobal": true, + "Output": out, + "Error": errText, + "FirstRetry": policy.FirstRetry(), + "BackoffCap": policy.BackoffCap(), + "QueueLifetime": policy.QueueLifetime(), + "RetryFromDefaults": policy.FromDefaults, }) } diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index b70db28..9e032ab 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -8,7 +8,9 @@ import ( "strconv" "strings" "testing" + "time" + "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" ) @@ -450,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string { } 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) + } +} diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 30bab78..65b1747 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -663,6 +663,10 @@ h1.subject { overflow-wrap: anywhere; } wider than the column it shares. */ .fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; } .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, 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 diff --git a/internal/web/view/templates/mail_queue.html b/internal/web/view/templates/mail_queue.html index f9dba13..7174ef7 100644 --- a/internal/web/view/templates/mail_queue.html +++ b/internal/web/view/templates/mail_queue.html @@ -1,10 +1,40 @@ -{{/* A table of queue ids, sizes, senders and recipients: the whole column - rather than the reading measure (see the "wide" block in layout.html). */}} +{{/* Mail queue is two cards in the whole column: the retry-policy snapshot + (static, outside the HTMX poll) and the live postqueue listing. */}} {{define "wide"}}wide{{end}} {{define "content"}}
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.
+ {{if .RetryFromDefaults}} +Could not read the effective Postfix configuration; + showing compiled-in defaults. A live change is visible after the next + panel restart.
+ {{end}} +