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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 22:12:03 +03:00
parent 46191172d6
commit 705239568b
17 changed files with 669 additions and 30 deletions
+151
View File
@@ -0,0 +1,151 @@
package postfix
import (
"fmt"
"strconv"
"strings"
"time"
"unicode"
)
// ParseDuration converts a Postfix time value to a duration. Units are those
// postconf prints and accepts: s, m, h, d, w. A bare number is seconds. Values
// may concatenate units (`1h7m`), matching Postfix's own conv_time.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("postfix: empty time value")
}
var total time.Duration
i := 0
for i < len(s) {
for i < len(s) && unicode.IsSpace(rune(s[i])) {
i++
}
if i >= len(s) {
break
}
if s[i] == '-' {
return 0, fmt.Errorf("postfix: negative time value %q", s)
}
if s[i] == '+' {
i++
}
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == start {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
n, err := strconv.ParseInt(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
unit := byte('s')
if i < len(s) && isTimeUnit(s[i]) {
unit = s[i] | 0x20 // ASCII fold to lowercase
i++
}
part, err := durationForUnit(n, unit)
if err != nil {
return 0, err
}
total += part
}
return total, nil
}
func isTimeUnit(c byte) bool {
switch c | 0x20 {
case 's', 'm', 'h', 'd', 'w':
return true
}
return false
}
func durationForUnit(n int64, unit byte) (time.Duration, error) {
var unitDur time.Duration
switch unit {
case 's':
unitDur = time.Second
case 'm':
unitDur = time.Minute
case 'h':
unitDur = time.Hour
case 'd':
unitDur = 24 * time.Hour
case 'w':
unitDur = 7 * 24 * time.Hour
default:
return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit))
}
return time.Duration(n) * unitDur, nil
}
// FormatDuration renders a Postfix interval the way the Mail queue card and
// delivery history share it, so the two cannot drift. Exact day/hour/minute/
// second values stay exact (`5 minutes`, `5 days`, `1 hour`); a remainder that
// is rounded to the nearest minute is marked `about` (`about 1 hour 7 minutes`
// for the stock 4000s backoff cap).
func FormatDuration(d time.Duration) string {
if d < 0 {
d = -d
}
sec := int64(d / time.Second)
if sec == 0 {
return "0 seconds"
}
days := sec / 86400
rem := sec % 86400
hours := rem / 3600
rem %= 3600
minutes := rem / 60
seconds := rem % 60
about := false
if days > 0 || hours > 0 {
if seconds >= 30 {
minutes++
about = true
} else if seconds > 0 {
about = true
}
seconds = 0
if minutes >= 60 {
hours++
minutes = 0
}
if hours >= 24 {
days++
hours = 0
}
}
parts := make([]string, 0, 4)
if days > 0 {
parts = append(parts, counted(days, "day"))
}
if hours > 0 {
parts = append(parts, counted(hours, "hour"))
}
if minutes > 0 {
parts = append(parts, counted(minutes, "minute"))
}
if seconds > 0 {
parts = append(parts, counted(seconds, "second"))
}
s := strings.Join(parts, " ")
if about {
return "about " + s
}
return s
}
func counted(n int64, unit string) string {
if n == 1 {
return "1 " + unit
}
return strconv.FormatInt(n, 10) + " " + unit + "s"
}
+64
View File
@@ -0,0 +1,64 @@
package postfix
import (
"testing"
"time"
)
func TestParseDuration(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"5d", 5 * 24 * time.Hour},
{"300s", 300 * time.Second},
{"4000s", 4000 * time.Second},
{"1h", time.Hour},
{"0", 0},
{"0s", 0},
{"300", 300 * time.Second},
{" 300s ", 300 * time.Second},
{"1h7m", time.Hour + 7*time.Minute},
{"1w", 7 * 24 * time.Hour},
{"2m", 2 * time.Minute},
}
for _, tc := range cases {
got, err := ParseDuration(tc.in)
if err != nil {
t.Errorf("ParseDuration(%q): %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationRejectsInvalid(t *testing.T) {
for _, in := range []string{"", "foo", "5x", "-300s", "s", "1h 5x"} {
if _, err := ParseDuration(in); err == nil {
t.Errorf("ParseDuration(%q) = nil, want error", in)
}
}
}
func TestFormatDuration(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{5 * 24 * time.Hour, "5 days"},
{300 * time.Second, "5 minutes"},
{4000 * time.Second, "about 1 hour 7 minutes"},
{time.Hour, "1 hour"},
{0, "0 seconds"},
{time.Second, "1 second"},
{2 * time.Minute, "2 minutes"},
{24 * time.Hour, "1 day"},
}
for _, tc := range cases {
if got := FormatDuration(tc.in); got != tc.want {
t.Errorf("FormatDuration(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package postfix
import (
"fmt"
"log"
"os/exec"
"strings"
"time"
)
// RetryPolicy is this Postfix's deferred-mail retry timings, as postconf
// reports them. The panel loads it once at HTTP start (architecture.md) and
// never re-reads it for a request.
type RetryPolicy struct {
QueueRunDelay time.Duration
MinimalBackoff time.Duration
MaximalBackoff time.Duration
MaximalQueueLifetime time.Duration
BounceQueueLifetime time.Duration
DelayWarningTime time.Duration
// FromDefaults is true when postconf could not be read and the compiled-in
// Postfix 3.x values were substituted. The Mail queue card shows a muted
// note in that case so an operator who overrode the parameters is not
// silently shown the stock numbers.
FromDefaults bool
}
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
// build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts.
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
QueueRunDelay: 300 * time.Second,
MinimalBackoff: 300 * time.Second,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 5 * 24 * time.Hour,
BounceQueueLifetime: 5 * 24 * time.Hour,
DelayWarningTime: 0,
FromDefaults: true,
}
}
// retryConfKeys is the fixed argv tail for `postconf -h`. Order matches the
// fields of RetryPolicy. No user input is interpolated (security.md).
var retryConfKeys = []string{
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
}
// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it
// the same way logtail stubs queueIDs.
var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) {
// Fixed argv, no user input — same pattern as Queue (security.md).
cmd := exec.Command("postconf", "-h",
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out)))
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(retryConfKeys) {
return nil, fmt.Errorf("postconf -h: got %d values, want %d", len(lines), len(retryConfKeys))
}
for i := range lines {
lines[i] = strings.TrimSpace(lines[i])
}
return lines, nil
}
// LoadRetryPolicy reads the effective Postfix retry parameters. On any failure
// it logs a warning and returns DefaultRetryPolicy; the HTTP role must not
// refuse to start because postconf is absent.
func LoadRetryPolicy() RetryPolicy {
lines, err := readRetryConf()
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
p, err := parseRetryPolicy(lines)
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
return p
}
func parseRetryPolicy(lines []string) (RetryPolicy, error) {
if len(lines) != len(retryConfKeys) {
return RetryPolicy{}, fmt.Errorf("got %d values, want %d", len(lines), len(retryConfKeys))
}
var durs [6]time.Duration
for i, line := range lines {
d, err := ParseDuration(line)
if err != nil {
return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err)
}
durs[i] = d
}
return RetryPolicy{
QueueRunDelay: durs[0],
MinimalBackoff: durs[1],
MaximalBackoff: durs[2],
MaximalQueueLifetime: durs[3],
BounceQueueLifetime: durs[4],
DelayWarningTime: durs[5],
}, nil
}
// FirstRetry is the human string for the first deferred retry (minimal
// backoff), shared by the Mail queue card and delivery history.
func (p RetryPolicy) FirstRetry() string {
return FormatDuration(p.MinimalBackoff)
}
// BackoffCap is the human string for maximal_backoff_time.
func (p RetryPolicy) BackoffCap() string {
return FormatDuration(p.MaximalBackoff)
}
// QueueLifetime is the human string for maximal_queue_lifetime.
func (p RetryPolicy) QueueLifetime() string {
return FormatDuration(p.MaximalQueueLifetime)
}
+79
View File
@@ -0,0 +1,79 @@
package postfix
import (
"errors"
"testing"
"time"
)
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "300s", "4000s", "2d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if p.FromDefaults {
t.Fatal("FromDefaults = true, want live postconf values")
}
if p.MinimalBackoff != 300*time.Second {
t.Errorf("MinimalBackoff = %v, want 300s", p.MinimalBackoff)
}
if p.MaximalBackoff != 4000*time.Second {
t.Errorf("MaximalBackoff = %v, want 4000s", p.MaximalBackoff)
}
if p.MaximalQueueLifetime != 2*24*time.Hour {
t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime)
}
if p.BounceQueueLifetime != 5*24*time.Hour {
t.Errorf("BounceQueueLifetime = %v, want 5d", p.BounceQueueLifetime)
}
if p.DelayWarningTime != 0 {
t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime)
}
}
func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return nil, errors.New("exec: not found")
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
want := DefaultRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when postconf fails")
}
if p.QueueRunDelay != want.QueueRunDelay || p.MinimalBackoff != want.MinimalBackoff ||
p.MaximalBackoff != want.MaximalBackoff || p.MaximalQueueLifetime != want.MaximalQueueLifetime {
t.Errorf("fallback = %+v, want compiled-in defaults %+v", p, want)
}
}
func TestLoadRetryPolicyFallsBackOnUnparseableValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "nope", "4000s", "5d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when a value cannot be parsed")
}
}
func TestParseRetryPolicyStock(t *testing.T) {
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "5d", "5d", "0"})
if err != nil {
t.Fatalf("parseRetryPolicy: %v", err)
}
if p.FromDefaults {
t.Error("parsed policy should not be marked FromDefaults")
}
if p.QueueRunDelay != 300*time.Second || p.MaximalQueueLifetime != 5*24*time.Hour {
t.Errorf("parsed = %+v", p)
}
}