log tailer: expiry, Postfix's time values and refusal lines as Postfix writes them
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:
mix
2026-10-14 20:21:51 +03:00
co-authored by Claude Opus 5.5
parent 55aef6e721
commit 0ed03b2094
24 changed files with 594 additions and 212 deletions
+59 -64
View File
@@ -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
+27 -19
View File
@@ -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)
}
}
}
+5 -2
View File
@@ -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{})
+8 -2
View File
@@ -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)
+21 -9
View File
@@ -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
}
+17 -2
View File
@@ -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) {