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
|
### 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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" .}}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user