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>
161 lines
5.1 KiB
Go
161 lines
5.1 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInsertInboundSpamKeepsItsOwnTime(t *testing.T) {
|
|
st := openTestStore(t)
|
|
at := time.Date(2026, 10, 1, 9, 30, 0, 0, time.FixedZone("msk", 3*3600))
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: "a@in.example.com",
|
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: at,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
var created string
|
|
if err := st.db.QueryRow("SELECT created_at FROM inbound_spam_log").Scan(&created); err != nil || created != "2026-10-01T06:30:00Z" {
|
|
t.Errorf("created_at = %q, %v", created, err)
|
|
}
|
|
}
|
|
|
|
func TestPruneInboundSpam(t *testing.T) {
|
|
st := openTestStore(t)
|
|
now := time.Now().UTC()
|
|
add := func(to string, age time.Duration) {
|
|
t.Helper()
|
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: to,
|
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: now.Add(-age),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertInboundSpam: %v", err)
|
|
}
|
|
}
|
|
left := func() string {
|
|
t.Helper()
|
|
rows, err := st.db.Query("SELECT to_addr FROM inbound_spam_log ORDER BY id")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var out string
|
|
for rows.Next() {
|
|
var to string
|
|
if err := rows.Scan(&to); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out += to + " "
|
|
}
|
|
return out
|
|
}
|
|
|
|
add("old", 100*24*time.Hour)
|
|
add("a", 3*time.Hour)
|
|
add("b", 2*time.Hour)
|
|
add("c", time.Hour)
|
|
|
|
// Age first: nothing else goes while the rest fits.
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 10); err != nil || n != 1 {
|
|
t.Fatalf("prune by age = %d, %v", n, err)
|
|
}
|
|
if got := left(); got != "a b c " {
|
|
t.Fatalf("left = %q", got)
|
|
}
|
|
// Then the cap, oldest first.
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 2); err != nil || n != 1 {
|
|
t.Fatalf("prune by count = %d, %v", n, err)
|
|
}
|
|
if got := left(); got != "b c " {
|
|
t.Fatalf("left = %q", got)
|
|
}
|
|
if n, err := st.PruneInboundSpam(now.AddDate(0, 0, -90), 2); err != nil || n != 0 {
|
|
t.Errorf("a second prune removed %d, %v", n, err)
|
|
}
|
|
}
|
|
|
|
func TestInboundSpamLogRetentionDays(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if days, err := st.GetInboundSpamLogRetentionDays(30); err != nil || days != 30 {
|
|
t.Errorf("unset, env 30: %d, %v", days, err)
|
|
}
|
|
if days, err := st.GetInboundSpamLogRetentionDays(3); err != nil || days != InboundSpamLogRetentionDaysDefault {
|
|
t.Errorf("unset, env out of range: %d, %v", days, err)
|
|
}
|
|
if err := st.EnsureInboundSpamLogRetentionDays(30); err != nil {
|
|
t.Fatalf("Ensure: %v", err)
|
|
}
|
|
if err := st.EnsureInboundSpamLogRetentionDays(60); err != nil {
|
|
t.Fatalf("Ensure again: %v", err)
|
|
}
|
|
if days, _ := st.GetInboundSpamLogRetentionDays(90); days != 30 {
|
|
t.Errorf("after Ensure(30), Ensure(60): %d, want the first seed kept", days)
|
|
}
|
|
if err := st.SetInboundSpamLogRetentionDays(6); err == nil {
|
|
t.Error("6 days was accepted")
|
|
}
|
|
if err := st.SetInboundSpamLogRetentionDays(365); err != nil {
|
|
t.Errorf("365 days: %v", err)
|
|
}
|
|
if days, _ := st.GetInboundSpamLogRetentionDays(90); days != 365 {
|
|
t.Errorf("after Set(365): %d", days)
|
|
}
|
|
}
|