The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.
- Wrong From: cleanup gives the message a queue id before the headers are
judged; the sender check reads {i} at end-of-headers and writes it on the
refused row. Its lines - stored under the id like any other's - are on the
page, but only those from 10 minutes before to 1 minute after the refusal
(store.SendLogLines for a rejected row): Postfix frees the id at once, and
a later message with it lends nothing. No mail.log fallback for a refused
row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
"NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
of that sender (and login, when the line names it) within two minutes; a
line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
"none - refused at MAIL FROM, before one is given"; the box's caption is
"The line from mail.log for this refusal" for the latter; a row from
before says the line was not kept. LogLineLevel colours a refusal by its
reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
not muted as in the picture: the kit has no muted inline.
Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.4 KiB
Go
75 lines
2.4 KiB
Go
package logtail
|
|
|
|
import (
|
|
"log"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// Postfix's line about a message the journal milter refused at MAIL FROM - a
|
|
// 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; the line is matched to its row by
|
|
// sender, login and time. As the stand writes it:
|
|
//
|
|
// 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>
|
|
//
|
|
// 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=<([^>]*)>(?:.*? sasl_username=<?([^>\s]*)>?)?`)
|
|
|
|
// 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.
|
|
const refusalLineWait = 30 * time.Second
|
|
|
|
// RefusalStore is where the lines go. *store.Store satisfies it.
|
|
type RefusalStore interface {
|
|
AttachRefusalLine(login, from string, at time.Time, line string) (bool, error)
|
|
}
|
|
|
|
type pendingRefusal struct {
|
|
login, from, line string
|
|
at, until time.Time
|
|
}
|
|
|
|
// refusalLines puts each refusal line on its row, and keeps for a while the
|
|
// lines whose row is not there yet.
|
|
type refusalLines struct {
|
|
st RefusalStore
|
|
pending []pendingRefusal
|
|
}
|
|
|
|
// handle looks at one mail.log line.
|
|
func (r *refusalLines) handle(line string, now time.Time) {
|
|
at, rest := lineTime(line, now)
|
|
m := refusalLineRe.FindStringSubmatch(rest)
|
|
if m == nil || m[1] == "inbound" {
|
|
return
|
|
}
|
|
p := pendingRefusal{login: m[3], from: m[2], line: line, at: at, until: now.Add(refusalLineWait)}
|
|
if !r.attach(p) {
|
|
r.pending = append(r.pending, p)
|
|
}
|
|
}
|
|
|
|
// retry tries the waiting lines again and lets go of those that waited long
|
|
// enough.
|
|
func (r *refusalLines) retry(now time.Time) {
|
|
kept := r.pending[:0]
|
|
for _, p := range r.pending {
|
|
if r.attach(p) || now.After(p.until) {
|
|
continue
|
|
}
|
|
kept = append(kept, p)
|
|
}
|
|
r.pending = kept
|
|
}
|
|
|
|
func (r *refusalLines) attach(p pendingRefusal) bool {
|
|
ok, err := r.st.AttachRefusalLine(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
|
|
}
|
|
return ok
|
|
}
|