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>
147 lines
3.4 KiB
Go
147 lines
3.4 KiB
Go
package postfix
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
i := 0
|
|
for i < len(s) && isCSpace(s[i]) {
|
|
i++
|
|
}
|
|
neg := false
|
|
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
|
neg = s[i] == '-'
|
|
i++
|
|
}
|
|
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 'w':
|
|
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: invalid time value %q", s)
|
|
}
|
|
// 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
|
|
// 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"
|
|
}
|