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>
62 lines
2.0 KiB
Go
62 lines
2.0 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInsertInboundSpam(t *testing.T) {
|
|
st := openTestStore(t)
|
|
e := InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7",
|
|
From: "sender@example.net", To: "a@in.example.com", Subject: "Hello",
|
|
Decision: SpamDecisionAccept, Engine: SpamEngineRspamd, Detail: "no action, score 0.10 / 15.00",
|
|
}
|
|
if err := st.InsertInboundSpam(e); err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
var got InboundSpamEntry
|
|
var created string
|
|
err := st.db.QueryRow(
|
|
"SELECT inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at FROM inbound_spam_log",
|
|
).Scan(&got.InboundDomain, &got.ClientIP, &got.From, &got.To, &got.Subject, &got.Decision, &got.Engine, &got.Detail, &created)
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if got != e {
|
|
t.Errorf("row = %+v\nwant %+v", got, e)
|
|
}
|
|
if at, err := time.Parse(time.RFC3339, created); err != nil || time.Since(at) > time.Minute {
|
|
t.Errorf("created_at = %q (%v)", created, err)
|
|
}
|
|
|
|
// Every decision and engine the code names is one the table takes.
|
|
for _, d := range []string{SpamDecisionAccept, SpamDecisionReject, SpamDecisionTempfail, SpamDecisionQuarantine} {
|
|
for _, eng := range []string{SpamEngineRspamd, SpamEnginePostfix, SpamEngineSelfPost} {
|
|
e.Decision, e.Engine = d, eng
|
|
if err := st.InsertInboundSpam(e); err != nil {
|
|
t.Errorf("decision %s by %s: %v", d, eng, err)
|
|
}
|
|
}
|
|
}
|
|
e.Decision = "bounce"
|
|
if err := st.InsertInboundSpam(e); err == nil {
|
|
t.Error("an unknown decision was stored")
|
|
}
|
|
}
|
|
|
|
func TestIsInboundDomain(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if _, err := st.AddInboundDomain("in.example.com"); err != nil {
|
|
t.Fatalf("AddInboundDomain: %v", err)
|
|
}
|
|
for name, want := range map[string]bool{
|
|
"in.example.com": true, "IN.Example.COM": true,
|
|
"example.com": false, "": false, "other.example.com": false,
|
|
} {
|
|
if got, err := st.IsInboundDomain(name); err != nil || got != want {
|
|
t.Errorf("IsInboundDomain(%q) = %v, %v; want %v", name, got, err, want)
|
|
}
|
|
}
|
|
}
|