test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
9.3 KiB
Go
251 lines
9.3 KiB
Go
// Package milter implements the SelfPost journal-milter: a lightweight milter
|
|
// (architecture.md § Mail path) attached to Postfix's smtpd_milters alongside
|
|
// OpenDKIM. On the receive path it reads the SASL login, From, recipients and
|
|
// Subject of each accepted message and records one send-log row per (queue-id,
|
|
// recipient), giving the panel a structured, filterable history that raw
|
|
// mail.log cannot.
|
|
//
|
|
// It is monitoring only: it never rejects, and every callback returns Continue
|
|
// or Accept so a failure of this milter can never block the relay. Postfix is
|
|
// configured with default_action=accept for this milter's socket, so even a
|
|
// crash or hang fails open (architecture.md § Mail path).
|
|
package milter
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-milter"
|
|
|
|
"github.com/mixeme/selfpost/internal/mailhdr"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// Store is the persistence the milter needs on the receive path: recording
|
|
// accepted messages (architecture.md § Mail path) and, for level-2 rate
|
|
// limiting (guide § Rate limiting), looking up the configured limits and
|
|
// counting recent messages. *store.Store satisfies it; tests substitute a
|
|
// fake.
|
|
type Store interface {
|
|
InsertQueued(e store.SendLogEntry) error
|
|
InsertRejected(e store.SendLogEntry) error
|
|
RateLimit(scope, ref string) (store.RateLimit, bool, error)
|
|
CountMessages(scope, ref string, since time.Time) (int64, error)
|
|
ApplicationByLogin(login string) (store.Application, error)
|
|
}
|
|
|
|
// session accumulates the fields of one message as the milter callbacks fire.
|
|
// Milter macros arrive per-stage and do not accumulate, so each value is
|
|
// captured at the stage that carries it (architecture.md § Mail path): SASL
|
|
// login and From at MAIL, each recipient at RCPT, Subject in the headers, and
|
|
// the queue-id at end-of-message. go-milter creates one session per
|
|
// connection; a connection may carry several messages, so per-message fields
|
|
// are reset at MailFrom (the start of every transaction).
|
|
type session struct {
|
|
milter.NoOpMilter
|
|
rec Store
|
|
// flight is shared by every session of the process; it holds the messages
|
|
// that passed the level-2 check but are not in the send log yet. Nil is a
|
|
// valid zero value (no in-flight accounting).
|
|
flight *inflight
|
|
|
|
// clientIP is the client's address. Postfix gives it at Connect, once per
|
|
// connection, and again with every MAIL FROM as the {client_addr} macro
|
|
// (milter_mail_macros in build/postfix-config.sh). The second is the one
|
|
// that can be relied on: see MailFrom.
|
|
clientIP string
|
|
|
|
login string
|
|
from string
|
|
rcpts []string
|
|
subject string
|
|
reserved []*reservation // level-2 slots held by the current message
|
|
}
|
|
|
|
// Connect captures the client IP of a connection's first message. It is not
|
|
// enough on its own - see MailFrom.
|
|
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
|
if addr != nil {
|
|
s.clientIP = addr.String()
|
|
}
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// MailFrom starts a new message: reset per-message state, then capture the
|
|
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
|
|
// macros). This is also the earliest stage where both the sending domain (from
|
|
// the sender) and the application (the login) are known, so the level-2 rate
|
|
// limit is enforced here: over the limit, the message is refused with a 4xx
|
|
// tempfail before recipients are even offered (guide § Rate limiting).
|
|
// Enforcement is fail-open — see overLimit.
|
|
//
|
|
// The client address is read again here, from the {client_addr} macro. The
|
|
// milter library replaces the session with a new one after every reply that is
|
|
// not "continue" - a refusal, or the accept that used to end a message - and
|
|
// Postfix does not send Connect again on the same connection. A session made
|
|
// that way never saw the client, so every message of a connection after the
|
|
// first, and every MAIL FROM after a refused one, used to be judged with no
|
|
// client address: the level-2 limit and an application's client-address list
|
|
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
|
|
// one connection.)
|
|
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
|
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
|
if ip := clientAddr(m); ip != "" {
|
|
s.clientIP = ip
|
|
}
|
|
s.from = cleanAddress(from)
|
|
s.login = macro(m, "auth_authen")
|
|
s.rcpts = nil
|
|
s.subject = ""
|
|
if !s.authIPAllowed() {
|
|
s.recordRejected(store.ReasonClient)
|
|
return milter.RespTempFail, nil
|
|
}
|
|
if s.overLimit() {
|
|
s.recordRejected(store.ReasonLimit)
|
|
return milter.RespTempFail, nil
|
|
}
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// RcptTo records each recipient. Postfix calls this once per recipient, which
|
|
// is what lets the journal keep a separate row per (queue-id, recipient).
|
|
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
|
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// Header captures the Subject. Only the first Subject header is kept.
|
|
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
|
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
|
|
s.subject = mailhdr.DecodeSubject(value)
|
|
}
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// Body fires at end-of-message, when the queue-id macro {i} is set and the
|
|
// message is about to be committed to the queue. This is where the "queued"
|
|
// rows are written. The reply is "continue", which at end-of-message lets the
|
|
// message through just as "accept" does - and, unlike it, leaves this session
|
|
// in place for the connection's next message (see MailFrom).
|
|
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
|
s.record(macro(m, "i"))
|
|
// The rows are in the send log now, so the stored count sees this message
|
|
// and its level-2 slots are no longer needed.
|
|
s.releaseReservations()
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// Abort ends the current transaction without an end-of-message (client RSET, or
|
|
// Postfix rejecting the message for its own reasons). No send-log row will be
|
|
// written, so the level-2 slots this message held must go back.
|
|
func (s *session) Abort(m *milter.Modifier) error {
|
|
s.releaseReservations()
|
|
s.rcpts = nil
|
|
s.subject = ""
|
|
return nil
|
|
}
|
|
|
|
// macro reads a milter macro, tolerating Postfix's convention of wrapping
|
|
// multi-character macro names in curly braces (e.g. {auth_authen}) while
|
|
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
|
|
// Postfix sends verbatim, so a lookup must try both forms.
|
|
func macro(m *milter.Modifier, name string) string {
|
|
if v, ok := m.Macros[name]; ok {
|
|
return v
|
|
}
|
|
return m.Macros["{"+name+"}"]
|
|
}
|
|
|
|
// clientAddr is the client's address from the {client_addr} macro, or "" when
|
|
// Postfix did not send one that reads as an address. An IPv6 address may come
|
|
// in Sendmail's form, with "IPv6:" in front.
|
|
func clientAddr(m *milter.Modifier) string {
|
|
raw := strings.TrimSpace(macro(m, "client_addr"))
|
|
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
|
|
raw = raw[5:]
|
|
}
|
|
ip := net.ParseIP(raw)
|
|
if ip == nil {
|
|
return ""
|
|
}
|
|
return ip.String()
|
|
}
|
|
|
|
// record writes one send-log row per recipient. Failures are logged, never
|
|
// propagated: journalling must not affect mail acceptance (architecture.md §
|
|
// Mail path).
|
|
func (s *session) record(queueID string) {
|
|
domain := domainOf(s.from)
|
|
rcpts := s.rcpts
|
|
if len(rcpts) == 0 {
|
|
// No recipient seen (unusual) — still record the message so it is
|
|
// visible in the log rather than silently dropped.
|
|
rcpts = []string{""}
|
|
}
|
|
for _, to := range rcpts {
|
|
err := s.rec.InsertQueued(store.SendLogEntry{
|
|
QueueID: queueID,
|
|
Domain: domain,
|
|
AppLogin: s.login,
|
|
From: s.from,
|
|
To: to,
|
|
Subject: s.subject,
|
|
})
|
|
if err != nil {
|
|
log.Printf("journal-milter: record %s -> %s: %v", queueID, to, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanAddress strips the angle brackets and any ESMTP parameters Postfix may
|
|
// pass with an address, leaving the bare mailbox.
|
|
func cleanAddress(a string) string {
|
|
a = strings.TrimSpace(a)
|
|
if i := strings.IndexByte(a, ' '); i >= 0 { // drop "addr SIZE=… BODY=…" params
|
|
a = a[:i]
|
|
}
|
|
a = strings.TrimPrefix(a, "<")
|
|
a = strings.TrimSuffix(a, ">")
|
|
return a
|
|
}
|
|
|
|
// domainOf returns the lower-cased domain of an email address, or "" if there
|
|
// is no domain part. Sender binding guarantees the From domain equals the
|
|
// application's domain, so this is the sending domain (architecture.md § Mail
|
|
// path).
|
|
func domainOf(addr string) string {
|
|
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
|
|
return strings.ToLower(addr[i+1:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
|
|
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
|
|
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
|
flight := &inflight{} // shared: the level-2 window spans all connections
|
|
srv := &milter.Server{
|
|
NewMilter: func() milter.Milter { return &session{rec: rec, flight: flight} },
|
|
Actions: 0, // read-only: we make no message modifications
|
|
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = srv.Close()
|
|
}()
|
|
|
|
if err := srv.Serve(ln); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil // expected: Close() during shutdown unblocks Serve
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|