Files
selfpost/internal/milter/sender.go
T
mixandClaude Opus 5.5 e166a2ebd6 refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.

- Wrong From: cleanup gives the message a queue id before the headers are
  judged; the sender check reads {i} at end-of-headers and writes it on the
  refused row. Its lines - stored under the id like any other's - are on the
  page, but only those from 10 minutes before to 1 minute after the refusal
  (store.SendLogLines for a rejected row): Postfix frees the id at once, and
  a later message with it lends nothing. No mail.log fallback for a refused
  row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
  0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
  "NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
  CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
  of that sender (and login, when the line names it) within two minutes; a
  line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
  "none - refused at MAIL FROM, before one is given"; the box's caption is
  "The line from mail.log for this refusal" for the latter; a row from
  before says the line was not kept. LogLineLevel colours a refusal by its
  reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
  not muted as in the picture: the kit has no muted inline.

Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00

513 lines
16 KiB
Go

package milter
import (
"context"
"fmt"
"io"
"log"
"mime"
"net"
"net/mail"
"net/textproto"
"os"
"strings"
"sync"
"time"
"github.com/emersion/go-milter"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/store"
)
// The sender check: a message's From: line is held against the application
// that sends it, under the rule Postfix holds the envelope sender to
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by
// that line, so without the check an application could have mail signed as
// another domain of the server, or as the server itself.
//
// Unlike the journal milter this one is attached strictly
// (default_action=tempfail in build/postfix-config.sh): while it does not
// answer, Postfix defers mail. It therefore depends on as little as it can.
// It reads no database: what an application may send as comes from the file
// of sender bindings Postfix itself reads for the envelope, so the two go by
// one table, by one lookup: the address, the address without its
// "+extension", the domain. (Postfix compares logins without regard to case;
// the check compares them as written, which is how the panel writes them.) The send-log row of a refusal is written by another goroutine and
// is not waited for.
// tableFile is a table of the mail path that the panel rewrites and the check
// reads: it is read again when it has changed. An unreadable file is an error
// and the check then defers mail - a table from before would let a deleted
// application go on sending, and no table would refuse everyone for good.
type tableFile struct {
path string
modTime time.Time
size int64
loaded bool
}
// changed returns the file's lines when they have to be read (again), and
// nil when what was read last still stands.
func (f *tableFile) changed() (lines []string, again bool, err error) {
fi, err := os.Stat(f.path)
if err != nil {
return nil, false, err
}
if f.loaded && fi.ModTime().Equal(f.modTime) && fi.Size() == f.size {
return nil, false, nil
}
raw, err := os.ReadFile(f.path)
if err != nil {
return nil, false, err
}
f.modTime, f.size, f.loaded = fi.ModTime(), fi.Size(), true
return strings.Split(string(raw), "\n"), true, nil
}
// Bindings is Postfix's sender_login_maps file as the check reads it: each
// sender - an address, or "@domain" for any address of a domain - with the
// logins that may use it.
type Bindings struct {
mu sync.Mutex
file tableFile
logins map[string]map[string]bool // sender -> logins
domainOf map[string]string // login -> the domain it sends as
}
// NewBindings reads the file at path on first use.
func NewBindings(path string) *Bindings {
return &Bindings{file: tableFile{path: path}}
}
// fresh makes the table match the file.
func (b *Bindings) fresh() error {
lines, again, err := b.file.changed()
if err != nil || !again {
return err
}
logins := make(map[string]map[string]bool)
domains := make(map[string]string)
for _, line := range lines {
// texthash: <key><whitespace><value>, the value a comma-separated
// list of logins (internal/postfix renderSenderLoginMaps).
fields := strings.Fields(line)
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
continue
}
sender := strings.ToLower(fields[0])
set := logins[sender]
if set == nil {
set = make(map[string]bool)
logins[sender] = set
}
for _, l := range strings.Split(strings.Join(fields[1:], ""), ",") {
if l != "" {
set[l] = true
if _, known := domains[l]; !known {
domains[l] = domainOf(sender)
}
}
}
}
b.logins, b.domainOf = logins, domains
return nil
}
// Allowed reports whether login may send as addr: the address itself is bound
// to it, or its whole domain is. This is the lookup Postfix makes for the
// envelope sender.
func (b *Bindings) Allowed(login, addr string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
if err := b.fresh(); err != nil {
return false, err
}
addr = strings.ToLower(addr)
if b.logins[addr][login] {
return true, nil
}
// Postfix's second try: the address without its extension, so that an
// application listed as invoices@ may also send as invoices+march@
// (recipient_delimiter in build/postfix-config.sh).
if bare := withoutExtension(addr); bare != addr && b.logins[bare][login] {
return true, nil
}
if d := domainOf(addr); d != "" && b.logins["@"+d][login] {
return true, nil
}
return false, nil
}
// extensionDelimiter is Postfix's recipient_delimiter, which
// build/postfix-config.sh sets to this.
const extensionDelimiter = "+"
// withoutExtension drops the extension of an address's local part: what
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
// an address whose local part begins with the delimiter has no extension.
func withoutExtension(addr string) string {
at := strings.LastIndexByte(addr, '@')
if at < 0 {
return addr
}
if i := strings.Index(addr[:at], extensionDelimiter); i > 0 {
return addr[:i] + addr[at:]
}
return addr
}
// DomainOf returns the domain login sends as, from the first of its bindings
// in the file's order; "" for a login that has none. An application belongs
// to one domain, so all of its bindings name the same one.
func (b *Bindings) DomainOf(login string) string {
b.mu.Lock()
defer b.mu.Unlock()
if b.fresh() != nil {
return ""
}
return b.domainOf[login]
}
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
// server signs for - every sending domain, with applications or without, and
// the server's own name.
type Signers struct {
mu sync.Mutex
file tableFile
names map[string]bool
}
// NewSigners reads the file at path on first use.
func NewSigners(path string) *Signers {
return &Signers{file: tableFile{path: path}}
}
// Signs reports whether mail whose From: is at domain gets a signature.
func (s *Signers) Signs(domain string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
lines, again, err := s.file.changed()
if err != nil {
return false, err
}
if again {
names := make(map[string]bool)
for _, line := range lines {
// "*@<name> <key name>" (internal/domain renderTables).
fields := strings.Fields(line)
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
continue
}
if d := domainOf(fields[0]); d != "" {
names[d] = true
}
}
s.names = names
}
return s.names[strings.ToLower(domain)], nil
}
// Refusals is where the check leaves the send-log rows of what it refused.
// *store.Store satisfies it.
type Refusals interface {
InsertRejected(e store.SendLogEntry) error
}
// senderSession is one connection of the sender check.
type senderSession struct {
milter.NoOpMilter
bind *Bindings
signers *Signers
refused chan<- store.SendLogEntry
login string
from string
rcpts []string
subject string
froms []string // the value of every From: field of the message
}
// MailFrom starts a message and forgets the one before it: a connection may
// carry several, and after a temporary failure the library hands the
// connection a new session.
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts, s.subject, s.froms = nil, "", nil
return milter.RespContinue, nil
}
func (s *senderSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
return milter.RespContinue, nil
}
func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
// The name is trimmed: the obsolete syntax allows blanks before the colon.
switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) {
case "From":
s.froms = append(s.froms, value)
case "Subject":
if s.subject == "" {
s.subject = mailhdr.DecodeSubject(value)
}
}
return milter.RespContinue, nil
}
// Headers is the end of the headers, where the message is judged.
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
shown, why, err := s.judge()
if err != nil {
// Strict: what cannot be checked is not let through.
log.Printf("sender-check: %v - deferring mail of %q", err, s.login)
return milter.RespTempFail, nil
}
if why == "" {
return milter.RespContinue, nil
}
log.Printf("sender-check: application %q refused: %s (From: %s)", s.login, why, shown)
// cleanup has given the message a queue id by the end of its headers
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the
// refusal among them - are found by it on the message's page.
s.record(shown, macro(m, "i"))
// A reply code of its own, so that the application is told what is wrong
// and, the refusal being permanent, does not send the message again.
return milter.NewResponseStr('y', "550 5.7.1 From: "+shown+" - "+why), nil
}
func (s *senderSession) Abort(m *milter.Modifier) error {
s.rcpts, s.subject, s.froms = nil, "", nil
return nil
}
// judge decides about the message at hand. why is empty when it may pass;
// otherwise it says what is wrong, and shown is the From: line in a form that
// is safe to print - both go into the reply to the client.
func (s *senderSession) judge() (shown, why string, err error) {
if s.login == "" {
// Not reachable while the submission ports demand authentication; a
// message nobody answers for is not signed either way.
return "(none)", "no authenticated application", nil
}
switch len(s.froms) {
case 0:
return "(none)", "the message has no From: line", nil
case 1:
default:
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
}
// Postfix hands a milter a header as it came, folded lines and all; the
// address parser reads one line.
raw := unfold(s.froms[0])
shown = printable(raw)
list, perr := fromParser.ParseList(raw)
if perr != nil || len(list) != 1 || list[0].Address == "" {
return shown, "it must be one address", nil
}
addr := strings.ToLower(list[0].Address)
shown = printable(addr)
ok, err := s.bind.Allowed(s.login, addr)
if err != nil {
return shown, "", fmt.Errorf("sender bindings: %w", err)
}
if !ok {
return shown, "not an address this application sends as", nil
}
// A cruder second look. OpenDKIM finds the address in this line with a
// parser of its own, and the key it signs with follows what that parser
// finds. Wherever the two could read the line differently - a display
// name, a comment, a quoted string - the other address would have to be
// spelled out in it, so no other name this server signs for may appear.
//
// The display name is looked at once more as a reader will see it: a mail
// program decodes an encoded word, and "=?utf-8?q?director=40example.org?="
// shows as an address of another sender though no "@" is written in it.
own := domainOf(addr)
for _, d := range append(mentionedDomains(raw), mentionedDomains(list[0].Name)...) {
if d == own {
continue
}
signs, err := s.signers.Signs(d)
if err != nil {
return shown, "", fmt.Errorf("signing table: %w", err)
}
if signs {
// Which one is not said: the reply would confirm to whoever
// tries names that another tenant sends from this server (audit
// 2026-10-12, L8). The line as written is in the reply and on
// the refused message's page.
return printable(raw), "it names another sender of this server", nil
}
}
// And one that needs to know nothing of OpenDKIM's parser: outside quoted
// strings the line has the one "@" of its address. A second one - in a
// comment, in an unquoted name - is where two parsers could part ways.
if strings.Count(unquoted(raw), "@") != 1 {
return printable(raw), "outside quotes it must have one @, the address's own", nil
}
return shown, "", nil
}
// record leaves a row for the Outbound log. It never waits: a database that
// is slow must not make the check slow. One row for the message, not one for
// each recipient as an accepted message has: nothing was sent to anybody, and
// a refused message with a thousand recipients would otherwise be a thousand
// rows at the price of one.
func (s *senderSession) record(shown, queueID string) {
if s.refused == nil {
return
}
to := ""
if len(s.rcpts) > 0 {
to = s.rcpts[0]
}
if more := len(s.rcpts) - 1; more > 0 {
to += fmt.Sprintf(" and %d more", more)
}
e := store.SendLogEntry{
QueueID: queueID,
Domain: s.domain(),
AppLogin: s.login,
From: s.from,
To: to,
Subject: s.subject,
Reason: store.ReasonFrom,
HeaderFrom: shown,
}
select {
case s.refused <- e:
default:
log.Printf("sender-check: the refusal of %s -> %s is not in the log: too many are waiting to be written", s.from, to)
}
}
// fromParser reads the From: line. A display name encoded in a charset Go
// does not convert (windows-1251, koi8-r, ...) is still a display name: its
// bytes are passed through unread, since only the address is judged.
var fromParser = mail.AddressParser{WordDecoder: &mime.WordDecoder{
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { return input, nil },
}}
// unfold joins the lines of a folded header value (RFC 5322 2.2.3): a line
// break is dropped, the white space that follows it stays.
func unfold(v string) string {
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
}
// unquoted returns v without its quoted strings, a backslash inside one
// escaping the next character.
func unquoted(v string) string {
var sb strings.Builder
in := false
for i := 0; i < len(v); i++ {
switch c := v[i]; {
case in && c == '\\':
i++
case c == '"':
in = !in
case !in:
sb.WriteByte(c)
}
}
return sb.String()
}
// domain is the sending domain of the refused message for its row in the
// log: the envelope sender's, which Postfix has held against the login - or,
// for the null sender of a bounce an application writes itself, which Postfix
// holds against nobody, the domain the login is bound to. Without it the row
// would belong to no domain and its administrator would never see it.
func (s *senderSession) domain() string {
if d := domainOf(s.from); d != "" {
return d
}
return s.bind.DomainOf(s.login)
}
// mentionedDomains returns, lower-cased, what follows every "@" of v as far
// as it reads as a host name.
func mentionedDomains(v string) []string {
var out []string
for i := 0; i < len(v); i++ {
if v[i] != '@' {
continue
}
j := i + 1
for j < len(v) && isHostByte(v[j]) {
j++
}
if d := strings.Trim(strings.ToLower(v[i+1:j]), ".-"); d != "" {
out = append(out, d)
}
}
return out
}
func isHostByte(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-'
}
// printable reduces what a client wrote to printable ASCII without the
// characters that mean something in an SMTP reply or a log line, and cuts it:
// it is said back to the client, logged and stored.
func printable(v string) string {
const max = 120
var sb strings.Builder
for _, r := range v {
if sb.Len() >= max {
sb.WriteString("...")
break
}
switch {
case r == '%' || r < 0x20 || r > 0x7e:
sb.WriteByte('?')
default:
sb.WriteRune(r)
}
}
return strings.TrimSpace(sb.String())
}
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
// nil: refusals are then not logged.
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
var refused chan store.SendLogEntry
if rec != nil {
refused = make(chan store.SendLogEntry, 256)
go func() {
for {
select {
case <-ctx.Done():
return
case e := <-refused:
if err := rec.InsertRejected(e); err != nil {
log.Printf("sender-check: record refused %s: %v", e.From, err)
}
}
}
}()
}
srv := &milter.Server{
NewMilter: func() milter.Milter {
return &senderSession{bind: bind, signers: signers, refused: refused}
},
Actions: 0, // read-only
Protocol: milter.OptNoBody, // the headers decide
}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
if err := srv.Serve(ln); err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
return nil
}