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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user