test / test (push) Canceled after 0s
The sender check answers 550 5.7.1 and writes the refused row through a
buffer a moment later; a panel stopped in between (a rollout, a kill) left
an application refused and a log that did not know it. A refusal at MAIL
FROM needs nothing: the journal milter writes its row before it answers.
- logtail.refusalLines remembers each queue id's login from smtpd's
"<id>: client=..., sasl_username=<login>" (4096 ids at most) and, on
cleanup's "<id>: milter-reject: END-OF-MESSAGE ...: 5.7.1 From: <line> -
<why>; from=<...> to=<...>", waits 30 s and hands it to
store.RecoverRefusal - an INSERT ... WHERE NOT EXISTS a refused row of that
queue id within two minutes: the row the panel wrote, or one recovered
before, means nothing is written. It is read from the saved offset on, so
a kill is covered as well as a clean stop - which a flush of the buffer at
shutdown would not.
- Migration 0009 (not deployed yet) gains send_log.recovered; the row is
written with the line's time, the login's domain id, reason "from", the
From: line as the check printed it (to the last " - " before ";").
- The message page: a fact "Written: from mail.log after a restart: the
panel stopped before it wrote the refusal".
Tests: store (not written when the panel wrote it; written once however
often); logtail (the login from client=, the From: line with a " - " in it,
nothing before the wait is over); view (the fact). e2e
refusal_recovered_from_the_log: a From: refusal, the panel stopped, its row
deleted and the tailer's saved place moved before the message's first line
(python3 in the container, as the panel's user - a kill cannot be timed to
that moment from outside); started again, the row is back, recovered, once.
Also fixed: handlers TestMessagePageOfARefusedMessage still expected the
old sentence; it failed on the server after e166a2e, whose run showed only
the schema failures - now it wants the new one.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 26/62.
Evidence on the test server for this tree but the handler-test fix: make e2e
exit 0 (the new case 37 s); go test 22/23 ok, the 23rd that test - fixed,
passes locally; the full run follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
4.0 KiB
Go
93 lines
4.0 KiB
Go
package logtail
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
type refusalProbe struct {
|
|
rows int // rows waiting for a line
|
|
lines []string
|
|
who []string
|
|
recovered []store.SendLogEntry
|
|
}
|
|
|
|
func (r *refusalProbe) RecoverRefusal(e store.SendLogEntry, _ time.Time) (bool, error) {
|
|
r.recovered = append(r.recovered, e)
|
|
return true, nil
|
|
}
|
|
|
|
func (r *refusalProbe) AttachRefusalLine(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, login+"|"+from)
|
|
return true, nil
|
|
}
|
|
|
|
// The NOQUEUE line of a refusal at MAIL FROM goes on its row; a line whose
|
|
// row is not written yet waits and is tried again; port 25's and other
|
|
// lines are not refusals of an application.
|
|
func TestRefusalLines(t *testing.T) {
|
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
st := &refusalProbe{}
|
|
r := &refusalLines{st: st}
|
|
limit := `2026-10-14T12:00:00+00:00 mail 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>`
|
|
for _, other := range []string{
|
|
`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>`,
|
|
} {
|
|
r.handle(other, now)
|
|
}
|
|
if len(r.pending) != 0 {
|
|
t.Fatalf("lines that are not an application's refusal at MAIL wait: %+v", r.pending)
|
|
}
|
|
|
|
r.handle(limit, now) // its row is not there yet
|
|
if len(r.pending) != 1 {
|
|
t.Fatalf("pending %d, want the line waiting", len(r.pending))
|
|
}
|
|
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] != "l2app|billing@sender.e2e.test" {
|
|
t.Fatalf("after the retry: pending %d, attached %v", len(r.pending), st.who)
|
|
}
|
|
|
|
r.handle(limit, now) // no row ever comes
|
|
r.retry(now.Add(refusalLineWait + time.Second))
|
|
if len(r.pending) != 0 {
|
|
t.Fatal("a line kept waiting past its time")
|
|
}
|
|
}
|
|
|
|
// cleanup's line about a From: refusal is the row's, written by the panel; once
|
|
// the panel has had the time to, the line is handed to RecoverRefusal, which
|
|
// writes the row only if it is missing - with the login smtpd named for the
|
|
// queue id, the sender, the recipient and the From: line as printed.
|
|
func TestFromRefusalIsRecoveredAfterTheWait(t *testing.T) {
|
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
st := &refusalProbe{}
|
|
r := &refusalLines{st: st}
|
|
r.handle(`2026-10-14T12:00:00+00:00 mail postfix/smtpd[11]: E543D3C75DA0: client=unknown[10.77.0.1], sasl_method=PLAIN, sasl_username=app1`, now)
|
|
r.handle(`2026-10-14T12:00:01+00:00 mail postfix/cleanup[13]: E543D3C75DA0: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: "The - Shop" <director@other.e2e.test> - it names another sender of this server; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<x>`, now)
|
|
r.retry(now.Add(time.Second))
|
|
if len(st.recovered) != 0 {
|
|
t.Fatal("recovered before the panel had the time to write the row")
|
|
}
|
|
r.retry(now.Add(refusalLineWait + time.Second))
|
|
if len(st.recovered) != 1 {
|
|
t.Fatalf("recovered %d, want 1", len(st.recovered))
|
|
}
|
|
e := st.recovered[0]
|
|
want := store.SendLogEntry{QueueID: "E543D3C75DA0", AppLogin: "app1", From: "alerts@sender.e2e.test", To: "rcpt@sink.e2e.test",
|
|
Domain: "sender.e2e.test", Reason: store.ReasonFrom, HeaderFrom: `"The - Shop" <director@other.e2e.test>`}
|
|
if e != want {
|
|
t.Fatalf("recovered %+v, want %+v", e, want)
|
|
}
|
|
}
|