logtail: a refusal the panel did not get to write is written from mail.log (stage 2b)
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>
This commit is contained in:
mix
2026-10-14 17:09:05 +03:00
co-authored by Claude Opus 5.5
parent e166a2ebd6
commit 65d916f26a
18 changed files with 322 additions and 20 deletions
+80
View File
@@ -378,3 +378,83 @@ func testRefusalsShowPostfixsLine(t *testing.T, sc *scenario) {
}
}
}
// asPanel runs python3 code inside the container as the panel's user, so the
// database's WAL files stay the panel's.
func asPanel(code string, args ...string) (string, error) {
full := append([]string{"exec", "-T", "-u", "panel", "selfpost", "python3", "-c", code}, args...)
out, err := h.compose(full...)
return strings.TrimSpace(string(out)), err
}
// testRefusalRecoveredFromTheLog: the sender check answers 550 and writes the
// row a moment later; a panel stopped in between leaves a refusal the log
// knows and the panel does not (audit 2026-10-12, stage 2b). The stop cannot
// be timed to that moment from outside, so what it leaves is made by hand: a
// From: refusal, then - the panel stopped - its row deleted and the tailer's
// saved place moved back before the message's first line. Started again, the
// panel writes the row from mail.log, marked, and once.
func testRefusalRecoveredFromTheLog(t *testing.T, sc *scenario) {
token := uniqueToken("recover")
res := attemptSend(sendAttempt{authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, headerFrom: "director@" + otherDomain, to: recipient,
subject: "e2e " + token, body: "refused, and its row lost"})
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err != nil {
t.Fatal(err)
}
var queueID string
if err := waitFor("the refused row", 15*time.Second, 300*time.Millisecond, func() (bool, error) {
out, err := asPanel(`import sqlite3,sys
r=sqlite3.connect("/data/selfpost.db").execute("select queue_id from send_log where subject like ?",("%"+sys.argv[1]+"%",)).fetchone()
print(r[0] if r else "")`, token)
queueID = out
return err == nil && out != "", err
}); err != nil {
t.Fatal(err)
}
supervisorctl := func(verb string) {
if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", verb, "panel"); err != nil {
t.Fatalf("%s panel: %v", verb, err)
}
}
supervisorctl("stop")
t.Cleanup(func() {
_, _ = h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "start", "panel")
})
if out, err := asPanel(`import sqlite3,sys
q=sys.argv[1]
data=open("/data/log/mail.log","rb").read()
pos=data.find((q+": client=").encode())
if pos < 0: sys.exit("no client= line for "+q)
start=data.rfind(b"\n",0,pos)+1
c=sqlite3.connect("/data/selfpost.db")
c.execute("delete from send_log where queue_id=?",(q,))
c.execute("update logtail_state set read_offset=?",(start,))
c.commit()
print(start)`, queueID); err != nil {
t.Fatalf("lose the row: %v %s", err, out)
}
supervisorctl("start")
if err := waitFor("panel HTTP to answer again", 30*time.Second, 300*time.Millisecond, func() (bool, error) {
_, err := sc.panel.status()
return err == nil, err
}); err != nil {
t.Fatal(err)
}
count := func() (string, error) {
return asPanel(`import sqlite3,sys
print(sqlite3.connect("/data/selfpost.db").execute("select count(*), coalesce(sum(recovered),0) from send_log where queue_id=? and status='rejected'",(sys.argv[1],)).fetchone())`, queueID)
}
if err := waitFor("the refusal written from mail.log", 90*time.Second, time.Second, func() (bool, error) {
out, err := count()
return err == nil && out == "(1, 1)", fmt.Errorf("rows (count, recovered) = %s, %v", out, err)
}); err != nil {
t.Fatal(err)
}
// Once: the next passes over the log write it no more.
time.Sleep(3 * time.Second)
if out, _ := count(); out != "(1, 1)" {
t.Fatalf("after more passes the rows are %s, want (1, 1)", out)
}
}