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:
@@ -29,6 +29,12 @@ type SASLDB struct {
|
|||||||
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
||||||
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||||
// Postfix's SASL configuration.
|
// Postfix's SASL configuration.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
|
||||||
|
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
|
||||||
|
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
|
||||||
|
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
|
||||||
|
// server's name (common/plugin_common.c, _plug_parseuser()).
|
||||||
func NewSASLDB(path, realm string) *SASLDB {
|
func NewSASLDB(path, realm string) *SASLDB {
|
||||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||||
}
|
}
|
||||||
@@ -46,6 +52,11 @@ func (s *SASLDB) Set(login, password string) error {
|
|||||||
if err := validateLogin(login); err != nil {
|
if err := validateLogin(login); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Cyrus SASL 2.1.28, utils/saslpasswd.c, main(): getopt(), which ends the
|
||||||
|
// options at "--"; -p reads the password with one read() and no prompt
|
||||||
|
// (read_password()); -c is SASL_SET_CREATE, which lib/server.c,
|
||||||
|
// sasl_setpass(), checks against nothing - an existing entry is written
|
||||||
|
// over (sasldb/db_berkeley.c, _sasldb_putdata(), put with no flags).
|
||||||
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||||
// -c: create the account / set the password.
|
// -c: create the account / set the password.
|
||||||
// -f: operate on our sasldb2 rather than the system default path.
|
// -f: operate on our sasldb2 rather than the system default path.
|
||||||
@@ -62,6 +73,11 @@ func (s *SASLDB) Set(login, password string) error {
|
|||||||
|
|
||||||
// Delete removes an application's SASL account (product.md). A missing account
|
// Delete removes an application's SASL account (product.md). A missing account
|
||||||
// is not treated as an error, so deletion is idempotent and safe to retry.
|
// is not treated as an error, so deletion is idempotent and safe to retry.
|
||||||
|
//
|
||||||
|
// Cyrus SASL 2.1.28, plugins/sasldb.c, sasldb_auxprop_store(): "Deleting
|
||||||
|
// something which is not there is not an error" - the SASL_NOUSER of
|
||||||
|
// sasldb/db_berkeley.c, _sasldb_putdata(), becomes SASL_OK, so saslpasswd2 -d
|
||||||
|
// on a missing login exits 0 (utils/saslpasswd.c, main()).
|
||||||
func (s *SASLDB) Delete(login string) error {
|
func (s *SASLDB) Delete(login string) error {
|
||||||
if err := validateLogin(login); err != nil {
|
if err := validateLogin(login); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,6 +126,17 @@ func (s *SASLDB) Secret(login string) (string, error) {
|
|||||||
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||||
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||||
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
||||||
|
//
|
||||||
|
// Cyrus SASL 2.1.28: sasldb/allockey.c, _sasldb_alloc_key(), builds the key
|
||||||
|
// with no NUL at its end; plugins/sasldb.c, sasldb_auxprop_store(), stores the
|
||||||
|
// value with strlen(); lib/server.c, sasl_setpass(), stores the plaintext
|
||||||
|
// userPassword unless SASL_SET_NOPLAIN (saslpasswd2 -n, which Set does not
|
||||||
|
// pass). Built against Berkeley DB (m4/sasldb.m4 auto_detect; debian/rules
|
||||||
|
// records BDB_VERSION). Berkeley DB 5.3 Programmer's Reference, "Dump output
|
||||||
|
// formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/
|
||||||
|
// dumpload_format.html): header lines up to HEADER=END, then for a hash or
|
||||||
|
// btree database key and data lines in turn, each a single space and hex,
|
||||||
|
// and DATA=END.
|
||||||
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||||
sc := bufio.NewScanner(bytes.NewReader(dump))
|
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||||
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ func TestSASLDeleteArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
// makeDump builds a db_dump byte-value document from key/value byte pairs. It
|
||||||
// same shape `db_dump <sasldb2>` emits.
|
// is composed by hand in the shape the Berkeley DB 5.3 "Dump output formats"
|
||||||
|
// page gives; a real dump may have more name=value header lines (the
|
||||||
|
// db_load keywords) before HEADER=END, which the parser skips.
|
||||||
func makeDump(pairs [][2][]byte) []byte {
|
func makeDump(pairs [][2][]byte) []byte {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func invalid(format string, args ...any) error {
|
|||||||
// applications to the panel. The store refuses such a pair as well
|
// applications to the panel. The store refuses such a pair as well
|
||||||
// (idx_applications_login_nocase); a login imported from another instance is
|
// (idx_applications_login_nocase); a login imported from another instance is
|
||||||
// not folded, since its SASL account and its client use it as it is.
|
// not folded, since its SASL account and its client use it as it is.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||||
|
// the SASL login is compared with each owner by strcasecmp_utf8().
|
||||||
func foldLogin(login string) string {
|
func foldLogin(login string) string {
|
||||||
return strings.ToLower(login)
|
return strings.ToLower(login)
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,9 @@ func foldLogin(login string) string {
|
|||||||
// validateLogin enforces a strict server-side whitelist for the SASL login
|
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||||
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
|
||||||
// where '@' separates the user from the realm, so allowing it would change the
|
// where '@' separates the user from the realm, so allowing it would change the
|
||||||
// account's identity. Client validation is never trusted.
|
// account's identity. Client validation is never trusted. (Cyrus SASL 2.1.28,
|
||||||
|
// common/plugin_common.c, _plug_parseuser(): a login is split into user and
|
||||||
|
// realm at its first "@", for saslpasswd2's store and for the lookup alike.)
|
||||||
//
|
//
|
||||||
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
||||||
// command argument (never through a shell, security.md); this whitelist is what
|
// command argument (never through a shell, security.md); this whitelist is what
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
|
|||||||
// and entrypoint.sh); the parent directory carries setgid so the group is
|
// and entrypoint.sh); the parent directory carries setgid so the group is
|
||||||
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
||||||
// a half-written key.
|
// a half-written key.
|
||||||
|
//
|
||||||
|
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
|
||||||
|
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
|
||||||
|
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
|
||||||
|
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
|
||||||
|
// (build/opendkim.conf).
|
||||||
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
||||||
block := &pem.Block{
|
block := &pem.Block{
|
||||||
Type: "RSA PRIVATE KEY",
|
Type: "RSA PRIVATE KEY",
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
|
|||||||
// The selector is part of the question because the panel shows and checks the
|
// The selector is part of the question because the panel shows and checks the
|
||||||
// record of one key; tables that name another would have OpenDKIM sign with a
|
// record of one key; tables that name another would have OpenDKIM sign with a
|
||||||
// key whose record nobody is told to publish.
|
// key whose record nobody is told to publish.
|
||||||
|
//
|
||||||
|
// OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is
|
||||||
|
// matched as ^.*@name$ (case aside), so it signs name and none of its
|
||||||
|
// subdomains (see Signers.Signs in internal/milter/sender.go).
|
||||||
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
|
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
|
||||||
has := func(path, prefix string) (bool, error) {
|
has := func(path, prefix string) (bool, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -222,6 +226,13 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
|||||||
sorted := append([]SigningDomain(nil), domains...)
|
sorted := append([]SigningDomain(nil), domains...)
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||||
|
|
||||||
|
// OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the
|
||||||
|
// first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE);
|
||||||
|
// opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and
|
||||||
|
// key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()),
|
||||||
|
// a key starting with "/" being a file. SigningTable is refile:, a pattern
|
||||||
|
// up to the first blank and the key name after it (dkimf_db_open(), case
|
||||||
|
// DKIMF_DB_TYPE_REFILE).
|
||||||
var kt, st strings.Builder
|
var kt, st strings.Builder
|
||||||
for _, d := range sorted {
|
for _, d := range sorted {
|
||||||
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
||||||
@@ -240,7 +251,9 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
|
|||||||
|
|
||||||
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
|
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
|
||||||
// value: whitespace and the newline that would end the line, and the colon and
|
// value: whitespace and the newline that would end the line, and the colon and
|
||||||
// path separators that carry meaning inside a table entry.
|
// path separators that carry meaning inside a table entry. (OpenDKIM
|
||||||
|
// 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a
|
||||||
|
// KeyTable value at ":"; the domain and selector are also the key's path.)
|
||||||
const opendkimForbidden = " \t\r\n:/\\"
|
const opendkimForbidden = " \t\r\n:/\\"
|
||||||
|
|
||||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
// assertConfigSafe rejects any domain/selector value that could break out of a
|
||||||
@@ -268,6 +281,14 @@ func assertConfigSafe(domainName, selector string) error {
|
|||||||
//
|
//
|
||||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||||
// and it never goes through a shell (security.md).
|
// and it never goes through a shell (security.md).
|
||||||
|
//
|
||||||
|
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets
|
||||||
|
// reload when a configuration file was given (-x in build/supervisord.conf);
|
||||||
|
// dkimf_config_reload(), from mlfi_negotiate() at the next milter connection,
|
||||||
|
// loads the configuration and opens the tables again (opendkim.8, OPERATION).
|
||||||
|
// supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and
|
||||||
|
// process.py, signal(): kill() of the program's own pid - opendkim itself, run
|
||||||
|
// with -f (not forked into the background) and exec'd with no shell.
|
||||||
func reloadViaSupervisor() error {
|
func reloadViaSupervisor() error {
|
||||||
return supervisor.Signal("USR1", "opendkim")
|
return supervisor.Signal("USR1", "opendkim")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ type InboundStore interface {
|
|||||||
// Like the send-log milter it only watches: every callback continues, and
|
// Like the send-log milter it only watches: every callback continues, and
|
||||||
// Postfix attaches the socket with default_action=accept, so
|
// Postfix attaches the socket with default_action=accept, so
|
||||||
// neither an error here nor the panel being down holds up inbound mail.
|
// 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 {
|
type inboundSession struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
rec InboundStore
|
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
|
// 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
|
// the last one read is its own - when it is up. Whether it was is decided at
|
||||||
// end-of-message, by the signature.
|
// 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) {
|
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||||
switch textproto.CanonicalMIMEHeaderKey(name) {
|
switch textproto.CanonicalMIMEHeaderKey(name) {
|
||||||
case "Subject":
|
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
|
// 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
|
// fits one queue id, so the header going on to the upstream with the mail
|
||||||
// gives nothing to whoever reads it there.
|
// 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 {
|
func FilterSignature(key, queueID string) string {
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
mac.Write([]byte(queueID))
|
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
|
// 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
|
// upstream. "Continue" lets it through as "accept" would, and keeps this
|
||||||
// session for the connection's next message.
|
// 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) {
|
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
// The queue id is known only now, and with it whether the verdict headers
|
// The queue id is known only now, and with it whether the verdict headers
|
||||||
// read on the way here are the filter's.
|
// read on the way here are the filter's.
|
||||||
@@ -196,6 +220,9 @@ const (
|
|||||||
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
|
// (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN.
|
||||||
const ListSymbolPrefix = "SELFPOST_"
|
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 (
|
var (
|
||||||
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
|
// "default: False [7.20 / 15.00]" - the score and the reject threshold.
|
||||||
rspamdScore = regexp.MustCompile(`\[(-?[0-9]+(?:\.[0-9]+)?) / (-?[0-9]+(?:\.[0-9]+)?)\]`)
|
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.
|
// 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
|
// 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
|
// 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 {
|
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
|
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
|
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)[]"
|
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.
|
// 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.
|
// A composed header with the allow entry's -100 behind six others. rspamd
|
||||||
// Filter lists counts an entry's matches by finding that symbol in the journal:
|
// 3.11 itself sorts by absolute weight and would put it first
|
||||||
// it is kept whatever else there is.
|
// (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) {
|
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]"
|
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)
|
_, detail := rspamdVerdict("no action", result)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
// against the limit forever — a fail-closed drift this milter must not have.
|
// 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
|
// The TTL is generously longer than any realistic DATA transfer, so a message
|
||||||
// still being received is never dropped from the count.
|
// 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
|
const reservationTTL = 10 * time.Minute
|
||||||
|
|
||||||
// reservation is one message that passed the level-2 check and has not been
|
// 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
|
// 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
|
// configured with default_action=accept for this milter's socket, so even a
|
||||||
// crash or hang fails open (architecture.md § Mail path).
|
// 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
|
package milter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -45,6 +49,12 @@ type Store interface {
|
|||||||
// the queue-id at end-of-message. go-milter creates one session per
|
// the queue-id at end-of-message. go-milter creates one session per
|
||||||
// connection; a connection may carry several messages, so per-message fields
|
// connection; a connection may carry several messages, so per-message fields
|
||||||
// are reset at MailFrom (the start of every transaction).
|
// 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 {
|
type session struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
rec Store
|
rec Store
|
||||||
@@ -56,7 +66,9 @@ type session struct {
|
|||||||
// clientIP is the client's address. Postfix gives it at Connect, once per
|
// 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
|
// 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
|
// (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
|
clientIP string
|
||||||
|
|
||||||
login string
|
login string
|
||||||
@@ -68,6 +80,11 @@ type session struct {
|
|||||||
|
|
||||||
// Connect captures the client IP of a connection's first message. It is not
|
// Connect captures the client IP of a connection's first message. It is not
|
||||||
// enough on its own - see MailFrom.
|
// 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) {
|
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
s.clientIP = addr.String()
|
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
|
// 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
|
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
|
||||||
// one connection.)
|
// 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) {
|
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
||||||
if ip := clientAddr(m); ip != "" {
|
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
|
// RcptTo records each recipient. Postfix calls this once per recipient, which
|
||||||
// is what lets the journal keep a separate row per (queue-id, recipient).
|
// 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) {
|
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
||||||
return milter.RespContinue, nil
|
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
|
// 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
|
// message through just as "accept" does - and, unlike it, leaves this session
|
||||||
// in place for the connection's next message (see MailFrom).
|
// 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) {
|
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
s.record(macro(m, "i"))
|
s.record(macro(m, "i"))
|
||||||
// The rows are in the send log now, so the stored count sees this message
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
func (s *session) Abort(m *milter.Modifier) error {
|
||||||
s.releaseReservations()
|
s.releaseReservations()
|
||||||
s.rcpts = nil
|
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
|
// 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
|
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
|
||||||
// Postfix sends verbatim, so a lookup must try both forms.
|
// 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 {
|
func macro(m *milter.Modifier, name string) string {
|
||||||
if v, ok := m.Macros[name]; ok {
|
if v, ok := m.Macros[name]; ok {
|
||||||
return v
|
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
|
// 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
|
// Postfix did not send one that reads as an address. An IPv6 address may come
|
||||||
// in Sendmail's form, with "IPv6:" in front.
|
// 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 {
|
func clientAddr(m *milter.Modifier) string {
|
||||||
raw := strings.TrimSpace(macro(m, "client_addr"))
|
raw := strings.TrimSpace(macro(m, "client_addr"))
|
||||||
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
|
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
|
// 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.
|
// 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 {
|
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
||||||
flight := &inflight{} // shared: the level-2 window spans all connections
|
flight := &inflight{} // shared: the level-2 window spans all connections
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
|
|||||||
@@ -197,9 +197,11 @@ func TestSessionResetsBetweenMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
|
// Postfix sends each macro name as milter_*_macros spells it (src/milter/
|
||||||
// {i} for some versions), so the milter must resolve those too — this is the
|
// milter.c, milter_macro_lookup()): {auth_authen} in braces, and {i} too where
|
||||||
// case the SASL-less spike missed and that produced empty app_login at first.
|
// 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) {
|
func TestBracedMacros(t *testing.T) {
|
||||||
rec := &fakeRecorder{}
|
rec := &fakeRecorder{}
|
||||||
s := &session{rec: rec}
|
s := &session{rec: rec}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ import (
|
|||||||
// "+extension", the domain. (Postfix compares logins without regard to case;
|
// "+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
|
// 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.
|
// 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
|
// 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
|
// 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 {
|
for _, line := range lines {
|
||||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
// texthash: <key><whitespace><value>, the value a comma-separated
|
||||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
// 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)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
||||||
continue
|
continue
|
||||||
@@ -146,6 +158,10 @@ const extensionDelimiter = "+"
|
|||||||
// withoutExtension drops the extension of an address's local part: what
|
// withoutExtension drops the extension of an address's local part: what
|
||||||
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
||||||
// an address whose local part begins with the delimiter has no extension.
|
// 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 {
|
func withoutExtension(addr string) string {
|
||||||
at := strings.LastIndexByte(addr, '@')
|
at := strings.LastIndexByte(addr, '@')
|
||||||
if at < 0 {
|
if at < 0 {
|
||||||
@@ -184,6 +200,12 @@ func NewSigners(path string) *Signers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signs reports whether mail whose From: is at domain gets a signature.
|
// 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) {
|
func (s *Signers) Signs(domain string) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -231,6 +253,11 @@ type senderSession struct {
|
|||||||
// MailFrom starts a message and forgets the one before it: a connection may
|
// MailFrom starts a message and forgets the one before it: a connection may
|
||||||
// carry several, and after a temporary failure the library hands the
|
// carry several, and after a temporary failure the library hands the
|
||||||
// connection a new session.
|
// 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) {
|
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
s.login = macro(m, "auth_authen")
|
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.
|
// 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) {
|
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
|
||||||
shown, why, err := s.judge()
|
shown, why, err := s.judge()
|
||||||
if err != nil {
|
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
|
// 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
|
// (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.
|
// 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"))
|
s.record(shown, macro(m, "i"))
|
||||||
// A reply code of its own, so that the application is told what is wrong
|
// 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.
|
// 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 == "" {
|
if s.login == "" {
|
||||||
// Not reachable while the submission ports demand authentication; a
|
// Not reachable while the submission ports demand authentication; a
|
||||||
// message nobody answers for is not signed either way.
|
// 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
|
return "(none)", "no authenticated application", nil
|
||||||
}
|
}
|
||||||
switch len(s.froms) {
|
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
|
// 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
|
// 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.
|
// 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 {
|
func (s *senderSession) domain() string {
|
||||||
if d := domainOf(s.from); d != "" {
|
if d := domainOf(s.from); d != "" {
|
||||||
return d
|
return d
|
||||||
@@ -452,6 +496,11 @@ func isHostByte(c byte) bool {
|
|||||||
// printable reduces what a client wrote to printable ASCII without the
|
// 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:
|
// 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.
|
// 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 {
|
func printable(v string) string {
|
||||||
const max = 120
|
const max = 120
|
||||||
var sb strings.Builder
|
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
|
// 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 {
|
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
|
||||||
var refused chan store.SendLogEntry
|
var refused chan store.SendLogEntry
|
||||||
if rec != nil {
|
if rec != nil {
|
||||||
|
|||||||
@@ -13,18 +13,21 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// senderMaps is a sender_login_maps file as internal/postfix writes it: shop
|
// senderMaps is a sender_login_maps file composed by hand in the format
|
||||||
// and news send as any address of their domains, billing only as two listed
|
// internal/postfix writes (key, blank, comma-separated logins), though not in
|
||||||
// addresses of example.org, and two applications share shop.example.org.
|
// 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
|
const senderMaps = `@news.example.net news
|
||||||
@shop.example.org shop,shop2
|
@shop.example.org shop,shop2
|
||||||
invoices@example.org billing
|
invoices@example.org billing
|
||||||
billing@example.org billing
|
billing@example.org billing
|
||||||
`
|
`
|
||||||
|
|
||||||
// signingTable is OpenDKIM's SigningTable for the same server: the three
|
// signingTable is a SigningTable for the same server, composed by hand: the
|
||||||
// sending domains, one more that has no application yet, and the server's own
|
// three sending domains, one more that has no application yet, and the
|
||||||
// name.
|
// 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
|
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
|
||||||
*@shop.example.org selfpost._domainkey.shop.example.org
|
*@shop.example.org selfpost._domainkey.shop.example.org
|
||||||
*@example.org selfpost._domainkey.example.org
|
*@example.org selfpost._domainkey.example.org
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
# (internal/milter/sender_test.go) wants each refused; the e2e
|
# (internal/milter/sender_test.go) wants each refused; the e2e
|
||||||
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
|
# (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
|
# {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}"
|
||||||
"<b@{other}>"
|
"<b@{other}>"
|
||||||
"\"a@{domain}\"@{other}"
|
"\"a@{domain}\"@{other}"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
# is {domain} in capitals; {other} is another sender of the server, which a
|
# 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
|
# 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.
|
# @) - 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}"
|
||||||
"<a@{domain}>"
|
"<a@{domain}>"
|
||||||
"\"The Shop\" <Orders@{domain}>"
|
"\"The Shop\" <Orders@{domain}>"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
|||||||
return p.reload()
|
return p.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Postfix 3.7.11, man/man5/access.5: "OK" accepts the address the key
|
||||||
|
// matches; man/man5/transport.5: "user@domain transport:nexthop", and "a
|
||||||
|
// non-null transport field with a null nexthop field resets the nexthop
|
||||||
|
// information to the recipient domain"; relay_domains as in renderInboundMaps.
|
||||||
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
||||||
recipients = []byte{}
|
recipients = []byte{}
|
||||||
transport = []byte{}
|
transport = []byte{}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
|
|||||||
return p.reload()
|
return p.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Postfix 3.7.11, man/man5/postconf.5: a relay_domains table "is matched when
|
||||||
|
// a (parent) domain appears as lookup key", the value unused; relay_recipient_maps
|
||||||
|
// is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c,
|
||||||
|
// MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain
|
||||||
|
// transport:nexthop" for that domain. README_FILES/TLS_README: the
|
||||||
|
// smtp_tls_policy_maps key is the verbatim next-hop; src/tls/tls_level.c names
|
||||||
|
// the levels none, may and encrypt.
|
||||||
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
|
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
|
||||||
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
|
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
|
||||||
|
|
||||||
@@ -87,6 +94,11 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
|
|||||||
// inboundNexthop is the Postfix next-hop [host]:port form. The brackets are
|
// inboundNexthop is the Postfix next-hop [host]:port form. The brackets are
|
||||||
// what disable the MX lookup, so the operator's explicit upstream is used as
|
// what disable the MX lookup, so the operator's explicit upstream is used as
|
||||||
// given; they also delimit a bare IPv6 address.
|
// given; they also delimit a bare IPv6 address.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/smtp/smtp_connect.c: no MX lookup for a destination that
|
||||||
|
// starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host
|
||||||
|
// a name or an address ("ipv6:" in front is optional), checked by
|
||||||
|
// valid_hostaddr().
|
||||||
func inboundNexthop(host string, port int) string {
|
func inboundNexthop(host string, port int) string {
|
||||||
return "[" + host + "]:" + strconv.Itoa(port)
|
return "[" + host + "]:" + strconv.Itoa(port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ type Binding struct {
|
|||||||
// architecture.md § Mail path) — their logins are merged onto a single line as
|
// architecture.md § Mail path) — their logins are merged onto a single line as
|
||||||
// a comma-separated list, which is how Postfix expects multiple owners of one
|
// a comma-separated list, which is how Postfix expects multiple owners of one
|
||||||
// sender.
|
// sender.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||||
|
// the value is split into owners with mystrtok(&cp, CHARS_COMMA_SP).
|
||||||
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
|
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
|
||||||
content, err := renderSenderLoginMaps(bindings)
|
content, err := renderSenderLoginMaps(bindings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,6 +131,12 @@ func appendUnique(list []string, v string) []string {
|
|||||||
// end it, the comma that separates values, the colon a lookup key cannot carry,
|
// end it, the comma that separates values, the colon a lookup key cannot carry,
|
||||||
// and the backslash. A login additionally may not carry "@" — that would make it
|
// and the backslash. A login additionally may not carry "@" — that would make it
|
||||||
// look like an address.
|
// look like an address.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key ends at
|
||||||
|
// the first blank outside quotes, a backslash escapes the next character;
|
||||||
|
// src/util/readlline.c: a newline ends the line (one that starts with a blank
|
||||||
|
// continues the one before); the owners split on comma and blank (see
|
||||||
|
// RebuildSenderLoginMaps).
|
||||||
const (
|
const (
|
||||||
senderMapAddressForbidden = " \t\r\n,:\\"
|
senderMapAddressForbidden = " \t\r\n,:\\"
|
||||||
senderMapLoginForbidden = " \t\r\n,:@\\"
|
senderMapLoginForbidden = " \t\r\n,:@\\"
|
||||||
@@ -161,6 +170,15 @@ func assertMapSafe(address, login string) error {
|
|||||||
//
|
//
|
||||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||||
// and it never goes through a shell (security.md).
|
// and it never goes through a shell (security.md).
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, conf/postfix-script, start-fg: a process that is not PID 1
|
||||||
|
// runs "$daemon_directory/master -s" as its child, and src/master/master.c
|
||||||
|
// puts that master in a session of its own (setsid()). "reload" sends the
|
||||||
|
// master SIGHUP; src/master/master.c calls master_refresh(), and
|
||||||
|
// master_conf.c, master_config(), lets the running children "commit suicide
|
||||||
|
// whenever it is convenient" while new ones get the new configuration; a
|
||||||
|
// texthash table is read into memory once per process
|
||||||
|
// (README_FILES/DATABASE_README).
|
||||||
func reloadViaSupervisor() error {
|
func reloadViaSupervisor() error {
|
||||||
return supervisor.Start("postfix-reload")
|
return supervisor.Start("postfix-reload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ var postqueueP = func(ctx context.Context) *exec.Cmd {
|
|||||||
|
|
||||||
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
|
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
|
||||||
// truncated says whether there was more.
|
// truncated says whether there was more.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/postqueue/postqueue.c, show_queue(): any user passes
|
||||||
|
// authorized_mailq_users (DEF_SHOWQ_ACL static:anyone), and a failure is
|
||||||
|
// msg_fatal_status() - a non-zero exit with the reason on stderr;
|
||||||
|
// src/postqueue/showq_compat.c, showq_compat(): "Mail queue is empty".
|
||||||
func listQueue() (text string, truncated bool, err error) {
|
func listQueue() (text string, truncated bool, err error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -101,6 +106,9 @@ func Queue() (string, error) {
|
|||||||
// queue. A listing cut at the size cap is such an error: the messages past the
|
// queue. A listing cut at the size cap is such an error: the messages past the
|
||||||
// cut are still in the queue, and a backlog that large is the outage the sweep
|
// cut are still in the queue, and a backlog that large is the outage the sweep
|
||||||
// exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way.
|
// exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/showq/showq.c, showq_service(): maildrop, active,
|
||||||
|
// incoming, deferred and hold are the queues it lists.
|
||||||
func QueueIDs() (map[string]struct{}, error) {
|
func QueueIDs() (map[string]struct{}, error) {
|
||||||
out, truncated, err := listQueue()
|
out, truncated, err := listQueue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
|
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
|
||||||
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
|
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
|
||||||
// queue entries.
|
// queue entries composed by hand after the layout of Postfix 3.7.11's
|
||||||
|
// src/postqueue/showq_compat.c.
|
||||||
func TestFakePostqueue(t *testing.T) {
|
func TestFakePostqueue(t *testing.T) {
|
||||||
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
|
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
|
||||||
t.Skip("helper process")
|
t.Skip("helper process")
|
||||||
@@ -73,10 +74,12 @@ func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The reconcile sweep decides whether a message is still Postfix's problem, so
|
// The reconcile sweep decides whether a message is still Postfix's problem, so
|
||||||
// the parser must pick queue ids out of a real listing and nothing else out of
|
// the parser must pick queue ids out of a listing and nothing else out of it:
|
||||||
// it: not the header, not the byte-count trailer, and above all not a deferred
|
// not the header, not the byte-count trailer, and above all not a deferred
|
||||||
// entry's reason line, which — unlike the recipient lines — starts at the left
|
// entry's reason line. The listing is composed by hand. Postfix 3.7.11
|
||||||
// margin just as an entry does.
|
// (src/postqueue/showq_compat.c, showq_message()) right-aligns a reason to
|
||||||
|
// column 76, so it reaches the left margin only when it is 76 characters or
|
||||||
|
// longer; the one here is put at the margin as the hard case.
|
||||||
func TestParseQueueIDs(t *testing.T) {
|
func TestParseQueueIDs(t *testing.T) {
|
||||||
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||||
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
|
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import (
|
|||||||
|
|
||||||
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
|
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
|
||||||
// it: where it is, and since when.
|
// it: where it is, and since when.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/postqueue/showq_json.c, format_json(): one object and a
|
||||||
|
// newline per message - "queue_name", "queue_id", "arrival_time" (seconds,
|
||||||
|
// %ld), "message_size", "forced_expire", "sender", "recipients" [{"address",
|
||||||
|
// "delay_reason"}]; the queue names are those of src/global/mail_queue.h that
|
||||||
|
// src/showq/showq.c, showq_service(), scans.
|
||||||
type QueuedMessage struct {
|
type QueuedMessage struct {
|
||||||
ID string
|
ID string
|
||||||
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
|
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The listing is composed by hand: its first line in the field order of
|
||||||
|
// Postfix 3.7.11's src/postqueue/showq_json.c, format_json(); the others
|
||||||
|
// short of fields or broken on purpose.
|
||||||
func TestParseQueueJSON(t *testing.T) {
|
func TestParseQueueJSON(t *testing.T) {
|
||||||
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
|
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
|
||||||
{"queue_name": "active", "queue_id": "5F00AA12", "arrival_time": 1791250000, "message_size": 900, "sender": "", "recipients": [{"address": "b@example.net"}]}
|
{"queue_name": "active", "queue_id": "5F00AA12", "arrival_time": 1791250000, "message_size": 900, "sender": "", "recipients": [{"address": "b@example.net"}]}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type RetryPolicy struct {
|
|||||||
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
|
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
|
||||||
// build/postfix-config.sh does not set. Used when postconf is missing (unit
|
// build/postfix-config.sh does not set. Used when postconf is missing (unit
|
||||||
// tests, a binary outside the container) so the panel still starts.
|
// tests, a binary outside the container) so the panel still starts.
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/global/mail_params.h: DEF_QUEUE_RUN_DELAY "300s",
|
||||||
|
// DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s",
|
||||||
|
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h".
|
||||||
func DefaultRetryPolicy() RetryPolicy {
|
func DefaultRetryPolicy() RetryPolicy {
|
||||||
return RetryPolicy{
|
return RetryPolicy{
|
||||||
QueueRunDelay: 300 * time.Second,
|
QueueRunDelay: 300 * time.Second,
|
||||||
@@ -57,6 +61,9 @@ var readRetryConf = postconfRetryValues
|
|||||||
|
|
||||||
func postconfRetryValues() ([]string, error) {
|
func postconfRetryValues() ([]string, error) {
|
||||||
// Fixed argv, no user input — same pattern as Queue (security.md).
|
// Fixed argv, no user input — same pattern as Queue (security.md).
|
||||||
|
// Postfix 3.7.11, src/postconf/postconf_main.c, pcf_show_parameters():
|
||||||
|
// named parameters are printed in argument order, with -h one "%s\n"
|
||||||
|
// value each, as written in main.cf (not expanded without -x).
|
||||||
cmd := exec.Command("postconf", "-h",
|
cmd := exec.Command("postconf", "-h",
|
||||||
"queue_run_delay",
|
"queue_run_delay",
|
||||||
"minimal_backoff_time",
|
"minimal_backoff_time",
|
||||||
@@ -133,6 +140,10 @@ func (p RetryPolicy) BackoffCap() string {
|
|||||||
// larger of the queue lifetime for ordinary mail and for mail from the null
|
// larger of the queue lifetime for ordinary mail and for mail from the null
|
||||||
// sender, which an application may submit too. It bounds which stored log
|
// sender, which an application may submit too. It bounds which stored log
|
||||||
// lines can be about a send-log row (store.SendLogLines).
|
// lines can be about a send-log row (store.SendLogLines).
|
||||||
|
//
|
||||||
|
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done(): a message
|
||||||
|
// expires after var_max_queue_time, or var_dsn_queue_time when its sender is
|
||||||
|
// null - whoever submitted it (qmgr.c caps the second at the first).
|
||||||
func (p RetryPolicy) LongestLifetime() time.Duration {
|
func (p RetryPolicy) LongestLifetime() time.Duration {
|
||||||
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
|
if p.BounceQueueLifetime > p.MaximalQueueLifetime {
|
||||||
return p.BounceQueueLifetime
|
return p.BounceQueueLifetime
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The postconf values in these tests are composed by hand: values main.cf
|
||||||
|
// could hold. Stock Postfix 3.7.11 prints "0h" for delay_warning_time
|
||||||
|
// (src/global/mail_params.h, DEF_DELAY_WARN_TIME), not "0".
|
||||||
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
|
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
|
||||||
old := readRetryConf
|
old := readRetryConf
|
||||||
readRetryConf = func() ([]string, error) {
|
readRetryConf = func() ([]string, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user