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:
+50
-19
@@ -30,13 +30,14 @@ import (
|
||||
// far into mail.log it has read. *store.Store satisfies it.
|
||||
type StatusStore interface {
|
||||
UpdateStatus(queueID, recipient, status string) (int64, error)
|
||||
ListQueuedOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
|
||||
ExpireMessage(queueID string) (int64, error)
|
||||
ListOpenOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
|
||||
CloseUnreported(id int64) (int64, error)
|
||||
// The counters of delivery_log_days (store/deliverylog.go).
|
||||
AddReconcileClosed(at time.Time, n int64) error
|
||||
AddReconcileMissed(at time.Time, n int64) error
|
||||
AddTailerBlind(from time.Time, d time.Duration) error
|
||||
CountQueuedOlderThan(cutoff time.Time) (int64, error)
|
||||
CountOpenOlderThan(cutoff time.Time) (int64, error)
|
||||
SetQueuedOverLifetime(at time.Time, n int64) error
|
||||
PruneDeliveryLogDays(now time.Time) error
|
||||
RefusalStore
|
||||
@@ -132,15 +133,35 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
|
||||
status = store.StatusDeferred
|
||||
case "bounced":
|
||||
status = store.StatusBounced
|
||||
case "expired":
|
||||
// Postfix gave up after the queue lifetime; a final failure for us.
|
||||
status = store.StatusBounced
|
||||
default:
|
||||
return "", "", "", false
|
||||
}
|
||||
return m[1], m[2], status, true
|
||||
}
|
||||
|
||||
// expiryRe matches qmgr's line for a message it gave up on:
|
||||
//
|
||||
// postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender
|
||||
//
|
||||
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic():
|
||||
// msg_info("%s: from=<%s>, status=%s, returned to sender") with "expired"
|
||||
// once the queue lifetime has passed, "force-expired" after postsuper -e.
|
||||
// It is the only line about the expiry, one per message, and no recipient is
|
||||
// named (assumptions A-8). The line must end there: the sender is the
|
||||
// client's text, and only qmgr writes this shape.
|
||||
var expiryRe = regexp.MustCompile(`^\S+ \S*/qmgr\[\d+\]: ([0-9A-Za-z]+): from=<.*>, status=(?:force-)?expired, returned to sender\s*$`)
|
||||
|
||||
// parseExpiry returns the queue id of a message Postfix gave up on, from its
|
||||
// expiry line; ok is false for any other line.
|
||||
func parseExpiry(line string) (queueID string, ok bool) {
|
||||
_, rest := SplitTimestamp(line)
|
||||
m := expiryRe.FindStringSubmatch(rest)
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
return m[1], true
|
||||
}
|
||||
|
||||
// Run follows path and updates send-log statuses until ctx is cancelled, while
|
||||
// a background sweep prunes rows older than retention(). Reading resumes at
|
||||
// the offset the previous run persisted, so a restart parses the delivery lines
|
||||
@@ -199,6 +220,11 @@ func Run(ctx context.Context, path string, st StatusStore, retention RetentionDa
|
||||
refusals.handle(line, now, Place{FP: tr.line.FP, End: tr.lineStart})
|
||||
queueID, recipient, status, ok := parseDelivery(line)
|
||||
if !ok {
|
||||
if queueID, ok := parseExpiry(line); ok {
|
||||
if _, err := st.ExpireMessage(queueID); err != nil {
|
||||
log.Printf("log-tailer: expire %s: %v", queueID, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := st.UpdateStatus(queueID, recipient, status); err != nil {
|
||||
@@ -243,11 +269,11 @@ func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}
|
||||
}
|
||||
}
|
||||
|
||||
// countStuck records how many rows are still queued past Postfix's queue
|
||||
// countStuck records how many rows are still open past Postfix's queue
|
||||
// lifetime - what the log would leave unresolved if there were no sweep. Each
|
||||
// sweep overwrites the day's value, so a past day keeps its last sweep's.
|
||||
func countStuck(st StatusStore, now time.Time, overLifetime time.Duration) {
|
||||
n, err := st.CountQueuedOlderThan(now.Add(-overLifetime))
|
||||
n, err := st.CountOpenOlderThan(now.Add(-overLifetime))
|
||||
if err == nil {
|
||||
err = st.SetQueuedOverLifetime(now, n)
|
||||
}
|
||||
@@ -256,10 +282,10 @@ func countStuck(st StatusStore, now time.Time, overLifetime time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile marks as bounced every row still "queued" from before cutoff whose
|
||||
// message Postfix no longer holds, with reason "reconcile". The close itself
|
||||
// is conditional on the row still being queued, so a delivery line read
|
||||
// between the listing and the close wins.
|
||||
// reconcile marks as bounced every row still open ("queued" or "deferred")
|
||||
// from before cutoff whose message Postfix no longer holds, with reason
|
||||
// "reconcile". The close itself is conditional on the row still being open,
|
||||
// so a final delivery line read between the listing and the close wins.
|
||||
//
|
||||
// A row reaches this state only when its delivery lines are gone for good — the
|
||||
// log rotated past its fourteen files while the panel was down, or was deleted
|
||||
@@ -279,9 +305,9 @@ func countStuck(st StatusStore, now time.Time, overLifetime time.Duration) {
|
||||
// is what tells a tailer that loses lines from a log that lost them, which
|
||||
// reconcile_contradicted cannot - a lost line is never read late.
|
||||
func reconcile(st StatusStore, cutoff time.Time, path string) {
|
||||
rows, err := st.ListQueuedOlderThan(cutoff)
|
||||
rows, err := st.ListOpenOlderThan(cutoff)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: reconcile: list queued rows: %v", err)
|
||||
log.Printf("log-tailer: reconcile: list open rows: %v", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
@@ -307,7 +333,7 @@ func reconcile(st StatusStore, cutoff time.Time, path string) {
|
||||
continue
|
||||
}
|
||||
closed += n
|
||||
if n > 0 && deliveryInLog(path, row) {
|
||||
if n > 0 && resultInLog(path, row) {
|
||||
log.Printf("log-tailer: reconcile: %s/%s had a delivery line in the log the tailer did not read", row.QueueID, row.To)
|
||||
missed++
|
||||
}
|
||||
@@ -323,13 +349,15 @@ func reconcile(st StatusStore, cutoff time.Time, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryInLog says whether the current log or the one rotated last holds a
|
||||
// delivery line for row. Rows reach the sweep minutes after they were
|
||||
// written, so the end of those two files is where such a line would be.
|
||||
// resultInLog says whether the current log or the one rotated last holds a
|
||||
// final result for row - a delivery line that says sent or bounced, or the
|
||||
// message's expiry line. A deferred line is not one: a deferred row has one
|
||||
// by definition. Rows reach the sweep minutes after they were written, so
|
||||
// the end of those two files is where such a line would be.
|
||||
// logrotate 3.21.0, logrotate.c, prerotateSingleLog(): the first rotated
|
||||
// name is "<log>.1", without the compression suffix under delaycompress
|
||||
// (build/logrotate-mail.conf).
|
||||
func deliveryInLog(path string, row store.QueuedDelivery) bool {
|
||||
func resultInLog(path string, row store.QueuedDelivery) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
@@ -339,7 +367,10 @@ func deliveryInLog(path string, row store.QueuedDelivery) bool {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
if q, r, _, ok := parseDelivery(line); ok && q == row.QueueID && strings.EqualFold(r, row.To) {
|
||||
if q, r, s, ok := parseDelivery(line); ok && q == row.QueueID && strings.EqualFold(r, row.To) && s != store.StatusDeferred {
|
||||
return true
|
||||
}
|
||||
if q, ok := parseExpiry(line); ok && q == row.QueueID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,69 @@ import (
|
||||
// The mail.log lines in this file are composed by hand, after the formats the
|
||||
// code cites (Postfix 3.7.11, src/global/log_adhoc.c and the others), unless a
|
||||
// comment says where one was seen. Some are shapes Postfix does not write:
|
||||
// "to=..., status=expired" (qmgr logs expiry once per message as "from=<..>,
|
||||
// status=expired, returned to sender" - assumptions A-8), ISO stamps
|
||||
// (msg_logger.c writes "%b %d %H:%M:%S" - A-12), and opendkim lines in
|
||||
// mail.log (build/opendkim.conf has Syslog no).
|
||||
// ISO stamps (msg_logger.c writes "%b %d %H:%M:%S" - A-12), and opendkim
|
||||
// lines in mail.log (build/opendkim.conf has Syslog no).
|
||||
// qmgr's expiry line, as Postfix 3.7.11 writes it (src/qmgr/qmgr_active.c,
|
||||
// qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to
|
||||
// sender"); composed by hand after that format. The sender is the client's
|
||||
// text, so a line only resembles one when it ends as qmgr's does.
|
||||
func TestParseExpiry(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
line string
|
||||
id string
|
||||
}{
|
||||
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender", "41E862C00D9E"},
|
||||
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 3Fq1Lw0bZ8z9tN: from=<>, status=force-expired, returned to sender", "3Fq1Lw0bZ8z9tN"},
|
||||
// Not qmgr.
|
||||
{"Oct 14 09:01:02 relay postfix/smtp[26]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender", ""},
|
||||
// qmgr's ordinary from= line, whose sender carries the text.
|
||||
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: from=<x>, status=expired, returned to sender>, size=500, nrcpt=1 (queue active)", ""},
|
||||
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: removed", ""},
|
||||
} {
|
||||
id, ok := parseExpiry(c.line)
|
||||
if ok != (c.id != "") || id != c.id {
|
||||
t.Errorf("parseExpiry(%q) = %q, %v; want %q", c.line, id, ok, c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tailer closes an expired message's rows when it reads qmgr's line.
|
||||
func TestExpiryLineClosesTheMessage(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs := &captureStore{}
|
||||
stop := startRun(t, path, cs)
|
||||
defer stop()
|
||||
appendLine(t, path, "Oct 14 09:01:00 relay postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=none, delay=9, dsn=4.4.1, status=deferred (connect timed out)")
|
||||
appendLine(t, path, "Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender")
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "41E862C00D9E|*|expired") })
|
||||
if !contains(cs.snapshot(), "41E862C00D9E|a@example.net|"+store.StatusDeferred) {
|
||||
t.Fatalf("calls = %v, want the deferral before the expiry", cs.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// A deferred row the sweep closes is not "missed" for its deferred line - it
|
||||
// has one by definition - but is for its message's expiry line.
|
||||
func TestReconcileMissedCountsOnlyFinalLines(t *testing.T) {
|
||||
stubQueue(t, nil)
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
if err := os.WriteFile(path, []byte(
|
||||
"Oct 14 10:59:58 mail postfix/smtp[26]: DEFQ: to=<late@example.net>, relay=none, delay=9, dsn=4.4.1, status=deferred (connect timed out)\n"+
|
||||
"Oct 14 10:59:59 mail postfix/qmgr[10]: EXPQ: from=<app@example.org>, status=expired, returned to sender\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs := &captureStore{queued: []store.QueuedDelivery{
|
||||
{ID: 1, QueueID: "DEFQ", To: "late@example.net"},
|
||||
{ID: 2, QueueID: "EXPQ", To: "gone@example.net"},
|
||||
}}
|
||||
reconcile(cs, time.Now().UTC(), path)
|
||||
if cs.closed != 2 || cs.missed != 1 {
|
||||
t.Fatalf("closed %d, missed %d; want 2 and 1", cs.closed, cs.missed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDelivery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -53,12 +112,11 @@ func TestParseDelivery(t *testing.T) {
|
||||
status: store.StatusBounced,
|
||||
},
|
||||
{
|
||||
name: "expired maps to bounced",
|
||||
line: "host postfix/smtp[26]: DEF: to=<slow@example.net>, relay=none, status=expired (delivery temporarily suspended)",
|
||||
wantOK: true,
|
||||
queueID: "DEF",
|
||||
recipient: "slow@example.net",
|
||||
status: store.StatusBounced,
|
||||
// Expiry is not a delivery line (TestParseExpiry); qmgr's line
|
||||
// for it names no recipient.
|
||||
name: "qmgr expiry line ignored",
|
||||
line: "host postfix/qmgr[10]: DEF: from=<app@example.org>, status=expired, returned to sender",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "qmgr from-line ignored",
|
||||
@@ -189,10 +247,10 @@ type captureStore struct {
|
||||
haveState bool
|
||||
stateErr error
|
||||
|
||||
// queued is what ListQueuedOlderThan returns, for the reconcile sweep.
|
||||
// queued is what ListOpenOlderThan returns, for the reconcile sweep.
|
||||
queued []store.QueuedDelivery
|
||||
queuedErr error
|
||||
// closed sums AddReconcileClosed; stuckCount is what CountQueuedOlderThan
|
||||
// closed sums AddReconcileClosed; stuckCount is what CountOpenOlderThan
|
||||
// answers and stuck what SetQueuedOverLifetime was given.
|
||||
closed int64
|
||||
missed int64
|
||||
@@ -259,6 +317,14 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// ExpireMessage records an expiry line's close as "queue-id|*|expired".
|
||||
func (c *captureStore) ExpireMessage(queueID string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls = append(c.calls, queueID+"|*|expired")
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// CloseUnreported records the sweep's close as "queue-id|recipient|reconcile",
|
||||
// finding the row by its id among queued.
|
||||
func (c *captureStore) CloseUnreported(id int64) (int64, error) {
|
||||
@@ -290,11 +356,11 @@ func (c *captureStore) AddReconcileMissed(_ time.Time, n int64) error {
|
||||
return nil
|
||||
}
|
||||
func (c *captureStore) AddTailerBlind(time.Time, time.Duration) error { return nil }
|
||||
func (c *captureStore) AttachRefusalLine(string, string, time.Time, string) (bool, error) {
|
||||
func (c *captureStore) AttachRefusalLine(string, string, string, time.Time, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (c *captureStore) RecoverRefusal(store.SendLogEntry, time.Time) (bool, error) { return false, nil }
|
||||
func (c *captureStore) CountQueuedOlderThan(time.Time) (int64, error) {
|
||||
func (c *captureStore) CountOpenOlderThan(time.Time) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.stuckCount, nil
|
||||
@@ -307,7 +373,7 @@ func (c *captureStore) SetQueuedOverLifetime(_ time.Time, n int64) error {
|
||||
}
|
||||
func (c *captureStore) PruneDeliveryLogDays(time.Time) error { return nil }
|
||||
|
||||
func (c *captureStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
func (c *captureStore) ListOpenOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.queued, c.queuedErr
|
||||
|
||||
@@ -14,20 +14,24 @@ import (
|
||||
// level-2 limit, a client address off the application's list - is put on the
|
||||
// row the milter wrote, so the message's page shows it (audit 2026-10-12,
|
||||
// stage 2b). The refusal has no queue id; it is matched to its row by client
|
||||
// address, sender and time. As the stand writes it (e2e stand, date not
|
||||
// recorded):
|
||||
// address, login, sender and time:
|
||||
//
|
||||
// postfix/smtpd[1149]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>
|
||||
// postfix/smtpd[1149]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test> sasl_username=<l2app>
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c, check_milter_reply(): "%s: %s: %s from
|
||||
// %s: %s;%s" with "NOQUEUE" before the queue file is opened, and
|
||||
// smtpd_whatsup() for the rest - " from=<..>" first. smtpd_whatsup() also
|
||||
// ends the line with " sasl_username=<login>" on an authenticated session,
|
||||
// which the quote above lacks and the regexp does not use (assumptions A-20,
|
||||
// A-21).
|
||||
// smtpd_whatsup() for the rest: " from=<..>", " to=<..>", " proto=..",
|
||||
// " helo=<..>", then " sasl_username=<..>" when the session authenticated
|
||||
// (assumptions A-20, A-21). The login is read at the end of the line, where
|
||||
// Postfix puts it last; a line without one is no application's refusal - the
|
||||
// journal milter refuses at MAIL FROM only an authenticated session
|
||||
// (milter/ratelimit.go). The HELO name before it is the client's text and can
|
||||
// end in what looks like the field, but only in a session that has no login,
|
||||
// and so no refused row to take the line; a row must also be of the line's
|
||||
// client address and sender, within minutes.
|
||||
//
|
||||
// Port 25's smtpd (postfix/inbound/smtpd) is a stranger's and has no such row.
|
||||
var refusalLineRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: NOQUEUE: milter-reject: MAIL from [^\[\s]*\[([^\]]+)\]: [45]\d\d .*?; from=<([^>]*)>`)
|
||||
var refusalLineRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: NOQUEUE: milter-reject: MAIL from [^\[\s]*\[([^\]]+)\]: [45]\d\d .*?; from=<([^>]*)>(?:.* sasl_username=<([^<>]*)>|.*)$`)
|
||||
|
||||
// refusalLineWait is how long a line waits for its row: the milter writes the
|
||||
// row when it refuses, which can be read a moment after the line.
|
||||
@@ -35,7 +39,7 @@ const refusalLineWait = 30 * time.Second
|
||||
|
||||
// RefusalStore is where the lines go. *store.Store satisfies it.
|
||||
type RefusalStore interface {
|
||||
AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error)
|
||||
AttachRefusalLine(clientIP, login, from string, at time.Time, line string) (bool, error)
|
||||
RecoverRefusal(e store.SendLogEntry, at time.Time) (bool, error)
|
||||
}
|
||||
|
||||
@@ -56,18 +60,19 @@ var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d
|
||||
|
||||
// clientLoginRe is smtpd's line naming the login a queue id was submitted
|
||||
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c, mail_open_stream(): ", sasl_sender=<..>"
|
||||
// follows when the client gave AUTH= on MAIL FROM, and "\S+" then takes
|
||||
// "login," with the comma (assumptions A-23).
|
||||
var clientLoginRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=.*?, sasl_username=(\S+)`)
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c, mail_open_stream(): ", sasl_sender=.."
|
||||
// follows when the client gave AUTH= on MAIL FROM, then ", orig_queue_id=.."
|
||||
// and ", orig_client=.." for XFORWARD; the login ends at the comma (a login
|
||||
// holds none - internal/postfix assertMapSafe; assumptions A-23).
|
||||
var clientLoginRe = regexp.MustCompile(`^\S+ postfix/(?:([a-z0-9-]+)/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=.*?, sasl_username=([^,\s]+)`)
|
||||
|
||||
// refusalLogins bounds the queue ids whose login is remembered.
|
||||
const refusalLogins = 4096
|
||||
|
||||
type pendingRefusal struct {
|
||||
clientIP, from, line string
|
||||
at, until time.Time
|
||||
start Place // where the line starts in the log
|
||||
clientIP, login, from, line string
|
||||
at, until time.Time
|
||||
start Place // where the line starts in the log
|
||||
}
|
||||
|
||||
// pendingRecovery is a From: refusal whose row is looked for once the panel
|
||||
@@ -139,14 +144,17 @@ func (r *refusalLines) handle(line string, now time.Time, start Place) {
|
||||
return
|
||||
}
|
||||
m := refusalLineRe.FindStringSubmatch(rest)
|
||||
if m == nil || m[1] == "inbound" {
|
||||
if m == nil || m[1] == "inbound" || m[4] == "" {
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimPrefix(m[2], "IPv6:"))
|
||||
// The address in "name[addr]" is smtpd's state->addr, which has no
|
||||
// "IPv6:" (Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_init();
|
||||
// assumptions A-24).
|
||||
ip := net.ParseIP(m[2])
|
||||
if ip == nil {
|
||||
return
|
||||
}
|
||||
p := pendingRefusal{clientIP: ip.String(), from: m[3], line: line, at: at, until: now.Add(refusalLineWait), start: start}
|
||||
p := pendingRefusal{clientIP: ip.String(), login: m[4], from: m[3], line: line, at: at, until: now.Add(refusalLineWait), start: start}
|
||||
if !r.attach(p) {
|
||||
r.pending = append(r.pending, p)
|
||||
}
|
||||
@@ -206,7 +214,7 @@ func domainOf(addr string) string {
|
||||
}
|
||||
|
||||
func (r *refusalLines) attach(p pendingRefusal) bool {
|
||||
ok, err := r.st.AttachRefusalLine(p.clientIP, p.from, p.at, p.line)
|
||||
ok, err := r.st.AttachRefusalLine(p.clientIP, p.login, p.from, p.at, p.line)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: refusal line: %v", err)
|
||||
return true // not retried: the store said no for its own reason
|
||||
|
||||
@@ -19,13 +19,13 @@ func (r *refusalProbe) RecoverRefusal(e store.SendLogEntry, _ time.Time) (bool,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *refusalProbe) AttachRefusalLine(clientIP, from string, _ time.Time, line string) (bool, error) {
|
||||
func (r *refusalProbe) AttachRefusalLine(clientIP, login, from string, _ time.Time, line string) (bool, error) {
|
||||
if r.rows == 0 {
|
||||
return false, nil
|
||||
}
|
||||
r.rows--
|
||||
r.lines = append(r.lines, line)
|
||||
r.who = append(r.who, clientIP+"|"+from)
|
||||
r.who = append(r.who, clientIP+"|"+login+"|"+from)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ func TestRefusalLines(t *testing.T) {
|
||||
`2026-10-14T12:00:00+00:00 mail postfix/inbound/smtpd[372]: NOQUEUE: milter-reject: MAIL from unknown[203.0.113.7]: 451 4.7.1 Service unavailable - try again later; from=<a@stranger.example> proto=ESMTP helo=<x>`,
|
||||
`2026-10-14T12:00:00+00:00 mail postfix/smtpd[11]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`,
|
||||
`2026-10-14T12:00:00+00:00 mail postfix/smtpd[11]: NOQUEUE: reject: RCPT from unknown[10.77.0.1]: 553 5.7.1 <a@x>: Sender address rejected: not owned by user app1; from=<a@x> to=<b@y> proto=ESMTP helo=<x>`,
|
||||
// No login: no application's refusal (the panel down refuses every
|
||||
// session, an unauthenticated one too).
|
||||
`2026-10-14T12:00:00+00:00 mail postfix/smtpd[12]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<x>`,
|
||||
} {
|
||||
r.handle(other, now, Place{})
|
||||
}
|
||||
@@ -59,7 +62,7 @@ func TestRefusalLines(t *testing.T) {
|
||||
}
|
||||
st.rows = 1 // the milter's row is written
|
||||
r.retry(now.Add(time.Second))
|
||||
if len(r.pending) != 0 || len(st.lines) != 1 || st.who[0] != "10.77.0.1|billing@sender.e2e.test" {
|
||||
if len(r.pending) != 0 || len(st.lines) != 1 || st.who[0] != "10.77.0.1|l2app|billing@sender.e2e.test" {
|
||||
t.Fatalf("after the retry: pending %d, attached %v", len(r.pending), st.who)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *retentionProbeStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
func (s *retentionProbeStore) ListOpenOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *retentionProbeStore) ExpireMessage(string) (int64, error) { return 0, nil }
|
||||
|
||||
func (s *retentionProbeStore) CloseUnreported(int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -30,7 +32,7 @@ func (s *retentionProbeStore) CloseUnreported(int64) (int64, error) {
|
||||
func (s *retentionProbeStore) AddReconcileMissed(time.Time, int64) error { return nil }
|
||||
func (s *retentionProbeStore) AddReconcileClosed(time.Time, int64) error { return nil }
|
||||
func (s *retentionProbeStore) AddTailerBlind(time.Time, time.Duration) error { return nil }
|
||||
func (s *retentionProbeStore) CountQueuedOlderThan(time.Time) (int64, error) { return 0, nil }
|
||||
func (s *retentionProbeStore) CountOpenOlderThan(time.Time) (int64, error) { return 0, nil }
|
||||
func (s *retentionProbeStore) SetQueuedOverLifetime(time.Time, int64) error { return nil }
|
||||
func (s *retentionProbeStore) PruneDeliveryLogDays(time.Time) error { return nil }
|
||||
|
||||
@@ -99,7 +101,7 @@ func TestRetentionLoopUsesUpdatedValue(t *testing.T) {
|
||||
t.Fatalf("second prune cutoff age %v, want about 7 days", age)
|
||||
}
|
||||
|
||||
func (s *retentionProbeStore) AttachRefusalLine(string, string, time.Time, string) (bool, error) {
|
||||
func (s *retentionProbeStore) AttachRefusalLine(string, string, string, time.Time, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user