log tailer: expiry, Postfix's time values and refusal lines as Postfix writes them
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
Expiry (assumptions A-8). Postfix 3.7.11 logs the end of a message's queue lifetime once per message, "<id>: from=<..>, status=expired, returned to sender" (src/qmgr/qmgr_active.c, qmgr_active_done_25_generic()), with no line per recipient; the tailer waited for "to=..., status=expired", which no Postfix writes, and the sweep closed only queued rows, so such rows stayed deferred. The tailer now reads qmgr's line (ExpireMessage: each recipient's newest open row of the id becomes bounced); the sweep closes a deferred row too when Postfix no longer holds the message, and counts as missed only a final line (sent, bounced, expiry). The queue listing's '#' (forced expiry, showq_compat.c showq_message()) is read as a flag (B-PQ1). Time values (B-PQ10, B-PQ11): conv_time() ported - one number, one lower-case unit, the parameter's default unit when none (get_def_time_unit: d for the lifetimes, h for delay_warning_time), the rest ignored; postconf -h -x expands $name; bounce_queue_lifetime capped at maximal_queue_lifetime as qmgr_post_init() caps it, so LongestLifetime is the queue lifetime. Refusals (A-21, A-23, A-24, A-15): smtpd_whatsup() ends the NOQUEUE line with sasl_username=<login> on an authenticated session; the line is now matched to its row by client, login and sender, and a line without a login is no application's. The client= line's login ends at the comma. The "IPv6:" trim goes (namaddr is built from state->addr). A row recovered from cleanup's line says that line names the last recipient only. Stand (2026-10-14, make e2e, exit 0): new expired_message_is_closed - defer_transports=smtp makes the retry service defer the message, postsuper -e and postqueue -i force its expiry; mail.log has "<id>: from=<alerts@sender.e2e.test>, status=force-expired, returned to sender" and the row goes deferred -> bounced; refusals_show_postfixs_line passes with the login match. go vet and go test ./... pass on the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,85 +2,80 @@ package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"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)
|
||||
// ParseTime reads a Postfix time value as Postfix 3.7.11 reads it:
|
||||
// src/global/conv_time.c, conv_time(), called by get_mail_conf_time() in
|
||||
// src/global/mail_conf_time.c. One number - strtol(), so leading blanks and a
|
||||
// "+" are taken, a negative number or one past an int is invalid - followed
|
||||
// by one unit, w d h m or s in lower case; with no unit, defUnit, which
|
||||
// Postfix takes from the parameter's default value (get_def_time_unit(): "5d"
|
||||
// gives d, "300s" s, "0h" h). Whatever follows the unit is ignored, so "1h7m"
|
||||
// is one hour (assumptions B-PQ10, B-PQ11). A string with no digits is read as
|
||||
// 0 of the unit it starts with, as strtol() leaves it there.
|
||||
func ParseTime(s string, defUnit byte) (time.Duration, error) {
|
||||
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
|
||||
for i < len(s) && isCSpace(s[i]) {
|
||||
i++
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func isTimeUnit(c byte) bool {
|
||||
switch c | 0x20 {
|
||||
case 's', 'm', 'h', 'd', 'w':
|
||||
return true
|
||||
neg := false
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
neg = s[i] == '-'
|
||||
i++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func durationForUnit(n int64, unit byte) (time.Duration, error) {
|
||||
var unitDur time.Duration
|
||||
start := i
|
||||
var n int64
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
n = n*10 + int64(s[i]-'0')
|
||||
if n > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("postfix: time value %q out of range", s)
|
||||
}
|
||||
i++
|
||||
}
|
||||
end := i
|
||||
if i == start {
|
||||
// strtol() converted nothing: the end is the start of the string.
|
||||
end, n, neg = 0, 0, false
|
||||
}
|
||||
if neg && n > 0 {
|
||||
return 0, fmt.Errorf("postfix: negative time value %q", s)
|
||||
}
|
||||
unit := defUnit
|
||||
if end < len(s) {
|
||||
unit = s[end]
|
||||
}
|
||||
var per int64
|
||||
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
|
||||
per = 7 * 24 * 3600
|
||||
case 'd':
|
||||
per = 24 * 3600
|
||||
case 'h':
|
||||
per = 3600
|
||||
case 'm':
|
||||
per = 60
|
||||
case 's':
|
||||
per = 1
|
||||
default:
|
||||
return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit))
|
||||
return 0, fmt.Errorf("postfix: invalid time value %q", s)
|
||||
}
|
||||
return time.Duration(n) * unitDur, nil
|
||||
// conv_time(): "if (intval < INT_MAX / WEEK)" and the like.
|
||||
if per > 1 && n >= math.MaxInt32/per {
|
||||
return 0, fmt.Errorf("postfix: time value %q out of range", s)
|
||||
}
|
||||
return time.Duration(n*per) * time.Second, nil
|
||||
}
|
||||
|
||||
// isCSpace is C's isspace() in the "C" locale, which strtol() skips.
|
||||
func isCSpace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'
|
||||
}
|
||||
|
||||
// FormatDuration renders a Postfix interval the way the Mail queue card and
|
||||
|
||||
@@ -5,39 +5,47 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
// Postfix 3.7.11, src/global/conv_time.c, conv_time(): one number, one
|
||||
// lower-case unit, the default unit when there is none, the rest ignored.
|
||||
func TestParseTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
unit byte
|
||||
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},
|
||||
{"5d", 's', 5 * 24 * time.Hour},
|
||||
{"300s", 'd', 300 * time.Second},
|
||||
{"4000s", 's', 4000 * time.Second},
|
||||
{"1h", 's', time.Hour},
|
||||
{"0", 's', 0},
|
||||
{"0s", 's', 0},
|
||||
{"300", 's', 300 * time.Second},
|
||||
{"3", 'd', 3 * 24 * time.Hour}, // maximal_queue_lifetime = 3
|
||||
{"2", 'h', 2 * time.Hour}, // delay_warning_time = 2
|
||||
{" 300s ", 's', 300 * time.Second},
|
||||
{"+5m", 's', 5 * time.Minute},
|
||||
{"1h7m", 's', time.Hour}, // what follows the unit is ignored
|
||||
{"1h 5x", 's', time.Hour},
|
||||
{"1w", 's', 7 * 24 * time.Hour},
|
||||
{"2m", 's', 2 * time.Minute},
|
||||
{"s", 'd', 0}, // strtol() reads nothing and stops at the "s"
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ParseDuration(tc.in)
|
||||
got, err := ParseTime(tc.in, tc.unit)
|
||||
if err != nil {
|
||||
t.Errorf("ParseDuration(%q): %v", tc.in, err)
|
||||
t.Errorf("ParseTime(%q, %c): %v", tc.in, tc.unit, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
t.Errorf("ParseTime(%q, %c) = %v, want %v", tc.in, tc.unit, 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 TestParseTimeRejectsInvalid(t *testing.T) {
|
||||
for _, in := range []string{"", "foo", "5x", "-300s", "5D", "1H", " s", "99999999999s", "400000w"} {
|
||||
if _, err := ParseTime(in, 's'); err == nil {
|
||||
t.Errorf("ParseTime(%q) = nil, want error", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +125,15 @@ func QueueIDs() (map[string]struct{}, error) {
|
||||
// 3C5B04E6C1* 446 Thu Aug 7 10:12:31 app@example.com
|
||||
//
|
||||
// The id is at the start of the line, optionally flagged '*' (in the active
|
||||
// queue) or '!' (on hold), and is followed by the message size. Requiring the
|
||||
// queue) or '!' (on hold), then '#' when the message was forced to expire
|
||||
// (Postfix 3.7.11, src/postqueue/showq_compat.c, showq_message(): the flags
|
||||
// are appended to the id in that order; assumptions B-PQ1), and is followed
|
||||
// by the message size. Requiring the
|
||||
// size is what separates an entry from the listing's other left-margin lines:
|
||||
// the '-Queue ID-' header, the '-- 5 Kbytes in 2 Requests.' trailer, a deferred
|
||||
// entry's '(connect timed out)' reason, and 'Mail queue is empty'. Recipient
|
||||
// lines are indented and never match.
|
||||
var queueEntryRe = regexp.MustCompile(`^([0-9A-Za-z]+)[*!]?\s+\d+\s`)
|
||||
var queueEntryRe = regexp.MustCompile(`^([0-9A-Za-z]+)[*!]?#?\s+\d+\s`)
|
||||
|
||||
func parseQueueIDs(listing string) map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
|
||||
@@ -92,10 +92,16 @@ func TestParseQueueIDs(t *testing.T) {
|
||||
A1B2C3D4E5F! 891 Fri Aug 8 10:14:31 app@example.com
|
||||
held@example.net
|
||||
|
||||
-- 1 Kbytes in 3 Requests.
|
||||
6D7E8F9A0B*# 512 Fri Aug 8 10:15:31 app@example.com
|
||||
expiring@example.net
|
||||
|
||||
7E8F9A0B1C# 512 Fri Aug 8 10:16:31 app@example.com
|
||||
expiring2@example.net
|
||||
|
||||
-- 2 Kbytes in 5 Requests.
|
||||
`
|
||||
ids := parseQueueIDs(listing)
|
||||
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F"}
|
||||
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F", "6D7E8F9A0B", "7E8F9A0B1C"}
|
||||
for _, id := range want {
|
||||
if _, ok := ids[id]; !ok {
|
||||
t.Errorf("queue id %s not found in %v", id, ids)
|
||||
|
||||
@@ -55,6 +55,13 @@ var retryConfKeys = []string{
|
||||
"delay_warning_time",
|
||||
}
|
||||
|
||||
// retryConfUnits is each key's default unit: the last letter of its default
|
||||
// value (Postfix 3.7.11, src/global/mail_params.h, DEF_QUEUE_RUN_DELAY
|
||||
// "300s", DEF_MIN_BACKOFF_TIME, DEF_MAX_BACKOFF_TIME "4000s",
|
||||
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME
|
||||
// "0h"; src/global/mail_conf_time.c, get_def_time_unit()).
|
||||
var retryConfUnits = []byte{'s', 's', 's', 'd', 'd', 'h'}
|
||||
|
||||
// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it
|
||||
// the same way logtail stubs queueIDs.
|
||||
var readRetryConf = postconfRetryValues
|
||||
@@ -63,8 +70,9 @@ func postconfRetryValues() ([]string, error) {
|
||||
// Fixed argv, no user input — same pattern as Queue (security.md).
|
||||
// Postfix 3.7.11, src/postconf/postconf_main.c, pcf_show_parameters():
|
||||
// named parameters are printed in argument order, with -h one "%s\n"
|
||||
// value each, as written in main.cf (not expanded without -x).
|
||||
cmd := exec.Command("postconf", "-h",
|
||||
// value each; -x expands "$name" as Postfix itself does when it reads
|
||||
// the value (mail_conf_eval()).
|
||||
cmd := exec.Command("postconf", "-h", "-x",
|
||||
"queue_run_delay",
|
||||
"minimal_backoff_time",
|
||||
"maximal_backoff_time",
|
||||
@@ -109,12 +117,18 @@ func parseRetryPolicy(lines []string) (RetryPolicy, error) {
|
||||
}
|
||||
var durs [6]time.Duration
|
||||
for i, line := range lines {
|
||||
d, err := ParseDuration(line)
|
||||
d, err := ParseTime(line, retryConfUnits[i])
|
||||
if err != nil {
|
||||
return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err)
|
||||
}
|
||||
durs[i] = d
|
||||
}
|
||||
// Postfix 3.7.11, src/qmgr/qmgr.c, qmgr_post_init(): "bounce_queue_lifetime
|
||||
// is larger than maximal_queue_lifetime - adjusting" - qmgr uses the
|
||||
// smaller.
|
||||
if durs[4] > durs[3] {
|
||||
durs[4] = durs[3]
|
||||
}
|
||||
return RetryPolicy{
|
||||
QueueRunDelay: durs[0],
|
||||
MinimalBackoff: durs[1],
|
||||
@@ -141,13 +155,11 @@ func (p RetryPolicy) BackoffCap() string {
|
||||
// sender, which an application may submit too. It bounds which stored log
|
||||
// lines can be about a send-log row (store.SendLogLines).
|
||||
//
|
||||
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done(): a message
|
||||
// expires after var_max_queue_time, or var_dsn_queue_time when its sender is
|
||||
// null - whoever submitted it (qmgr.c caps the second at the first).
|
||||
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic(): a
|
||||
// message expires after var_max_queue_time, or var_dsn_queue_time when its
|
||||
// sender is null - whoever submitted it; qmgr.c caps the second at the
|
||||
// first, and so does parseRetryPolicy, which leaves the first the longer.
|
||||
func (p RetryPolicy) LongestLifetime() time.Duration {
|
||||
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
|
||||
return p.BounceQueueLifetime
|
||||
}
|
||||
return p.MaximalQueueLifetime
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,29 @@ func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
|
||||
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)
|
||||
// 5d over a maximal 2d: qmgr uses 2d (Postfix 3.7.11, src/qmgr/qmgr.c,
|
||||
// qmgr_post_init(), "adjusting bounce_queue_lifetime").
|
||||
if p.BounceQueueLifetime != 2*24*time.Hour {
|
||||
t.Errorf("BounceQueueLifetime = %v, want 2d", p.BounceQueueLifetime)
|
||||
}
|
||||
if p.DelayWarningTime != 0 {
|
||||
t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime)
|
||||
}
|
||||
}
|
||||
|
||||
// A message lives at most maximal_queue_lifetime: qmgr caps
|
||||
// bounce_queue_lifetime at it (Postfix 3.7.11, src/qmgr/qmgr.c,
|
||||
// qmgr_post_init()), so a larger bounce lifetime does not lengthen it.
|
||||
func TestLongestLifetimeIsTheQueueLifetime(t *testing.T) {
|
||||
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "1d", "5d", "0h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.LongestLifetime() != 24*time.Hour || p.BounceQueueLifetime != 24*time.Hour {
|
||||
t.Fatalf("LongestLifetime %v, BounceQueueLifetime %v; want 24h both", p.LongestLifetime(), p.BounceQueueLifetime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) {
|
||||
old := readRetryConf
|
||||
readRetryConf = func() ([]string, error) {
|
||||
|
||||
Reference in New Issue
Block a user