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