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>
80 lines
2.9 KiB
Go
80 lines
2.9 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Postfix's line about a refusal at MAIL FROM goes on the row of that sender
|
|
// and login refused about then, the newest without a line; a queued row, a
|
|
// row of another sender and a row from long ago are not it (audit
|
|
// 2026-10-12, stage 2b).
|
|
func TestAttachRefusalLine(t *testing.T) {
|
|
st := openTestStore(t)
|
|
now := time.Now().UTC()
|
|
for _, e := range []SendLogEntry{
|
|
{AppLogin: "l2app", From: "billing@example.com", Reason: ReasonLimit},
|
|
{AppLogin: "l2app", From: "other@example.com", Reason: ReasonLimit},
|
|
} {
|
|
if err := st.InsertRejected(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", AppLogin: "l2app", From: "billing@example.com", To: "x@example.net"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line := "Oct 14 12:00:00 mail postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable"
|
|
if ok, err := st.AttachRefusalLine("l2app", "Billing@example.com", now, line); err != nil || !ok {
|
|
t.Fatalf("attach = %v, %v", ok, err)
|
|
}
|
|
// The row has its line now; a second line finds no row without one.
|
|
if ok, _ := st.AttachRefusalLine("l2app", "billing@example.com", now, line); ok {
|
|
t.Fatal("a second line took a row that has one")
|
|
}
|
|
if ok, _ := st.AttachRefusalLine("l2app", "other@example.com", now.Add(-time.Hour), line); ok {
|
|
t.Fatal("a line an hour off took a row")
|
|
}
|
|
if ok, _ := st.AttachRefusalLine("someone", "other@example.com", now, line); ok {
|
|
t.Fatal("a line of another login took a row")
|
|
}
|
|
rows, _ := st.QuerySendLog(SendLogFilter{AllDomains: true}, 10, 0)
|
|
got := 0
|
|
for _, r := range rows {
|
|
full, _ := st.GetSendLog(r.ID)
|
|
if full.RefusalLine != "" {
|
|
got++
|
|
if full.From != "billing@example.com" || full.Status != StatusRejected {
|
|
t.Errorf("the line went to %+v", full)
|
|
}
|
|
}
|
|
}
|
|
if got != 1 {
|
|
t.Fatalf("%d rows have a line, want 1", got)
|
|
}
|
|
}
|
|
|
|
// A refused message's lines are those of a few minutes around the refusal:
|
|
// its queue id was freed at once, and a later message with the same id lends
|
|
// it nothing.
|
|
func TestSendLogLinesOfARefusalAreItsOwn(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if err := st.InsertRejected(SendLogEntry{QueueID: "7BCAF3C75DA0", AppLogin: "shop", From: "shop@shop.example", Reason: ReasonFrom}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if _, err := st.InsertSendLogLines([]SendLogLine{
|
|
{QueueID: "7BCAF3C75DA0", At: now.Add(-time.Second), Line: "its own refusal"},
|
|
{QueueID: "7BCAF3C75DA0", At: now.Add(20 * time.Minute), Line: "a later message with the id"},
|
|
}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, _ := st.QuerySendLog(SendLogFilter{AllDomains: true}, 1, 0)
|
|
lines, err := st.SendLogLines(rows[0], 5*24*time.Hour, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(lines) != 1 || lines[0].Line != "its own refusal" {
|
|
t.Fatalf("lines = %+v, want its own alone", lines)
|
|
}
|
|
}
|