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
+69 -3
View File
@@ -142,6 +142,72 @@ func TestCloseUnreportedClosesOnlyQueuedRows(t *testing.T) {
}
}
// Postfix's expiry line closes the message's open rows - each recipient's
// newest, queued or deferred - and leaves a final result, a refusal and an
// older message that had the same queue id alone (assumptions A-8).
func TestExpireMessageClosesOnlyOpenRows(t *testing.T) {
st := openTestStore(t)
for _, e := range []SendLogEntry{
{QueueID: "OLDQ", To: "a@example.net"}, // an earlier message, same id
{QueueID: "Q1", To: "a@example.net"},
{QueueID: "Q1", To: "b@example.net"},
{QueueID: "Q1", To: "c@example.net"},
} {
if err := st.InsertQueued(e); err != nil {
t.Fatal(err)
}
}
if _, err := st.db.Exec(`UPDATE send_log SET queue_id = 'Q1' WHERE queue_id = 'OLDQ'`); err != nil {
t.Fatal(err)
}
if _, err := st.UpdateStatus("Q1", "a@example.net", StatusDeferred); err != nil {
t.Fatal(err)
}
if _, err := st.UpdateStatus("Q1", "c@example.net", StatusSent); err != nil {
t.Fatal(err)
}
if n, err := st.ExpireMessage("Q1"); err != nil || n != 2 {
t.Fatalf("ExpireMessage = %d, %v; want 2 (a deferred, b queued)", n, err)
}
rows, err := st.db.Query(`SELECT to_addr, status FROM send_log ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var got []string
for rows.Next() {
var to, status string
if err := rows.Scan(&to, &status); err != nil {
t.Fatal(err)
}
got = append(got, to+" "+status)
}
want := []string{
"a@example.net " + StatusQueued, // the earlier message's row
"a@example.net " + StatusBounced,
"b@example.net " + StatusBounced,
"c@example.net " + StatusSent,
}
if strings.Join(got, "; ") != strings.Join(want, "; ") {
t.Fatalf("rows = %v, want %v", got, want)
}
}
// The sweep closes a deferred row as it closes a queued one: Postfix can drop
// a deferred message without a line per recipient.
func TestCloseUnreportedClosesADeferredRow(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
t.Fatal(err)
}
if _, err := st.UpdateStatus("Q1", "a@example.net", StatusDeferred); err != nil {
t.Fatal(err)
}
if n, err := st.CloseUnreported(rowID(t, st, "Q1")); err != nil || n != 1 {
t.Fatalf("CloseUnreported of a deferred row = %d, %v; want 1", n, err)
}
}
// A delivery line about a row the sweep closed proves the sweep guessed what
// the log could have said: the log's status wins, the row is marked
// reconcile-contradicted, and the day counts one contradiction - once,
@@ -205,7 +271,7 @@ func TestUpdateStatusRewritesOnlyTheNewestRow(t *testing.T) {
// reported on them by now. A message accepted moments ago is simply in flight,
// and one the milter refused never reached the queue at all, so neither is the
// sweep's business.
func TestListQueuedOlderThan(t *testing.T) {
func TestListOpenOlderThan(t *testing.T) {
st := openTestStore(t)
for _, e := range []SendLogEntry{
@@ -232,9 +298,9 @@ func TestListQueuedOlderThan(t *testing.T) {
backdate(t, st, "OLD1", cutoff.Add(-time.Hour))
backdate(t, st, "DONE1", cutoff.Add(-time.Hour))
got, err := st.ListQueuedOlderThan(cutoff)
got, err := st.ListOpenOlderThan(cutoff)
if err != nil {
t.Fatalf("ListQueuedOlderThan: %v", err)
t.Fatalf("ListOpenOlderThan: %v", err)
}
if len(got) != 1 || got[0] != (QueuedDelivery{ID: got[0].ID, QueueID: "OLD1", To: "stale@example.net"}) {
t.Fatalf("got %+v, want only the stale queued row", got)