inbound-antispam-panel: inbound journal milter on port 25

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

The panel serves a second milter while the inbound relay is on
(internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET,
default /run/selfpost/inbound-journal.sock). build/postfix-config.sh
puts it last on port 25, after the optional antispam milter, with
default_action=accept; postfix-wrapper.sh waits for its socket. At
end-of-message it writes one inbound_spam_log row per recipient at an
inbound domain - other port-25 recipients (DMARC report addresses) get
none - and always accepts. With a filter attached it reads the verdict
from X-Rspamd-Action and X-Spamd-Result (action, score, up to six
weighted symbols, only what the patterns recognise, 255 bytes); without
one those headers are the sender's and are not read. The plan named
X-Rspamd-Score, which rspamd does not have.

The antispam compose fragment mounts rspamd/milter_headers.conf into
the sidecar so that it adds the two headers (checked in rspamd 3.11:
both routines exist and remove a sender's own headers of those names).

Store: InsertInboundSpam, IsInboundDomain.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the relayed message of
inbound_relay_delivers_and_refuses has its journal row (accept by
selfpost) and the DMARC report sent through the same listener has none.

Not yet seen in a real run: a verdict written by rspamd reaching this
milter. The e2e stand has no filter, and the test server's only inbound
domain has no upstream, so it accepts no mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 22:18:14 +03:00
co-authored by Claude Opus 5.5
parent a3cc52d6d4
commit 28ec784ae2
17 changed files with 748 additions and 31 deletions
+24
View File
@@ -265,6 +265,30 @@ func testInboundRelay(t *testing.T, sc *scenario) {
t.Fatalf("mail for the inbound domain never reached its upstream: %v\n%s", err, mailLogTail(h, "postfix/"))
}
// 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"))
}
}
// 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 {
t.Fatalf("the journal holds %d rows, want the relayed message alone:\n%s", n, rows)
}
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
res := deliverPort25("someone@stranger.e2e.test", to, message(to, uniqueToken("refused")))
if res.rcptErr == nil {