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:
@@ -78,3 +78,75 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
|
||||
t.Fatalf("after mail.log was rotated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// testExpiredMessageIsClosed: a message Postfix gives up on is closed as
|
||||
// bounced. Postfix logs expiry once per message, "<id>: from=<..>,
|
||||
// status=expired, returned to sender" (here "force-expired"), and no line per
|
||||
// recipient (Postfix 3.7.11, src/qmgr/qmgr_active.c,
|
||||
// qmgr_active_done_25_generic(); assumptions A-8). The queue lifetime cannot
|
||||
// be waited out, so the message is deferred by hand - defer_transports=smtp,
|
||||
// under which qmgr hands the recipient to the retry service, "4.3.2 deferred
|
||||
// transport" (src/qmgr/qmgr_message.c, qmgr_message_resolve()) - and then
|
||||
// forced to expire: postsuper -e marks it, postqueue -i has qmgr look at it
|
||||
// at once (man/man1/postsuper.1, postqueue.1).
|
||||
func testExpiredMessageIsClosed(t *testing.T, sc *scenario) {
|
||||
exec := func(args ...string) {
|
||||
t.Helper()
|
||||
if out, err := h.execIn("selfpost", args...); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
exec("postconf", "-e", "defer_transports=smtp")
|
||||
exec("postfix", "reload")
|
||||
defer func() {
|
||||
// The bounce to the sender waits behind the same setting.
|
||||
h.execIn("selfpost", "postconf", "-e", "defer_transports=")
|
||||
h.execIn("selfpost", "postfix", "reload")
|
||||
h.execIn("selfpost", "postqueue", "-f")
|
||||
}()
|
||||
|
||||
token := uniqueToken("expire")
|
||||
if res := attemptSend(sendAttempt{
|
||||
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||
from: "alerts@" + senderDomain, headerFrom: "alerts@" + senderDomain, to: recipient,
|
||||
subject: "e2e " + token, body: "deferred, then expired",
|
||||
}); !res.ok() {
|
||||
t.Fatalf("send: %v", res.firstErr())
|
||||
}
|
||||
row := func() (string, error) {
|
||||
return asPanel(`import sqlite3,sys
|
||||
r=sqlite3.connect("/data/selfpost.db").execute("select queue_id, status from send_log where subject like ? order by id desc",("%"+sys.argv[1]+"%",)).fetchone()
|
||||
print("" if r is None else r[0]+" "+r[1])`, token)
|
||||
}
|
||||
var queueID string
|
||||
if err := waitFor("the row to be deferred", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
out, err := row()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if f := strings.Fields(out); len(f) == 2 && f[1] == "deferred" {
|
||||
queueID = f[0]
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("row %q", out)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exec("postsuper", "-e", queueID)
|
||||
exec("postqueue", "-i", queueID)
|
||||
if err := waitFor("the expired message's row to be bounced", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
out, err := row()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out == queueID+" bounced", fmt.Errorf("row %q", out)
|
||||
}); err != nil {
|
||||
out, _ := h.execIn("selfpost", "grep", queueID, "/data/log/mail.log")
|
||||
t.Fatalf("%v\nmail.log:\n%s", err, out)
|
||||
}
|
||||
out, err := h.execIn("selfpost", "grep", queueID+": from=<alerts@"+senderDomain+">, status=force-expired, returned to sender", "/data/log/mail.log")
|
||||
if err != nil {
|
||||
t.Fatalf("no expiry line in mail.log: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user