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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user