Show this Postfix's retry policy on Mail queue and delivery history.
test / test (push) Waiting to run
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
+10
-10
@@ -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**
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
<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">
|
||||
<h2>Pending messages</h2>
|
||||
{{template "mail_queue_body" .}}
|
||||
|
||||
@@ -527,3 +527,39 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
"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/handlers"
|
||||
@@ -77,6 +78,10 @@ type Config struct {
|
||||
// display and 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. Handlers read the cache; they never call
|
||||
// postconf (architecture.md).
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
}
|
||||
|
||||
// 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,
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user