inbound-antispam-panel: refused inbound mail from mail.log; retention

Step 4/12 of docs/plans/inbound-antispam-panel.md.

The log tailer writes an inbound_spam_log row for mail port 25 refused
(internal/logtail/inbound.go): a "reject:" line of the port-25 smtpd is
Postfix's own policy; a milter-reject / milter-hold / milter-discard
line of cleanup is the filter's verdict. Only a recipient at an inbound
domain is a row, so "Relay access denied" for a stranger's domain is
not. The row is dated by its log line; the reply text is cut to 255
bytes of printable ASCII; a reply starting with 4 is a tempfail.

To tell port 25 from 465 / 587 the listener now logs as
postfix/inbound (syslog_name). cleanup logs under one name for every
listener, so its verdict counts only for a queue id the port-25 smtpd
was seen to open (4096 remembered). Lines are matched from the head,
after timestamp and host: their tail is the sender's own text.

Retention: PruneInboundSpam every six hours - older than the
inbound_spam_log_retention_days setting (seeded from
INBOUND_SPAM_LOG_RETENTION_DAYS, 90, range 7-365), then down to 10 000
rows. The setting has no field in the panel yet.

Known limits, in architecture.md: a log line names one recipient, so a
message refused at end-of-message is one row; a line re-read after an
unclean stop would be a second row.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the unknown recipient at
the inbound domain refused on port 25 has its row (reject by postfix),
the stranger's domain has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 22:42:25 +03:00
co-authored by Claude Opus 5.5
parent 94e94490af
commit 27fd355e53
18 changed files with 744 additions and 25 deletions
+3
View File
@@ -47,6 +47,9 @@ var port25Cases = []port25Case{
{param: "smtpd_sasl_auth_enable", contains: "no", wantExact: true},
{param: "smtpd_relay_restrictions", contains: "reject_unauth_destination", wantExact: true},
{param: "smtpd_recipient_restrictions", contains: "reject_unauth_destination,reject_unlisted_recipient", wantExact: true},
// The log tailer tells this listener's refusals from the
// submission ports' by the name they are logged under.
{param: "syslog_name", contains: "postfix/inbound", wantExact: true},
// No antispam milter configured: the inbound journal milter
// alone. The submission chain (strict OpenDKIM + journal-milter)
// must not reach inbound mail.
+25 -14
View File
@@ -268,24 +268,27 @@ func testInboundRelay(t *testing.T, sc *scenario) {
// The inbound journal milter saw it go through. No page shows the journal
// yet, so the row is read where it is kept. No filter is attached on the
// stand: the row is an accept by nobody.
want := fmt.Sprintf("%s|someone@stranger.e2e.test|%s|e2e %s|accept|selfpost", inboundDomain, inboundRecipient, token)
var rows string
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(time.Second) {
rows, err = h.execIn("selfpost", "python3", "-c",
`import sqlite3; print("\n".join("|".join(r) for r in sqlite3.connect("file:/data/selfpost.db?mode=ro", uri=True).execute("select inbound_domain, from_addr, to_addr, subject, decision, engine from inbound_spam_log")))`)
if err != nil {
t.Fatalf("read the inbound journal: %v\n%s", err, rows)
}
if strings.Contains(rows, want) {
break
}
if time.Now().After(deadline) {
t.Fatalf("no journal row for the relayed message, want %q, journal:\n%s\n%s", want, rows, h.logs("selfpost"))
// journalHas waits for a row and returns the journal as it then stands.
journalHas := func(want string) string {
t.Helper()
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(time.Second) {
rows, err := h.execIn("selfpost", "python3", "-c",
`import sqlite3; print("\n".join("|".join(r) for r in sqlite3.connect("file:/data/selfpost.db?mode=ro", uri=True).execute("select inbound_domain, from_addr, to_addr, subject, decision, engine from inbound_spam_log")))`)
if err != nil {
t.Fatalf("read the inbound journal: %v\n%s", err, rows)
}
if strings.Contains(rows, want) {
return strings.TrimSpace(rows)
}
if time.Now().After(deadline) {
t.Fatalf("no journal row %q, journal:\n%s\n%s", want, rows, mailLogTail(h, "postfix/"))
}
}
}
rows := journalHas(fmt.Sprintf("%s|someone@stranger.e2e.test|%s|e2e %s|accept|selfpost", inboundDomain, inboundRecipient, token))
// A report mailed to the server's own name earlier in the run came through
// the same listener and is not the relay's mail.
if n := strings.Count(strings.TrimSpace(rows), "\n") + 1; n != 1 {
if n := strings.Count(rows, "\n") + 1; n != 1 {
t.Fatalf("the journal holds %d rows, want the relayed message alone:\n%s", n, rows)
}
@@ -295,4 +298,12 @@ func testInboundRelay(t *testing.T, sc *scenario) {
t.Fatalf("port 25 accepted the recipient %s (first error: %v)", to, res.firstErr())
}
}
// The refusal of an unknown recipient at the inbound domain is read from
// the mail log into the journal; the refusal to relay for a stranger's
// domain is not the relay's mail and is not.
rows = journalHas(fmt.Sprintf("%s|someone@stranger.e2e.test|nobody@%s||reject|postfix", inboundDomain, inboundDomain))
if n := strings.Count(rows, "\n") + 1; n != 2 {
t.Fatalf("the journal holds %d rows, want the relayed message and one refusal:\n%s", n, rows)
}
}