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>
65 lines
2.2 KiB
Go
65 lines
2.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/mixeme/selfpost/internal/milter"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
|
// (architecture.md § Mail path), recording accepted messages into the send
|
|
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
|
|
// in listenMilter; the protocol handler lives in internal/milter.
|
|
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
|
ln, err := listenMilter(cfg.journalSocket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("journal-milter listening on %s", cfg.journalSocket)
|
|
return milter.Serve(ctx, ln, st)
|
|
}
|
|
|
|
// serveInboundJournal runs the inbound journal milter, the last milter on
|
|
// port 25 (internal/milter/inbound.go). It exists only while the inbound relay
|
|
// is on: build/postfix-config.sh attaches the socket under the same condition.
|
|
func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error {
|
|
ln, err := listenMilter(cfg.inboundJournalSocket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
|
|
return milter.ServeInbound(ctx, ln, st, cfg.inboundAntispamMilter != "")
|
|
}
|
|
|
|
// listenMilter opens a milter's Unix socket where Postfix can reach it.
|
|
func listenMilter(socketPath string) (net.Listener, error) {
|
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
|
// listen below fails with "address already in use".
|
|
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
|
|
ln, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
|
// group access. The socket inherits group `selfpost` from the setgid parent
|
|
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
|
// it (connecting to a Unix socket needs write permission on the node).
|
|
if err := os.Chmod(socketPath, 0o660); err != nil {
|
|
ln.Close()
|
|
return nil, err
|
|
}
|
|
return ln, nil
|
|
}
|