build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)

Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:39:21 +03:00
co-authored by Claude Opus 5.5
parent ad3d834392
commit 599149bf83
39 changed files with 417 additions and 59 deletions
+15
View File
@@ -36,6 +36,16 @@ ENV DEBIAN_FRONTEND=noninteractive
# Preseed Postfix so its install is non-interactive and yields a working # Preseed Postfix so its install is non-interactive and yields a working
# main.cf. The real relay configuration is generated by the panel. # main.cf. The real relay configuration is generated by the panel.
# (postfix 3.7.11-0+deb12u1, debian/templates: postfix/main_mailer_type and
# postfix/mailname are the questions the install asks.)
# What else arrives through Depends, and is used here: openssl, by
# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/
# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor
# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb
# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2
# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util
# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist).
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
&& apt-get update \ && apt-get update \
@@ -65,6 +75,8 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# The id is fixed, and it is the one the group has always had: an operator # The id is fixed, and it is the one the group has always had: an operator
# gives the TLS private key on the host to this group by number, so that the # gives the TLS private key on the host to this group by number, so that the
# panel can put it into a full backup (guide § Taking a full backup). # panel can put it into a full backup (guide § Taking a full backup).
# groupadd(8) refuses a --gid already in use, so a package that took 998 would
# fail this build rather than share the group.
RUN groupadd --system --gid 998 selfpost \ RUN groupadd --system --gid 998 selfpost \
&& usermod -aG selfpost panel \ && usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
@@ -81,6 +93,9 @@ RUN groupadd --system --gid 998 selfpost \
# to write the maildrop directory (internal/notify/mailer.go). It does not let # to write the maildrop directory (internal/notify/mailer.go). It does not let
# the panel bypass any Postfix restriction that a local user does not already # the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail. # have through the world-executable sendmail.
# (Postfix 3.7.11, conf/postfix-files: postdrop and postqueue are
# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst:
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
# Runtime directories: milter sockets and the consolidated persistent root. # Runtime directories: milter sockets and the consolidated persistent root.
+6
View File
@@ -8,6 +8,12 @@ lingering "alive" with a dead Postfix/OpenDKIM/panel.
Communication with supervisord uses the event listener protocol over stdin and Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens. stdout, so stdout must carry only protocol tokens.
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
listener writes READY\\n, reads one header line of key:value tokens whose len
is the payload's length, reads the payload, and answers "RESULT 2\\nOK".
supervisord starts the listener as its own child, and on SIGTERM it shuts down
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
""" """
import os import os
import signal import signal
+14 -1
View File
@@ -62,6 +62,8 @@ esac
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare # trees under /data as other users. Go's testing.TempDir is 0700, and a bare
# chown would leave that mode in place — opendkim then cannot read KeyTable and # chown would leave that mode in place — opendkim then cannot read KeyTable and
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). # the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
# mode 0o700".)
chown panel:panel /data chown panel:panel /data
chmod 755 /data chmod 755 /data
# Restored backups or previously-created state may contain panel-owned files # Restored backups or previously-created state may contain panel-owned files
@@ -76,7 +78,8 @@ find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -ex
# tree on every start so it is correct whether /data is fresh, restarted, or # tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup: # just restored from a backup:
# - group `selfpost` + setgid on directories so anything the panel creates # - group `selfpost` + setgid on directories so anything the panel creates
# inherits the shared group OpenDKIM traverses; # inherits the shared group OpenDKIM traverses (inode(7), "The
# set-group-ID bit": files created in such a directory take its group);
# - private keys and tables group-readable (0640); # - private keys and tables group-readable (0640);
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the # - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
# daemon comes up cleanly with no domains yet. # daemon comes up cleanly with no domains yet.
@@ -135,6 +138,11 @@ chmod 0640 /data/postfix/sender_login_maps
# directory keeps the shared group on anything created inside it later, and # directory keeps the shared group on anything created inside it later, and
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to # 2750 keeps it group-traversable but not group-writable — logrotate refuses to
# rotate a log whose directory is writable by a non-root group. # rotate a log whose directory is writable by a non-root group.
# Postfix 3.7.11: src/util/logwriter.c, logwriter_open_or_die(), creates the
# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate
# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent
# directory has insecure permissions" when the directory's group is not root
# and has write permission.
mkdir -p /data/log mkdir -p /data/log
[ -e /data/log/mail.log ] || : > /data/log/mail.log [ -e /data/log/mail.log ] || : > /data/log/mail.log
chown -R postfix:selfpost /data/log chown -R postfix:selfpost /data/log
@@ -160,6 +168,7 @@ chmod 2750 /run/opendkim /run/selfpost
# running container, and a restart of the whole container is not the check's # running container, and a restart of the whole container is not the check's
# downtime (guide § Health) - a mark left from before would count the whole # downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs. # outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
# Generate the outbound-relay Postfix configuration from the environment (spec # Generate the outbound-relay Postfix configuration from the environment (spec
@@ -184,6 +193,10 @@ chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix. # It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only # Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start. # writer) and logs "warning: not owned by root" for each at every start.
# (Postfix 3.7.11, conf/postfix-files: $queue_directory root 755, maildrop
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
# directories $mail_owner 700; conf/postfix-script, "check-warn": "not owned by
# root".)
chown root /data/postfix/queue /data/postfix/queue/pid chown root /data/postfix/queue /data/postfix/queue/pid
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+19 -2
View File
@@ -7,18 +7,35 @@
# before the reload is lost. `create` (rather than `nocreate`) matters here # before the reload is lost. `create` (rather than `nocreate`) matters here
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by # beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# postfix, which the unprivileged panel process cannot read — confirmed on a # postfix, which the unprivileged panel process cannot read — confirmed on a
# live container — so logrotate must be the one to create it. # live container (date not recorded), and so in the source: Postfix 3.7.11,
# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's
# umask(077), src/master/master.c — so logrotate must be the one to create it.
# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill
# -HUP master); master_conf.c, master_config() -> master_restart_service() ->
# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c,
# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the
# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()).
# #
# logrotate itself only rotates once the configured "daily" period has elapsed # logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more # (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
# often than daily — polling merely bounds how late a legitimate rotation runs. # often than daily — polling merely bounds how late a legitimate rotation runs.
# (logrotate 3.21.0-1: debian/rules, --with-state-file-path=/var/lib/logrotate/
# status. That file is in the container's own filesystem: a recreated container
# starts without it, and logrotate.c, newState(), records a log it has no state
# for as rotated today, so the first rotation after a recreate comes a day
# later.)
set -eu set -eu
CONFIG=/etc/logrotate.d/mail CONFIG=/etc/logrotate.d/mail
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}" INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
# logrotate refuses configs writable by group or others and exits 0 while # logrotate refuses configs writable by group or others and exits 0 while
# ignoring them — fail here so supervisord reports the fault. # ignoring them — fail here so supervisord reports the fault. (logrotate
# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ...
# because it is writable by group or others." and returns 0, not an error; a
# config not owned by root is ignored the same way. "Potentially dangerous
# mode" is only a warning there, for any mode & 07533 other than 0400 - this
# loop chooses to treat it as fatal too.)
logrotate_config_ok() { logrotate_config_ok() {
mode=$(stat -c '%a' "$CONFIG") mode=$(stat -c '%a' "$CONFIG")
mode=${mode#0} mode=${mode#0}
+3
View File
@@ -4,6 +4,9 @@
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd # build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
# writes it as user postfix, the unprivileged panel reads it through the shared # writes it as user postfix, the unprivileged panel reads it through the shared
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root. # selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
# (logrotate 3.21.0, logrotate.8: "create mode owner group" makes the new log
# right after the rotation; "delaycompress" leaves the previous log, .1,
# uncompressed until the next cycle.)
/data/log/mail.log { /data/log/mail.log {
daily daily
rotate 14 rotate 14
+18 -3
View File
@@ -3,11 +3,16 @@
# Signing is strictly per-domain: each sending domain has its own key pair and # Signing is strictly per-domain: each sending domain has its own key pair and
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and # selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks # keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables. # supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM
# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is
# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(),
# opens SigningTable and KeyTable again with dkimf_db_open().)
# #
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, # Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply # so the daemon comes up cleanly on a fresh /data with no domains yet and simply
# signs nothing until the first domain is added. # signs nothing until the first domain is added. (opendkim/opendkim-db.c,
# dkimf_db_open(): a file or refile table is read line by line; an empty file
# is an empty table, not an error.)
Syslog no Syslog no
UMask 007 UMask 007
@@ -15,15 +20,25 @@ Mode s
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any # SigningTable uses refile: so the "*@example.com" left-hand patterns match any
# local-part for a domain; KeyTable maps each domain to its key and selector. # local-part for a domain; KeyTable maps each domain to its key and selector.
# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are
# wildcard patterns matched against the From: address, in file order.)
KeyTable /data/opendkim/KeyTable KeyTable /data/opendkim/KeyTable
SigningTable refile:/data/opendkim/SigningTable SigningTable refile:/data/opendkim/SigningTable
# The private keys are owned by `panel` and read by `opendkim` through the shared # The private keys are owned by `panel` and read by `opendkim` through the shared
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is # `selfpost` group, so they are deliberately group-readable (mode 0640). That is
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default # safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
# key-safety check, so the check is disabled here by design. # key-safety check, so the check is disabled here by design. (opendkim/
# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the
# executing uid, and a group-readable one must be in a group of no other user -
# `panel` owns these and `selfpost` has three members.)
RequireSafeKeys no RequireSafeKeys no
# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in,
# UMask: it applies to the socket); its group is selfpost by the setgid
# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's
# groups (opendkim.conf.5.in, UserID); with Background no and -f
# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself.
Socket local:/run/opendkim/opendkim.sock Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid PidFile /run/opendkim/opendkim.pid
UserID opendkim UserID opendkim
+5
View File
@@ -11,6 +11,11 @@
# copies - not the mounted files, which a renewer may be rewriting - are a # copies - not the mounted files, which a renewer may be rewriting - are a
# certificate and the key that belongs to it, renames both over the targets # certificate and the key that belongs to it, renames both over the targets
# and reloads Postfix. When nothing changed it does nothing. # and reloads Postfix. When nothing changed it does nothing.
# (Postfix 3.7.11: smtpd loads the certificate in pre_jail_init(), once per
# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master,
# conf/postfix-script, after which the old smtpd processes exit on the closed
# status pipe, src/master/master_conf.c, master_config() ->
# master_restart_service(), and new ones load the files again.)
# #
# Runs under supervisord as root. It sleeps first: at container start the # Runs under supervisord as root. It sleeps first: at container start the
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure # wrapper is still bringing Postfix up. It never exits non-zero, so a failure
+128 -20
View File
@@ -23,6 +23,19 @@ set -eu
# splits a restriction list on commas as well as on whitespace, so every value # splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in # passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container. # this script and stops the container.
#
# Postfix 3.7.11, man/man5/master.5, "-o name=value (short form)", NOTE 2: no
# whitespace in a short-form value (the long form "-o { name = value }" would
# keep it; this script does not write it). src/postconf/postconf_edit.c,
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c,
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
#
# master.5, SYNTAX: a line that starts with whitespace continues a logical
# line, so the "-o" line awk inserts below the header belongs to the service.
# A later postconf -F/-P re-parses a matched entry with its continuation lines
# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping
# these options.
master_cf_set_smtp_inet_o() { master_cf_set_smtp_inet_o() {
param="$1" param="$1"
value="$2" value="$2"
@@ -62,11 +75,19 @@ master_cf_set_smtp_inet_o() {
# sasldb2 accounts are looked up under. The panel creates accounts under realm # sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication # $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment. # fails. Fall back to the container hostname only outside a real deployment.
# Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
# looked up under serverFQDN.
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount. The # TLS material supplied by the reverse-proxy through a read-only bind mount. The
# relay requires TLS on 465; if these files are absent the master still starts # relay requires TLS on 465; if these files are absent the master still starts
# but TLS handshakes on 465 fail until they appear. # but TLS handshakes on 465 fail until they appear. Postfix 3.7.11,
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
# made, and smtpd_proto() then drops each wrapper-mode connection ("Wrapper-mode
# request dropped ... TLS context initialization failed"); every new smtpd
# process loads the files again in pre_jail_init().
# #
# Postfix insists the private key is root-owned and mode 0600. The bind mount # Postfix insists the private key is root-owned and mode 0600. The bind mount
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so # is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
@@ -102,7 +123,9 @@ JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}" SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
# Persistent panel-managed sender map; texthash needs no postmap, so the # Persistent panel-managed sender map; texthash needs no postmap, so the
# unprivileged panel can rewrite it and just ask for a reload. # unprivileged panel can rewrite it and just ask for a reload. Postfix 3.7.11,
# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read
# once by each process - a reload makes the new processes read it again.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container # Transit mail queue under /data so deferred/active messages survive container
# recreate (architecture.md § Persistence). Distinct from sender_login_maps. # recreate (architecture.md § Persistence). Distinct from sender_login_maps.
@@ -151,6 +174,14 @@ MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
# maillog_file lives under the persistent /data bind mount (architecture.md § # maillog_file lives under the persistent /data bind mount (architecture.md §
# Log tailer). Postfix's default maillog_file_prefixes are only /var and # Log tailer). Postfix's default maillog_file_prefixes are only /var and
# /dev/stdout — without /data, `postfix check` rejects the path. # /dev/stdout — without /data, `postfix check` rejects the path.
# inet_protocols=all in a bridge network without IPv6: Postfix 3.7.11,
# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to
# the next address when a connection fails - an AAAA that cannot be reached
# costs one failed attempt, then IPv4. Postfix
# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any
# prefix", in every Postfix program that logs, postfix(1) itself included.
postconf -e \ postconf -e \
"myhostname=${HOSTNAME_VALUE}" \ "myhostname=${HOSTNAME_VALUE}" \
"maillog_file=${MAIL_LOG_PATH}" \ "maillog_file=${MAIL_LOG_PATH}" \
@@ -160,7 +191,9 @@ postconf -e \
"inet_interfaces=all" \ "inet_interfaces=all" \
"inet_protocols=all" "inet_protocols=all"
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's # postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11,
# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script,
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded # stock master.cf usually has it; pin it explicitly so a stripped/upgraded
# image cannot lose the service. # image cannot lose the service.
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd" postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
@@ -178,7 +211,10 @@ postconf -e \
"smtp_tls_note_starttls_offer=yes" "smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service. auth_only guarantees # TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port. # credentials are never accepted before TLS is up on any port. Postfix 3.7.11,
# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c,
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
postconf -e \ postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \ "smtpd_tls_cert_file=${TLS_CERT}" \
"smtpd_tls_key_file=${TLS_KEY}" \ "smtpd_tls_key_file=${TLS_KEY}" \
@@ -191,6 +227,8 @@ postconf -e \
# uses for sender_login_maps is the BARE login the panel writes into the map; # uses for sender_login_maps is the BARE login the panel writes into the map;
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as # the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# the server realm, which equals the realm the accounts were created under. # the server realm, which equals the realm the accounts were created under.
# (Postfix 3.7.11, src/smtpd/smtpd_sasl_glue.c, smtpd_sasl_activate(): an empty
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
postconf -e \ postconf -e \
"smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \ "smtpd_sasl_type=cyrus" \
@@ -200,14 +238,19 @@ postconf -e \
"smtpd_sasl_tls_security_options=noanonymous" \ "smtpd_sasl_tls_security_options=noanonymous" \
"broken_sasl_auth_clients=yes" "broken_sasl_auth_clients=yes"
# Sender binding (the critical anti-spoofing control). texthash resolves the # Sender binding (the critical anti-spoofing control). smtpd looks the sender
# full address first, then the "@domain" wildcard, so both address modes work # up in this map with mail_addr_find(), the order being smtpd's and not
# from the same map. # texthash's: the full address, the address without its extension, then the
# "@domain" wildcard - so both address modes work from the same map (Postfix
# 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch();
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
# #
# recipient_delimiter is said here and not left to the distribution's main.cf: # recipient_delimiter is said here and not left to the distribution's main.cf:
# it is what lets an application listed as invoices@ send as invoices+march@, # it is what lets an application listed as invoices@ send as invoices+march@,
# and the panel's sender check makes the same second try for the From: line # and the panel's sender check makes the same second try for the From: line
# (extensionDelimiter in internal/milter/sender.go) - the two must agree. # (extensionDelimiter in internal/milter/sender.go) - the two must agree.
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
# strip_addr_internal(), for its second try.)
postconf -e \ postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \ "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"recipient_delimiter=+" \ "recipient_delimiter=+" \
@@ -216,7 +259,9 @@ postconf -e \
# Restrictions: authenticated clients only, no relay to foreign destinations, # Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO # and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by # permit_mynetworks anywhere — authorisation is by credentials, never by
# network. This is what makes an open relay impossible. # network. This is what makes an open relay impossible. (Postfix 3.7.11,
# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists
# are evaluated in turn and the first REJECT ends them.)
postconf -e \ postconf -e \
"smtpd_helo_required=yes" \ "smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
@@ -224,7 +269,11 @@ postconf -e \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit" "smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP. Backstop that keeps working even if the # Level-1 rate limit by client IP. Backstop that keeps working even if the
# journal-milter (level 2) is down. # journal-milter (level 2) is down. Postfix 3.7.11, src/smtpd/smtpd.c,
# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf
# default, debian/templates "postfix/mynetworks", is loopback only).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
postconf -e \ postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s" "anvil_rate_time_unit=${RATE_WINDOW}s"
@@ -244,6 +293,13 @@ postconf -e \
# signs them with the key of the server's own name; the journal-milter is not # signs them with the key of the server's own name; the journal-milter is not
# on this chain, since they are not an application's mail. Bounces Postfix # on this chain, since they are not an application's mail. Bounces Postfix
# generates itself do not pass it (internal_mail_filter_classes is empty). # generates itself do not pass it (internal_mail_filter_classes is empty).
#
# Postfix 3.7.11: html/MILTER_README.html, "Different settings for different
# Milter applications" (the braces, Postfix 3.0 and later) and "Signing
# internally-generated bounce messages"; src/milter/milter.c, milter_*_event()
# and milter_message(): milters are asked in list order and the first non-empty
# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES
# INT_FILT_CLASS_NONE.
postconf -e \ postconf -e \
"milter_protocol=6" \ "milter_protocol=6" \
"milter_default_action=tempfail" \ "milter_default_action=tempfail" \
@@ -254,7 +310,8 @@ postconf -e \
# address. The panel's milters judge every message by that address (an # address. The panel's milters judge every message by that address (an
# application's client-address list) and record it (the inbound journal), and # application's client-address list) and record it (the inbound journal), and
# the address given once at connect is not there for a connection's later # the address given once at connect is not there for a connection's later
# messages - see MailFrom in internal/milter/milter.go. # messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS.
postconf -e \ postconf -e \
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}" "milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
@@ -264,7 +321,8 @@ postconf -e \
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy # but within seconds rather than minutes. Values are well above any healthy
# response time (signing/DB insert are sub-second), so they never fire in normal # response time (signing/DB insert are sub-second), so they never fire in normal
# operation. # operation. The defaults: Postfix 3.7.11, src/global/mail_params.h,
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
postconf -e \ postconf -e \
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \ "milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \ "milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
@@ -274,6 +332,10 @@ postconf -e \
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission # smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data # service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot. # and the Cyrus config outside any chroot.
# The names smtps, submission and smtp need no /etc/services: Postfix 3.7.11,
# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
# smtps=submissions=465, submission=587" (src/util/find_inet.c,
# find_inet_port() via filter_known_tcp_port()).
postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \ postconf -P \
"smtps/inet/smtpd_tls_wrappermode=yes" \ "smtps/inet/smtpd_tls_wrappermode=yes" \
@@ -283,7 +345,10 @@ postconf -P \
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only # submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# difference is TLS is negotiated via STARTTLS, so require encryption before # difference is TLS is negotiated via STARTTLS, so require encryption before
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart # auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# after disabling it does not leave the port listening. # after disabling it does not leave the port listening. (Postfix 3.7.11,
# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets
# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c,
# pcf_edit_master(): -MX drops the matched entry, continuation lines included.)
if [ "${SUBMISSION_ENABLE}" = "true" ]; then if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd" postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \ postconf -P \
@@ -295,7 +360,9 @@ else
fi fi
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) ----- # --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove # Debian's stock master.cf enables smtp/inet (postfix 3.7.11-0+deb12u1,
# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd",
# uncommented). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses # that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only # smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses. # configured domains/recipients; DMARC accepts only allow-listed report addresses.
@@ -320,8 +387,15 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# postconf -P refuses them ("unmatched service_name/type") and leaves a # postconf -P refuses them ("unmatched service_name/type") and leaves a
# bare "pipe", which dies with "fatal: missing user= command-line # bare "pipe", which dies with "fatal: missing user= command-line
# attribute" on every report. They go into the service line itself. # attribute" on every report. They go into the service line itself.
# (Seen in 1.7.0 through 1.9.5, date not recorded. Postfix 3.7.11:
# src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes
# "-o name=value" and nothing else; postconf_edit.c warns "unmatched
# service_name/type" for a service master.cf does not have;
# src/pipe/pipe.c, get_service_attr(): "missing user= command-line
# attribute".)
# ${recipient} is expanded by pipe(8), not by the shell: the command # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument. # learns which report address the message came to from its argument
# (man/man8/pipe.8, ${recipient}: as many arguments as recipients).
# One at a time (the 1 before "pipe"): reports are in no hurry, every run # One at a time (the 1 before "pipe"): reports are in no hurry, every run
# writes to the database the panel writes to, and a hundred of them in # writes to the database the panel writes to, and a hundred of them in
# parallel - Postfix's default - is what a flood of reports would start. # parallel - Postfix's default - is what a flood of reports would start.
@@ -332,7 +406,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# command reads from the container's environment is named there. # command reads from the container's environment is named there.
# export_environment is a list split at whitespace in which Postfix # export_environment is a list split at whitespace in which Postfix
# expands "$": a path with either would reach the command as another # expands "$": a path with either would reach the command as another
# path, and it would quietly open a database of its own. # path, and it would quietly open a database of its own. (Postfix 3.7.11,
# man/man8/pipe.8, export_environment; src/global/mail_params.h,
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The
# parameter is main.cf's, so every Postfix process exports these too.)
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
*[[:space:]]* | *'$'*) *[[:space:]]* | *'$'*)
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2 echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
@@ -351,7 +428,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi fi
# Comma, not a space, between check_recipient_access and its table: see # Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o. # master_cf_set_smtp_inet_o (master.5, NOTE 2: "or use commas instead of
# spaces").
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
@@ -369,10 +447,17 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# relay domain too (the server's own name), so their table is a recipient # relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient # table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on. # table" and no report is ever accepted while both features are on.
# (Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): after
# the lists, check_recipient_rcpt_maps() runs unless the status is
# REJECT - an OK from check_recipient_access does not skip it.)
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}" RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}" RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi fi
# The panel writes the same "[host]:port" into transport and
# tls_policy: the policy lookup key is the verbatim next-hop, port and
# brackets included (Postfix 3.7.11, README_FILES/TLS_README, "TLS
# policy table" note: ":smtp", ":25" and no port are different keys).
postconf -e \ postconf -e \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \ "relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
@@ -389,7 +474,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# domain — polluting Deliveries and letting a forged From: consume that # domain — polluting Deliveries and letting a forged From: consume that
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden # domain's level-2 rate-limit budget. So smtpd_milters is always overridden
# here: the optional antispam milter, then - with the inbound relay on - the # here: the optional antispam milter, then - with the inbound relay on - the
# panel's inbound journal milter, or nothing at all. # panel's inbound journal milter, or nothing at all. (Postfix 3.7.11,
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
# src/smtpd/smtpd.c hands its own milter list to cleanup, milter_send(),
# and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.)
INBOUND_MILTERS="" INBOUND_MILTERS=""
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
# A glob's "*" matches anything, so the shape alone would pass # A glob's "*" matches anything, so the shape alone would pass
@@ -422,6 +510,11 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# never refuses, and must never hold up someone else's mail: accept when # never refuses, and must never hold up someone else's mail: accept when
# it is down. A comma, not a space, between the two - see # it is down. A comma, not a space, between the two - see
# master_cf_set_smtp_inet_o. # master_cf_set_smtp_inet_o.
# Postfix 3.7.11: src/milter/milter.c, milter_message(), sends the
# message to each milter in list order; src/milter/milter8.c,
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to
# the queue file at once, and the next milter8_message() reads that
# file with rec_get(), which follows the added records.
case "${INBOUND_JOURNAL_SOCK}" in case "${INBOUND_JOURNAL_SOCK}" in
*[!A-Za-z0-9._/-]*) *[!A-Za-z0-9._/-]*)
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2 echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
@@ -453,7 +546,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# syslog_name: this listener's lines read postfix/inbound/smtpd, the # syslog_name: this listener's lines read postfix/inbound/smtpd, the
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25 # submission ports' postfix/smtpd. The log tailer files a refusal on port 25
# into the inbound journal and has to tell the two apart. # into the inbound journal and has to tell the two apart. (Postfix 3.7.11,
# src/global/mail_task.c, mail_task(): the log tag is syslog_name "/"
# program name. Only smtpd carries it: cleanup and the rest are other
# services and log as postfix/.)
postconf -M "smtp/inet=smtp inet n - n - - smtpd" postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \ postconf -P \
"smtp/inet/syslog_name=postfix/inbound" \ "smtp/inet/syslog_name=postfix/inbound" \
@@ -484,13 +580,23 @@ fi
# Inside a container the chroot buys little (the container is the isolation # Inside a container the chroot buys little (the container is the isolation
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our # boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
# own smtps/submission services are already n; this covers the delivery agents # own smtps/submission services are already n; this covers the delivery agents
# and the rest. # and the rest. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff
# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the
# like into the chroot, and nothing runs it in this container. chroot(2) also
# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop:
# ALL - capabilities(7).)
postconf -F "*/*/chroot=n" postconf -F "*/*/chroot=n"
# --- Cyrus SASL app config for smtpd ----------------------------------------- # --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify # Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN # passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
# only — both are safe because TLS is mandatory before auth on every port. # only — both are safe because TLS is mandatory before auth on every port.
# The file is found by a Debian patch, not by upstream Postfix: postfix
# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in
# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl",
# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
# sasldb_path, mech_list).
mkdir -p /etc/postfix/sasl mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF cat > /etc/postfix/sasl/smtpd.conf <<EOF
pwcheck_method: auxprop pwcheck_method: auxprop
@@ -500,7 +606,9 @@ mech_list: PLAIN LOGIN
EOF EOF
# Validate the generated configuration; fail loudly if postconf produced # Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it. # anything Postfix rejects, before the wrapper tries to start it. (Postfix
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal
# also runs "post-install create-missing", which builds the queue tree.)
set +e set +e
check_out=$(postfix check 2>&1) check_out=$(postfix check 2>&1)
ec=$? ec=$?
+4
View File
@@ -39,4 +39,8 @@ for sock in $SOCKS; do
done done
echo "postfix-wrapper: milter sockets ready, starting postfix" echo "postfix-wrapper: milter sockets ready, starting postfix"
# Postfix 3.7.11: postfix(1) execs conf/postfix-script (src/postfix/postfix.c),
# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as
# its child; master then makes itself a session leader (src/master/master.c,
# setsid()). The process supervisord watches is that shell, not master.
exec postfix start-fg exec postfix start-fg
+18 -3
View File
@@ -3,7 +3,10 @@
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens ; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
; the sender check's and the journal-milter's sockets), then the Postfix ; the sender check's and the journal-milter's sockets), then the Postfix
; wrapper — which additionally blocks until the milter sockets are ready ; wrapper — which additionally blocks until the milter sockets are ready
; before starting Postfix. ; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one
; pass, with no wait for a program to be ready.)
; ;
; If any managed process exhausts its restart retries (FATAL), the crashexit ; If any managed process exhausts its restart retries (FATAL), the crashexit
; event listener brings the whole container down so Docker's restart policy can ; event listener brings the whole container down so Docker's restart policy can
@@ -22,7 +25,9 @@ loglevel=info
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still ; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
; unreachable by world; only root and the two SelfPost services are in the ; unreachable by world; only root and the two SelfPost services are in the
; group. No HTTP auth is configured because access is gated by socket ; group. No HTTP auth is configured because access is gated by socket
; permissions, not the network. ; permissions, not the network. (supervisor 4.2.5, docs/configuration.rst,
; [unix_http_server]: chmod and chown set the socket's mode and owner; without
; username and password no authentication is asked.)
[unix_http_server] [unix_http_server]
file=/run/supervisor.sock file=/run/supervisor.sock
chown=root:selfpost chown=root:selfpost
@@ -45,6 +50,10 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; user=panel also gives the panel its supplementary groups, selfpost and
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; drop_privileges(), os.setgroups() from the user's memberships in
; grp.getgrall().
[program:panel] [program:panel]
command=/usr/local/bin/panel command=/usr/local/bin/panel
user=panel user=panel
@@ -76,7 +85,10 @@ stderr_logfile_maxbytes=0
; supervisord — over the group-accessible control socket — to run this program, ; supervisord — over the group-accessible control socket — to run this program,
; which executes the canonical `postfix reload` as root. autostart is off (it ; which executes the canonical `postfix reload` as root. autostart is off (it
; only runs on demand) and a fast, clean exit is expected, so it never trips the ; only runs on demand) and a fast, clean exit is expected, so it never trips the
; crashexit listener. ; crashexit listener. (start-fg: see build/postfix-wrapper.sh. supervisor
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
; never FATAL.)
[program:postfix-reload] [program:postfix-reload]
command=/usr/sbin/postfix reload command=/usr/sbin/postfix reload
autostart=false autostart=false
@@ -120,6 +132,9 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
[eventlistener:crashexit] [eventlistener:crashexit]
command=/usr/local/bin/crashexit.py command=/usr/local/bin/crashexit.py
events=PROCESS_STATE_FATAL events=PROCESS_STATE_FATAL
@@ -24,6 +24,10 @@ services:
- antispam - antispam
antispam: antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
# (its Dockerfile, USER 11333:11333).
image: rspamd/rspamd:3.11 image: rspamd/rspamd:3.11
restart: unless-stopped restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network. # No host ports: only SelfPost talks to the milter on the compose network.
@@ -17,6 +17,18 @@
# SelfPost reads a verdict only next to a signature it can recompute. The # SelfPost reads a verdict only next to a signature it can recompute. The
# signature travels on with the mail and is of no use to whoever sees it: it # signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id. # fits that one queue id.
#
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
# as function(task, common_meta) and returns error, headers to add, headers to
# remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
# "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"]; use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
custom { custom {
+16 -1
View File
@@ -3,6 +3,11 @@
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost # The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
# writes them as six files under data/antispam/, which the fragment mounts at # writes them as six files under data/antispam/, which the fragment mounts at
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart. # /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
# ev_stat - inotify where the kernel has it, else a stat poll every
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
# internal/inbound writes with.)
# #
# Deny wins over allow, whichever entries match. A denied sender is refused # Deny wins over allow, whichever entries match. A denied sender is refused
# before anything else is looked at (a prefilter). An allowed one is not # before anything else is looked at (a prefilter). An allowed one is not
@@ -10,7 +15,17 @@
# first decide - but given a weight no spam score outweighs, which a refusal # first decide - but given a weight no spam score outweighs, which a refusal
# by the deny list still overrides. # by the deny list still overrides.
# #
# A domain entry is that domain, not its subdomains. # A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
#
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
# with the rule's action and message, and that action stands whatever the score
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
# puts it into messages.smtp_message, src/libserver/milter.c,
# rspamd_milter_send_task_results(), replies with it.
# #
# SelfPost counts how often an entry decided a message by what these rules # SelfPost counts how often an entry decided a message by what these rules
# leave behind: the deny message below and the names of the allow symbols. # leave behind: the deny message below and the names of the allow symbols.
+9 -1
View File
@@ -11,10 +11,18 @@
# storage detail, not a documented public API — VERIFY the exact path against # storage detail, not a documented public API — VERIFY the exact path against
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`) # the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
# before wiring it into the volume mount below. # before wiring it into the volume mount below.
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
# #
# Merge with the base file — run this from the deploy/ directory (Compose # Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the # resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...): # FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# #
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d # docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
+7
View File
@@ -116,6 +116,13 @@ services:
# FOWNER/FSETID are required by build/entrypoint.sh's permission # FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container # self-healing; without them chmod fails with EPERM and the container
# crash-loops on start. # crash-loops on start.
# Each capability above as capabilities(7) defines it (man7.org,
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
# no-new-privileges only changes execve() (Linux,
# Documentation/userspace-api/no_new_privs.rst): root may still call
# setuid()/setgid() with these capabilities.
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
+6 -1
View File
@@ -8,7 +8,9 @@
# takes over 80/443 and proxies to the panel over the compose network # takes over 80/443 and proxies to the panel over the compose network
# instead). Merge it with the base file — run this from the deploy/ directory # instead). Merge it with the base file — run this from the deploy/ directory
# (Compose resolves every relative path in both files against the directory of # (Compose resolves every relative path in both files against the directory of
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...): # the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# #
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d # docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
# #
@@ -54,4 +56,7 @@ services:
- ./nginx/certbot-www:/var/www/certbot - ./nginx/certbot-www:/var/www/certbot
# Renewal twice a day is certbot's own recommended cadence; it no-ops # Renewal twice a day is certbot's own recommended cadence; it no-ops
# until a certificate is within its renewal window. # until a certificate is within its renewal window.
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
# certificate near expiry.)
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done' entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
+2
View File
@@ -17,6 +17,8 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
http2 on; http2 on;
server_name mail.example.com; server_name mail.example.com;
+7 -2
View File
@@ -6,7 +6,9 @@
# #
# Merge with the base file — run this from the deploy/ directory (Compose # Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the # resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...): # FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# #
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d # docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
@@ -25,7 +27,10 @@ services:
# Traefik only proxies the panel; it never sees the mail ports. # Traefik only proxies the panel; it never sees the mail ports.
# These live on the selfpost service (not traefik's) because the # These live on the selfpost service (not traefik's) because the
# docker provider reads routing labels off the container being routed # docker provider reads routing labels off the container being routed
# to, not off traefik itself. # to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
# docker/, "Routing Configuration": "Traefik uses container labels";
# with exposedByDefault=false only containers labelled
# traefik.enable=true.)
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`) - traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
- traefik.http.routers.selfpost.entrypoints=websecure - traefik.http.routers.selfpost.entrypoints=websecure
+7
View File
@@ -6,6 +6,13 @@
# by SelfPost/Postfix. # by SelfPost/Postfix.
# #
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir> # Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
#
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
# docker-compose.traefik.yml names.
set -eu set -eu
ACME_JSON="${1:?path to acme.json}" ACME_JSON="${1:?path to acme.json}"
+8 -2
View File
@@ -88,7 +88,9 @@ type Params struct {
// copy taken then can hold a message twice or not at all, so what it would // copy taken then can hold a message twice or not at all, so what it would
// bring back is not the queue. Nor could the panel read it - Postfix keeps // bring back is not the queue. Nor could the panel read it - Postfix keeps
// every queue file readable by its own user alone, which is why the panel's // every queue file readable by its own user alone, which is why the panel's
// backup failed outright from 1.3.0 to 2.0.0. What is not carried over is mail // backup failed outright from 1.3.0 to 2.0.0 (date not recorded; Postfix
// 3.7.11, conf/postfix-files: active, deferred, incoming and the other queue
// directories are $mail_owner 700). What is not carried over is mail
// still waiting for delivery; a start without a queue directory builds an // still waiting for delivery; a start without a queue directory builds an
// empty one. Whoever needs the queue stops the instance and archives the // empty one. Whoever needs the queue stops the instance and archives the
// project directory (guide § Taking a full backup). // project directory (guide § Taking a full backup).
@@ -380,7 +382,11 @@ func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string)
// snapshotDB produces a consistent copy of the SQLite database at dbPath using // snapshotDB produces a consistent copy of the SQLite database at dbPath using
// VACUUM INTO, so the backup captures a coherent point-in-time image even while // VACUUM INTO, so the backup captures a coherent point-in-time image even while
// the panel is writing to the live file under WAL. It returns the snapshot path // the panel is writing to the live file under WAL. It returns the snapshot path
// and a cleanup function the caller must defer. // and a cleanup function the caller must defer. (sqlite.org/lang_vacuum.html,
// "VACUUM INTO": "transactional in the sense that the generated output
// database is a consistent snapshot"; modernc.org/sqlite v1.60.1, sqlite.go,
// applyQueryParams(): each _pragma is run as "pragma <value>" on connect,
// busy_timeout first.)
func snapshotDB(dbPath string) (path string, cleanup func(), err error) { func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
dir, err := os.MkdirTemp("", "selfpost-backup-") dir, err := os.MkdirTemp("", "selfpost-backup-")
if err != nil { if err != nil {
+2 -1
View File
@@ -49,7 +49,8 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal") writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm") writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
// Postfix's delivery log and its rotated files: diagnostic output, not // Postfix's delivery log and its rotated files: diagnostic output, not
// state, and the bulkiest thing under /data. // state, and the bulkiest thing under /data. The line is composed by hand;
// only its absence from the archive matters.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)") writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older") writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n") writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
+4
View File
@@ -154,6 +154,10 @@ func extractAggregatePayload(raw []byte) ([]byte, error) {
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body) return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
} }
// looksLikeAggregate takes a part by its file name or Content-Type. RFC 7489
// § 7.2.1.1: the report is "application/gzip" if compressed and "text/xml"
// otherwise, its name ending ".xml" or ".xml.gz"; zip is outside the RFC and
// is what some reporters send (see decompress).
func looksLikeAggregate(name string) bool { func looksLikeAggregate(name string) bool {
name = strings.ToLower(name) name = strings.ToLower(name)
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip") return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
+10 -1
View File
@@ -40,6 +40,11 @@ type ParsedRecord struct {
HeaderFrom string HeaderFrom string
} }
// feedbackXML follows RFC 7489, Appendix C. The schema's targetNamespace is
// "http://dmarc.org/dmarc-xml/0.1"; a tag without a namespace matches the
// local name in any namespace (Go, src/encoding/xml/read.go: the namespace is
// compared only when the tag names one), so a report that declares one is
// read the same.
type feedbackXML struct { type feedbackXML struct {
XMLName xml.Name `xml:"feedback"` XMLName xml.Name `xml:"feedback"`
Metadata struct { Metadata struct {
@@ -160,6 +165,9 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
return out, nil return out, nil
} }
// dmarcRecordPasses: RFC 7489, Appendix C, PolicyEvaluatedType - dkim and spf
// are "The DMARC-aligned authentication result"; § 4.2: DMARC passes when one
// supported mechanism passes aligned.
func dmarcRecordPasses(r ParsedRecord) bool { func dmarcRecordPasses(r ParsedRecord) bool {
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass") return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
} }
@@ -170,7 +178,8 @@ const maxAggregateXML = 8 << 20
// decompress unwraps the container the reporter used. Both are seen in the // decompress unwraps the container the reporter used. Both are seen in the
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo // wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
// endpoints) ship it as a single-entry zip. // endpoints) ship it as a single-entry zip. (A sighting: where and when not
// recorded. RFC 7489 § 7.2.1.1 names gzip only.)
func decompress(raw []byte) ([]byte, error) { func decompress(raw []byte) ([]byte, error) {
switch { switch {
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b: case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
+3
View File
@@ -11,6 +11,9 @@ import (
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
// sampleXML and the messages in this file are composed by hand, in the shape
// RFC 7489 gives (Appendix C, the schema; § 7.2.1.1, application/gzip and the
// .xml.gz name). The org name is any reporter's; no report was copied.
const sampleXML = `<?xml version="1.0"?> const sampleXML = `<?xml version="1.0"?>
<feedback> <feedback>
<report_metadata> <report_metadata>
+17 -1
View File
@@ -33,6 +33,12 @@ type ListStore interface {
// line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads // line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads
// a file when it changes. Which list wins when both match is the filter's // a file when it changes. Which list wins when both match is the filter's
// configuration: deny. // configuration: deny.
//
// rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
// ev_stat (inotify, or a stat poll every map_watch_interval x
// map_file_watch_multiplier = 30 s, conf/options.inc), so a change takes
// effect within that, not at once; docs.rspamd.com/configuration/maps asks
// for the atomic rename atomicfile.Write does.
type Lists struct { type Lists struct {
store ListStore store ListStore
dir string dir string
@@ -157,7 +163,10 @@ func (l *Lists) sync() error {
} }
// Values were validated on the way in; this is the writer's own guard // Values were validated on the way in; this is the writer's own guard
// against a row that got into the table some other way. In a map file a // against a row that got into the table some other way. In a map file a
// newline ends the entry, whitespace starts its value and # a comment. // newline ends the entry, whitespace starts its value and # a comment
// (rspamd 3.11.1, src/libserver/maps/map_helpers.c,
// rspamd_parse_kv_list()). An IP map takes bare v4 and v6 addresses and
// CIDR networks (docs.rspamd.com/configuration/maps, IP maps).
if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil { if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil {
return err return err
} }
@@ -165,6 +174,8 @@ func (l *Lists) sync() error {
} }
// The filter runs under another user in another container: the directory // The filter runs under another user in another container: the directory
// and the files are readable by anyone. They hold addresses, not secrets. // and the files are readable by anyone. They hold addresses, not secrets.
// (The rspamd image runs as uid 11333: rspamd-docker v3.11.1+3,
// Dockerfile, USER 11333:11333.)
if err := os.MkdirAll(l.dir, 0o755); err != nil { if err := os.MkdirAll(l.dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", l.dir, err) return fmt.Errorf("create %s: %w", l.dir, err)
} }
@@ -297,6 +308,11 @@ func normalizeNote(raw string) (string, error) {
// refuses with a message, which the log tailer files as the reply of a // refuses with a message, which the log tailer files as the reply of a
// rejected row. An allow rule adds a symbol named after the kind of entry, // rejected row. An allow rule adds a symbol named after the kind of entry,
// which the journal milter keeps in the verdict of an accepted row. // which the journal milter keeps in the verdict of an accepted row.
//
// rspamd 3.11.1: the deny message reaches Postfix as the milter reply "554
// 5.7.1 <message>" (src/libserver/milter.c, rspamd_milter_send_task_results());
// src/plugins/lua/milter_headers.lua, routine x-spamd-result, writes each
// symbol as "%s(%.2f)[%s]" - name, score, options.
const ( const (
denyReplyMark = "this server's deny list" denyReplyMark = "this server's deny list"
allowMark = "SELFPOST_ALLOW_" allowMark = "SELFPOST_ALLOW_"
+3
View File
@@ -314,6 +314,9 @@ func TestListsUsage(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
// The rows below are composed by hand. Their replies and verdicts follow
// rspamd 3.11.1's forms: "Spam message rejected" is the proxy's default
// reject_message (conf/worker-proxy.inc).
// hour 1: the address and its domain are both on the deny list, and both fit. // hour 1: the address and its domain are both on the deny list, and both fit.
row("Spammer@Evil.Example", "192.0.2.9", store.SpamDecisionReject, store.SpamEngineRspamd, denied) row("Spammer@Evil.Example", "192.0.2.9", store.SpamDecisionReject, store.SpamEngineRspamd, denied)
// hour 2: another address of the domain. // hour 2: another address of the domain.
+3
View File
@@ -13,6 +13,9 @@
# replace the base file's list rather than concatenate with it (the default # replace the base file's list rather than concatenate with it (the default
# merge behaviour for sequences) — otherwise the real 465/587/8080 would stay # merge behaviour for sequences) — otherwise the real 465/587/8080 would stay
# published here too and collide with a prod container on the same host. # published here too and collide with a prod container on the same host.
# (docs.docker.com/reference/compose-file/merge/, "!override": "fully replace
# an attribute, bypassing the standard merge rules". The page names no
# minimum Compose version; the stand's own Compose is what each run uses.)
services: services:
selfpost: selfpost:
+2
View File
@@ -55,6 +55,8 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
// The rotation ends with `postfix reload`, which fails while Postfix is // The rotation ends with `postfix reload`, which fails while Postfix is
// still starting - and this step follows one that restarts the container. // still starting - and this step follows one that restarts the container.
// (Postfix 3.7.11, conf/postfix-script: "reload" and "status" both test
// "master -t" and exit 1 while master does not hold its pid lock.)
if err := waitFor("Postfix to be running", 30*time.Second, 500*time.Millisecond, func() (bool, error) { if err := waitFor("Postfix to be running", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
if _, err := h.execIn("selfpost", "postfix", "status"); err != nil { if _, err := h.execIn("selfpost", "postfix", "status"); err != nil {
return false, err return false, err
+5 -2
View File
@@ -48,7 +48,9 @@ func writeZone(stageDir string, extra []txtRecord) error {
fmt.Fprintf(&b, "ns IN A 10.77.0.10\n") fmt.Fprintf(&b, "ns IN A 10.77.0.10\n")
// The sink-MX: Postfix's outbound delivery for the recipient domain used by // The sink-MX: Postfix's outbound delivery for the recipient domain used by
// every positive-path send resolves here via the domain's implicit MX // every positive-path send resolves here via the domain's implicit MX
// fallback to its own A record (RFC 5321) — no explicit MX record needed. // fallback to its own A record (RFC 5321 § 5.1: with no MX records the
// domain is treated as having an implicit MX of itself) — no explicit MX
// record needed.
fmt.Fprintf(&b, "sink IN A 10.77.0.11\n") fmt.Fprintf(&b, "sink IN A 10.77.0.11\n")
for _, r := range extra { for _, r := range extra {
fmt.Fprintf(&b, "%s IN TXT %s\n", r.name, chunkTXT(r.value)) fmt.Fprintf(&b, "%s IN TXT %s\n", r.name, chunkTXT(r.value))
@@ -57,7 +59,8 @@ func writeZone(stageDir string, extra []txtRecord) error {
} }
// chunkTXT splits a TXT value into <255-byte quoted strings (the DNS // chunkTXT splits a TXT value into <255-byte quoted strings (the DNS
// <character-string> limit), which resolvers concatenate back into one value. // <character-string> limit, RFC 1035 § 3.3), which a DKIM verifier
// concatenates back into one value (RFC 6376 § 3.6.2.2).
// A DKIM RSA-2048 public key's base64 comfortably exceeds 255 bytes on its // A DKIM RSA-2048 public key's base64 comfortably exceeds 255 bytes on its
// own, so this is required, not defensive. // own, so this is required, not defensive.
func chunkTXT(value string) string { func chunkTXT(value string) string {
+2 -1
View File
@@ -17,7 +17,8 @@ func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
// Entrypoint also chmod 755 /data; mirror that here so a pre-fix image // Entrypoint also chmod 755 /data; mirror that here so a pre-fix image
// still gets a traversable bind mount under Go's 0700 TempDir. // still gets a traversable bind mount under Go's 0700 TempDir (testing.TempDir
// uses os.MkdirTemp, src/os/tempfile.go: "created with mode 0o700").
if err := os.Chmod(dataDir, 0o755); err != nil { if err := os.Chmod(dataDir, 0o755); err != nil {
t.Fatalf("chmod data dir: %v", err) t.Fatalf("chmod data dir: %v", err)
} }
+2
View File
@@ -69,6 +69,8 @@ func verifyDKIM(raw []byte, domain string) error {
// observed for a just-made connection (needed to configure a level-2 rate // observed for a just-made connection (needed to configure a level-2 rate
// limit's allowed-IPs list — the address a host-published port is seen as // limit's allowed-IPs list — the address a host-published port is seen as
// inside the container depends on Docker's NAT and isn't worth hard-coding). // inside the container depends on Docker's NAT and isn't worth hard-coding).
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_service(): msg_info("connect from
// %s", namaddr), namaddr being "name[addr]".
var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`) var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`)
// lastSMTPClientIP reads mail.log inside the selfpost container and returns // lastSMTPClientIP reads mail.log inside the selfpost container and returns
+16 -4
View File
@@ -47,7 +47,9 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
authLogin: login, authPassword: password, authLogin: login, authPassword: password,
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected", from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
}) })
// The milter answers tempfail at MAIL FROM, which Postfix words as below. // The milter answers tempfail at MAIL FROM, which Postfix words as below
// (Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_TEMPFAIL:
// "451 4.7.1 Service unavailable - try again later").
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil { if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
t.Fatalf("the second message over the level-2 limit: %v", err) t.Fatalf("the second message over the level-2 limit: %v", err)
} }
@@ -58,7 +60,8 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
// introduce the client again, so a second MAIL FROM on the same connection // introduce the client again, so a second MAIL FROM on the same connection
// used to arrive with no client address and was let through (found by the // used to arrive with no client address and was let through (found by the
// technical review of 2026-10-12; the same hole let an application past its // technical review of 2026-10-12; the same hole let an application past its
// client-address list). // client-address list). Postfix's half: 3.7.11, src/smtpd/smtpd.c,
// smtpd_proto(), calls milter_conn_event() once per connection.
errs := mailTwiceOnOneConnection(login, password, from) errs := mailTwiceOnOneConnection(login, password, from)
for i, err := range errs { for i, err := range errs {
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil { if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
@@ -91,7 +94,10 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
}) })
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes // Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
// (the default): the mismatch is detected at MAIL FROM but the reject is // (the default): the mismatch is detected at MAIL FROM but the reject is
// only sent back at RCPT TO. // only sent back at RCPT TO. (Postfix 3.7.11, src/global/mail_params.h,
// DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected:
// not owned by user".)
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil { if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
t.Fatalf("a sender the application does not own: %v", err) t.Fatalf("a sender the application does not own: %v", err)
} }
@@ -103,6 +109,10 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail. // testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail.
// The sender address used here belongs to no registered domain, so the // The sender address used here belongs to no registered domain, so the
// rejection is unambiguously about the missing AUTH and not sender ownership. // rejection is unambiguously about the missing AUTH and not sender ownership.
// The reply: smtps's client restrictions end in "reject" (build/
// postfix-config.sh), Postfix 3.7.11, src/smtpd/smtpd_check.c,
// generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied",
// delayed to RCPT by smtpd_delay_reject.
func testNoAuthRejected(t *testing.T, sc *scenario) { func testNoAuthRejected(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{ res := attemptSend(sendAttempt{
from: "anyone@unregistered.e2e.test", to: recipient, from: "anyone@unregistered.e2e.test", to: recipient,
@@ -181,7 +191,9 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
// when the connection was reset because Postfix was still starting - and, // when the connection was reset because Postfix was still starting - and,
// held to anvil's reply, it turned out the first message had always been // held to anvil's reply, it turned out the first message had always been
// refused for another reason: the new login was not yet in the sender map an // refused for another reason: the new login was not yet in the sender map an
// smtpd from before the panel's reload held (waitForSenderMap). // smtpd from before the panel's reload held (waitForSenderMap). (An incident
// on the stand, date not recorded.) Anvil's reply: Postfix 3.7.11,
// src/smtpd/smtpd.c, mail_cmd(): "450 4.7.1 Error: too much mail from %s".
func testLevel1RateLimit(t *testing.T, sc *scenario) { func testLevel1RateLimit(t *testing.T, sc *scenario) {
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "") login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
if err != nil { if err != nil {
+2 -1
View File
@@ -59,7 +59,8 @@ func deliverPort25(from, to, message string) sendResult {
// waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect // waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect
// is not enough: Docker's proxy accepts on the published port whether or not // is not enough: Docker's proxy accepts on the published port whether or not
// anything listens behind it. // anything listens behind it (seen on the stand, date not recorded - see
// waitForSMTPSReady).
func waitForPort25Ready(timeout time.Duration) error { func waitForPort25Ready(timeout time.Duration) error {
return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) { return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second) conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second)
+5 -2
View File
@@ -33,7 +33,8 @@ type port25Case struct {
// ingestService is the dmarc-ingest service as Postfix must read it back. // ingestService is the dmarc-ingest service as Postfix must read it back.
// flags=, user= and argv= are arguments of pipe(8); set as -o parameters they // flags=, user= and argv= are arguments of pipe(8); set as -o parameters they
// are dropped, and a bare "pipe" dies on every report with "fatal: missing // are dropped, and a bare "pipe" dies on every report with "fatal: missing
// user= command-line attribute" - the state of 1.7.0 through 1.9.5. // user= command-line attribute" - the state of 1.7.0 through 1.9.5 (date not
// recorded; Postfix 3.7.11, src/pipe/pipe.c, get_service_attr()).
const ingestService = "dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}" const ingestService = "dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}"
var port25Cases = []port25Case{ var port25Cases = []port25Case{
@@ -145,7 +146,9 @@ func checkPort25ConfigCombinations(s *stack) error {
// a service line on whitespace, so a value with a space in it is // a service line on whitespace, so a value with a space in it is
// cut at the space and the rest becomes a stray smtpd argument // cut at the space and the rest becomes a stray smtpd argument
// ("fatal: unexpected command-line argument", port 25 dead while // ("fatal: unexpected command-line argument", port 25 dead while
// `postfix check` stays silent). Ask Postfix itself. // `postfix check` stays silent). Ask Postfix itself. (Seen in 1.9.1,
// date not recorded; Postfix 3.7.11, man/man5/master.5, "-o
// name=value (short form)", NOTE 2.)
seen, err := s.execIn("selfpost", "postconf", "-Ph", "smtp/inet/"+c.param) seen, err := s.execIn("selfpost", "postconf", "-Ph", "smtp/inet/"+c.param)
if err != nil { if err != nil {
return fmt.Errorf("%s: postconf -P smtp/inet/%s: %v\n%s", tc.name, c.param, err, seen) return fmt.Errorf("%s: postconf -P smtp/inet/%s: %v\n%s", tc.name, c.param, err, seen)
+8 -1
View File
@@ -58,6 +58,7 @@ func testEnablePort25Features(t *testing.T, sc *scenario) {
const reportReporter = "reporter.e2e.test" const reportReporter = "reporter.e2e.test"
// aggregateXML is a DMARC aggregate report on domain, as a receiver sends it. // aggregateXML is a DMARC aggregate report on domain, as a receiver sends it.
// Composed by hand in the shape of RFC 7489, Appendix C; no report was copied.
func aggregateXML(domain, reportID string) string { func aggregateXML(domain, reportID string) string {
return `<?xml version="1.0"?> return `<?xml version="1.0"?>
<feedback> <feedback>
@@ -88,7 +89,9 @@ func aggregateXML(domain, reportID string) string {
} }
// reportMessage is the mail a receiver sends a report in: one gzip attachment, // reportMessage is the mail a receiver sends a report in: one gzip attachment,
// base64 in 76-column lines. // base64 in 76-column lines. Composed by hand: RFC 7489 § 7.2.1.1
// (application/gzip, a .xml.gz name), RFC 2045 § 6.8 (base64 lines of at most
// 76 characters).
func reportMessage(t *testing.T, to, xml string) string { func reportMessage(t *testing.T, to, xml string) string {
t.Helper() t.Helper()
var gz bytes.Buffer var gz bytes.Buffer
@@ -344,6 +347,10 @@ func testInboundRelay(t *testing.T, sc *scenario) {
t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n")) t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n"))
} }
// Postfix 3.7.11, src/smtpd/smtpd_check.c: "User unknown in relay recipient
// table" (check_rcpt_maps(); unknown_relay_recipient_reject_code,
// DEF_RELAY_RCPT_CODE 550) and "Relay access denied"
// (reject_unauth_destination(); DEF_RELAY_CODE 554).
for _, c := range []struct { for _, c := range []struct {
to string to string
code int code int
+4 -1
View File
@@ -65,7 +65,10 @@ func parseSupervisorStatus(out string) map[string]string {
// checkInboundRelayOff asserts the default image does not accept mail on // checkInboundRelayOff asserts the default image does not accept mail on
// port 25: smtp/inet is absent from master.cf (Debian's stock listener is // port 25: smtp/inet is absent from master.cf (Debian's stock listener is
// removed when INBOUND_RELAY_ENABLE is not true). // removed when INBOUND_RELAY_ENABLE is not true). For an absent service
// postconf -M prints nothing on stdout and "warning: unmatched request" on
// stderr (Postfix 3.7.11, src/postconf/postconf_master.c,
// pcf_show_master_entries()).
func checkInboundRelayOff(s *stack) error { func checkInboundRelayOff(s *stack) error {
out, err := s.execIn("selfpost", "postconf", "-M", "smtp/inet") out, err := s.execIn("selfpost", "postconf", "-M", "smtp/inet")
combined := out combined := out
+9 -4
View File
@@ -52,7 +52,10 @@ func testSenderCheck(t *testing.T, sc *scenario) {
{"the listed address", list, "billing@" + senderDomain, true}, {"the listed address", list, "billing@" + senderDomain, true},
{"the listed address with an extension, in envelope and header", who{listLogin, listPassword, "billing+march@" + senderDomain}, "billing+march@" + senderDomain, true}, {"the listed address with an extension, in envelope and header", who{listLogin, listPassword, "billing+march@" + senderDomain}, "billing+march@" + senderDomain, true},
// What a mail library does with a long display name: the line is // What a mail library does with a long display name: the line is
// folded, and Postfix hands the milter both of its lines. // folded, and Postfix hands the milter both of its lines (Postfix
// 3.7.11, src/milter/milter8.c, milter8_header(): the value is the
// whole header as cleanup's MIME state machine collected it,
// continuation line included).
{"a long name, folded onto a second line", wildcard, "\"The Shop, its orders and its returns desk\"\r\n <orders@" + senderDomain + ">", true}, {"a long name, folded onto a second line", wildcard, "\"The Shop, its orders and its returns desk\"\r\n <orders@" + senderDomain + ">", true},
{"another registered domain", wildcard, "director@" + otherDomain, false}, {"another registered domain", wildcard, "director@" + otherDomain, false},
@@ -113,7 +116,8 @@ func testSenderCheck(t *testing.T, sc *scenario) {
// testPanelDownDefersMail: the sender check is the panel's and Postfix // testPanelDownDefersMail: the sender check is the panel's and Postfix
// attaches it strictly, so with the panel stopped mail is deferred - a // attaches it strictly, so with the panel stopped mail is deferred - a
// temporary error, nothing accepted unchecked - and the container stays up // temporary error, nothing accepted unchecked - and the container stays up
// (crashexit fires on PROCESS_STATE_FATAL only; a supervisor stop is STOPPED). // (crashexit fires on PROCESS_STATE_FATAL only; a supervisor stop is STOPPED -
// supervisor 4.2.5, supervisor/process.py, Subprocess.finish()).
// Once the panel is back it has counted both what that cost: the time it did // Once the panel is back it has counted both what that cost: the time it did
// not answer under a running Postfix, and the message that was deferred. // not answer under a running Postfix, and the message that was deferred.
// //
@@ -221,8 +225,9 @@ func senderCheckDeferredToday(sc *scenario) (int, error) {
// message is deferred, and the count on Health sees it. Past the DATA // message is deferred, and the count on Health sees it. Past the DATA
// command Postfix's cleanup, not smtpd, talks to the milters, so the warning // command Postfix's cleanup, not smtpd, talks to the milters, so the warning
// that names the socket is cleanup's (audit 2026-10-12, M13: the counter read // that names the socket is cleanup's (audit 2026-10-12, M13: the counter read
// smtpd's lines only). The panel is frozen with SIGSTOP once DATA is // smtpd's lines only; Postfix 3.7.11, src/smtpd/smtpd.c, milter_send() to
// answered, and thawed after the client has its reply. // cleanup, src/cleanup/cleanup_milter.c). The panel is frozen with SIGSTOP
// once DATA is answered, and thawed after the client has its reply.
func testPanelHungMidMessage(t *testing.T, sc *scenario) { func testPanelHungMidMessage(t *testing.T, sc *scenario) {
before, err := senderCheckDeferredToday(sc) before, err := senderCheckDeferredToday(sc)
if err != nil { if err != nil {
+4 -1
View File
@@ -21,6 +21,9 @@ WORKDIR /mail
# smtp-sink refuses to run as root without an explicit -u to switch to after # smtp-sink refuses to run as root without an explicit -u to switch to after
# binding — "-u root" satisfies that check while staying root throughout, # binding — "-u root" satisfies that check while staying root throughout,
# which is fine here: throwaway container, isolated e2e network, never # which is fine here: throwaway container, isolated e2e network, never
# exposed. # exposed. (Postfix 3.7.11, src/smtpstone/smtp-sink.c: -d "Dump each mail
# transaction to a single-message file" named by the template plus "a
# pseudo-random hexadecimal number"; main(): "-u option is required if running
# as root".)
ENTRYPOINT ["/usr/sbin/smtp-sink"] ENTRYPOINT ["/usr/sbin/smtp-sink"]
CMD ["-u", "root", "-d", "/mail/%Y%m%d%H%M%S.", "0.0.0.0:25", "100"] CMD ["-u", "root", "-d", "/mail/%Y%m%d%H%M%S.", "0.0.0.0:25", "100"]
+8 -3
View File
@@ -161,7 +161,8 @@ func attemptSend(a sendAttempt) sendResult {
// waited for the panel could still race a not-yet-listening smtpd. A bare TCP // waited for the panel could still race a not-yet-listening smtpd. A bare TCP
// connect is not enough: Docker's port proxy accepts on the host side before // connect is not enough: Docker's port proxy accepts on the host side before
// anything listens in the container and then resets the connection, which is // anything listens in the container and then resets the connection, which is
// how the level-1 rate-limit case once took a reset for its limit. // how the level-1 rate-limit case once took a reset for its limit (seen on the
// stand, date not recorded; Docker's documentation does not describe it).
func waitForSMTPSReady(timeout time.Duration) error { func waitForSMTPSReady(timeout time.Duration) error {
return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) { return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{
@@ -232,8 +233,12 @@ func mailTwiceOnOneConnection(login, password, from string) [2]error {
// the panel has just created is written into the sender map and Postfix is // the panel has just created is written into the sender map and Postfix is
// reloaded, and an smtpd that was idle from before the reload can still take // reloaded, and an smtpd that was idle from before the reload can still take
// a connection in the second that follows and answer 553 "not owned by user" // a connection in the second that follows and answer 553 "not owned by user"
// (seen on the stand; two seconds after the save it no longer happened, at 0, // (seen on the stand, date not recorded; two seconds after the save it no
// 10, 40 or 110 s of waiting). It goes as far as RCPT and then RSET, so no // longer happened, at 0, 10, 40 or 110 s of waiting). Postfix 3.7.11: an smtpd
// reads the texthash map once, when it starts (html/DATABASE_README.html,
// "texthash"), and one from before the reload exits only when it next sees
// master's status pipe closed (src/master/multi_server.c,
// multi_server_abort()). It goes as far as RCPT and then RSET, so no
// message is sent and no row is written. Only that 553 is waited out, for 15 // message is sent and no row is written. Only that 553 is waited out, for 15
// s at most; any other answer fails at once. // s at most; any other answer fails at once.
func waitForSenderMap(login, password, from string) error { func waitForSenderMap(login, password, from string) error {