milter, postfix, app, domain: cite the source of what they rely on (assumptions part B)
Comments only, no behaviour changed. go-milter v0.4.1 (session.go: one session per connection, replaced after a non-continue reply; macros per stage), Postfix 3.7.11 (milter8.c, smtpd_check.c reject_auth_sender_login_mismatch(), mail_addr_find.c, showq_compat.c, mail_conf_time.c, mail_params.h), Cyrus SASL 2.1.28 (saslpasswd.c, plugins/sasldb.c: deleting a missing login is not an error), OpenDKIM 2.11.0~beta2 (opendkim-db.c, opendkim-genkey), Berkeley DB 5.3 db_dump, rspamd 3.11.0. Comments that claimed what the source denies are corrected (sasldb keeps the password as given, not hashed; an embedded newline is kept, not truncated; rspamd sorts symbols by absolute weight); fixtures say seen or composed. The sender check's From: parsing gets no citations: it is replaced by the port of OpenDKIM's dkim_mail_parse next. Contradictions left for their own commits: B-G5 cleanAddress, B-PS1 first key wins, B-PQ1 the forced-expire flag, B-PQ10/PQ11 Postfix's time units. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,11 @@ type InboundStore interface {
|
||||
// Like the send-log milter it only watches: every callback continues, and
|
||||
// Postfix attaches the socket with default_action=accept, so
|
||||
// neither an error here nor the panel being down holds up inbound mail.
|
||||
//
|
||||
// Postfix 3.7.11, src/milter/milter.c, milter_message(): the milters are asked
|
||||
// in turn while the reply is null, so one that refuses is the last asked;
|
||||
// src/milter/milter8.c, milter8_comm_error(): default_action=accept gives no
|
||||
// reply when this milter cannot answer.
|
||||
type inboundSession struct {
|
||||
milter.NoOpMilter
|
||||
rec InboundStore
|
||||
@@ -85,6 +90,17 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons
|
||||
// filter's headers arrive here. The filter replaces headers of these names, so
|
||||
// the last one read is its own - when it is up. Whether it was is decided at
|
||||
// end-of-message, by the signature.
|
||||
//
|
||||
// Postfix 3.7.11, src/milter/milter.c, milter_message(), hands each milter the
|
||||
// queue file from the start of the content; src/milter/milter8.c,
|
||||
// milter8_message(), reads it with rec_get(), which follows the pointer
|
||||
// records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after
|
||||
// the last header for a header the milter before added.
|
||||
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routines x-spamd-result
|
||||
// and x-rspamd-action: remove = 0, every instance
|
||||
// (src/libserver/milter.c, rspamd_milter_remove_header_safe()), and the new
|
||||
// one added at the end (order -1, ADDHEADER) - when skip_wanted() does not
|
||||
// skip the routine (skip_local, skip_authenticated).
|
||||
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||
switch textproto.CanonicalMIMEHeaderKey(name) {
|
||||
case "Subject":
|
||||
@@ -112,6 +128,10 @@ var signatureHeader = textproto.CanonicalMIMEHeaderKey(SignatureHeader)
|
||||
// filter, in hex. The key is used as the text it is stored as. A signature
|
||||
// fits one queue id, so the header going on to the upstream with the mail
|
||||
// gives nothing to whoever reads it there.
|
||||
//
|
||||
// Measured, not read: what rspamd 3.11 writes for a key and a queue id was
|
||||
// taken in a throwaway container on 2026-10-12
|
||||
// (TestFilterSignatureIsWhatTheFilterWrites).
|
||||
func FilterSignature(key, queueID string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(queueID))
|
||||
@@ -130,6 +150,10 @@ func (s *inboundSession) signed(queueID string) bool {
|
||||
// Body fires at end-of-message: the message is about to be queued for its
|
||||
// upstream. "Continue" lets it through as "accept" would, and keeps this
|
||||
// session for the connection's next message.
|
||||
//
|
||||
// The queue id: Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
|
||||
// "i", answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). The
|
||||
// rest as session.Body in milter.go.
|
||||
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
|
||||
// The queue id is known only now, and with it whether the verdict headers
|
||||
// read on the way here are the filter's.
|
||||
@@ -196,6 +220,9 @@ const (
|
||||
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
|
||||
const ListSymbolPrefix = "SELFPOST_"
|
||||
|
||||
// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routine x-spamd-result:
|
||||
// string.format('default: %s [%.2f / %.2f]', ...), then one
|
||||
// '%s(%.2f)[%s]' per symbol, joined by '; '.
|
||||
var (
|
||||
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
|
||||
rspamdScore = regexp.MustCompile(`\[(-?[0-9]+(?:\.[0-9]+)?) / (-?[0-9]+(?:\.[0-9]+)?)\]`)
|
||||
@@ -267,7 +294,8 @@ func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) {
|
||||
// ServeInbound runs the inbound journal milter on ln until ctx is cancelled.
|
||||
// key is what the external filter in front of it signs messages with, or
|
||||
// empty when none is attached (INBOUND_ANTISPAM_MILTER): no verdict is then
|
||||
// read at all. It returns nil on a clean shutdown.
|
||||
// read at all. It returns nil on a clean shutdown. What it relies on of
|
||||
// go-milter and Postfix is cited at Serve in milter.go.
|
||||
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
|
||||
srv := &milter.Server{
|
||||
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
|
||||
|
||||
@@ -34,7 +34,9 @@ func (f *fakeInbound) IsInboundDomain(name string) (bool, error) {
|
||||
return f.domains[name], nil
|
||||
}
|
||||
|
||||
// A header of the kind the documented rspamd sidecar adds.
|
||||
// A header in the format of the documented rspamd sidecar, composed by hand:
|
||||
// rspamd 3.11's milter_headers.lua writes the same fields, but sorts the
|
||||
// symbols by absolute weight (sort_by = 'score'), which this order is not.
|
||||
const spamdResult = "default: False [7.20 / 15.00];\n\tBAYES_SPAM(5.10)[100.00%];\n\tMIME_GOOD(-0.10)[text/plain];\n\tARC_NA(0.00)[];\n\tRCVD_COUNT_ZERO(0.00)[0];\n\tR_DKIM_NA(2.20)[]"
|
||||
|
||||
// message replays one message through s: envelope, headers in order, end.
|
||||
@@ -233,9 +235,11 @@ func TestInboundAcceptsWhenTheStoreFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// rspamd writes symbols heaviest first, so an allow entry's -100 comes last.
|
||||
// Filter lists counts an entry's matches by finding that symbol in the journal:
|
||||
// it is kept whatever else there is.
|
||||
// A composed header with the allow entry's -100 behind six others. rspamd
|
||||
// 3.11 itself sorts by absolute weight and would put it first
|
||||
// (milter_headers.lua, x-spamd-result, sort_by = 'score'); the order here is
|
||||
// the hard case for the reader. Filter lists counts an entry's matches by
|
||||
// finding that symbol in the journal: it is kept whatever else there is.
|
||||
func TestRspamdVerdictKeepsTheListSymbols(t *testing.T) {
|
||||
result := "default: False [-91.00 / 15.00]; SYM_A(3.00)[]; SYM_B(2.00)[]; SYM_C(1.50)[]; SYM_D(1.00)[]; SYM_E(0.80)[]; SYM_F(0.50)[]; SYM_G(0.20)[]; ARC_NA(0.00)[]; SELFPOST_ALLOW_FROM(-100.00)[billing@vendor.example]"
|
||||
_, detail := rspamdVerdict("no action", result)
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
// against the limit forever — a fail-closed drift this milter must not have.
|
||||
// The TTL is generously longer than any realistic DATA transfer, so a message
|
||||
// still being received is never dropped from the count.
|
||||
//
|
||||
// go-milter v0.4.1, server.go, type Milter: no callback for the end of a
|
||||
// connection; session.go, HandleMilterCommands(), returns on SMFIC_QUIT or a
|
||||
// read error without calling the backend.
|
||||
const reservationTTL = 10 * time.Minute
|
||||
|
||||
// reservation is one message that passed the level-2 check and has not been
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// 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).
|
||||
//
|
||||
// Postfix 3.7.11, src/milter/milter8.c, milter8_comm_error(): a milter that
|
||||
// cannot be reached, or does not answer within milter_*_timeout, gives no
|
||||
// reply with default_action=accept - the message goes on.
|
||||
package milter
|
||||
|
||||
import (
|
||||
@@ -45,6 +49,12 @@ type Store interface {
|
||||
// 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).
|
||||
//
|
||||
// go-milter v0.4.1, session.go, Process(), case CodeMacro: every macro packet
|
||||
// replaces the map; server.go, Serve(): NewMilter() for each connection.
|
||||
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(): a macro packet, empty
|
||||
// or not, goes before every event (milter.c, milter_macro_lookup(), never
|
||||
// returns null).
|
||||
type session struct {
|
||||
milter.NoOpMilter
|
||||
rec Store
|
||||
@@ -56,7 +66,9 @@ type session struct {
|
||||
// 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.
|
||||
// that can be relied on: see MailFrom. Postfix 3.7.11,
|
||||
// src/smtpd/smtpd_milter.c, smtpd_milter_eval(): {client_addr} is
|
||||
// state->rfc_addr (see clientAddr).
|
||||
clientIP string
|
||||
|
||||
login string
|
||||
@@ -68,6 +80,11 @@ type session struct {
|
||||
|
||||
// Connect captures the client IP of a connection's first message. It is not
|
||||
// enough on its own - see MailFrom.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_proto(): milter_conn_event() is
|
||||
// given state->addr, the address without "IPv6:". go-milter v0.4.1,
|
||||
// session.go, case CodeConn: net.ParseIP of it - nil for "unknown" and for a
|
||||
// family that sends no address.
|
||||
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()
|
||||
@@ -92,6 +109,17 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
|
||||
// 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.)
|
||||
//
|
||||
// go-milter v0.4.1, session.go, HandleMilterCommands(): after a reply whose
|
||||
// Continue() is false (response.go: accept, discard, reject, tempfail) the
|
||||
// backend is replaced by NewMilter(). Postfix 3.7.11, src/milter/milter8.c,
|
||||
// milter8_conn_event(): CONNECT goes once per milter connection.
|
||||
// {auth_authen}: src/smtpd/smtpd_milter.c, smtpd_milter_eval(), gives
|
||||
// state->sasl_username, null without authentication, and then milter.c,
|
||||
// milter_macro_lookup(), sends no such macro.
|
||||
// The tempfail: milter8.c, milter8_event(), SMFIR_TEMPFAIL is "451 4.7.1";
|
||||
// src/smtpd/smtpd.c, mail_cmd(), replies it and calls mail_reset() - the
|
||||
// transaction ends, the connection goes on.
|
||||
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 != "" {
|
||||
@@ -114,6 +142,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
|
||||
|
||||
// RcptTo records each recipient. Postfix calls this once per recipient, which
|
||||
// is what lets the journal keep a separate row per (queue-id, recipient).
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c, rcpt_cmd(): milter_rcpt_event() once per
|
||||
// RCPT TO; a recipient Postfix refused goes only to a milter that asked for
|
||||
// SMFIP_RCPT_REJ, which Serve's Protocol does not.
|
||||
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
||||
return milter.RespContinue, nil
|
||||
@@ -132,6 +164,12 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
|
||||
// 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).
|
||||
//
|
||||
// go-milter v0.4.1, session.go, case CodeEOB: Body(). Postfix 3.7.11,
|
||||
// src/global/mail_params.h: DEF_MILT_EOD_MACROS "i", which
|
||||
// src/cleanup/cleanup_milter.c, cleanup_milter_eval(), answers with the queue
|
||||
// id. src/milter/milter8.c, milter8_event(): SMFIR_CONTINUE returns the
|
||||
// milter's default reply, none, and the message goes on.
|
||||
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
|
||||
@@ -143,6 +181,11 @@ func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
||||
// 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd.c: rset_cmd() and the refusals call
|
||||
// mail_reset(), which calls milter_abort() (src/milter/milter8.c,
|
||||
// milter8_abort(): SMFIC_ABORT); so do the end of every transaction and the
|
||||
// end of the session in smtpd_proto().
|
||||
func (s *session) Abort(m *milter.Modifier) error {
|
||||
s.releaseReservations()
|
||||
s.rcpts = nil
|
||||
@@ -154,6 +197,10 @@ func (s *session) Abort(m *milter.Modifier) error {
|
||||
// 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/milter/milter.c, milter_macro_lookup(): each name is
|
||||
// sent as milter_*_macros spells it. go-milter v0.4.1, session.go, case
|
||||
// CodeMacro: m.macros[name] = value, the name as received.
|
||||
func macro(m *milter.Modifier, name string) string {
|
||||
if v, ok := m.Macros[name]; ok {
|
||||
return v
|
||||
@@ -164,6 +211,10 @@ func macro(m *milter.Modifier, name string) string {
|
||||
// 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_sockaddr_to_hostaddr():
|
||||
// rfc_addr is the dotted quad, "IPv6:" + the address for IPv6, a V4-mapped
|
||||
// address rewritten to IPv4, or "unknown".
|
||||
func clientAddr(m *milter.Modifier) string {
|
||||
raw := strings.TrimSpace(macro(m, "client_addr"))
|
||||
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
|
||||
@@ -248,6 +299,12 @@ func domainOf(addr string) string {
|
||||
|
||||
// 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.
|
||||
//
|
||||
// go-milter v0.4.1, server.go: Close() closes the listener and Serve() then
|
||||
// returns ErrServerClosed. Postfix 3.7.11, src/milter/milter8.c,
|
||||
// milter8_message(): SMFIP_NOBODY leaves out only the body chunks - headers,
|
||||
// end-of-headers and end-of-body (milter8_eob()) still go; milter8_event()
|
||||
// takes a reply code (SMFIR_REPLYCODE) whatever the action mask.
|
||||
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
||||
flight := &inflight{} // shared: the level-2 window spans all connections
|
||||
srv := &milter.Server{
|
||||
|
||||
@@ -197,9 +197,11 @@ func TestSessionResetsBetweenMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
|
||||
// {i} for some versions), so the milter must resolve those too — this is the
|
||||
// case the SASL-less spike missed and that produced empty app_login at first.
|
||||
// Postfix sends each macro name as milter_*_macros spells it (src/milter/
|
||||
// milter.c, milter_macro_lookup()): {auth_authen} in braces, and {i} too where
|
||||
// a configuration spells it so; the macros here are composed. The milter must
|
||||
// resolve those too — this is the case the SASL-less spike missed and that
|
||||
// produced empty app_login at first.
|
||||
func TestBracedMacros(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
s := &session{rec: rec}
|
||||
|
||||
@@ -35,6 +35,13 @@ import (
|
||||
// "+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.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||
// the SASL login is compared with an owner by strcasecmp_utf8();
|
||||
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
|
||||
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2,
|
||||
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
|
||||
// domain is that of the first From: field, as dkim_mail_parse() reads it.
|
||||
|
||||
// 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
|
||||
@@ -91,6 +98,11 @@ func (b *Bindings) fresh() error {
|
||||
for _, line := range lines {
|
||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
||||
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key
|
||||
// ends at the first blank; src/util/readlline.c skips a line whose
|
||||
// first non-blank is "#"; src/util/dict_ht.c folds the key to lower
|
||||
// case (DICT_FLAG_FOLD_FIX, set for smtpd_sender_login_maps in
|
||||
// src/smtpd/smtpd_check.c).
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
||||
continue
|
||||
@@ -146,6 +158,10 @@ 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/global/strip_addr.c, strip_addr_internal(), and
|
||||
// split_addr.c, split_addr_internal(): the local part, up to the last "@", is
|
||||
// cut at its first delimiter, unless that is its first character.
|
||||
func withoutExtension(addr string) string {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at < 0 {
|
||||
@@ -184,6 +200,12 @@ func NewSigners(path string) *Signers {
|
||||
}
|
||||
|
||||
// Signs reports whether mail whose From: is at domain gets a signature.
|
||||
//
|
||||
// OpenDKIM 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_open(), case
|
||||
// DKIMF_DB_TYPE_REFILE: the pattern is a line up to its first blank, "#"
|
||||
// starts a comment; opendkim/util.c, dkimf_mkregexp(): "^" + the pattern
|
||||
// ("*" as ".*", "." escaped) + "$", compiled REG_ICASE (DKIMF_DB_FLAG_ICASE,
|
||||
// opendkim.c). So "*@d" signs user@d in any case, and no subdomain of d.
|
||||
func (s *Signers) Signs(domain string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -231,6 +253,11 @@ type senderSession struct {
|
||||
// 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.
|
||||
//
|
||||
// go-milter v0.4.1, session.go, HandleMilterCommands(): a new backend after a
|
||||
// reply whose Continue() is false - the tempfail of Headers; the "y" reply is
|
||||
// a CustomResponse whose Continue() is true (response.go), so it keeps this
|
||||
// one. {auth_authen}: see session.MailFrom in milter.go.
|
||||
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.from = cleanAddress(from)
|
||||
s.login = macro(m, "auth_authen")
|
||||
@@ -257,6 +284,13 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
|
||||
}
|
||||
|
||||
// Headers is the end of the headers, where the message is judged.
|
||||
//
|
||||
// go-milter v0.4.1, session.go, case CodeEOH: Headers(). Postfix 3.7.11,
|
||||
// src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: a "4"/"5" reply
|
||||
// whose enhanced code starts with the same digit is the message's reply;
|
||||
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): a "5" rejects the
|
||||
// message, and src/smtpd/smtpd.c, common_post_message_handling(), says that
|
||||
// reply to the client.
|
||||
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
|
||||
shown, why, err := s.judge()
|
||||
if err != nil {
|
||||
@@ -271,6 +305,8 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil
|
||||
// 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.
|
||||
// Postfix 3.7.11, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
|
||||
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
|
||||
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.
|
||||
@@ -289,6 +325,10 @@ 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.
|
||||
// Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): with
|
||||
// smtpd_delay_reject (default yes) smtpd_client_restrictions
|
||||
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
|
||||
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
|
||||
return "(none)", "no authenticated application", nil
|
||||
}
|
||||
switch len(s.froms) {
|
||||
@@ -419,6 +459,10 @@ func unquoted(v string) string {
|
||||
// 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd_check.c, generic_checks():
|
||||
// reject_*_sender_login_mismatch run only "if (state->sender &&
|
||||
// *state->sender)".
|
||||
func (s *senderSession) domain() string {
|
||||
if d := domainOf(s.from); d != "" {
|
||||
return d
|
||||
@@ -452,6 +496,11 @@ func isHostByte(c byte) bool {
|
||||
// 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.
|
||||
//
|
||||
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE:
|
||||
// "%%" becomes "%" and a single "%" is dropped; src/cleanup/cleanup_milter.c,
|
||||
// cleanup_milter_apply(), logs "<id>: milter-reject: END-OF-MESSAGE from
|
||||
// <client>: <text>; from=<...> to=<...> ...".
|
||||
func printable(v string) string {
|
||||
const max = 120
|
||||
var sb strings.Builder
|
||||
@@ -476,7 +525,8 @@ func printable(v string) string {
|
||||
}
|
||||
|
||||
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
|
||||
// nil: refusals are then not logged.
|
||||
// nil: refusals are then not logged. What it relies on of go-milter and
|
||||
// Postfix is cited at Serve in milter.go.
|
||||
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
|
||||
var refused chan store.SendLogEntry
|
||||
if rec != nil {
|
||||
|
||||
@@ -13,18 +13,21 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// senderMaps is a sender_login_maps file as internal/postfix writes it: shop
|
||||
// and news send as any address of their domains, billing only as two listed
|
||||
// addresses of example.org, and two applications share shop.example.org.
|
||||
// senderMaps is a sender_login_maps file composed by hand in the format
|
||||
// internal/postfix writes (key, blank, comma-separated logins), though not in
|
||||
// its order (renderSenderLoginMaps sorts the keys): shop and news send as any
|
||||
// address of their domains, billing only as two listed addresses of
|
||||
// example.org, and two applications share shop.example.org.
|
||||
const senderMaps = `@news.example.net news
|
||||
@shop.example.org shop,shop2
|
||||
invoices@example.org billing
|
||||
billing@example.org billing
|
||||
`
|
||||
|
||||
// signingTable is OpenDKIM's SigningTable for the same server: the three
|
||||
// sending domains, one more that has no application yet, and the server's own
|
||||
// name.
|
||||
// signingTable is a SigningTable for the same server, composed by hand: the
|
||||
// three sending domains, one more that has no application yet, and the
|
||||
// server's own name. Its key-name column is not what internal/domain
|
||||
// renderTables writes (the domain name); the check reads only the pattern.
|
||||
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
|
||||
*@shop.example.org selfpost._domainkey.shop.example.org
|
||||
*@example.org selfpost._domainkey.example.org
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
# (internal/milter/sender_test.go) wants each refused; the e2e
|
||||
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
|
||||
# {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
|
||||
# dot escaped as a quoted pair. One Go-quoted string per line.
|
||||
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
|
||||
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
|
||||
# e2e on every run.
|
||||
"b@{other}"
|
||||
"<b@{other}>"
|
||||
"\"a@{domain}\"@{other}"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
# is {domain} in capitals; {other} is another sender of the server, which a
|
||||
# line here names in a way the check's scan does not see (a space after the
|
||||
# @) - passed by design, and so signed for {domain} alone.
|
||||
# The lines are composed by hand; what OpenDKIM signs each with is measured,
|
||||
# by the e2e on every run.
|
||||
"a@{domain}"
|
||||
"<a@{domain}>"
|
||||
"\"The Shop\" <Orders@{domain}>"
|
||||
|
||||
Reference in New Issue
Block a user