inbound-antispam-panel: migration 0002, journal and filter-list tables
Step 2/12 of docs/plans/inbound-antispam-panel.md, the first migration after the 2.0 baseline (2.0.1 was cut before it, as agreed). 0002_inbound_spam_log.sql adds inbound_spam_log (one row per recipient: domain, client IP, envelope, subject, decision, engine, detail) with indexes on created_at and (inbound_domain, created_at), and inbound_spam_list (allow / deny entries, unique per list). DDL only; decision, engine, list_type and entry_type carry CHECKs, as the baseline does for its own enumerations. Nothing reads or writes the tables yet. Tests: TestBaselineSchema pins both tables and user_version 2; TestOpenMigratesA20Database opens a file holding the baseline alone and finds its user kept; TestInboundSpamConstraints. go test ./internal/store/ ok; go vet ./... ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
-- Inbound antispam journal and filter lists (inbound-antispam-panel, 2.1.0).
|
||||
-- The first migration after the 2.0 baseline: DDL only, so a 2.0.x database
|
||||
-- gains two empty tables and nothing in it changes.
|
||||
|
||||
-- What happened to a message offered on port 25, one row per recipient.
|
||||
-- Metadata only - SelfPost relays inbound mail and stores none of it. Accepted
|
||||
-- messages are written by the inbound journal milter, refused ones by the
|
||||
-- mail.log tailer, which is why a row can lack a subject (refused before DATA)
|
||||
-- and why engine says who decided: the external filter, Postfix's own policy,
|
||||
-- or nobody (selfpost: relayed with no filter attached).
|
||||
CREATE TABLE inbound_spam_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inbound_domain TEXT NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
to_addr TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
decision TEXT NOT NULL CHECK (decision IN ('accept', 'reject', 'tempfail', 'quarantine')),
|
||||
engine TEXT NOT NULL CHECK (engine IN ('rspamd', 'postfix', 'selfpost')),
|
||||
detail TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- Retention prunes by age; the Log page lists newest first, for the whole
|
||||
-- server or for one domain.
|
||||
CREATE INDEX idx_inbound_spam_log_created_at ON inbound_spam_log (created_at);
|
||||
CREATE INDEX idx_inbound_spam_log_domain_created_at ON inbound_spam_log (inbound_domain, created_at);
|
||||
|
||||
-- Allow / deny entries for the external filter, instance-wide. The panel
|
||||
-- writes them out as map files under /data/antispam/; SelfPost itself does not
|
||||
-- consult them. value is stored normalised, so the same entry cannot be added
|
||||
-- twice to one list.
|
||||
CREATE TABLE inbound_spam_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_type TEXT NOT NULL CHECK (list_type IN ('allow', 'deny')),
|
||||
entry_type TEXT NOT NULL CHECK (entry_type IN ('address', 'domain', 'ip')),
|
||||
value TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (list_type, entry_type, value)
|
||||
);
|
||||
@@ -55,6 +55,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"user_inbound_domains": "user_id inbound_domain_id",
|
||||
"dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient",
|
||||
"dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from",
|
||||
"inbound_spam_log": "id inbound_domain client_ip from_addr to_addr subject decision engine detail created_at",
|
||||
"inbound_spam_list": "id list_type entry_type value note created_at",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
@@ -99,6 +101,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
rows.Close()
|
||||
wantIdx := []string{
|
||||
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
|
||||
"idx_inbound_spam_log_created_at", "idx_inbound_spam_log_domain_created_at",
|
||||
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_queue_id",
|
||||
"idx_sessions_expires_at",
|
||||
}
|
||||
@@ -108,8 +111,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 1 {
|
||||
t.Errorf("user_version = %d, %v; want 1", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 2 {
|
||||
t.Errorf("user_version = %d, %v; want 2", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
@@ -156,6 +159,96 @@ func TestReopenKeepsTheDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A 2.0.x data directory - the baseline and nothing after it - is brought to
|
||||
// the head by opening it, with what it holds left alone.
|
||||
func TestOpenMigratesA20Database(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
baseline, err := migrationsFS.ReadFile("migrations/0001_init.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read the baseline: %v", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("create a 2.0 db: %v", err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
string(baseline),
|
||||
"PRAGMA user_version = 1",
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES ('admin', 'hash', 'global', '2026-10-11T00:00:00Z')",
|
||||
} {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("prepare a 2.0 db: %v", err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open a 2.0 database: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
var version int
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 2 {
|
||||
t.Errorf("user_version = %d, %v; want 2", version, err)
|
||||
}
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
||||
}
|
||||
for _, table := range []string{"inbound_spam_log", "inbound_spam_list"} {
|
||||
if len(tableColumns(t, st, table)) == 0 {
|
||||
t.Errorf("table %s was not created", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the antispam tables refuse: a decision, engine or list the panel does
|
||||
// not know, and the same entry twice in one list.
|
||||
func TestInboundSpamConstraints(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
logRow := func(decision, engine string) error {
|
||||
_, err := st.db.Exec(
|
||||
"INSERT INTO inbound_spam_log (inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at)"+
|
||||
" VALUES ('in.example.com', '192.0.2.1', 'a@example.net', 'b@in.example.com', '', ?, ?, '', '2026-10-11T00:00:00Z')",
|
||||
decision, engine)
|
||||
return err
|
||||
}
|
||||
if err := logRow("reject", "postfix"); err != nil {
|
||||
t.Fatalf("a reject by postfix: %v", err)
|
||||
}
|
||||
if err := logRow("bounce", "postfix"); err == nil {
|
||||
t.Error("decision bounce was accepted")
|
||||
}
|
||||
if err := logRow("accept", "spamassassin"); err == nil {
|
||||
t.Error("engine spamassassin was accepted")
|
||||
}
|
||||
|
||||
entry := func(list, kind, value string) error {
|
||||
_, err := st.db.Exec(
|
||||
"INSERT INTO inbound_spam_list (list_type, entry_type, value, created_at) VALUES (?, ?, ?, '2026-10-11T00:00:00Z')",
|
||||
list, kind, value)
|
||||
return err
|
||||
}
|
||||
if err := entry("allow", "address", "billing@vendor.example"); err != nil {
|
||||
t.Fatalf("an allow entry: %v", err)
|
||||
}
|
||||
if err := entry("allow", "address", "billing@vendor.example"); err == nil {
|
||||
t.Error("the same entry was added to one list twice")
|
||||
}
|
||||
if err := entry("deny", "address", "billing@vendor.example"); err != nil {
|
||||
t.Errorf("the same value on the other list: %v", err)
|
||||
}
|
||||
if err := entry("block", "address", "x@example.net"); err == nil {
|
||||
t.Error("list type block was accepted")
|
||||
}
|
||||
if err := entry("deny", "subject", "pills"); err == nil {
|
||||
t.Error("entry type subject was accepted")
|
||||
}
|
||||
var note string
|
||||
if err := st.db.QueryRow("SELECT note FROM inbound_spam_list WHERE list_type = 'allow'").Scan(¬e); err != nil || note != "" {
|
||||
t.Errorf("note of an entry added without one = %q, %v", note, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A 1.x data directory must be refused, loudly. Its user_version (10 at the
|
||||
// last 1.x release) would otherwise read as "the baseline is already applied",
|
||||
// and the panel would start on tables it does not know.
|
||||
|
||||
Reference in New Issue
Block a user