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:
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Decisions of the inbound antispam journal. Kept in sync with the CHECK
|
||||
// constraint of inbound_spam_log.
|
||||
const (
|
||||
SpamDecisionAccept = "accept"
|
||||
SpamDecisionReject = "reject"
|
||||
SpamDecisionTempfail = "tempfail"
|
||||
SpamDecisionQuarantine = "quarantine"
|
||||
)
|
||||
|
||||
// Who made a decision of the inbound antispam journal. Kept in sync with the
|
||||
// CHECK constraint of inbound_spam_log.
|
||||
const (
|
||||
SpamEngineRspamd = "rspamd" // the external filter
|
||||
SpamEnginePostfix = "postfix" // Postfix's own policy: unknown recipient, size, rate
|
||||
SpamEngineSelfPost = "selfpost" // nobody: relayed with no filter attached
|
||||
)
|
||||
|
||||
// InboundSpamEntry is one row of the inbound antispam journal: what happened
|
||||
// to a message offered on port 25 for one recipient. Metadata only - the
|
||||
// message itself is relayed, not stored. Subject and Detail may be empty.
|
||||
type InboundSpamEntry struct {
|
||||
InboundDomain string
|
||||
ClientIP string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Decision string
|
||||
Engine string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// InsertInboundSpam records one journal row. It is called from the inbound
|
||||
// journal milter's hot path and from the log tailer, so it returns any error
|
||||
// for the caller to log; neither of them may hold up mail over it.
|
||||
func (s *Store) InsertInboundSpam(e InboundSpamEntry) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO inbound_spam_log
|
||||
(inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.InboundDomain, e.ClientIP, e.From, e.To, e.Subject, e.Decision, e.Engine, e.Detail,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert inbound_spam_log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInboundDomain reports whether name is a configured inbound domain. Port 25
|
||||
// also takes mail that is not the relay's - DMARC reports for the server's own
|
||||
// name - and the journal keeps rows for inbound domains only.
|
||||
func (s *Store) IsInboundDomain(name string) (bool, error) {
|
||||
var one int
|
||||
err := s.db.QueryRow("SELECT 1 FROM inbound_domains WHERE name = ?", strings.ToLower(name)).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("look up inbound domain: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user