base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
test / test (push) Canceled after 0s

The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.

What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
  (smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
  port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
  deprecated-parameter warning on stderr made one value too many and the
  panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
  the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
  07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
  explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
  opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
  cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
  trixie a package took 998 and the build failed (owner's decision:
  a conflict-free id, the distribution's own ids untouched). The
  entrypoint hands /data back by name when the user or the group differs.
  Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 19:31:35 +03:00
co-authored by Claude Opus 5.5
parent 68ec8b274c
commit 1c6e04b7d3
80 changed files with 970 additions and 454 deletions
+75
View File
@@ -5,6 +5,81 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Security
- **The image runs Debian trixie instead of bookworm** (base-trixie).
Bookworm left regular security support on 2026-07-12, and its Postfix
3.7.11 lacks upstream's 2026 security fixes, which carry no CVE ids and
so will not reach bookworm LTS. Trixie brings Postfix 3.10.13 with the
August batch (among them a BDAT memory exhaustion, a crash by DATA after
a BDAT error, and a policy and milter bypass after an end-of-data
reject), Debian's security support until 2028, OpenSSL 3.5 and libmilter
8.18.1. OpenDKIM's code is unchanged. The build stage is
`golang:1.26-trixie`.
### Changed
- **Every listener holds clients to two SMTP rules,** by Postfix 3.10's
defaults: DATA ends only at `<CR><LF>.<CR><LF>`
(`smtpd_forbid_bare_newline=normalize`, now on 465 and 587 too), and a
client that sends commands ahead of the replies where RFC 2920 does not
allow it gets `554 5.5.0 Error: SMTP protocol synchronization` and is
disconnected (`smtpd_forbid_unauth_pipelining=yes`). An application whose
mail library breaks either rule stops sending; the guide says so under
Ports.
- **The code's citations of other programs name the trixie sources:**
every place that relies on Postfix, Cyrus SASL, supervisor, logrotate or
libmilter was re-read against the trixie package and its comment
updated; the cited-package table in development.md and the e2e check of
it hold the trixie versions.
- **Postfix's own start, stop and reload messages reach `docker logs`**
as well as mail.log: since 3.8 postfix(1) writes to stderr even when it
is not a terminal.
- **The SASL configuration's directory is set by the image:**
bookworm's Postfix found `/etc/postfix/sasl/smtpd.conf` through a Debian
patch that trixie dropped; trixie's package writes
`cyrus_sasl_config_path=/etc/postfix/sasl` into its main.cf instead. The
image now sets it itself, so the login on 465 and 587 does not hang on
the package's file.
- **The `Received:` header no longer shows where an application runs.**
On 465 and 587 Postfix now writes the short form, `Received: by <host>
(Postfix) id <queue id> for <recipient>; <date>`, without the
application's address, host name, HELO name, TLS details or login
(`smtpd_hide_client_session=yes`, new in Postfix 3.10; the owner's
decision). The queue id still leads to the message in *Outbound › Log*
and to the client address in mail.log. Port 25 keeps the full header, as
RFC 5321 requires; e2e checks both.
- **The container's `selfpost` group is 10998, no longer 998.** 998 lies
in the range the distribution hands out to system groups (100-999): in
the trixie image a package took it and the build failed, and on a host it
is often a system group of the host's own, which could then read the key
as well (on the test server's host it is `systemd-network`). 10998 is
outside that range. **On upgrade, give the private key in `certs/` to
the new group on the host** - `chgrp 10998` where the guide said
`chgrp 998`, in a certbot deploy hook too - or a full backup is refused
until then; Postfix reads its own copy and is not affected. The other
accounts keep the numbers their packages give them, and the entrypoint
hands everything under `/data` back to them by name on every start.
- `opendkim-tools` is no longer installed: nothing in the image ran it.
`openssl` is installed by name, for the certificate reload.
### Fixed
- **The retry policy shown in the panel could fall back to Postfix's
compiled-in defaults:** the panel read `postconf`'s warnings together
with its values, and since Postfix 3.9 `postconf` warns about deprecated
parameters. It reads the values alone now and logs the warning.
- **logrotate 3.22 exits 1 when it skips a config it does not trust;**
3.21 exited 0. The rotation loop now refuses to run on that output
whatever the exit code, instead of retrying for ever.
- **The log tailer reads Postfix 3.10's new line shapes:** refusal lines
that carry `sasl_method=`, `sasl_username=` and `sasl_sender=`, and
cleanup's `removed (canceled)` for a transaction that did not complete.
- Debian's chroot sync no longer copies `/etc/resolv.conf` and the like
into the queue under `/data` at every start (`SYNC_CHROOT=` in
`/etc/default/postfix`; nothing runs chrooted). A Postfix-created
mail.log is `0640`, as the entrypoint and logrotate create it.
## [2.6.1] - 2026-10-15 ## [2.6.1] - 2026-10-15
The inbound relay's port 25 ends a message only at the standard The inbound relay's port 25 ends a message only at the standard
+62 -26
View File
@@ -6,7 +6,7 @@
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
# ---- build stage ------------------------------------------------------------- # ---- build stage -------------------------------------------------------------
FROM golang:1.26-bookworm AS build FROM golang:1.26-trixie AS build
WORKDIR /src WORKDIR /src
@@ -30,61 +30,95 @@ RUN go vet ./... \
-o /out/selfpost-backup ./cmd/selfpost-backup -o /out/selfpost-backup ./cmd/selfpost-backup
# ---- runtime stage ----------------------------------------------------------- # ---- runtime stage -----------------------------------------------------------
FROM debian:bookworm-slim AS runtime FROM debian:trixie-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive 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 3.10.13-0+deb13u1, debian/templates:14 and :50:
# postfix/mailname are the questions the install asks.) # postfix/main_mailer_type and postfix/mailname are the questions the install
# What else arrives through Depends, and is used here: openssl, by # asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/ # configuration".)
# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor # openssl is named because postfix-cert-reload.sh runs it, not left to arrive
# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings # with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb # debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2 # saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util # debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist). # (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
# the panel runs (internal/app/sasl.go); that db-util ships that path on
# trixie is not in a source read here - the image build and the e2e sasldb
# test say whether it does.
# opendkim-tools is not installed: nothing in the image runs its programs
# (DKIM keys are made in Go, internal/domain/dkim.go).
# systemd-standalone-sysusers is named so that cron-daemon-common, which
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
# than with the whole of systemd, the first alternative of its "Depends:
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
# image, 2026-10-15). Nothing here runs systemd.
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 \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
postfix \ postfix \
opendkim \ opendkim \
opendkim-tools \
sasl2-bin \ sasl2-bin \
libsasl2-modules \ libsasl2-modules \
db-util \ db-util \
supervisor \ supervisor \
logrotate \ logrotate \
ca-certificates \ ca-certificates \
openssl \
curl \ curl \
systemd-standalone-sysusers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process. # Debian's postfix runs its chroot sync on every "postfix check" and "postfix
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
# debian-run-configure-instance-from-create-missing.patch); with no service
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
# Unprivileged user for the panel process. Its ids are left to useradd: the
# entrypoint gives /data back to it by name on every start, so a number
# that differs from one image to the next changes nothing.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services: the panel generates # Shared group bridging the unprivileged services: the panel generates
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix # per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# sender map, while OpenDKIM and Postfix (different users) must read them. # sender map, while OpenDKIM and Postfix (different users) must read them.
# Membership in this group — plus setgid dirs under /data (set up in # Membership in this group - plus setgid dirs under /data (set up in
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read # entrypoint.sh) - lets OpenDKIM read the panel-owned keys and lets Postfix
# the sasldb2/sender map. (The supervisor control socket has a group of its # read the sasldb2/sender map. (The supervisor control socket has a group of
# own, `supervisor`, below.) # its own, `supervisor`, below.)
# The id is fixed, and it is the one the group has always had: an operator # The id is fixed, because the host sees it: an operator gives the TLS
# gives the TLS private key on the host to this group by number, so that the # private key on the host to this group by number, so that the panel can put
# panel can put it into a full backup (guide § Taking a full backup). # it into a full backup (guide § Taking a full backup). It lies outside the
# groupadd(8) refuses a --gid already in use, so a package that took 998 would # range the distribution hands out to system groups, 100-999 (adduser 3.152,
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
# read in the image, 2026-10-15), so no package can take it, and far above
# the 1000 a host starts its users' groups at, so a host is unlikely to have
# a group of that number that would read the key too. Until 2.7.0 it was
# 998, inside that range: in the trixie image systemd took 998 for
# systemd-network and the build failed, and on the test server's host 998
# is systemd-network too (seen 2026-10-15).
# groupadd(8) refuses a --gid already in use, so an id taken after all would
# fail this build rather than share the group. # fail this build rather than share the group.
RUN groupadd --system --gid 998 selfpost \ RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \ && usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (and the status page's # The panel reads the mail queue with `postqueue -p` (and the status page's
# queue card). postqueue is setgid postdrop, which normally gives it the group # queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket — but the documented deployment runs # needed to reach Postfix's showq socket - but the documented deployment runs
# with `no-new-privileges`, which disables setgid transitions, so the panel # with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop # would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening # grants the same access without relying on a setgid escalation the hardening
@@ -93,8 +127,10 @@ 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 # (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst: # root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. Linux,
# Documentation/userspace-api/no_new_privs.rst:
# with no_new_privs, execve() no longer changes the gid for a setgid file.) # with no_new_privs, execve() no longer changes the gid for a setgid file.)
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
# The supervisord control socket's group (supervisord.conf, [unix_http_server]): # The supervisord control socket's group (supervisord.conf, [unix_http_server]):
+23 -16
View File
@@ -70,8 +70,13 @@ chmod 755 /data
# under /data; make sure they stay writable without disturbing anything that a # under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service. /data/log is exempt: it is # later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below. # normalised on its own below, as are the trees shared through group selfpost.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} + # A file's owner is a number: an entry whose user or group is not the panel's
# by that number - unpacked by root, or written under another image whose
# useradd gave the panel other ids (bookworm's 999:999, until 2.7.0) - is
# handed over whole.
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
# DKIM key tree. The panel (user `panel`) generates keys and writes # DKIM key tree. The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
@@ -133,7 +138,7 @@ chmod 2750 /data/postfix
# so the directory and its files are readable by anyone: they hold sender # so the directory and its files are readable by anyone: they hold sender
# addresses and networks, no secrets. # addresses and networks, no secrets.
mkdir -p /data/antispam mkdir -p /data/antispam
chown panel:selfpost /data/antispam chown -R panel:selfpost /data/antispam
chmod 0755 /data/antispam chmod 0755 /data/antispam
# Delivery log (architecture.md § Log tailer). postlogd writes it as user # Delivery log (architecture.md § Log tailer). postlogd writes it as user
@@ -141,17 +146,19 @@ chmod 0755 /data/antispam
# lives under /data — not the ephemeral /var/log — so the delivery lines that # lives under /data — not the ephemeral /var/log — so the delivery lines that
# resolve a "queued" send-log row survive a container recreate. # resolve a "queued" send-log row survive a container recreate.
# #
# postlogd creates a missing log itself, but at 0600, which the unprivileged # postlogd creates a missing log itself, by default at 0600, which the
# panel cannot read; so create it here (and re-normalise an existing one, plus # unprivileged panel cannot read (postfix-config.sh now asks it for 0640,
# whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid # maillog_file_permissions); so create it here (and re-normalise an existing
# one, plus whatever logrotate left behind) at 0640 owned postfix:selfpost. The setgid
# 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 # Postfix 3.10.13: src/util/logwriter.c:99, logwriter_open_or_die(), creates the
# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate # file with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent # otherwise (proto/postconf.proto:18500, default 0600; Postfix 3.9 and later).
# directory has insecure permissions" when the directory's group is not root # logrotate 3.22.0, logrotate.c:1406-1408, findNeedRotating(): "skipping ...
# and has write permission. # 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
@@ -199,8 +206,8 @@ ln -sfn /run/syslog/log /dev/log
# The queue tree, on a first start or after a restore, is already built here: # The queue tree, on a first start or after a restore, is already built here:
# postfix-config.sh ends with "postfix check", whose check-fatal runs # postfix-config.sh ends with "postfix check", whose check-fatal runs
# "post-install create-missing" (Postfix 3.7.11, conf/postfix-script; # "post-install create-missing" (Postfix 3.10.13, conf/postfix-script:314;
# conf/postfix-files, "$queue_directory/active:d:$mail_owner:-:700:ucr"). # conf/postfix-files:55, "$queue_directory/active:d:$mail_owner:-:700:ucr").
# A "postfix set-permissions" for a missing active/ stood here and never ran # A "postfix set-permissions" for a missing active/ stood here and never ran
# (assumptions C-P34). # (assumptions C-P34).
chown -R postfix:postfix /data/postfix/queue chown -R postfix:postfix /data/postfix/queue
@@ -214,10 +221,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 # (Postfix 3.10.13, conf/postfix-files:51-68: $queue_directory root 755, maildrop
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue # $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 # directories $mail_owner 700; conf/postfix-script:354-356, "check-warn": "not
# root".) # owned by root".)
chown root /data/postfix/queue /data/postfix/queue/pid chown root /data/postfix/queue /data/postfix/queue/pid
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the # [program:postfix]'s startsecs (build/supervisord.conf): longer than the
+36 -24
View File
@@ -7,25 +7,29 @@
# 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 (date not recorded), and so in the source: Postfix 3.7.11, # live container (date not recorded, Postfix 3.7.11), and so in the source:
# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's # Postfix 3.10.13, src/util/logwriter.c:99, logwriter_open_or_die(), creates it
# umask(077), src/master/master.c — so logrotate must be the one to create it. # with logwriter_perms, 0600 (:86) unless maillog_file_permissions says
# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill # otherwise (Postfix 3.9 and later). build/postfix-config.sh now sets that to
# -HUP master); master_conf.c, master_config() -> master_restart_service() -> # 0640, so a file postlogd makes is readable too; logrotate still creates it,
# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c, # so that the new log is there, with its mode and owner, at once.
# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the # The reload: Postfix 3.10.13, conf/postfix-script:535-566, "logrotate" (mv,
# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()). # then kill -HUP master); master_conf.c:92, master_config() -> :147,
# master_restart_service() -> master_status_cleanup(), on which postlogd exits
# (src/master/dgram_server.c:241, dgram_server_abort(), :640 on
# MASTER_STATUS_FD), and the next postlogd opens the path anew
# (src/postlogd/postlogd.c:236, 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 its state file), so it is safe to invoke this more often than # (tracked in its state file), so it is safe to invoke this more often than
# daily — polling merely bounds how late a legitimate rotation runs. The state # daily — polling merely bounds how late a legitimate rotation runs. The state
# file is beside the log, under /data, not at the default # file is beside the log, under /data, not at the default
# /var/lib/logrotate/status (logrotate 3.21.0-1: debian/rules, # /var/lib/logrotate/status (logrotate 3.22.0-1: debian/rules:11,
# --with-state-file-path) in the container's own filesystem: a recreated # --with-state-file-path) in the container's own filesystem: a recreated
# container started without it, logrotate.c, newState(), records a log it has # container started without it, logrotate.c:456, newState(), records a log it
# no state for as rotated today, and so a host that recreates the container # has no state for as rotated today, and so a host that recreates the container
# daily - an .env change, an image bump - never rotated mail.log at all (audit # daily - an .env change, an image bump - never rotated mail.log at all (audit
# 2026-10-12, M19). (logrotate 3.21.0, logrotate.8.in: "-s, --state # 2026-10-12, M19). (logrotate 3.22.0, logrotate.8.in:74: "-s, --state
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as # statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
# root. # root.
set -eu set -eu
@@ -34,13 +38,18 @@ CONFIG=/etc/logrotate.d/mail
STATE=/data/log/.logrotate.status STATE=/data/log/.logrotate.status
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 goes on without
# ignoring them — fail here so supervisord reports the fault. (logrotate # them — fail here so supervisord reports the fault. (logrotate 3.22.0,
# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ... # config.c:1099, readConfigFile(): as root, mode & 0022 gives (:1160-1165)
# because it is writable by group or others." and returns 0, not an error; a # "Ignoring ... because it is writable by group or others." and returns 1; a
# config not owned by root is ignored the same way. "Potentially dangerous # config not owned by root is ignored the same way (:1168-1173). Since 3.22.0
# mode" is only a warning there, for any mode & 07533 other than 0400 - this # that makes logrotate's exit status 1 (logrotate.c:3278-3279 and :3301;
# loop chooses to treat it as fatal too.) # ChangeLog.md, 3.22.0: "in case a configuration file was skipped due to
# unsafe permissions the exit status after rotattion will be `1` (#508)");
# 3.21.0 returned 0 there and exited 0. So the output is looked at whatever
# the status. "Potentially dangerous mode" is only a warning there
# (:1154-1157), 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}
@@ -61,16 +70,19 @@ if ! logrotate_config_ok; then
fi fi
run_logrotate() { run_logrotate() {
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || { status=0
echo "$out" >&2 out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || status=$?
return 1
}
case "$out" in case "$out" in
*Ignoring*|*Potentially\ dangerous\ mode*) *Ignoring*|*Potentially\ dangerous\ mode*)
echo "$out" >&2 echo "$out" >&2
logrotate_config_fatal echo "logrotate-loop: refusing to run: logrotate did not take $CONFIG as it is (above)" >&2
exit 1
;; ;;
esac esac
if [ "$status" -ne 0 ]; then
echo "$out" >&2
return 1
fi
return 0 return 0
} }
+1 -1
View File
@@ -4,7 +4,7 @@
# 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 # (logrotate 3.22.0, logrotate.8:402: "create mode owner group" makes the new log
# right after the rotation; "delaycompress" leaves the previous log, .1, # right after the rotation; "delaycompress" leaves the previous log, .1,
# uncompressed until the next cycle.) # uncompressed until the next cycle.)
/data/log/mail.log { /data/log/mail.log {
+3 -3
View File
@@ -11,9 +11,9 @@
# 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 # (Postfix 3.10.13: smtpd loads the certificate in pre_jail_init(), once per
# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master, # process, src/smtpd/smtpd.c:6442; "postfix reload" is SIGHUP to master,
# conf/postfix-script, after which the old smtpd processes exit on the closed # conf/postfix-script:277, after which the old smtpd processes exit on the closed
# status pipe, src/master/master_conf.c, master_config() -> # status pipe, src/master/master_conf.c, master_config() ->
# master_restart_service(), and new ones load the files again.) # master_restart_service(), and new ones load the files again.)
# #
+191 -120
View File
@@ -24,18 +24,19 @@ set -eu
# 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 # Postfix 3.10.13, man/man5/master.5:215-226, "-o name=value (short form)":
# whitespace in a short-form value (the long form "-o { name = value }" would # "The "short form" does not support whitespace around the "=" or in
# keep it; this script does not write it). src/postconf/postconf_edit.c, # parameter values" (the long form "-o { name = value }" would keep it; this
# script does not write it). src/postconf/postconf_edit.c:415,
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value". # pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c, # src/smtpd/smtpd_check.c:664, smtpd_check_parse(), and src/milter/milter.c:646,
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace. # milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
# #
# master.5, SYNTAX: a line that starts with whitespace continues a logical # master.5:41-42, SYNTAX: a line that starts with whitespace continues a
# line, so the "-o" line awk inserts below the header belongs to the service. # logical line, so the "-o" line awk inserts below the header belongs to the
# A later postconf -F/-P re-parses a matched entry with its continuation lines # service. A later postconf -F/-P re-parses a matched entry with its
# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping # continuation lines (postconf_edit.c:134, pcf_gobble_cf_line()) and prints
# these options. # 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"
@@ -75,7 +76,7 @@ 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(): # Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:339, xsasl_cyrus_server_create():
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28, # 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 # plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
# looked up under serverFQDN. # looked up under serverFQDN.
@@ -83,17 +84,17 @@ 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. Postfix 3.7.11, # but TLS handshakes on 465 fail until they appear. Postfix 3.10.13,
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be # 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 # made, and smtpd_proto() then drops each wrapper-mode connection (:5846,
# request dropped ... TLS context initialization failed"); every new smtpd # "Wrapper-mode request dropped ... TLS context initialization failed"); every
# process loads the files again in pre_jail_init(). # new smtpd process loads the files again in pre_jail_init().
# #
# Postfix's TLS_README advises a private key owned by root and readable by no # Postfix's TLS_README advises a private key owned by root and readable by no
# other user; Postfix does not enforce it (smtpd loads the key as root in # other user; Postfix does not enforce it (smtpd loads the key as root in
# pre_jail_init(), Postfix 3.7.11 src/smtpd/smtpd.c), but the mount cannot # pre_jail_init(), Postfix 3.10.13 src/smtpd/smtpd.c), but the mount cannot
# give that mode - it is :ro and owned by the host's user, often readable by # give that mode - it is :ro and owned by the host's user, often readable by
# group 998 for the panel's backup. So the pair is copied into a directory of # group 10998 for the panel's backup. So the pair is copied into a directory of
# Postfix's own, the key root:root 0600, before postconf and `postfix check` # Postfix's own, the key root:root 0600, before postconf and `postfix check`
# (assumptions D-arch:154). # (assumptions D-arch:154).
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
@@ -123,11 +124,13 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# first connection, or anvil and with it the rate limit (audit 2026-10-12, # first connection, or anvil and with it the rate limit (audit 2026-10-12,
# L31; review of stage 5) - so the start stops here, saying which and why. # L31; review of stage 5) - so the start stops here, saying which and why.
# Counts and sizes are whole numbers from min up to 2147483647, an int's top # Counts and sizes are whole numbers from min up to 2147483647, an int's top
# (Postfix 3.7.11 reads them with get_mail_conf_int*, src/global/ # (Postfix 3.10.13 reads the counts with get_mail_conf_int*, src/global/
# mail_conf_int.c: an int, held to the parameter's own minimum). A time value # mail_conf_int.c, check_mail_conf_int(): an int, held to the parameter's own minimum;
# message_size_limit is a long, src/global/mail_params.h:2014, and is held to
# the int's top here all the same). A time value
# is a whole number from 1 with an optional unit, s m h d or w, at most # is a whole number from 1 with an optional unit, s m h d or w, at most
# 2147483647 seconds # 2147483647 seconds
# (proto/postconf.proto: "Specify a non-zero time value (an integral value # (proto/postconf.proto:266-268: "Specify a non-zero time value (an integral value
# plus an optional one-letter suffix ...). Time units: s (seconds), m # plus an optional one-letter suffix ...). Time units: s (seconds), m
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as # (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
# 1h, src/global/conv_time.c - so it is refused here). # 1h, src/global/conv_time.c - so it is refused here).
@@ -184,8 +187,8 @@ 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. Postfix 3.7.11, # unprivileged panel can rewrite it and just ask for a reload. Postfix 3.10.13,
# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read # html/DATABASE_README.html:458-464, "texthash": no postmap(1), and the file is read
# once by each process - a reload makes the new processes read it again. # 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
@@ -239,25 +242,37 @@ postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
# 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, # inet_protocols=all in a bridge network without IPv6: Postfix 3.10.13,
# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6) # src/util/inet_proto.c:200, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to # succeeds, and src/smtp/smtp_connect.c:1102-1142, smtp_connect_inet(), goes on
# the next address when a connection fails - an AAAA that cannot be reached # to the next address when a connection fails - an AAAA that cannot be reached
# costs one failed attempt, then IPv4. Postfix # costs one failed attempt, then IPv4. Postfix
# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout"; # 3.10.13, src/global/mail_params.h:4406, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any # src/global/maillog_client.c:232, maillog_client_init(): fatal "does not match any
# prefix", in every Postfix program that logs, postfix(1) itself included. # prefix", in every Postfix program that logs, postfix(1) itself included.
#
# maillog_file_permissions=0640: the log is the panel's to read, through
# group selfpost (the setgid /data/log, build/entrypoint.sh). entrypoint.sh
# and logrotate's "create" make it 0640 themselves; this is for a file that
# Postfix makes - one removed while the container runs - which would
# otherwise be 0600 and unreadable to the panel. (Postfix 3.10.13,
# src/global/mail_params.h:4418, DEF_MAILLOG_FILE_PERMS "0600";
# src/global/mail_params.c:997, set_logwriter_create_perms(); src/util/
# logwriter.c:143-148: "644", "640" or "600", leading zeros ignored, anything
# else a warning and 0600; :99, logwriter_open_or_die() creates with it;
# Postfix 3.9 and later, proto/postconf.proto:18500-18509.)
postconf -e \ postconf -e \
"myhostname=${HOSTNAME_VALUE}" \ "myhostname=${HOSTNAME_VALUE}" \
"maillog_file=${MAIL_LOG_PATH}" \ "maillog_file=${MAIL_LOG_PATH}" \
"maillog_file_prefixes=/var,/dev/stdout,/data" \ "maillog_file_prefixes=/var,/dev/stdout,/data" \
"maillog_file_permissions=0640" \
"mydestination=" \ "mydestination=" \
"relayhost=" \ "relayhost=" \
"inet_interfaces=all" \ "inet_interfaces=all" \
"inet_protocols=all" "inet_protocols=all"
# postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11, # postlogd is mandatory whenever maillog_file is set (Postfix 3.10.13,
# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script, # html/MAILLOG_README.html:42, "Configuring logging to file"; conf/postfix-script:333,
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's # "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.
@@ -266,10 +281,10 @@ postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
# This is an outbound relay: no local delivery, no per-user aliases. Empty # This is an outbound relay: no local delivery, no per-user aliases. Empty
# these so a misfiled recipient never gets delivered locally. An empty # these so a misfiled recipient never gets delivered locally. An empty
# mydestination is not enough: an address literal of an interface the # mydestination is not enough: an address literal of an interface the
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.7.11, # container listens on - x@[127.0.0.1] - is local too (Postfix 3.10.13,
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8) # src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
# itself is replaced by a refusal, as Postfix's null-client example does # itself is replaced by a refusal, as Postfix's null-client example does
# (README_FILES/STANDARD_CONFIGURATION_README, "local_transport = error:local # (README_FILES/STANDARD_CONFIGURATION_README:209, "local_transport = error:local
# mail delivery is disabled"; assumptions C-P8). # mail delivery is disabled"; assumptions C-P8).
postconf -e \ postconf -e \
"local_recipient_maps=" \ "local_recipient_maps=" \
@@ -283,9 +298,9 @@ 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. Postfix 3.7.11, # credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption # src/smtpd/smtpd_sasl_proto.c:188, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c, # required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c:6546-6547,
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only. # 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}" \
@@ -297,12 +312,13 @@ postconf -e \
# TLS 1.2 at least, opportunistic or required - and the client's too where an # TLS 1.2 at least, opportunistic or required - and the client's too where an
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols, # inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
# the security review of stage 5) (audit 2026-10-12, L31; Postfix # the security review of stage 5) (audit 2026-10-12, L31; Postfix
# 3.7.11, proto/postconf.proto, smtpd_tls_protocols: since 3.6 the default is # 3.10.13, proto/postconf.proto:13240-13250, smtpd_tls_protocols: since 3.6 the
# ">=TLSv1", and a lower bound is written ">=version"). A sender that offers # default is ">=TLSv1", and a lower bound is written ">=version"). A sender that
# less on port 25 sends in the clear, as one without TLS does. VRFY answers # offers less on port 25 sends in the clear, as one without TLS does. VRFY
# no question about who is accepted on port 25 (relay_recipient_maps), and # answers no question about who is accepted on port 25 (relay_recipient_maps),
# the banner names no distribution (Debian's adds "(Debian/GNU)"; the # and the banner names no distribution (Debian's adds "(Debian)": postfix
# upstream default is "$myhostname ESMTP $mail_name"). # 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by
# debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name").
postconf -e \ postconf -e \
"smtpd_tls_protocols=>=TLSv1.2" \ "smtpd_tls_protocols=>=TLSv1.2" \
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \ "smtpd_tls_mandatory_protocols=>=TLSv1.2" \
@@ -315,7 +331,7 @@ 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 # (Postfix 3.10.13, src/smtpd/smtpd_sasl_glue.c:249, smtpd_sasl_activate(): an empty
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.) # 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" \
@@ -330,7 +346,7 @@ postconf -e \
# up in this map with mail_addr_find(), the order being smtpd's and not # up in this map with mail_addr_find(), the order being smtpd's and not
# texthash's: the full address, the address without its extension, then the # texthash's: the full address, the address without its extension, then the
# "@domain" wildcard - so both address modes work from the same map (Postfix # "@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(); # 3.10.13, src/smtpd/smtpd_check.c:3933, reject_auth_sender_login_mismatch();
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT). # 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:
@@ -347,9 +363,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. (Postfix 3.7.11, # network. This is what makes an open relay impossible. (Postfix 3.10.13,
# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists # src/smtpd/smtpd_check.c:5076, smtpd_check_rcpt(), :5160-5173: the relay and
# are evaluated in turn and the first REJECT ends them.) # 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" \
@@ -357,16 +373,24 @@ 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. Postfix 3.7.11, src/smtpd/smtpd.c, # journal-milter (level 2) is down. Postfix 3.10.13, src/smtpd/smtpd.c:2566,
# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in # mail_cmd(), :2624: "450 4.7.1 Error: too much mail from", except for clients
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf # in smtpd_client_event_limit_exceptions (:2619, hogger_list; default
# default, debian/templates "postfix/mynetworks", is loopback only). # $mynetworks, src/smtpd/smtpd.c:779).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)). # anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
# "Client IP" is the address the container sees: an IPv4 client's own, but # "Client IP" is the address the container sees: an IPv4 client's own, but
# for a client over IPv6 the compose network's gateway, through Docker's # for a client over IPv6 the compose network's gateway, through Docker's
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions # proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
# C-K5; guide, "Rate limiting - level 1"). # C-K5; guide, "Rate limiting - level 1").
#
# mynetworks is said here, loopback only, and not left to the package: it is
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
# spare. The value is the one Debian's debconf default gives (postfix
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
# by debian/postfix.postinst:249-262; debian/main.cf.in:28 has 127.0.0.0/8
# alone), so on this image it changes nothing.
postconf -e \ postconf -e \
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
"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"
@@ -386,12 +410,12 @@ postconf -e \
# 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 # Postfix 3.10.13: html/MILTER_README.html:584, "Different settings for different
# Milter applications" (the braces, Postfix 3.0 and later) and "Signing # Milter applications" (the braces, Postfix 3.0 and later) and :449, "Signing
# internally-generated bounce messages"; src/milter/milter.c, milter_*_event() # internally-generated bounce messages"; src/milter/milter.c:399-564,
# and milter_message(): milters are asked in list order and the first non-empty # milter_*_event() and milter_message(): milters are asked in list order and
# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES # the first non-empty reply ends the round; src/global/mail_params.h:3599,
# INT_FILT_CLASS_NONE. # 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" \
@@ -403,7 +427,7 @@ postconf -e \
# 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. The defaults: Postfix # messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS. # 3.10.13, src/global/mail_params.h:3532-3534, 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}"
@@ -413,7 +437,7 @@ 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. The defaults: Postfix 3.7.11, src/global/mail_params.h, # operation. The defaults: Postfix 3.10.13, src/global/mail_params.h:3559-3567,
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s". # 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}" \
@@ -424,12 +448,26 @@ 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, # The names smtps, submission and smtp need no /etc/services: Postfix 3.10.13,
# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25, # src/global/mail_params.h:4450-4451, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
# smtps=submissions=465, submission=587" (src/util/find_inet.c, # smtps=submissions=465, submission=587" (src/util/find_inet.c:94-99,
# find_inet_port() via filter_known_tcp_port()). # find_inet_port() via filter_known_tcp_port()).
#
# smtpd_hide_client_session=yes on both submission services (owner's
# decision, 2026-10-15): the Received: header Postfix adds names neither the
# application's HELO name, reverse name and address, nor its TLS session,
# SASL login or the protocol - only "Received: by <myhostname> (Postfix) id
# <queue id>", "for <rcpt>" when there is one recipient, and the date
# (Postfix 3.10.13, src/smtpd/smtpd.c:3495, the client part written only
# when the parameter is off; :3627-3645, the "by" line opening the header
# when it is on). proto/postconf.proto:19580-19594: "may be used for the
# port 587 and 465 MUA services"; port 25 must keep the default, RFC 5321's
# trace, and does (smtp/inet below sets nothing). The queue id stays in the
# header, so a complaint about a message still leads to its row in the
# panel's log and to smtpd's "client=" line in mail.log.
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_hide_client_session=yes" \
"smtps/inet/smtpd_tls_wrappermode=yes" \ "smtps/inet/smtpd_tls_wrappermode=yes" \
"smtps/inet/smtpd_sasl_auth_enable=yes" \ "smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
@@ -437,13 +475,15 @@ 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. (Postfix 3.7.11, # after disabling it does not leave the port listening. (Postfix 3.10.13,
# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets # src/smtpd/smtpd.c:6525-6526, pre_jail_init(): smtpd_tls_security_level=encrypt
# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c, # sets enforce_tls, which implies smtpd_tls_auth_only (:6547);
# pcf_edit_master(): -MX drops the matched entry, continuation lines included.) # src/postconf/postconf_edit.c:319, 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 \
"submission/inet/smtpd_hide_client_session=yes" \
"submission/inet/smtpd_tls_security_level=encrypt" \ "submission/inet/smtpd_tls_security_level=encrypt" \
"submission/inet/smtpd_sasl_auth_enable=yes" \ "submission/inet/smtpd_sasl_auth_enable=yes" \
"submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
@@ -452,9 +492,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 (postfix 3.7.11-0+deb12u1, # Debian's stock master.cf enables smtp/inet (postfix 3.10.13-0+deb13u1,
# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd", # debian/patches/40_chroot_by_default.diff:16: "smtp inet n - y - - smtpd",
# uncommented). When both flags are off, remove # uncommented; conf/master.cf:12). 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.
@@ -479,15 +519,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: # (Seen in 1.7.0 through 1.9.5, date not recorded, on Postfix 3.7.11.
# src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes # Postfix 3.10.13: src/postconf/postconf_master.c:1029,
# "-o name=value" and nothing else; postconf_edit.c warns "unmatched # pcf_edit_master_param(), -P writes "-o name=value" and nothing else;
# service_name/type" for a service master.cf does not have; # postconf_edit.c:617 warns "unmatched service_name/type" for a service
# src/pipe/pipe.c, get_service_attr(): "missing user= command-line # master.cf does not have; src/pipe/pipe.c:867, get_service_attr(),
# attribute".) # :1018: "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). # (man/man8/pipe.8:289, ${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.
@@ -498,9 +538,9 @@ 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. (Postfix 3.7.11, # path, and it would quietly open a database of its own. (Postfix 3.10.13,
# man/man8/pipe.8, export_environment; src/global/mail_params.h, # man/man8/pipe.8:417, export_environment; src/global/mail_params.h:2650,
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The # DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/util/clean_env.c. The
# parameter is main.cf's, so every Postfix process exports these too.) # 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:]]* | *'$'*)
@@ -520,8 +560,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.5, NOTE 2: "or use commas instead of # master_cf_set_smtp_inet_o (master.5, "-o name=value (short form)": "use
# spaces"). # commas instead of spaces if the parameter supports that").
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
@@ -539,16 +579,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 # (Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(),
# the lists, check_recipient_rcpt_maps() runs unless the status is # :5196-5200: after the lists, check_recipient_rcpt_maps() runs unless
# REJECT - an OK from check_recipient_access does not skip it.) # 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 # The panel writes the same "[host]:port" into transport and
# tls_policy: the policy lookup key is the verbatim next-hop, port 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 # brackets included (Postfix 3.10.13, README_FILES/TLS_README:914, "TLS
# policy table" note: ":smtp", ":25" and no port are different keys). # 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}" \
@@ -566,10 +607,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. (Postfix 3.7.11, # panel's inbound journal milter, or nothing at all. (Postfix 3.10.13,
# man/man5/master.5: "-o" overrides the main.cf parameter for this service; # 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(), # src/smtpd/smtpd.c:3475 hands its own milter list to cleanup, milter_send(),
# and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.) # and src/cleanup/cleanup_milter.c:1997, 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
@@ -602,11 +643,12 @@ 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 # Postfix 3.10.13: src/milter/milter.c:564, milter_message(), sends the
# message to each milter in list order; src/milter/milter8.c, # message to each milter in list order; src/milter/milter8.c:960,
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to # milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER,
# the queue file at once, and the next milter8_message() reads that # :1424) to the queue file at once, and the next milter8_message()
# file with rec_get(), which follows the added records. # (:2544) reads that file with rec_get() (:2612), 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
@@ -638,23 +680,29 @@ 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. (Postfix 3.7.11, # into the inbound journal and has to tell the two apart. (Postfix 3.10.13,
# src/global/mail_task.c, mail_task(): the log tag is syslog_name "/" # src/global/mail_task.c:56, mail_task(): the log tag is syslog_name "/"
# program name. Only smtpd carries it: cleanup and the rest are other # program name. Only smtpd carries it: cleanup and the rest are other
# services and log as postfix/.) # services and log as postfix/.)
postconf -M "smtp/inet=smtp inet n - n - - smtpd" postconf -M "smtp/inet=smtp inet n - n - - smtpd"
# smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence, which this # smtpd_forbid_bare_newline: the 2023 SMTP-smuggling defence. normalize
# Postfix leaves off. normalize takes a bare <LF> as a line end but ends # takes a bare <LF> as a line end but ends DATA only at the standard
# DATA only at the standard <CR><LF>.<CR><LF>: a "." line that a bare # <CR><LF>.<CR><LF>: a "." line that a bare <LF> precedes or ends is
# <LF> precedes or ends is dropped, not taken as the end of the message # dropped, not taken as the end of the message (Postfix 3.10.13,
# (Postfix 3.7.11, src/smtpd/smtpd.c:807, the default "no" for Postfix # src/smtpd/smtpd.c:1693-1694, bare_lf_mask_table, normalize =
# < 3.9; :1656, bare_lf_mask_table, normalize = BARE_LF_FLAG_WANT_STD_EOD; # BARE_LF_FLAG_WANT_STD_EOD; :3712-3716, the DATA loop; RELEASE_NOTES-3.9:
# :3649-3652, the DATA loop; RELEASE_NOTES, "Major changes with Postfix # 166-167, "the recommended setting "smtpd_forbid_bare_newline
# 3.8.5, 3.7.10": normalize is the recommended setting and 3.9's # = normalize""). Since 3.9 normalize is also the default (src/smtpd/
# default). Its exclusions default to $mynetworks, loopback only here # smtpd.c:830, "Postfix >= 3.9: normalize"; src/global/mail_params.h:4313,
# (above), so no client is spared. The submission ports keep the # DEF_SMTPD_FORBID_BARE_LF "normalize"); the line stays so that port 25
# default: their smuggled transaction would still be the authenticated # does not depend on a default (it came in 2.6.1, while the image's
# session's, under its bindings and milters (own-mta-builds, § 5). # Postfix 3.7.11 defaulted to "no").
# Its exclusions default to $mynetworks (mail_params.h:4316), loopback
# only here (above), so no client is spared. The submission ports take
# the default, normalize as well since this Postfix: an application whose
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
# end there (own-mta-builds, § 5: their smuggled transaction would still
# be the authenticated session's, so they were left to the default).
postconf -P \ postconf -P \
"smtp/inet/smtpd_forbid_bare_newline=normalize" \ "smtp/inet/smtpd_forbid_bare_newline=normalize" \
"smtp/inet/syslog_name=postfix/inbound" \ "smtp/inet/syslog_name=postfix/inbound" \
@@ -671,12 +719,12 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# smtpd's -o limit is not the whole of it: cleanup writes the queue file # smtpd's -o limit is not the whole of it: cleanup writes the queue file
# under its own message_size_limit, main.cf's, which is never set here and # under its own message_size_limit, main.cf's, which is never set here and
# so is 10240000 (Postfix 3.7.11, src/cleanup/cleanup_init.c, # so is 10240000 (Postfix 3.10.13, src/cleanup/cleanup_init.c:444,
# cleanup_post_jail(), set_file_limit(); src/global/mail_params.h, # cleanup_post_jail(), :455, set_file_limit(); src/global/mail_params.h:2013,
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA # DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
# (assumptions C-P31). cleanup gets port 25's limit when it is larger; # (assumptions C-P31). cleanup gets port 25's limit when it is larger;
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c, # smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c:3721,
# the DATA and BDAT size checks). # :3964, :4136, :4250, the DATA and BDAT size checks).
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}" postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
fi fi
@@ -697,23 +745,44 @@ 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. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff # and the rest. (postfix 3.10.13-0+deb13u1: debian/patches/40_chroot_by_default.diff
# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the # sets chroot y. chroot(2) also needs CAP_SYS_CHROOT, which
# like into the chroot, and nothing runs it in this container. chroot(2) also # deploy/docker-compose.yml drops with cap_drop: ALL - capabilities(7).)
# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop: #
# ALL - capabilities(7).) # Debian's chroot setup now runs on every "postfix check" and "postfix
# start-fg": conf/post-install:587 (debian/patches/
# debian-run-configure-instance-from-create-missing.patch) calls
# /usr/lib/postfix/configure-instance.sh at the end of create-missing, and
# that copies etc/localtime, etc/resolv.conf, etc/hosts and the like into
# $queue_directory/etc - here /data/postfix/queue/etc - whether a service is
# chrooted or not (debian/configure-instance.in:80-91, before its
# need_chroot test at :93). With nothing chrooted those copies are read by
# nobody, so the image turns the sync off with SYNC_CHROOT= in
# /etc/default/postfix (build/Dockerfile; configure-instance.in:22-24, :44,
# "[ -n "$SYNC_CHROOT" ] || exit 0"; debian/README.Debian:136-138).
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 # The directory is said here, cyrus_sasl_config_path, and not left to the
# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in # package's main.cf. Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c:227-232,
# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl", # xsasl_cyrus_server_init(): a non-empty cyrus_sasl_config_path goes to Cyrus
# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL # with sasl_set_path(SASL_PATH_TYPE_CONFIG, ...), and :246,
# sasl_server_init(callbacks, path_info) with smtpd_sasl_path=smtpd
# (src/smtpd/smtpd_sasl_glue.c:188-189) names smtpd.conf there; the default
# is empty (src/global/mail_params.h:1690, DEF_CYRUS_CONF_PATH ""), and then
# Cyrus looks in its own path, /etc/sasl2:/etc/sasl:/usr/lib/<triplet>/sasl2:
# /usr/lib/sasl2 (cyrus-sasl2 2.1.28+dfsg1, debian/rules:96, --with-configdir;
# lib/common.c:210, sasl_set_path()), which this file is not in. Bookworm's
# package found it with a patch (07_sasl_config.diff, gone in 3.10.13); trixie's
# writes cyrus_sasl_config_path = /etc/postfix/sasl into a new main.cf
# (postfix 3.10.13-0+deb13u1, debian/main.cf.in:71) and into an upgraded one
# only when it is empty (debian/postfix.postinst:88-97). The keys: Cyrus SASL
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin, # 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
# sasldb_path, mech_list). # sasldb_path, mech_list).
postconf -e "cyrus_sasl_config_path=/etc/postfix/sasl"
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
@@ -724,8 +793,10 @@ 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. (Postfix # anything Postfix rejects, before the wrapper tries to start it. (Postfix
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal # 3.10.13, conf/postfix-script:292, "check": "check-fatal || exit 1"; check-fatal
# also runs "post-install create-missing", which builds the queue tree.) # also runs "post-install create-missing" (:314), which builds the queue tree
# and, in Debian's package, runs configure-instance.sh - see the chroot note
# above.)
set +e set +e
check_out=$(postfix check 2>&1) check_out=$(postfix check 2>&1)
ec=$? ec=$?
+7 -4
View File
@@ -6,11 +6,14 @@
# behind the milter wait (build/postfix-wrapper.sh) - and the master that # behind the milter wait (build/postfix-wrapper.sh) - and the master that
# starts next reads the files as they are now, so the panel takes that as # starts next reads the files as they are now, so the panel takes that as
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix # done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
# 3.7.11, conf/postfix-script, "reload": exit 1 when not running or when # 3.10.13, conf/postfix-script:266-279, "reload": exit 1 when not running or
# postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master # when postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
# -t" exits 0, silently, while no master holds the lock (src/master/master.c, # -t" exits 0, silently, while no master holds the lock (src/master/master.c:495-499,
# test_lock). A master that stops between the two is a failure the panel # test_lock). A master that stops between the two is a failure the panel
# reports - the rarer mistake (review of stage 2c, F5). # reports - the rarer mistake (review of stage 2c, F5). Since Postfix 3.8 the
# reload also writes "refreshing the Postfix mail system" to stderr, which
# goes to the container's log (postfix-wrapper.sh, the note on 3.8's stderr
# logging); the panel reads only the exit status.
d=$(postconf -h daemon_directory) || exit 1 d=$(postconf -h daemon_directory) || exit 1
if "$d/master" -t 2>/dev/null; then if "$d/master" -t 2>/dev/null; then
echo "postfix-reload: Postfix is not running; its start reads the files" >&2 echo "postfix-reload: Postfix is not running; its start reads the files" >&2
+22 -8
View File
@@ -44,23 +44,37 @@ 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), # Postfix 3.10.13: postfix(1) execs conf/postfix-script (src/postfix/postfix.c:669),
# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as # whose "start-fg" (:200-210) execs master only as PID 1 and otherwise runs
# its child; master then makes itself a session leader (src/master/master.c, # "master -s" as its child; master then makes itself a session leader
# setsid()). A signal supervisord sends this program never reaches master # (src/master/master.c:441, setsid()). A signal supervisord sends this
# program never reaches master
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid # (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
# alone without stopasgroup, and master is not in its group anyway): before, # alone without stopasgroup, and master is not in its group anyway): before,
# a stop left master running - a restart of the program then found Postfix # a stop left master running - a restart of the program then found Postfix
# "already running", and a stop of the container cut Postfix off with # "already running", and a stop of the container cut Postfix off with
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs # SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
# "postfix stop" (conf/postfix-script, "stop": TERM to master, then waits up # "postfix stop" (conf/postfix-script:237-253, "stop": TERM to master, then
# to five seconds and kills its group; src/master/master_sig.c, # waits up to five seconds and kills its group; src/master/master_sig.c:205,
# master_sigdeath(): "terminating on signal 15"). # master_sigdeath(): "terminating on signal 15"). Debian's redirect of
# "postfix start" to systemd (debian/patches/
# debian-re-run-startup-through-systemd.patch, conf/postfix-script:170-189)
# leaves start-fg alone and needs /run/systemd/system, which this container
# has not.
#
# Since Postfix 3.8 postfix(1) and postlog(1) - postfix-script's $INFO, $WARN
# and $FATAL - write their messages to stderr even when it is not a terminal
# (src/postfix/postfix.c:546, msg_vstream_init() no longer under isatty();
# RELEASE_NOTES-3.8:114-115, [Incompat 20230308]), besides maillog_file. So
# "starting the Postfix mail system", "stopping ..." and check-warn's
# warnings now reach the container's log through this program's stderr
# (build/supervisord.conf) as well as /data/log/mail.log; nothing reads them
# there but a person.
# #
# "postfix start-fg" spends its first seconds in its checks before master # "postfix start-fg" spends its first seconds in its checks before master
# runs; a stop asked then would find nothing to stop, so it is asked again # runs; a stop asked then would find nothing to stop, so it is asked again
# until the child is gone. "master -t" exits 0, silently, while no master # until the child is gone. "master -t" exits 0, silently, while no master
# holds the lock (src/master/master.c, test_lock). # holds the lock (src/master/master.c:495-499, test_lock).
daemon_directory=$(postconf -h daemon_directory) daemon_directory=$(postconf -h daemon_directory)
child= child=
stop_asked= stop_asked=
+6 -4
View File
@@ -32,7 +32,7 @@ func runDMARCIngestMode() error {
// Not a report this relay keeps. It is dropped: deferring would have // Not a report this relay keeps. It is dropped: deferring would have
// Postfix try it again for days, and a bounce would go to an envelope // Postfix try it again for days, and a bounce would go to an envelope
// sender anyone can write - the report addresses are public. Postfix // sender anyone can write - the report addresses are public. Postfix
// 3.7.11: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via // 3.10.13: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
// ... service ...)" (src/global/pipe_command.c, pipe_command(); // ... service ...)" (src/global/pipe_command.c, pipe_command();
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until // src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice // maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
@@ -49,7 +49,7 @@ func runDMARCIngestMode() error {
// what a delivery command wrote - about a hundred bytes - into its own line // what a delivery command wrote - about a hundred bytes - into its own line
// for the message ("status=sent (delivered via dmarc-ingest service (...))"), // for the message ("status=sent (delivered via dmarc-ingest service (...))"),
// so the reason goes out first and bare: no prefix, no date, no recipient, // so the reason goes out first and bare: no prefix, no date, no recipient,
// all of which that line already has. Postfix 3.7.11, src/pipe/pipe.c, // all of which that line already has. Postfix 3.10.13, src/pipe/pipe.c,
// eval_command_status(): " (%.100s)" of the output, which // eval_command_status(): " (%.100s)" of the output, which
// src/global/pipe_command.c takes from stdout and stderr together. postlog is // src/global/pipe_command.c takes from stdout and stderr together. postlog is
// no use here: pipe(8) starts the command without the group that may write to // no use here: pipe(8) starts the command without the group that may write to
@@ -58,7 +58,9 @@ func runDMARCIngestMode() error {
// installs it set-gid, Debian's package does not - measured in the image, // installs it set-gid, Debian's package does not - measured in the image,
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix // 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and // 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
// postqueue (assumptions A-43). // postqueue (assumptions A-43). postfix 3.10.13-0+deb13u1 is the same by its
// source: debian/postfix.postinst adds statoverrides for /usr/sbin/postdrop
// and /usr/sbin/postqueue alone, and nothing in debian/ makes postlog set-gid.
func logRejected(reason error) { func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": "))) fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
} }
@@ -83,7 +85,7 @@ func logSafe(s string) string {
// ingestRecipient is the report address the message was delivered to. Postfix's // ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv, // pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8). // build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Postfix 3.7.11, src/pipe/pipe.c, deliver_message(): pipe_command() gets only // Postfix 3.10.13, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment; // CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
// src/local/command.c sets "RECIPIENT". // src/local/command.c sets "RECIPIENT".
// Without it the check that a report came to its own domain's address is // Without it the check that a report came to its own domain's address is
+1 -1
View File
@@ -99,7 +99,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
// postfix-config.sh has already run (entrypoint). postconf -h is the // postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this // effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md). Postfix 3.7.11, // snapshot for the process lifetime (architecture.md). Postfix 3.10.13,
// postconf(1): without -d or -n it shows main.cf's value or the default, // postconf(1): without -d or -n it shows main.cf's value or the default,
// and -h drops the "name = " label; values are not $-expanded without -x. // and -h drops the "name = " label; values are not $-expanded without -x.
retryPolicy := postfix.LoadRetryPolicy() retryPolicy := postfix.LoadRetryPolicy()
+2 -2
View File
@@ -37,7 +37,7 @@ func main() {
log.SetPrefix("dmarc-ingest: ") log.SetPrefix("dmarc-ingest: ")
if err := runDMARCIngestMode(); err != nil { if err := runDMARCIngestMode(); err != nil {
log.Printf("ingest failed: %v", err) log.Printf("ingest failed: %v", err)
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.7.11, // EX_TEMPFAIL — ask Postfix to defer. Postfix 3.10.13,
// src/global/pipe_command.c, pipe_command(): a sysexits code maps // src/global/pipe_command.c, pipe_command(): a sysexits code maps
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers, // to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
// unless the output starts with a "D.S.N text" of its own - the // unless the output starts with a "D.S.N text" of its own - the
@@ -223,7 +223,7 @@ func antispamAction(raw string) string {
// saslRealm chooses the realm new SASL accounts live under. It mirrors the // saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare // hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account. Postfix 3.7.11, // login resolves to the right account. Postfix 3.10.13,
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is // src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm // $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28, // NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
+1 -1
View File
@@ -19,7 +19,7 @@ const aliveEvery = 2 * time.Second
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a // serveSenderCheck runs the sender check (internal/milter/sender.go) on a
// socket of its own. Postfix attaches that socket strictly // socket of its own. Postfix attaches that socket strictly
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.7.11, // (default_action=tempfail in build/postfix-config.sh; Postfix 3.10.13,
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable - // src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
// try again later"), so while this role is not serving, mail is deferred: // try again later"), so while this role is not serving, mail is deferred:
// what that costs is counted here and // what that costs is counted here and
+2 -2
View File
@@ -43,8 +43,8 @@
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter # 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 # macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at # 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 # end-of-message (Postfix 3.10.13, src/global/mail_params.h:3555,
# "i"). # DEF_MILT_EOD_MACROS "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"]; use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
skip_local = false; skip_local = false;
skip_authenticated = false; skip_authenticated = false;
+1 -1
View File
@@ -59,7 +59,7 @@ grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM" grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
chmod 0640 "$tmp_cert" "$tmp_key" chmod 0640 "$tmp_cert" "$tmp_key"
# The group the files had stays theirs: the guide gives the key to group 998 # The group the files had stays theirs: the guide gives the key to group 10998
# so the panel can read it for a full backup, and a run from root's cron would # so the panel can read it for a full backup, and a run from root's cron would
# otherwise hand it to root's group (review of audit stage 5). # otherwise hand it to root's group (review of audit stage 5).
for f in fullchain privkey; do for f in fullchain privkey; do
+22 -9
View File
@@ -191,9 +191,22 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter. `anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
- **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** — - **Port 25 ends DATA only at `<CR><LF>.<CR><LF>`** —
`smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix `smtpd_forbid_bare_newline=normalize` on the `smtp/inet` listener (Postfix
3.7.11, `src/smtpd/smtpd.c`, the DATA loop at 3649: a `.` line next to a 3.10.13, `src/smtpd/smtpd.c`, `receive_data_message()`, the DATA loop at
bare `<LF>` is dropped, not taken as the end); a bare `<LF>` elsewhere is 3713: a `.` line next to a bare `<LF>` is dropped, not taken as the end); a
read as `<CR><LF>`. The submission ports keep Postfix's default. bare `<LF>` elsewhere is read as `<CR><LF>`. The submission ports keep
Postfix's default, which is the same `normalize` since Postfix 3.9
(`src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`); the image ran
Postfix 3.7.11 until `2.7.0`, whose default was `no`, hence the explicit
setting on port 25.
- **The submission ports hide the application in `Received:`** (`2.7.0`) —
`smtpd_hide_client_session=yes` on `smtps/inet` and `submission/inet`:
Postfix's header reads `Received: by <hostname> (Postfix) id <queue id>`,
with the recipient when there is one, and the date - no HELO name, client
name or address, TLS, login or protocol (Postfix 3.10.13,
`src/smtpd/smtpd.c:3495` and `:3627-3645`). Port 25 keeps the full
header, which RFC 5321 requires there (`proto/postconf.proto:19587-19589`).
The queue id in the header leads to the message's row in the panel's log
and to smtpd's `client=` line in mail.log.
- **Chroot disabled** for all services (DNS/TLS inside container). - **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied - **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied
at start into `/etc/postfix/tls-internal`, owned by root and `0600` as at start into `/etc/postfix/tls-internal`, owned by root and `0600` as
@@ -234,7 +247,7 @@ milter, § Postfix above, port 25):
`delay_warning_time`), reads each as Postfix does - one number and one `delay_warning_time`), reads each as Postfix does - one number and one
unit, the parameter's own default unit when there is none (`5d` for the unit, the parameter's own default unit when there is none (`5d` for the
lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at lifetimes, so a bare `3` is three days), `bounce_queue_lifetime` capped at
`maximal_queue_lifetime` as qmgr caps it (Postfix 3.7.11, `maximal_queue_lifetime` as qmgr caps it (Postfix 3.10.13,
`src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on `src/global/conv_time.c`, `src/qmgr/qmgr.c`) - and caches the snapshot on
the handlers config. The the handlers config. The
Outbound queue box and a delivery's `deferred` / `bounced` history print those Outbound queue box and a delivery's `deferred` / `bounced` history print those
@@ -295,7 +308,7 @@ server itself.
reason), then `@domain` - and the **first of these keys in the table reason), then `@domain` - and the **first of these keys in the table
decides**, its owners alone: an address listed for one application is not decides**, its owners alone: an address listed for one application is not
open to another that holds the whole domain. Logins are compared without open to another that holds the whole domain. Logins are compared without
regard to case, as Postfix compares them (Postfix 3.7.11, regard to case, as Postfix compares them (Postfix 3.10.13,
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over `src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written `src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
twice makes Postfix refuse the whole table, and the check defers then too. twice makes Postfix refuse the whole table, and the check defers then too.
@@ -461,7 +474,7 @@ recreates the file on its first line after the reload, as root and mode
`0600`, which the unprivileged panel user cannot read). `follow()` drains the `0600`, which the unprivileged panel user cannot read). `follow()` drains the
old inode once more before switching descriptors, and keeps it open and reads old inode once more before switching descriptors, and keeps it open and reads
it for a minute after: postlogd keeps writing to the renamed file until the it for a minute after: postlogd keeps writing to the renamed file until the
reload makes it exit, seconds later (Postfix 3.7.11, src/postlogd/postlogd.c; reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c;
src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing src/master/dgram_server.c, `dgram_server_abort()`). The panel treats a missing
log file as an empty tail, not an error. log file as an empty tail, not an error.
@@ -494,7 +507,7 @@ the lines stored under it - only those of the minutes around the refusal, since
Postfix frees the id at once. A message the journal milter refused at Postfix frees the id at once. A message the journal milter refused at
`MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL `MAIL FROM` has none: the tailer reads smtpd's `NOQUEUE: milter-reject: MAIL
from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is from <client>[<address>]: …; from=<…> … sasl_username=<login>` - the login is
Postfix's last field on an authenticated session (Postfix 3.7.11, Postfix's last field on an authenticated session (Postfix 3.10.13,
`src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no `src/smtpd/smtpd.c`, `smtpd_whatsup()`), and a line without one is no
application's - and puts it on the nearest refused row of that client application's - and puts it on the nearest refused row of that client
address, login and sender within two minutes address, login and sender within two minutes
@@ -513,7 +526,7 @@ a kill is covered as well as a clean stop.
**Expiry.** A message Postfix gives up on at the end of its queue lifetime has **Expiry.** A message Postfix gives up on at the end of its queue lifetime has
no line per recipient: qmgr writes one line for the message, `<id>: no line per recipient: qmgr writes one line for the message, `<id>:
from=<...>, status=expired, returned to sender` (`force-expired` after from=<...>, status=expired, returned to sender` (`force-expired` after
`postsuper -e`; Postfix 3.7.11, `src/qmgr/qmgr_active.c`, `postsuper -e`; Postfix 3.10.13, `src/qmgr/qmgr_active.c`,
`qmgr_active_done_25_generic()`), and the recipients' last lines say `qmgr_active_done_25_generic()`), and the recipients' last lines say
`deferred`. The tailer reads that line and closes the message's open rows - `deferred`. The tailer reads that line and closes the message's open rows -
each recipient's newest, `queued` or `deferred` - as `bounced`. each recipient's newest, `queued` or `deferred` - as `bounced`.
@@ -542,7 +555,7 @@ of the id's flags (`src/postqueue/showq_compat.c`, `showq_message()`).
**Which row a delivery line is.** The journal milter writes a row's **Which row a delivery line is.** The journal milter writes a row's
recipient as Postfix will log it: the address the client gave, its quoted recipient as Postfix will log it: the address the client gave, its quoted
local part unquoted and quoted again only where it needs to be (`"ab"@x` local part unquoted and quoted again only where it needs to be (`"ab"@x`
becomes `ab@x`, `"a b"@x` stays; Postfix 3.7.11, `tok822_parse.c`, becomes `ab@x`, `"a b"@x` stays; Postfix 3.10.13, `tok822_parse.c`,
`quote_822_local.c`), case kept, and a recipient given twice - in any case - `quote_822_local.c`), case kept, and a recipient given twice - in any case -
once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()` once, since cleanup keeps one (`cleanup_out_recipient.c`, `been_here()`
folding case). A delivery line names it in `orig_to=` when Postfix rewrote folding case). A delivery line names it in `orig_to=` when Postfix rewrote
+8
View File
@@ -38,6 +38,14 @@ versions `go.mod` pins (go-milter v0.4.1, modernc.org/sqlite v1.60.1 with
SQLite 3.53.4); htmx 2.0.4 as vendored. Upstream paths below are relative SQLite 3.53.4); htmx 2.0.4 as vendored. Upstream paths below are relative
to the package's source tree. to the package's source tree.
Since base-trixie (`2.7.0`) the image runs Debian trixie (Postfix 3.10.13,
opendkim 2.11.0~beta2-9.1+b1, cyrus-sasl2 2.1.28+dfsg1-9, supervisor
4.2.5-3, logrotate 3.22.0-1; development.md § Code that relies on another
component's behaviour), and the citations in the code were re-read against
the trixie sources. This register is left as it was made: its line numbers
and versions are those of the bookworm sources above, which are now kept
under `dev/upstream-bookworm/` (where it says `dev/upstream/`).
Counts at the inventory, by the justification the tree had: Counts at the inventory, by the justification the tree had:
| Part | Entries | CITED | MEASURED | GUESSED | WRONG? | | Part | Entries | CITED | MEASURED | GUESSED | WRONG? |
+19 -18
View File
@@ -152,8 +152,8 @@ commit rule but **no** version cut until the next product stage ships.
| **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) | | **SQLite** | `modernc.org/sqlite` (pure Go, no cgo) |
| **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) | | **Build** | [Makefile](../Makefile): primary targets `vet`, `test`, `build`, `e2e` (also `all`, `clean`) |
| **Container** | Docker + Compose v2 on the dev host and in CI | | **Container** | Docker + Compose v2 on the dev host and in CI |
| **Image (build stage)** | `golang:1.26-bookworm` — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit | | **Image (build stage)** | `golang:1.26-trixie` (bookworm until `2.7.0`) — [build/Dockerfile](../build/Dockerfile). The tag floats to the newest 1.26 patch release, and most standard-library security fixes (`net/http`, `html/template`, …) arrive only that way: rebuild the image after each Go patch release, no edit needed. Dependencies are raised by hand (`go get …@latest`, `go mod tidy`), at least at each audit |
| **Image (runtime)** | `debian:bookworm-slim` + Postfix, OpenDKIM, supervisord, SASL, logrotate | | **Image (runtime)** | `debian:trixie-slim` (bookworm until `2.7.0`) + Postfix, OpenDKIM, supervisord, SASL, logrotate |
| **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) | | **CI** | GitHub Actions — [.github/workflows/](../.github/workflows/) |
| **Image registry** | `ghcr.io/mixeme/selfpost` | | **Image registry** | `ghcr.io/mixeme/selfpost` |
@@ -209,8 +209,8 @@ the image.
### Debian packages in the runtime image ### Debian packages in the runtime image
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian bookworm repositories; licenses are in each package's `copyright` from Debian trixie repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/bookworm/>. file on <https://packages.debian.org/trixie/>.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and `/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
@@ -225,21 +225,22 @@ lines and reply codes, feeds OpenDKIM's tables, parses `postqueue`,
through go-milter, relies on SQLite's locking, on logrotate's and Docker's through go-milter, relies on SQLite's locking, on logrotate's and Docker's
behaviour. Every block of code that does so carries a comment that says behaviour. Every block of code that does so carries a comment that says
what it relies on and where that is written, for the version the image runs what it relies on and where that is written, for the version the image runs
(the Debian bookworm package, not upstream's head): (the Debian trixie package, not upstream's head):
```go ```go
// Postfix 3.7.11, src/smtpd/smtpd_check.c, // Postfix 3.10.13, src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): the SASL login is compared with // reject_auth_sender_login_mismatch(): the SASL login is compared with
// each owner in sender_login_maps by strcasecmp_utf8. // each owner in sender_login_maps by strcasecmp_utf8.
``` ```
The Debian sources of the image's versions are fetched with `apt-get source` The Debian sources of the image's versions are fetched with `apt-get source`
inside a `debian:bookworm` container (`postfix`, `opendkim`, `cyrus-sasl2`, inside a `debian:trixie` container (`postfix`, `opendkim`, `cyrus-sasl2`,
`supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/` `supervisor`, `logrotate`, and `sendmail` for libmilter); `dev/upstream/`
(not in git) is where they are kept for reading. Go modules are read in the (not in git) is where they are kept for reading, and `dev/upstream-bookworm/`
module cache at the version `go.mod` pins. holds the bookworm ones the citations named before `2.7.0`. Go modules are
read in the module cache at the version `go.mod` pins.
The image takes `debian:bookworm-slim` and installs without pinning (pinned The image takes `debian:trixie-slim` and installs without pinning (pinned
Debian versions leave the mirrors), so a rebuild after a point or security Debian versions leave the mirrors), so a rebuild after a point or security
release runs packages the citations were not read against. The versions release runs packages the citations were not read against. The versions
read are these; the e2e stand compares them with `dpkg-query` in the image read are these; the e2e stand compares them with `dpkg-query` in the image
@@ -249,14 +250,14 @@ re-read what changed, update the citations, then this table:
<!-- cited-package-versions --> <!-- cited-package-versions -->
| Package | Version read | | Package | Version read |
|---|---| |---|---|
| `postfix` | `3.7.11-0+deb12u1` | | `postfix` | `3.10.13-0+deb13u1` |
| `opendkim` | `2.11.0~beta2-8+deb12u1` | | `opendkim` | `2.11.0~beta2-9.1+b1` |
| `libopendkim11` | `2.11.0~beta2-8+deb12u1` | | `libopendkim11` | `2.11.0~beta2-9.1+b1` |
| `libmilter1.0.1` | `8.17.1.9-2+deb12u2` | | `libmilter1.0.1` | `8.18.1-6+b1` |
| `libsasl2-2` | `2.1.28+dfsg-10` | | `libsasl2-2` | `2.1.28+dfsg1-9` |
| `sasl2-bin` | `2.1.28+dfsg-10` | | `sasl2-bin` | `2.1.28+dfsg1-9` |
| `supervisor` | `4.2.5-1` | | `supervisor` | `4.2.5-3` |
| `logrotate` | `3.21.0-1` | | `logrotate` | `3.22.0-1` |
<!-- /cited-package-versions --> <!-- /cited-package-versions -->
A document instead of source: its URL and section. Where neither answers the A document instead of source: its URL and section. Where neither answers the
+21 -7
View File
@@ -73,6 +73,20 @@ listened on: refused from outside over IPv4 and from the host's public IPv4
address; accepted and closed through `127.0.0.1`, the same proxy. IPv6 itself address; accepted and closed through `127.0.0.1`, the same proxy. IPv6 itself
was not probed for this. was not probed for this.
Since `2.7.0` the image runs Postfix 3.10, whose defaults hold every
listener - 465 and 587 as well as 25 - to two rules of SMTP: a message ends
only at the standard `<CR><LF>.<CR><LF>` (a client that ends DATA with a
bare newline waits for a reply that does not come), and a client that sends
commands ahead of the server's replies where RFC 2920 does not allow it is
answered `554 5.5.0 Error: SMTP protocol synchronization` and disconnected.
Since `2.7.0` a message sent on 465 or 587 does not tell its recipients
where the application runs: the `Received:` header SelfPost adds reads
`Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>`,
without the application's address, host name or login. To trace a message
someone complains about, take that queue id to *Outbound › Log*; mail.log
keeps the client address on smtpd's `client=` line for the same id.
### Local trial ### Local trial
The [README quick start](../README.md#quick-start) runs a single container The [README quick start](../README.md#quick-start) runs a single container
@@ -312,13 +326,13 @@ ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
``` ```
The panel reads them as its own user (Health shows the certificate, a full The panel reads them as its own user (Health shows the certificate, a full
backup carries both files), through the container's group 998. certbot's backup carries both files), through the container's group 10998. certbot's
documentation gives the way for a server that is not root, and certbot keeps documentation gives the way for a server that is not root, and certbot keeps
the key's group and mode at every renewal: the key's group and mode at every renewal:
```sh ```sh
sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
``` ```
@@ -339,7 +353,7 @@ docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
The fragment mounts certbot's directory into both nginx and SelfPost, at The fragment mounts certbot's directory into both nginx and SelfPost, at
`/etc/letsencrypt`; `./certs` holds two links to `/etc/letsencrypt`; `./certs` holds two links to
`/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the `/etc/letsencrypt/live/mail.example.com/` — a path that exists inside the
container, not on the host — and the panel reads the key through group 998, container, not on the host — and the panel reads the key through group 10998,
as in the Apache case. Then start everything: as in the Apache case. Then start everything:
```sh ```sh
@@ -347,7 +361,7 @@ mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
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
@@ -714,11 +728,11 @@ instance keeps running, what goes into the archive, and what happens on restore.
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain → *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below. The panel runs as its own user, not as archive. Same archive as the CLI below. The panel runs as its own user, not as
root, so **the private key in `certs/` has to be readable by it**: give the root, so **the private key in `certs/` has to be readable by it**: give the
file to the container's `selfpost` group, which has the fixed id 998 and holds file to the container's `selfpost` group, which has the fixed id 10998 and holds
both the panel and Postfix — both the panel and Postfix —
```sh ```sh
chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem chgrp 10998 certs/privkey.pem && chmod 640 certs/privkey.pem
``` ```
— and have whatever renews the certificate do the same; certbot does it by — and have whatever renews the certificate do the same; certbot does it by
@@ -795,7 +809,7 @@ methods avoid it by stopping first.
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped | | SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) | | `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes | | Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate | | `certs/` private key | Yes (the panel needs it readable by group 10998; links are followed) | Yes when `certs/` holds the files; links are stored as links unless `tar -h` | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent | | `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No | | Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No | | Post-restore *Resync* | Yes, on first boot | No | No |
+3 -3
View File
@@ -827,9 +827,9 @@ supervisord; `dev/upstream` refetched from trixie; the cited-package table
and `checkCitedPackageVersions`; the e2e sink image; `opendkim-tools` and `checkCitedPackageVersions`; the e2e sink image; `opendkim-tools`
dropped from the image (used by nothing at run time). OpenDKIM's code is dropped from the image (used by nothing at run time). OpenDKIM's code is
the same in trixie, its citations stand. One stage, a MINOR release. Also the same in trixie, its citations stand. One stage, a MINOR release. Also
gained: `smtpd_hide_client_session` (3.10) is available - whether the gained: `smtpd_hide_client_session` (3.10); the owner decided on
relay should hide the application's address from `Received:` is a product 2026-10-15 to turn it on for 465 and 587 in the same release, so the
question to decide separately. `Received:` header no longer shows the application's address.
## panel-dkim-signing ## panel-dkim-signing
+18 -6
View File
@@ -218,6 +218,14 @@ operator can act on it.
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`; - **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **A recipient does not learn where an application runs (`2.7.0`).** The
`Received:` header Postfix adds on 465 and 587 carries no client name,
address, HELO name, TLS details or login (`smtpd_hide_client_session=yes`,
the owner's decision of 2026-10-15): before, every message told its
recipients the address and the internal host name of the machine the
application sent from. What a complaint needs stays with the operator - the
queue id in the header, the panel's log and smtpd's `client=` line in
mail.log. Port 25 keeps the full header, as RFC 5321 requires of an MTA.
- **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the - **An application signs only as itself (`2.4.0`).** OpenDKIM chooses the
key by the `From:` header and Postfix holds only the envelope sender key by the `From:` header and Postfix holds only the envelope sender
against the login, so until `2.4.0` an application of one sending domain against the login, so until `2.4.0` an application of one sending domain
@@ -264,12 +272,16 @@ operator can act on it.
on inbound. An optional antispam milter is inbound-only; default action is on inbound. An optional antispam milter is inbound-only; default action is
fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the
standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the
2023 SMTP-smuggling defence, which Postfix 3.7 leaves off by default): a 2023 SMTP-smuggling defence, which Postfix 3.7 left off by default): a
`.` line next to a bare newline is body, and what a smuggler would put `.` line next to a bare newline is body, and what a smuggler would put
behind it is delivered as part of the one message, not read as a second behind it is delivered as part of the one message, not read as a second
transaction. The submission ports keep the default - there a smuggled transaction. The submission ports keep the default. Since `2.7.0` the
transaction is still the authenticated session's, under its sender image runs Postfix 3.10.13, whose default is `normalize` too (Postfix 3.9
bindings and milters ([own-mta-builds](own-mta-builds-2026-10-15.md) § 5). and later; `src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`), so
they end DATA the same way; the port-25 setting stays explicit. Before
`2.7.0` a smuggled transaction on a submission port was still the
authenticated session's, under its sender bindings and milters
([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
- **The panel's milters judge every message of a connection.** Postfix - **The panel's milters judge every message of a connection.** Postfix
introduces a client to a milter once per connection, and the milter library introduces a client to a milter once per connection, and the milter library
replaces the milter's session after any reply that is not "continue". Until replaces the milter's session after any reply that is not "continue". Until
@@ -303,7 +315,7 @@ operator can act on it.
parsed from `mail.log` from the head of the line, with the reply ending at parsed from `mail.log` from the head of the line, with the reply ending at
Postfix's own `; from=<` so that the HELO name after it - the client's text, Postfix's own `; from=<` so that the HELO name after it - the client's text,
which Postfix logs with non-printable characters and ` <>()\";@` turned into which Postfix logs with non-printable characters and ` <>()\";@` turned into
`?` (Postfix 3.7.11, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for `?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept. the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept.
Only a recipient at an inbound domain makes a row. The journal is cut to Only a recipient at an inbound domain makes a row. The journal is cut to
10 000 rows every 500 rows the tailer writes, and by age every six hours. 10 000 rows every 500 rows the tailer writes, and by age every six hours.
@@ -524,7 +536,7 @@ deferred item from the roadmap.
§ Roles. § Roles.
- **Level-2 in-flight reservations can outlive a broken SMTP session until TTL.** - **Level-2 in-flight reservations can outlive a broken SMTP session until TTL.**
Postfix tells the milters ABORT when a client drops the connection Postfix tells the milters ABORT when a client drops the connection
mid-transaction (Postfix 3.7.11, src/smtpd/smtpd.c `mail_reset()` -> mid-transaction (Postfix 3.10.13, src/smtpd/smtpd.c `mail_reset()` ->
`milter_abort()`), and the journal milter releases the reservation then. `milter_abort()`), and the journal milter releases the reservation then.
When that never arrives - smtpd killed, the milter connection lost - go-milter When that never arrives - smtpd killed, the milter connection lost - go-milter
has no close callback, and the in-memory reservation is released by TTL has no close callback, and the in-memory reservation is released by TTL
+1 -1
View File
@@ -55,7 +55,7 @@ type SASLDB struct {
// realm should match SELFPOST_HOSTNAME so the account identity lines up with // realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration. // Postfix's SASL configuration.
// //
// Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): // Postfix 3.10.13, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty, // sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c, // no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
// sasldb_auxprop_lookup(), then looks a login without "@" up under the // sasldb_auxprop_lookup(), then looks a login without "@" up under the
+1 -1
View File
@@ -32,7 +32,7 @@ func invalid(format string, args ...any) error {
// (idx_applications_login_nocase); a login imported from another instance is // (idx_applications_login_nocase); a login imported from another instance is
// not folded, since its SASL account and its client use it as it is. // not folded, since its SASL account and its client use it as it is.
// //
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): // Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with each owner by strcasecmp_utf8(). // the SASL login is compared with each owner by strcasecmp_utf8().
func foldLogin(login string) string { func foldLogin(login string) string {
return strings.ToLower(login) return strings.ToLower(login)
+1 -1
View File
@@ -91,7 +91,7 @@ type Params struct {
// 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 (date not recorded; Postfix // 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 // 3.10.13, conf/postfix-files: active, deferred, incoming and the other queue
// directories are $mail_owner 700). What is not carried over is mail // 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
+2 -2
View File
@@ -26,7 +26,7 @@ const certWarnDays = 14
// Postfix itself serves the copy build/postfix-config.sh makes in // Postfix itself serves the copy build/postfix-config.sh makes in
// /etc/postfix/tls-internal, refreshed by build/postfix-cert-reload.sh up to // /etc/postfix/tls-internal, refreshed by build/postfix-cert-reload.sh up to
// TLS_RELOAD_INTERVAL_SECONDS later, and each smtpd loads it when it starts // TLS_RELOAD_INTERVAL_SECONDS later, and each smtpd loads it when it starts
// (Postfix 3.7.11, src/smtpd/smtpd.c, pre_jail_init(), TLS_SERVER_INIT) - so // (Postfix 3.10.13, src/smtpd/smtpd.c, pre_jail_init(), TLS_SERVER_INIT) - so
// the mounted file can be newer than what is served (assumptions A-65). // the mounted file can be newer than what is served (assumptions A-65).
// CheckServedCertificate reads the copy, and names the mounted file as a // CheckServedCertificate reads the copy, and names the mounted file as a
// renewal waiting while the two differ. // renewal waiting while the two differ.
@@ -52,7 +52,7 @@ type Renewal struct {
} }
// ServedCertPath is the certificate file Postfix serves, smtpd_tls_cert_file // ServedCertPath is the certificate file Postfix serves, smtpd_tls_cert_file
// as postconf prints it (Postfix 3.7.11, postconf(1): -h, the value without // as postconf prints it (Postfix 3.10.13, postconf(1): -h, the value without
// its name): build/postfix-config.sh's copy when the mounted files were there // its name): build/postfix-config.sh's copy when the mounted files were there
// at the start, the mounted file itself otherwise. "" when postconf fails. // at the start, the mounted file itself otherwise. "" when postconf fails.
// It is read once, at the panel's start, as the retry policy is: main.cf is // It is read once, at the panel's start, as the retry policy is: main.cf is
+1 -1
View File
@@ -17,7 +17,7 @@ import (
const milterDialTimeout = time.Second const milterDialTimeout = time.Second
// CheckMilter probes a milter address in the two forms build/postfix-config.sh // CheckMilter probes a milter address in the two forms build/postfix-config.sh
// lets INBOUND_ANTISPAM_MILTER take (Postfix 3.7.11, src/milter/milter8.c, // lets INBOUND_ANTISPAM_MILTER take (Postfix 3.10.13, src/milter/milter8.c,
// milter8_connect(), also accepts "local:", which that script refuses): // milter8_connect(), also accepts "local:", which that script refuses):
// unix:/path is stat-ed like any milter socket, and inet:host:port is dialled // unix:/path is stat-ed like any milter socket, and inet:host:port is dialled
// and the connection closed at once, with nothing written. required says what // and the connection closed at once, with nothing written. required says what
+2 -2
View File
@@ -26,7 +26,7 @@ type Socket struct {
// stops mail leaving the server, while the journal-milter is attached with // stops mail leaving the server, while the journal-milter is attached with
// default_action=accept and fails open — mail still goes out, only the send // default_action=accept and fails open — mail still goes out, only the send
// log stops being written (build/postfix-config.sh smtpd_milters; Postfix // log stops being written (build/postfix-config.sh smtpd_milters; Postfix
// 3.7.11, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451 // 3.10.13, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
// 4.7.1 Service unavailable - try again later", accept answers nothing). // 4.7.1 Service unavailable - try again later", accept answers nothing).
// //
// The socket is dialled, as Postfix will dial it; a file is not a listener. // The socket is dialled, as Postfix will dial it; a file is not a listener.
@@ -35,7 +35,7 @@ type Socket struct {
// a killed OpenDKIM or panel leaves a file that refuses every connection - // a killed OpenDKIM or panel leaves a file that refuses every connection -
// assumptions A-38. The connection is closed before a byte is sent, which // assumptions A-38. The connection is closed before a byte is sent, which
// none of the three milters logs: OpenDKIM's libmilter reads the EOF as // none of the three milters logs: OpenDKIM's libmilter reads the EOF as
// SMFIC_EOF and ends the session without a line (sendmail 8.17.1.9, // SMFIC_EOF and ends the session without a line (libmilter 8.18.1,
// libmilter/comm.c, mi_rd_cmd(); engine.c, mi_engine()), and OpenDKIM's // libmilter/comm.c, mi_rd_cmd(); engine.c, mi_engine()), and OpenDKIM's
// mlfi_close() has no connection to clean up (opendkim 2.11.0~beta2, // mlfi_close() has no connection to clean up (opendkim 2.11.0~beta2,
// opendkim/opendkim.c); go-milter v0.4.1 returns quietly on io.EOF // opendkim/opendkim.c); go-milter v0.4.1 returns quietly on io.EOF
+1 -1
View File
@@ -79,7 +79,7 @@ func (e *UpstreamShared) Error() string {
// SetTransportAs validates and saves the upstream of domain id, then rebuilds // SetTransportAs validates and saves the upstream of domain id, then rebuilds
// the maps. An upstream has one TLS mode: Postfix's TLS policy is keyed by the // the maps. An upstream has one TLS mode: Postfix's TLS policy is keyed by the
// next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11, // next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.10.13,
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains // README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
// to one [host]:port share one mode - the map held both keys and the first by // to one [host]:port share one mode - the map held both keys and the first by
// domain name won with a warning (audit 2026-10-12, L5). The other domains // domain name won with a warning (audit 2026-10-12, L5). The other domains
+28 -16
View File
@@ -45,11 +45,15 @@ type Inbound struct {
// so it counts only for a queue id the port-25 smtpd was seen to open // so it counts only for a queue id the port-25 smtpd was seen to open
// (inboundClientRe). // (inboundClientRe).
// //
// Postfix 3.7.11: smtpd's refusal is src/smtpd/smtpd_check.c, log_whatsup(): // Postfix 3.10.13: smtpd's refusal is src/smtpd/smtpd_check.c, log_whatsup():
// "%s: %s: %s from %s: %s;" then " from=<..>", " to=<..>", " proto=", // "%s: %s: %s from %s: %s;" then " from=<..>", " to=<..>", " proto=",
// " helo=<..>", nothing after; a milter's at smtpd is src/smtpd/smtpd.c, // " helo=<..>", then - new in 3.10 (RELEASE_NOTES, [Incompat 20241023]) -
// " sasl_method=%s", " sasl_username=%s" and " sasl_sender=%s" for a session
// that has them, nothing after; a milter's at smtpd is src/smtpd/smtpd.c,
// check_milter_reply(), whose smtpd_whatsup() appends " sasl_username=<..>" // check_milter_reply(), whose smtpd_whatsup() appends " sasl_username=<..>"
// only to an authenticated session (SASL is off on port 25). "name[addr]" is // only to an authenticated session. SASL is off on port 25
// (build/postfix-config.sh, smtpd_sasl_auth_enable=no), so neither tail is
// written there; the pattern takes the 3.10 one all the same. "name[addr]" is
// state->namaddr, the address without "IPv6:" (smtpd.h, // state->namaddr, the address without "IPv6:" (smtpd.h,
// SMTPD_BUILD_NAMADDRPORT). cleanup's is src/cleanup/cleanup_milter.c, // SMTPD_BUILD_NAMADDRPORT). cleanup's is src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(); smtpd hands it to the one cleanup service // cleanup_milter_apply(); smtpd hands it to the one cleanup service
@@ -64,21 +68,29 @@ type Inbound struct {
// journal rows. // journal rows.
// //
// For the same reason the reply is the shortest text before "; from=<", and // For the same reason the reply is the shortest text before "; from=<", and
// the HELO name - the last thing on the line, which Postfix logs as the client // the HELO name - the last thing on the line but the SASL fields above, which
// wrote it, spaces and brackets included - is whatever is left. Taking the // Postfix logs as the client wrote it, spaces and brackets included - is
// longest reply instead would let a HELO name that itself spells // whatever is left. Taking the longest reply instead would let a HELO name
// that itself spells
// "; from=<…> to=<…> proto=…" supply the sender, the recipient and the reply // "; from=<…> to=<…> proto=…" supply the sender, the recipient and the reply
// of the row (security review, 2026-10-12). // of the row (security review, 2026-10-12).
var ( var (
inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*?); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<.*>)?$`) inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*?); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<.*>)?(?: sasl_(?:method|username|sender)=.*)?$`)
inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`) inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`)
// A queue id stops being port 25's when the message is queued (qmgr takes // A queue id stops being port 25's when the message is queued (qmgr takes
// it: the filter let it through) or when another listener opens a message // it: the filter let it through), when cleanup removes the queue file of
// under the same id - Postfix reuses ids. Postfix 3.7.11, // a message it did not queue, or when another listener opens a message
// under the same id - Postfix reuses ids. Postfix 3.10.13,
// src/qmgr/qmgr_message.c, qmgr_message_read() calls opened() // src/qmgr/qmgr_message.c, qmgr_message_read() calls opened()
// (src/global/opened.c: "%s: from=<%s>, size=%ld, nrcpt=%d") for a queue // (src/global/opened.c: "%s: from=<%s>, size=%ld, nrcpt=%d") for a queue
// file cleanup has committed, each time it enters the active queue. // file cleanup has committed, each time it enters the active queue;
inboundForgetRe = regexp.MustCompile(`^\S+ postfix/(?:qmgr\[\d+\]: ([0-9A-Za-z]+): from=|(?:[a-z0-9-]+/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=)`) // src/cleanup/cleanup_api.c, cleanup_flush(): "%s: removed (%s)", with
// "canceled" or "discarded", once the queue file of a refused, discarded
// or unfinished message is gone (new in 3.10, HISTORY 20241112) - after
// the verdict line, which cleanup_milter_inspect() writes earlier in
// cleanup_flush(). Without it, the id of a message whose client went away
// in DATA stayed until inboundQueueIDs pushed it out.
inboundForgetRe = regexp.MustCompile(`^\S+ postfix/(?:qmgr\[\d+\]: ([0-9A-Za-z]+): from=|(?:[a-z0-9-]+/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=|cleanup\[\d+\]: ([0-9A-Za-z]+): removed \()`)
) )
// A row holds one line of reason; Postfix's reply text carries the far end's // A row holds one line of reason; Postfix's reply text carries the far end's
@@ -95,7 +107,7 @@ const inboundQueueIDs = 4096
// journal cut back to its row cap, without waiting for the six-hourly prune. // journal cut back to its row cap, without waiting for the six-hourly prune.
// Refusals are written at the rate strangers send RCPT TO. Postfix's one // Refusals are written at the rate strangers send RCPT TO. Postfix's one
// limit on that, smtpd_client_recipient_rate_limit, is per client address and // limit on that, smtpd_client_recipient_rate_limit, is per client address and
// off by default (postconf(5), Postfix 3.7.11) and not set here, so between // off by default (postconf(5), Postfix 3.10.13) and not set here, so between
// two prunes the journal would otherwise grow as fast as someone cared to // two prunes the journal would otherwise grow as fast as someone cared to
// make it. // make it.
const inboundPruneEvery = 500 const inboundPruneEvery = 500
@@ -140,7 +152,7 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
return return
} }
if m := inboundForgetRe.FindStringSubmatch(rest); m != nil { if m := inboundForgetRe.FindStringSubmatch(rest); m != nil {
delete(j.port25, m[1]+m[2]) delete(j.port25, m[1]+m[2]+m[3])
return return
} }
m := inboundRejectRe.FindStringSubmatch(rest) m := inboundRejectRe.FindStringSubmatch(rest)
@@ -150,7 +162,7 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
program, queueID, kind, clientIP, reason, from, to := m[1], m[2], m[3], m[4], m[5], m[6], m[7] program, queueID, kind, clientIP, reason, from, to := m[1], m[2], m[3], m[4], m[5], m[6], m[7]
// The sender's address is his own text too, and with a quoted local part it // The sender's address is his own text too, and with a quoted local part it
// may hold "> to=<…> proto=…" - which would end the sender early and supply // may hold "> to=<…> proto=…" - which would end the sender early and supply
// the recipient (Postfix 3.7.11 logs addresses in external form, the local // the recipient (Postfix 3.10.13 logs addresses in external form, the local
// part quoted when it holds spaces or specials: postconf(5) // part quoted when it holds spaces or specials: postconf(5)
// info_log_address_format, src/global/info_log_addr_form.c). Such a line // info_log_address_format, src/global/info_log_addr_form.c). Such a line
// shows it: the sender is left with an unclosed quote. An address with a // shows it: the sender is left with an unclosed quote. An address with a
@@ -198,7 +210,7 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
e.Engine = store.SpamEnginePostfix e.Engine = store.SpamEnginePostfix
} }
// "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply // "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply
// that starts with 4 asks the sender to come back. Postfix 3.7.11: // that starts with 4 asks the sender to come back. Postfix 3.10.13:
// smtpd_check_reject() logs smtpd's whole "%d %s ..." reply through // smtpd_check_reject() logs smtpd's whole "%d %s ..." reply through
// log_whatsup(); cleanup_milter_apply() logs a // log_whatsup(); cleanup_milter_apply() logs a
// milter's from its fifth byte (text = resp + 4), so the enhanced code // milter's from its fifth byte (text = resp + 4), so the enhanced code
@@ -263,7 +275,7 @@ func printable(s string, max int) string {
// tailer got to it: after the panel was down the two are hours apart. The // tailer got to it: after the panel was down the two are hours apart. The
// syslog format carries neither year nor zone - the year is taken from now // syslog format carries neither year nor zone - the year is taken from now
// (a stamp that would then lie in the future is last year's), the zone is the // (a stamp that would then lie in the future is last year's), the zone is the
// container's. Postfix 3.7.11, src/util/msg_logger.c, msg_logger_print(): the // container's. Postfix 3.10.13, src/util/msg_logger.c, msg_logger_print(): the
// stamp is made by the process that logs, with localtime(), and postlogd // stamp is made by the process that logs, with localtime(), and postlogd
// writes it unchanged (src/postlogd/postlogd.c, postlogd_service()); that // writes it unchanged (src/postlogd/postlogd.c, postlogd_service()); that
// process has the container's zone (TZ is in import_environment, // process has the container's zone (TZ is in import_environment,
+50 -7
View File
@@ -53,8 +53,9 @@ func inboundFixture() (*inboundJournal, *fakeInboundStore) {
var tailNow = time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC) var tailNow = time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)
// The mail.log lines in this file are composed by hand, after // The mail.log lines in this file are composed by hand, after
// src/smtpd/smtpd_check.c log_whatsup() and src/cleanup/cleanup_milter.c // src/smtpd/smtpd_check.c log_whatsup(), src/cleanup/cleanup_milter.c
// cleanup_milter_apply() (Postfix 3.7.11), with the stamp Postfix 3.7 writes // cleanup_milter_apply() and src/cleanup/cleanup_api.c cleanup_flush()
// (Postfix 3.10.13), with the stamp Postfix 3.10 writes
// (src/util/msg_logger.c, "%b %d %H:%M:%S"). // (src/util/msg_logger.c, "%b %d %H:%M:%S").
const ( const (
head = "Oct 11 12:00:05 mail " head = "Oct 11 12:00:05 mail "
@@ -89,6 +90,26 @@ func TestInboundTempfailByPostfix(t *testing.T) {
} }
} }
// Postfix 3.10's log_whatsup() appends " sasl_method=%s", " sasl_username=%s"
// and " sasl_sender=%s" after the HELO name for a session that has them
// (src/smtpd/smtpd_check.c; RELEASE_NOTES, [Incompat 20241023]). Port 25 has
// SASL off, so it never writes them there; a line that had them, with the
// HELO name or without it, is still read for what comes before. Composed
// from that format.
func TestInboundRejectTakesTheSASLTail(t *testing.T) {
for name, line := range map[string]string{
"after the HELO name": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<a@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<mx.example.net> sasl_method=PLAIN sasl_username=u sasl_sender=s",
"with no HELO name": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<a@example.net> to=<nobody@in.example.com> proto=ESMTP sasl_method=PLAIN sasl_username=u",
} {
j, st := inboundFixture()
j.handle(line, tailNow, false)
if len(st.rows) != 1 || st.rows[0].From != "a@example.net" || st.rows[0].To != "nobody@in.example.com" ||
st.rows[0].Detail != "550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table" {
t.Errorf("%s: rows = %+v", name, st.rows)
}
}
}
// The filter's verdict is written by cleanup, under one name for every // The filter's verdict is written by cleanup, under one name for every
// listener: it is the journal's only for a queue id port 25 opened. // listener: it is the journal's only for a queue id port 25 opened.
func TestInboundMilterVerdictNeedsAPort25QueueID(t *testing.T) { func TestInboundMilterVerdictNeedsAPort25QueueID(t *testing.T) {
@@ -137,7 +158,7 @@ func TestInboundIgnores(t *testing.T) {
for name, line := range map[string]string{ for name, line := range map[string]string{
"another domain (relay access denied)": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 554 5.7.1 <c@elsewhere.example>: Relay access denied; from=<a@example.net> to=<c@elsewhere.example> proto=ESMTP helo=<x>", "another domain (relay access denied)": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 554 5.7.1 <c@elsewhere.example>: Relay access denied; from=<a@example.net> to=<c@elsewhere.example> proto=ESMTP helo=<x>",
"no recipient (size limit at MAIL)": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: MAIL from x[203.0.113.9]: 552 5.3.4 Message size exceeds fixed limit; proto=ESMTP helo=<x>", "no recipient (size limit at MAIL)": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: MAIL from x[203.0.113.9]: 552 5.3.4 Message size exceeds fixed limit; proto=ESMTP helo=<x>",
"a submission port": head + "postfix/smtpd[20]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 553 5.7.1 <b@in.example.com>: Sender address rejected: not owned by user app; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<x>", "a submission port": head + "postfix/smtpd[20]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 553 5.7.1 <b@in.example.com>: Sender address rejected: not owned by user app; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<x> sasl_method=PLAIN sasl_username=app",
"a delivery line": head + "postfix/smtp[26]: 41E862C00D9E: to=<b@in.example.com>, relay=mx[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)", "a delivery line": head + "postfix/smtp[26]: 41E862C00D9E: to=<b@in.example.com>, relay=mx[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
"a connect line": head + "postfix/inbound/smtpd[41]: connect from mx.example.net[203.0.113.9]", "a connect line": head + "postfix/inbound/smtpd[41]: connect from mx.example.net[203.0.113.9]",
// The HELO name is the sender's; a line cannot be started from inside one. // The HELO name is the sender's; a line cannot be started from inside one.
@@ -176,11 +197,28 @@ func TestInboundLookupFailureLeavesNoRow(t *testing.T) {
// A message the filter holds goes on to the inbound journal milter, which // A message the filter holds goes on to the inbound journal milter, which
// writes its row; a second one from the log would be the same message twice. // writes its row; a second one from the log would be the same message twice.
func TestInboundHoldIsTheMiltersRow(t *testing.T) { func TestInboundHoldIsTheMiltersRow(t *testing.T) {
// Postfix 3.10.13 logs a quarantine with the milter's reason, or
// "default_action" when milter_default_action asked for it, where 3.7
// wrote "milter triggers HOLD action" (src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(), and src/smtpd/smtpd.c, check_milter_reply():
// text = reply[1] ? reply + 1 : "milter triggers HOLD action";
// src/milter/milter8.c, SMFIR_QUARANTINE "H%.*s" and milter8_comm_error()
// "Hdefault_action"). Whatever the reason says, the line is a hold.
// Composed from those formats.
for _, hold := range []string{
"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: milter triggers HOLD action; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>",
"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: Gtube pattern; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>",
"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: default_action; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>",
// A reason that spells a reject is still the reason of a hold.
"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: x: milter-reject: END-OF-MESSAGE from y[192.0.2.1]: 5.7.1 no; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>",
"postfix/inbound/smtpd[41]: Q1: milter-hold: RCPT from mx.example.net[203.0.113.9]: held for review; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>",
} {
j, st := inboundFixture() j, st := inboundFixture()
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow, false) j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow, false)
j.handle(head+"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: milter triggers HOLD action; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>", tailNow, false) j.handle(head+hold, tailNow, false)
if len(st.rows) != 0 { if len(st.rows) != 0 {
t.Errorf("rows = %+v", st.rows) t.Errorf("%s: rows = %+v", hold, st.rows)
}
} }
} }
@@ -216,13 +254,18 @@ func TestInboundHeloCannotWriteTheRow(t *testing.T) {
} }
} }
// A queue id is port 25's only until the message is queued or the id is // A queue id is port 25's only until the message is queued, cleanup removes
// opened again by another listener - Postfix reuses ids. // it unqueued, or the id is opened again by another listener - Postfix reuses
// ids.
func TestInboundQueueIDIsForgotten(t *testing.T) { func TestInboundQueueIDIsForgotten(t *testing.T) {
const verdict = "postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from app[203.0.113.4]: 4.7.1 Service unavailable - try again later; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<app>" const verdict = "postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from app[203.0.113.4]: 4.7.1 Service unavailable - try again later; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<app>"
for name, forget := range map[string]string{ for name, forget := range map[string]string{
"queued": "postfix/qmgr[58]: Q1: from=<a@example.net>, size=600, nrcpt=1 (queue active)", "queued": "postfix/qmgr[58]: Q1: from=<a@example.net>, size=600, nrcpt=1 (queue active)",
"reused by the submission port": "postfix/smtpd[20]: Q1: client=app.example.com[203.0.113.4], sasl_method=PLAIN, sasl_username=app", "reused by the submission port": "postfix/smtpd[20]: Q1: client=app.example.com[203.0.113.4], sasl_method=PLAIN, sasl_username=app",
// Postfix 3.10's cleanup_flush(): "%s: removed (%s)", for a message
// whose client went away in DATA, or that was discarded.
"canceled by cleanup": "postfix/cleanup[52]: Q1: removed (canceled)",
"discarded by cleanup": "postfix/cleanup[52]: Q1: removed (discarded)",
} { } {
j, st := inboundFixture() j, st := inboundFixture()
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow, false) j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow, false)
+19 -9
View File
@@ -3,6 +3,7 @@ package logtail
import ( import (
"log" "log"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
@@ -28,7 +29,7 @@ import (
// enable_long_queue_ids, digits and letters without vowels; neither word is // enable_long_queue_ids, digits and letters without vowels; neither word is
// either. // either.
// //
// Postfix 3.7.11, src/global/mail_queue.c, mail_queue_enter(), with // Postfix 3.10.13, src/global/mail_queue.c, mail_queue_enter(), with
// src/global/mail_queue.h MQID_*: a short id is "%05X" of the microseconds // src/global/mail_queue.h MQID_*: a short id is "%05X" of the microseconds
// and "%lX" of the inode (6 to 21 characters); a long one is the seconds (6+) // and "%lX" of the inode (6 to 21 characters); a long one is the seconds (6+)
// and microseconds (4) in src/global/safe_ultostr.c's vowel-less alphabet, 'z', // and microseconds (4) in src/global/safe_ultostr.c's vowel-less alphabet, 'z',
@@ -38,7 +39,7 @@ var queueLineRe = regexp.MustCompile(`^\S+ ([^\s\[]+)\[\d+\]: ([0-9A-F]{6,24}|[0
// smtpdProgramRe tells an smtpd from the other programs, whatever listener it // smtpdProgramRe tells an smtpd from the other programs, whatever listener it
// serves: "postfix/smtpd", "postfix/submission/smtpd", "postfix/inbound/smtpd". // serves: "postfix/smtpd", "postfix/submission/smtpd", "postfix/inbound/smtpd".
// Postfix 3.7.11, src/global/mail_task.c, mail_task(): the tag is // Postfix 3.10.13, src/global/mail_task.c, mail_task(): the tag is
// "<syslog_name>/<process name>"; a master.cf "-o syslog_name=" reaches it // "<syslog_name>/<process name>"; a master.cf "-o syslog_name=" reaches it
// through mail_params_init() (src/global/mail_params.c) before // through mail_params_init() (src/global/mail_params.c) before
// single_server_main() calls maillog_client_init() again. // single_server_main() calls maillog_client_init() again.
@@ -55,7 +56,7 @@ const (
// logged when it arrives and its row is written at end-of-message; an hour // logged when it arrives and its row is written at end-of-message; an hour
// is a message body sent slowly. Postfix sets no such bound: smtpd_timeout // is a message body sent slowly. Postfix sets no such bound: smtpd_timeout
// limits each read, not the message (postconf(5) smtpd_timeout, // limits each read, not the message (postconf(5) smtpd_timeout,
// smtpd_per_request_deadline, Postfix 3.7.11) - assumptions A-34. // smtpd_per_request_deadline, Postfix 3.10.13) - assumptions A-34.
lineGrace = time.Hour lineGrace = time.Hour
// lineBatch is how many lines are held before they are written without // lineBatch is how many lines are held before they are written without
@@ -80,13 +81,21 @@ type lineKeeper struct {
// port25 holds the queue ids the port-25 smtpd opened: mail offered by // port25 holds the queue ids the port-25 smtpd opened: mail offered by
// strangers, which never gets a send-log row. Its lines are not kept - what // strangers, which never gets a send-log row. Its lines are not kept - what
// a stranger can make Postfix log is not something to store on his say-so // a stranger can make Postfix log is not something to store on his say-so
// and prune later. An id leaves when qmgr removes the message or when // and prune later. An id leaves when qmgr removes the message, when
// another listener opens a message under it (Postfix reuses ids). // cleanup removes a message it did not queue, or when another listener
// Postfix 3.7.11: smtpd writes "<id>: client=..." when it opens the queue // opens a message under it (Postfix reuses ids).
// Postfix 3.10.13: smtpd writes "<id>: client=..." when it opens the queue
// file (src/smtpd/smtpd.c, mail_open_stream()); qmgr writes "<id>: removed" // file (src/smtpd/smtpd.c, mail_open_stream()); qmgr writes "<id>: removed"
// when it deletes it (src/qmgr/qmgr_active.c, qmgr_active_done_3_generic()); // when it deletes it (src/qmgr/qmgr_active.c, qmgr_active_done_3_generic());
// short ids repeat (postconf(5) enable_long_queue_ids: the long ones are // cleanup writes "<id>: removed (canceled)" or "<id>: removed (discarded)"
// the "non-repeating" kind). // when it deletes the queue file of a message it refused, discarded or
// was not given in full (src/cleanup/cleanup_api.c, cleanup_flush(),
// msg_info("%s: removed (%s)"), new in 3.10: HISTORY 20241112), after its
// milter verdict line (cleanup_milter_inspect() runs earlier in
// cleanup_flush()) and after smtpd's line about a connection lost in DATA
// (src/smtpd/smtpd.c, smtpd_proto(), logs it before mail_reset() closes the
// cleanup stream); short ids repeat (postconf(5) enable_long_queue_ids:
// the long ones are the "non-repeating" kind).
port25 map[string]struct{} port25 map[string]struct{}
} }
@@ -121,7 +130,8 @@ func (k *lineKeeper) add(line string, now time.Time, catchingUp bool) {
} }
} }
if _, inbound := k.port25[queueID]; inbound { if _, inbound := k.port25[queueID]; inbound {
if program == "postfix/qmgr" && text == "removed" { if program == "postfix/qmgr" && text == "removed" ||
program == "postfix/cleanup" && strings.HasPrefix(text, "removed (") {
delete(k.port25, queueID) delete(k.port25, queueID)
} }
return return
+41
View File
@@ -24,6 +24,15 @@ func TestQueueLineHead(t *testing.T) {
{"port-25 smtpd", "mail postfix/inbound/smtpd[41]: 4A1B2C3D: client=mx.example.net[203.0.113.9]", "4A1B2C3D"}, {"port-25 smtpd", "mail postfix/inbound/smtpd[41]: 4A1B2C3D: client=mx.example.net[203.0.113.9]", "4A1B2C3D"},
{"cleanup", "mail postfix/cleanup[52]: 41E862C00D9E: message-id=<1@example.com>", "41E862C00D9E"}, {"cleanup", "mail postfix/cleanup[52]: 41E862C00D9E: message-id=<1@example.com>", "41E862C00D9E"},
{"qmgr removes it", "mail postfix/qmgr[10]: 41E862C00D9E: removed", "41E862C00D9E"}, {"qmgr removes it", "mail postfix/qmgr[10]: 41E862C00D9E: removed", "41E862C00D9E"},
// Postfix 3.10.13: src/cleanup/cleanup_api.c, cleanup_flush(), "%s:
// removed (%s)"; src/smtpd/smtpd.c, smtpd_proto(), "%s: %s after %s
// (%lu bytes) from %s" with the queue id or "NOQUEUE".
{"cleanup removes an unqueued message", "mail postfix/cleanup[52]: 41E862C00D9E: removed (canceled)", "41E862C00D9E"},
{"a connection lost in DATA", "mail postfix/submission/smtpd[10]: 41E862C00D9E: lost connection after DATA (5420 bytes) from unknown[203.0.113.7]", "41E862C00D9E"},
{"a connection lost before a queue id", "mail postfix/smtpd[10]: NOQUEUE: lost connection after EHLO from unknown[203.0.113.7]", ""},
// The same function's "%s: %s after %s from %s" for the other reasons.
{"too many errors with a queue id", "mail postfix/submission/smtpd[10]: 41E862C00D9E: too many errors after RCPT from unknown[203.0.113.7]", "41E862C00D9E"},
{"a timeout before a queue id", "mail postfix/smtpd[10]: NOQUEUE: timeout after MAIL from unknown[203.0.113.7]", ""},
{"a bounce is sent: the id at the head, not the new message's", "mail postfix/bounce[30]: 41E862C00D9E: sender non-delivery notification: 5F00AA12", "41E862C00D9E"}, {"a bounce is sent: the id at the head, not the new message's", "mail postfix/bounce[30]: 41E862C00D9E: sender non-delivery notification: 5F00AA12", "41E862C00D9E"},
{"long queue id", "mail postfix/qmgr[10]: 3sJ8Xh2Cgqz4Xy1: removed", "3sJ8Xh2Cgqz4Xy1"}, {"long queue id", "mail postfix/qmgr[10]: 3sJ8Xh2Cgqz4Xy1: removed", "3sJ8Xh2Cgqz4Xy1"},
{"refused before queueing", "mail postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <n@in.example.com>: Recipient address rejected; from=<a@example.net> to=<n@in.example.com> proto=ESMTP helo=<mx.example.net>", ""}, {"refused before queueing", "mail postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <n@in.example.com>: Recipient address rejected; from=<a@example.net> to=<n@in.example.com> proto=ESMTP helo=<mx.example.net>", ""},
@@ -162,6 +171,38 @@ func TestLineKeeperLeavesOutPort25Mail(t *testing.T) {
} }
} }
// A port-25 message that is never queued - refused at end-of-message, or its
// client gone in DATA - ends with cleanup's "removed (canceled)" rather than
// qmgr's "removed", and its id is anybody's again from there. Postfix 3.10.13:
// src/smtpd/smtpd.c, smtpd_proto(), logs the lost connection before
// mail_reset() closes the cleanup stream; src/cleanup/cleanup_api.c,
// cleanup_flush(), logs "%s: removed (%s)" after the milter verdict. The
// lines are composed from those formats.
func TestLineKeeperForgetsPort25AtCleanupRemoval(t *testing.T) {
cs := &captureStore{}
k := newLineKeeper(cs)
now := time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC)
const bounce = "host postfix/cleanup[52]: 4A1B2C3D: message-id=<20261012180000.4A1B2C3D@mail.example.com>"
for _, line := range []string{
"host postfix/inbound/smtpd[41]: 4A1B2C3D: client=mx.example.net[198.51.100.9]",
"host postfix/inbound/smtpd[41]: 4A1B2C3D: lost connection after DATA (5420 bytes) from mx.example.net[198.51.100.9]",
"host postfix/cleanup[52]: 4A1B2C3D: removed (canceled)",
// The id comes round for a bounce Postfix generates, which has no smtpd.
bounce,
} {
k.add(line, now, false)
}
k.flush()
if got := cs.kept(); len(got) != 1 || got[0] != "4A1B2C3D|"+bounce {
t.Errorf("kept %v, want only the bounce's line", got)
}
if len(k.port25) != 0 {
t.Errorf("port-25 ids still remembered: %v", k.port25)
}
}
// The tailer keeps queue-id lines as it follows the log, and after an unclean // The tailer keeps queue-id lines as it follows the log, and after an unclean
// stop - its saved place lies before lines it had already read - it does not // stop - its saved place lies before lines it had already read - it does not
// keep them a second time. // keep them a second time.
+19 -18
View File
@@ -62,7 +62,7 @@ func RetentionInterval() time.Duration {
} }
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep. // queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix. Postfix 3.7.11, // It is a var so tests can answer without a running Postfix. Postfix 3.10.13,
// src/showq/showq.c, showq_service(): the listing covers the maildrop, // src/showq/showq.c, showq_service(): the listing covers the maildrop,
// active, incoming, deferred and hold queues. // active, incoming, deferred and hold queues.
var queueIDs = postfix.QueueIDs var queueIDs = postfix.QueueIDs
@@ -91,7 +91,7 @@ type RetentionDays func() int
// postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK) // postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK)
// //
// The "<queue-id>: to=<addr>, …, status=<word>" shape is the per-recipient // The "<queue-id>: to=<addr>, …, status=<word>" shape is the per-recipient
// result line: Postfix 3.7.11, src/global/log_adhoc.c, log_adhoc() writes // result line: Postfix 3.10.13, src/global/log_adhoc.c, log_adhoc() writes
// "%s: to=<%s>", ", orig_to=<%s>" (only when the address was rewritten), // "%s: to=<%s>", ", orig_to=<%s>" (only when the address was rewritten),
// ", relay=%s", then ", dsn=%s, status=%s (%s)". It is called from sent(), // ", relay=%s", then ", dsn=%s, status=%s (%s)". It is called from sent(),
// defer_append() and bounce_append() in whichever process decides - the // defer_append() and bounce_append() in whichever process decides - the
@@ -150,7 +150,7 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
// //
// postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender // postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender
// //
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic(): // Postfix 3.10.13, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic():
// msg_info("%s: from=<%s>, status=%s, returned to sender") with "expired" // msg_info("%s: from=<%s>, status=%s, returned to sender") with "expired"
// once the queue lifetime has passed, "force-expired" after postsuper -e. // once the queue lifetime has passed, "force-expired" after postsuper -e.
// It is the only line about the expiry, one per message, and no recipient is // It is the only line about the expiry, one per message, and no recipient is
@@ -253,7 +253,7 @@ func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}
} }
// A message Postfix still works on stays queued until its first retry after // A message Postfix still works on stays queued until its first retry after
// the queue lifetime, which can be a whole backoff later. Postfix 3.7.11, // the queue lifetime, which can be a whole backoff later. Postfix 3.10.13,
// src/qmgr/qmgr_active.c: expiry is decided when an attempt ends // src/qmgr/qmgr_active.c: expiry is decided when an attempt ends
// (qmgr_active_done_25_generic()), and a deferred message waits at most // (qmgr_active_done_25_generic()), and a deferred message waits at most
// maximal_backoff_time (qmgr_active_done_3_generic()). The deferred queue // maximal_backoff_time (qmgr_active_done_3_generic()). The deferred queue
@@ -361,7 +361,7 @@ func reconcile(st StatusStore, cutoff time.Time, path string) {
// message's expiry line. A deferred line is not one: a deferred row has one // message's expiry line. A deferred line is not one: a deferred row has one
// by definition. Rows reach the sweep minutes after they were written, so // by definition. Rows reach the sweep minutes after they were written, so
// the end of those two files is where such a line would be. // the end of those two files is where such a line would be.
// logrotate 3.21.0, logrotate.c, prerotateSingleLog(): the first rotated // logrotate 3.22.0, logrotate.c, prerotateSingleLog(): the first rotated
// name is "<log>.1", without the compression suffix under delaycompress // name is "<log>.1", without the compression suffix under delaycompress
// (build/logrotate-mail.conf). // (build/logrotate-mail.conf).
func resultInLog(path string, row store.QueuedDelivery) bool { func resultInLog(path string, row store.QueuedDelivery) bool {
@@ -556,11 +556,11 @@ func QueueLines(path, queueID string, n int) ([]string, error) {
// mentionsQueueID reports whether a mail.log line is about queueID. Postfix // mentionsQueueID reports whether a mail.log line is about queueID. Postfix
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…"; // writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…";
// Postfix 3.7.11 "%s: ..." in log_adhoc(), smtpd's "%s: client=", qmgr's // Postfix 3.10.13 "%s: ..." in log_adhoc(), smtpd's "%s: client=", qmgr's
// "%s: removed"), // "%s: removed", cleanup's "%s: removed (%s)"), and the match is anchored on
// and the match is anchored on the character before it so a shorter id is not // the character before it so a shorter id is not found inside a longer one —
// found inside a longer one — queue ids are hexadecimal, and one being the tail // queue ids are hexadecimal, and one being the tail of another is ordinary,
// of another is ordinary, not unlikely. // not unlikely.
func mentionsQueueID(line, queueID string) bool { func mentionsQueueID(line, queueID string) bool {
needle := queueID + ":" needle := queueID + ":"
for i := 0; i <= len(line)-len(needle); { for i := 0; i <= len(line)-len(needle); {
@@ -582,16 +582,17 @@ func isQueueIDByte(b byte) bool {
} }
// syslogStampRe is the timestamp at the head of a mail.log line (maillog_file // syslogStampRe is the timestamp at the head of a mail.log line (maillog_file
// in build/postfix-config.sh), the one format Postfix 3.7 writes. Postfix 3.7.11, // in build/postfix-config.sh), the one format Postfix 3.10 writes. Postfix
// src/util/msg_logger.c, msg_logger_print(): every record is stamped // 3.10.13, src/util/msg_logger.c, msg_logger_print(): every record is stamped
// strftime("%b %d %H:%M:%S ") in the logging process's local time (the day // strftime("%b %d %H:%M:%S ") in the logging process's local time (the day
// zero-padded), then the first label of the host name, and postlogd writes it // zero-padded), then the first label of the host name, and postlogd writes it
// as received (src/postlogd/postlogd.c, postlogd_service()). It carries no // as received (src/postlogd/postlogd.c, postlogd_service()). It carries no
// year and no zone, so the stamp shown is a wall clock and nothing more, // year and no zone, so the stamp shown is a wall clock and nothing more,
// which is all this column claims to be. An RFC 3339 stamp once matched // which is all this column claims to be. An RFC 3339 stamp once matched
// here too, for a maillog_file_format parameter said to arrive in Postfix // here too, for a maillog_file_format parameter said to arrive in Postfix
// 3.9; there is none in 3.7.11, nor in the 3.12 snapshot of 2026-09-25 // 3.9; there is none in 3.7.11, nor in 3.10.13, nor in the 3.12 snapshot of
// (proto/postconf.proto; msg_logger.c keeps the strftime) - assumptions A-12. // 2026-09-25 (proto/postconf.proto; msg_logger.c keeps the strftime) -
// assumptions A-12.
var syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`) var syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
// SplitTimestamp separates the timestamp at the head of a mail.log line from // SplitTimestamp separates the timestamp at the head of a mail.log line from
@@ -615,7 +616,7 @@ func SplitTimestamp(line string) (stamp, rest string) {
// first ever run); it reopens the file when it is rotated, so nothing is missed. // first ever run); it reopens the file when it is rotated, so nothing is missed.
// The inode changes under logrotate's create, which build/logrotate-mail.conf // The inode changes under logrotate's create, which build/logrotate-mail.conf
// uses; a file shorter than the read position is taken as truncation. // uses; a file shorter than the read position is taken as truncation.
// logrotate 3.21.0, logrotate.c, rotateSingleLog(): without copytruncate the // logrotate 3.22.0, logrotate.c, rotateSingleLog(): without copytruncate the
// file is rename()d and, with create, a new one made by createOutputFile(); // file is rename()d and, with create, a new one made by createOutputFile();
// with copytruncate it is copied and truncated, which that config does not use. // with copytruncate it is copied and truncated, which that config does not use.
// //
@@ -817,14 +818,14 @@ func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{
} }
// rotatedTail is mail.log just rotated away, still open: postlogd writes to // rotatedTail is mail.log just rotated away, still open: postlogd writes to
// it until "postfix reload" makes it exit. Postfix 3.7.11, // it until "postfix reload" makes it exit. Postfix 3.10.13,
// src/postlogd/postlogd.c, pre_jail_init(): maillog_file is opened once per // src/postlogd/postlogd.c, pre_jail_init(): maillog_file is opened once per
// postlogd process; src/master/master_service.c, master_restart_service(), // postlogd process; src/master/master_service.c, master_restart_service(),
// closes the service's status pipe at a reload (master_status_cleanup()), // closes the service's status pipe at a reload (master_status_cleanup()),
// and src/master/dgram_server.c, dgram_server_abort(), exits when it reads // and src/master/dgram_server.c, dgram_server_abort(), exits when it reads
// its end - the next postlogd opens the new file. logrotate runs the reload // its end - the next postlogd opens the new file. logrotate runs the reload
// right after the rename and create (build/logrotate-mail.conf, postrotate; // right after the rename and create (build/logrotate-mail.conf, postrotate;
// logrotate 3.21.0, logrotate.c, rotateLogSet(): the postrotate script runs // logrotate 3.22.0, logrotate.c, rotateLogSet(): the postrotate script runs
// right after rotateSingleLog() has renamed and created), so the gap is short; // right after rotateSingleLog() has renamed and created), so the gap is short;
// rotatedGrace is far longer than it. // rotatedGrace is far longer than it.
type rotatedTail struct { type rotatedTail struct {
@@ -845,7 +846,7 @@ const rotatedGrace = time.Minute
// lost to a restart and lands on the days it covered. A stretch shorter than // lost to a restart and lands on the days it covered. A stretch shorter than
// blindMin is not counted: a rotation leaves the name missing for a tick // blindMin is not counted: a rotation leaves the name missing for a tick
// between rename and create, and that loses nothing - the new file is read // between rename and create, and that loses nothing - the new file is read
// from its start. logrotate 3.21.0, logrotate.c, rotateSingleLog(): the // from its start. logrotate 3.22.0, logrotate.c, rotateSingleLog(): the
// rename() and createOutputFile() follow each other in one pass, with no // rename() and createOutputFile() follow each other in one pass, with no
// script or wait between them (postrotate runs after). // script or wait between them (postrotate runs after).
type blindness struct { type blindness struct {
+26 -4
View File
@@ -15,12 +15,12 @@ import (
) )
// The mail.log lines in this file are composed by hand, after the formats the // The mail.log lines in this file are composed by hand, after the formats the
// code cites (Postfix 3.7.11, src/global/log_adhoc.c and the others), unless a // code cites (Postfix 3.10.13, src/global/log_adhoc.c and the others), unless a
// comment says where one was seen; some carry no stamp, which the parsers // comment says where one was seen; some carry no stamp, which the parsers
// take as they come. One shape Postfix does not write here: opendkim lines in // take as they come. One shape Postfix does not write here: opendkim lines in
// mail.log (OpenDKIM logs through syslog(3), to the container log - build/opendkim.conf). // mail.log (OpenDKIM logs through syslog(3), to the container log - build/opendkim.conf).
// qmgr's expiry line, as Postfix 3.7.11 writes it (src/qmgr/qmgr_active.c, // qmgr's expiry line, as Postfix 3.10.13 writes it (src/qmgr/qmgr_active.c,
// qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to // qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to
// sender"); composed by hand after that format. The sender is the client's // sender"); composed by hand after that format. The sender is the client's
// text, so a line only resembles one when it ends as qmgr's does. // text, so a line only resembles one when it ends as qmgr's does.
@@ -36,6 +36,11 @@ func TestParseExpiry(t *testing.T) {
// qmgr's ordinary from= line, whose sender carries the text. // qmgr's ordinary from= line, whose sender carries the text.
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: from=<x>, status=expired, returned to sender>, size=500, nrcpt=1 (queue active)", ""}, {"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: from=<x>, status=expired, returned to sender>, size=500, nrcpt=1 (queue active)", ""},
{"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: removed", ""}, {"Oct 14 09:01:02 relay postfix/qmgr[93]: 41E862C00D9E: removed", ""},
// cleanup's removal of a message it did not queue (Postfix 3.10.13,
// src/cleanup/cleanup_api.c, cleanup_flush(): "%s: removed (%s)")
// says nothing of what became of a queued one.
{"Oct 14 09:01:02 relay postfix/cleanup[52]: 41E862C00D9E: removed (canceled)", ""},
{"Oct 14 09:01:02 relay postfix/cleanup[52]: 41E862C00D9E: removed (discarded)", ""},
} { } {
id, ok := parseExpiry(c.line) id, ok := parseExpiry(c.line)
if ok != (c.id != "") || id != c.id { if ok != (c.id != "") || id != c.id {
@@ -194,7 +199,8 @@ func TestParseDelivery(t *testing.T) {
status: store.StatusSent, status: store.StatusSent,
}, },
{ {
// The stamp Postfix 3.7's postlogd writes, as on the stand. // The stamp postlogd writes, as on the stand (Postfix 3.7;
// 3.10.13's msg_logger_print() keeps the format).
name: "syslog stamp", name: "syslog stamp",
line: "Oct 14 11:05:51 mail postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=x, dsn=2.0.0, status=sent (250 OK)", line: "Oct 14 11:05:51 mail postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=x, dsn=2.0.0, status=sent (250 OK)",
wantOK: true, wantOK: true,
@@ -221,6 +227,22 @@ func TestParseDelivery(t *testing.T) {
line: "Oct 14 11:05:51 mail postfix/smtpd[10]: 5B4A2C1D3E: client=unknown[203.0.113.7], sasl_username=41E862C00D9E: to=<victim@example.net>, status=sent", line: "Oct 14 11:05:51 mail postfix/smtpd[10]: 5B4A2C1D3E: client=unknown[203.0.113.7], sasl_username=41E862C00D9E: to=<victim@example.net>, status=sent",
wantOK: false, wantOK: false,
}, },
{
// Postfix 3.10.13, src/cleanup/cleanup_api.c, cleanup_flush():
// "%s: removed (%s)" for a transaction cleanup did not queue. It
// is no result for any recipient. Composed from that format.
name: "cleanup's removed (canceled) is not one",
line: "Oct 14 11:05:51 mail postfix/cleanup[52]: 41E862C00D9E: removed (canceled)",
wantOK: false,
},
{
// Postfix 3.10.13, src/smtpd/smtpd.c, smtpd_proto(): "%s: %s after
// %s (%lu bytes) from %s", the queue id first since 3.10. Composed
// from that format.
name: "smtpd's lost connection with a queue id is not one",
line: "Oct 14 11:05:51 mail postfix/submission/smtpd[10]: 41E862C00D9E: lost connection after DATA (5420 bytes) from unknown[203.0.113.7]",
wantOK: false,
},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -920,7 +942,7 @@ func TestSplitTimestamp(t *testing.T) {
stamp, rest string stamp, rest string
}{ }{
{ {
// An RFC 3339 stamp is not one Postfix 3.7 writes (assumptions // An RFC 3339 stamp is not one Postfix 3.10 writes (assumptions
// A-12): it is not taken off. // A-12): it is not taken off.
name: "RFC 3339 is not a stamp Postfix writes", name: "RFC 3339 is not a stamp Postfix writes",
line: "2026-08-03T05:15:52.219218+00:00 mail postfix/qmgr[10]: 4A1B2C3D: removed", line: "2026-08-03T05:15:52.219218+00:00 mail postfix/qmgr[10]: 4A1B2C3D: removed",
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// TestMain reads mail.log stamps in UTC, whatever the zone of the machine the // TestMain reads mail.log stamps in UTC, whatever the zone of the machine the
// tests run on: Postfix 3.7 writes "%b %d %H:%M:%S" in the logging process's // tests run on: Postfix 3.10 writes "%b %d %H:%M:%S" in the logging process's
// local time (src/util/msg_logger.c, msg_logger_print()) and lineTime reads it // local time (src/util/msg_logger.c, msg_logger_print()) and lineTime reads it
// in time.Local, which in the container is the zone the fixtures are written // in time.Local, which in the container is the zone the fixtures are written
// in. // in.
+2 -2
View File
@@ -17,7 +17,7 @@ import (
const ( const (
// fingerprintSize is how many bytes from the head of the log identify it. // fingerprintSize is how many bytes from the head of the log identify it.
// Postfix writes a timestamped line per event (Postfix 3.7.11, // Postfix writes a timestamped line per event (Postfix 3.10.13,
// src/util/msg_logger.c, msg_logger_print(): "%b %d %H:%M:%S" on every // src/util/msg_logger.c, msg_logger_print(): "%b %d %H:%M:%S" on every
// record), so the first 512 bytes are // record), so the first 512 bytes are
// effectively unique per log generation — enough to tell "the file we were // effectively unique per log generation — enough to tell "the file we were
@@ -130,7 +130,7 @@ func (t *tracker) resume(f *os.File) int64 {
// readRotated reads, when the log was rotated while the panel was down, the // readRotated reads, when the log was rotated while the panel was down, the
// rest of the file the saved place belongs to: logrotate renamed it to // rest of the file the saved place belongs to: logrotate renamed it to
// mail.log.1 (delaycompress leaves it plain - logrotate 3.21.0, logrotate.c, // mail.log.1 (delaycompress leaves it plain - logrotate 3.22.0, logrotate.c,
// prerotateSingleLog()), and what Postfix wrote there // prerotateSingleLog()), and what Postfix wrote there
// between the panel's stop and the rotation - deliveries, refusals, deferrals - // between the panel's stop and the rotation - deliveries, refusals, deferrals -
// was otherwise never read (audit 2026-10-12, stage 2b). It is read from the // was otherwise never read (audit 2026-10-12, stage 2b). It is read from the
+11 -8
View File
@@ -18,14 +18,17 @@ import (
// //
// postfix/smtpd[1149]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test> sasl_username=<l2app> // postfix/smtpd[1149]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test> sasl_username=<l2app>
// //
// Postfix 3.7.11, src/smtpd/smtpd.c, check_milter_reply(): "%s: %s: %s from // Postfix 3.10.13, src/smtpd/smtpd.c, check_milter_reply(): "%s: %s: %s from
// %s: %s;%s" with "NOQUEUE" before the queue file is opened, and // %s: %s;%s" with "NOQUEUE" before the queue file is opened, and
// smtpd_whatsup() for the rest: " from=<..>", " to=<..>", " proto=..", // smtpd_whatsup() for the rest: " from=<..>", " to=<..>", " proto=..",
// " helo=<..>", then " sasl_username=<..>" when the session authenticated // " helo=<..>", then " sasl_username=<..>" when the session authenticated
// (assumptions A-20, A-21). The login is read at the end of the line, where // (assumptions A-20, A-21). smtpd's own refusals are another function,
// Postfix puts it last; a line without one is no application's refusal - the // src/smtpd/smtpd_check.c, log_whatsup(), whose line says "reject:", not
// journal milter refuses at MAIL FROM only an authenticated session // "milter-reject:", and since 3.10 ends in " sasl_method=%s sasl_username=%s"
// (milter/ratelimit.go). The HELO name before it is the client's text and can // without the brackets; it is not one of these. The login is read at the end
// of the line, where Postfix puts it last; a line without one is no
// application's refusal - the journal milter refuses at MAIL FROM only an
// authenticated session (milter/ratelimit.go). The HELO name before it is the client's text and can
// end in what looks like the field, but only in a session that has no login, // end in what looks like the field, but only in a session that has no login,
// and so no refused row to take the line; a row must also be of the line's // and so no refused row to take the line; a row must also be of the line's
// client address and sender, within minutes. // client address and sender, within minutes.
@@ -52,7 +55,7 @@ type RefusalStore interface {
// The From: line as the check printed it runs to the last " - " before the // The From: line as the check printed it runs to the last " - " before the
// first semicolon; the reason after it holds neither, and the check prints no // first semicolon; the reason after it holds neither, and the check prints no
// ";" of the client's (sender.go printable), so that semicolon is Postfix's // ";" of the client's (sender.go printable), so that semicolon is Postfix's
// and what follows it - the envelope - is Postfix's own text. Postfix 3.7.11, // and what follows it - the envelope - is Postfix's own text. Postfix 3.10.13,
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from // src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from
// %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then // %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then
// " from=<..>", " to=<..>" - the last recipient only (assumptions A-15). // " from=<..>", " to=<..>" - the last recipient only (assumptions A-15).
@@ -60,7 +63,7 @@ var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d
// clientLoginRe is smtpd's line naming the login a queue id was submitted // clientLoginRe is smtpd's line naming the login a queue id was submitted
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>". // under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
// Postfix 3.7.11, src/smtpd/smtpd.c, mail_open_stream(): ", sasl_sender=.." // Postfix 3.10.13, src/smtpd/smtpd.c, mail_open_stream(): ", sasl_sender=.."
// follows when the client gave AUTH= on MAIL FROM, then ", orig_queue_id=.." // follows when the client gave AUTH= on MAIL FROM, then ", orig_queue_id=.."
// and ", orig_client=.." for XFORWARD; the login ends at the comma (a login // and ", orig_client=.." for XFORWARD; the login ends at the comma (a login
// holds none - internal/postfix assertMapSafe; assumptions A-23). // holds none - internal/postfix assertMapSafe; assumptions A-23).
@@ -148,7 +151,7 @@ func (r *refusalLines) handle(line string, now time.Time, start Place) {
return return
} }
// The address in "name[addr]" is smtpd's state->addr, which has no // The address in "name[addr]" is smtpd's state->addr, which has no
// "IPv6:" (Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_init(); // "IPv6:" (Postfix 3.10.13, src/smtpd/smtpd_peer.c, smtpd_peer_init();
// assumptions A-24). // assumptions A-24).
ip := net.ParseIP(m[2]) ip := net.ParseIP(m[2])
if ip == nil { if ip == nil {
+4 -3
View File
@@ -31,8 +31,9 @@ func (r *refusalProbe) AttachRefusalLine(clientIP, login, from string, _ time.Ti
// The mail.log lines in this file are composed by hand, with the e2e stand's // The mail.log lines in this file are composed by hand, with the e2e stand's
// names and addresses (smtpd.c check_milter_reply(), mail_open_stream(); // names and addresses (smtpd.c check_milter_reply(), mail_open_stream();
// cleanup_milter.c cleanup_milter_apply(), Postfix 3.7.11), with the stamp // smtpd_check.c log_whatsup(); cleanup_milter.c cleanup_milter_apply(),
// Postfix 3.7 writes (src/util/msg_logger.c, "%b %d %H:%M:%S"). // Postfix 3.10.13), with the stamp Postfix 3.10 writes
// (src/util/msg_logger.c, "%b %d %H:%M:%S").
// //
// The NOQUEUE line of a refusal at MAIL FROM goes on its row; a line whose // The NOQUEUE line of a refusal at MAIL FROM goes on its row; a line whose
// row is not written yet waits and is tried again; port 25's and other // row is not written yet waits and is tried again; port 25's and other
@@ -45,7 +46,7 @@ func TestRefusalLines(t *testing.T) {
for _, other := range []string{ for _, other := range []string{
`Oct 14 12:00:00 mail postfix/inbound/smtpd[372]: NOQUEUE: milter-reject: MAIL from unknown[203.0.113.7]: 451 4.7.1 Service unavailable - try again later; from=<a@stranger.example> proto=ESMTP helo=<x>`, `Oct 14 12:00:00 mail postfix/inbound/smtpd[372]: NOQUEUE: milter-reject: MAIL from unknown[203.0.113.7]: 451 4.7.1 Service unavailable - try again later; from=<a@stranger.example> proto=ESMTP helo=<x>`,
`Oct 14 12:00:00 mail postfix/smtpd[11]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`, `Oct 14 12:00:00 mail postfix/smtpd[11]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`,
`Oct 14 12:00:00 mail postfix/smtpd[11]: NOQUEUE: reject: RCPT from unknown[10.77.0.1]: 553 5.7.1 <a@x>: Sender address rejected: not owned by user app1; from=<a@x> to=<b@y> proto=ESMTP helo=<x>`, `Oct 14 12:00:00 mail postfix/smtpd[11]: NOQUEUE: reject: RCPT from unknown[10.77.0.1]: 553 5.7.1 <a@x>: Sender address rejected: not owned by user app1; from=<a@x> to=<b@y> proto=ESMTP helo=<x> sasl_method=PLAIN sasl_username=app1`,
// No login: no application's refusal (the panel down refuses every // No login: no application's refusal (the panel down refuses every
// session, an unauthenticated one too). // session, an unauthenticated one too).
`Oct 14 12:00:00 mail postfix/smtpd[12]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<x>`, `Oct 14 12:00:00 mail postfix/smtpd[12]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<billing@sender.e2e.test> proto=ESMTP helo=<x>`,
+2 -2
View File
@@ -29,7 +29,7 @@ import (
// The reject lines do not: the same words are written when OpenDKIM does not // The reject lines do not: the same words are written when OpenDKIM does not
// answer and when the journal milter refuses under a level-2 limit. // answer and when the journal milter refuses under a level-2 limit.
// //
// Postfix 3.7.11, src/milter/milter8.c: milter8_connect() warns "connect to // Postfix 3.10.13, src/milter/milter8.c: milter8_connect() warns "connect to
// Milter service %s: %m" with the milter's configured name; then // Milter service %s: %m" with the milter's configured name; then
// milter8_comm_error() answers, for default_action=tempfail, "451 4.7.1 // milter8_comm_error() answers, for default_action=tempfail, "451 4.7.1
// Service unavailable - try again later" - the very text milter8_event() // Service unavailable - try again later" - the very text milter8_event()
@@ -50,7 +50,7 @@ import (
// //
// One cleanup process serves message after message and the first warning // One cleanup process serves message after message and the first warning
// ends the message, so each such warning is a message, counted on its own. // ends the message, so each such warning is a message, counted on its own.
// Postfix 3.7.11, src/milter/milter8.c, milter8_read_resp(): "milter %s: // Postfix 3.10.13, src/milter/milter8.c, milter8_read_resp(): "milter %s:
// can't read %s reply packet header: %m", then milter8_comm_error(), which // can't read %s reply packet header: %m", then milter8_comm_error(), which
// closes the milter's stream and leaves it in the error state for the rest of // closes the milter's stream and leaves it in the error state for the rest of
// that message. // that message.
+1 -1
View File
@@ -39,7 +39,7 @@ func (d *deferredStore) AddSenderCheckDeferred(at time.Time, n int64, place stri
// that use the same words - a level-2 refusal of the journal milter, OpenDKIM // that use the same words - a level-2 refusal of the journal milter, OpenDKIM
// not answering. Those - smtpd 1149, 1190 and 1201, the second client of // not answering. Those - smtpd 1149, 1190 and 1201, the second client of
// 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed, // 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed,
// after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.7.11). // after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.10.13).
func TestDeferredCounter(t *testing.T) { func TestDeferredCounter(t *testing.T) {
st := &deferredStore{} st := &deferredStore{}
c := newDeferredCounter(&SenderCheck{Store: st, Socket: "/run/selfpost/sender.sock"}) c := newDeferredCounter(&SenderCheck{Store: st, Socket: "/run/selfpost/sender.sock"})
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCleanAddress(t *testing.T) {
// The journal row's recipient has to be the address Postfix later logs in // The journal row's recipient has to be the address Postfix later logs in
// to=<…>, or the delivery line matches no row and the sweep closes it as // to=<…>, or the delivery line matches no row and the sweep closes it as
// bounced: the local part unquoted, then quoted again only when it is not a // bounced: the local part unquoted, then quoted again only when it is not a
// dot-string (Postfix 3.7.11, src/global/tok822_parse.c and // dot-string (Postfix 3.10.13, src/global/tok822_parse.c and
// quote_822_local.c). Composed by hand after those functions. // quote_822_local.c). Composed by hand after those functions.
func TestRecipientAsLogged(t *testing.T) { func TestRecipientAsLogged(t *testing.T) {
for _, c := range []struct{ in, want string }{ for _, c := range []struct{ in, want string }{
+3 -3
View File
@@ -34,7 +34,7 @@ type InboundStore interface {
// Postfix attaches the socket with default_action=accept, so // Postfix attaches the socket with default_action=accept, so
// neither an error here nor the panel being down holds up inbound mail. // neither an error here nor the panel being down holds up inbound mail.
// //
// Postfix 3.7.11, src/milter/milter.c, milter_message(): the milters are asked // Postfix 3.10.13, src/milter/milter.c, milter_message(): the milters are asked
// in turn while the reply is null, so one that refuses is the last asked; // in turn while the reply is null, so one that refuses is the last asked;
// src/milter/milter8.c, milter8_comm_error(): default_action=accept gives no // src/milter/milter8.c, milter8_comm_error(): default_action=accept gives no
// reply when this milter cannot answer. // reply when this milter cannot answer.
@@ -91,7 +91,7 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons
// the last one read is its own - when it is up. Whether it was is decided at // the last one read is its own - when it is up. Whether it was is decided at
// end-of-message, by the signature. // end-of-message, by the signature.
// //
// Postfix 3.7.11, src/milter/milter.c, milter_message(), hands each milter the // Postfix 3.10.13, src/milter/milter.c, milter_message(), hands each milter the
// queue file from the start of the content; src/milter/milter8.c, // queue file from the start of the content; src/milter/milter8.c,
// milter8_message(), reads it with rec_get(), which follows the pointer // milter8_message(), reads it with rec_get(), which follows the pointer
// records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after // records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after
@@ -151,7 +151,7 @@ func (s *inboundSession) signed(queueID string) bool {
// upstream. "Continue" lets it through as "accept" would, and keeps this // upstream. "Continue" lets it through as "accept" would, and keeps this
// session for the connection's next message. // session for the connection's next message.
// //
// The queue id: Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS // The queue id: Postfix 3.10.13, src/global/mail_params.h, DEF_MILT_EOD_MACROS
// "i", answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). The // "i", answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). The
// rest as session.Body in milter.go. // rest as session.Body in milter.go.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) { func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// reservationTTL bounds how long a message may stay reserved. A reservation is // reservationTTL bounds how long a message may stay reserved. A reservation is
// released at end-of-message or on ABORT. A client that drops the connection // released at end-of-message or on ABORT. A client that drops the connection
// mid-transaction still produces ABORT: Postfix 3.7.11, src/smtpd/smtpd.c, // mid-transaction still produces ABORT: Postfix 3.10.13, src/smtpd/smtpd.c,
// smtpd_proto() on a lost connection runs mail_reset(), which calls // smtpd_proto() on a lost connection runs mail_reset(), which calls
// milter_abort() (src/milter/milter8.c, milter8_abort(), SMFIC_ABORT) // milter_abort() (src/milter/milter8.c, milter8_abort(), SMFIC_ABORT)
// (assumptions B-G7). What produces neither is smtpd itself dying, or the // (assumptions B-G7). What produces neither is smtpd itself dying, or the
+1 -1
View File
@@ -2,7 +2,7 @@ package milter
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of // The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's // libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
// opendkim 2.11.0~beta2-8+deb12u1 (libopendkim/dkim-mailparse.c, identical // opendkim 2.11.0~beta2-9.1+b1 (libopendkim/dkim-mailparse.c, identical
// there to upstream's develop branch), line for line. OpenDKIM calls it on the // there to upstream's develop branch), line for line. OpenDKIM calls it on the
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(), // value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders), // dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
+15 -15
View File
@@ -13,7 +13,7 @@
// Postfix is configured with default_action=accept for this milter's socket, // Postfix is configured with default_action=accept for this milter's socket,
// so even a crash or hang fails open (architecture.md § Mail path). // so even a crash or hang fails open (architecture.md § Mail path).
// //
// Postfix 3.7.11, src/milter/milter8.c, milter8_comm_error(): a milter that // Postfix 3.10.13, src/milter/milter8.c, milter8_comm_error(): a milter that
// cannot be reached, or does not answer within milter_*_timeout, gives no // cannot be reached, or does not answer within milter_*_timeout, gives no
// reply with default_action=accept - the message goes on. // reply with default_action=accept - the message goes on.
package milter package milter
@@ -55,7 +55,7 @@ type Store interface {
// //
// go-milter v0.4.1, session.go, Process(), case CodeMacro: every macro packet // go-milter v0.4.1, session.go, Process(), case CodeMacro: every macro packet
// replaces the map; server.go, Serve(): NewMilter() for each connection. // replaces the map; server.go, Serve(): NewMilter() for each connection.
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(): a macro packet, empty // Postfix 3.10.13, src/milter/milter8.c, milter8_event(): a macro packet, empty
// or not, goes before every event (milter.c, milter_macro_lookup(), never // or not, goes before every event (milter.c, milter_macro_lookup(), never
// returns null). // returns null).
type session struct { type session struct {
@@ -69,7 +69,7 @@ type session struct {
// clientIP is the client's address. Postfix gives it at Connect, once per // clientIP is the client's address. Postfix gives it at Connect, once per
// connection, and again with every MAIL FROM as the {client_addr} macro // connection, and again with every MAIL FROM as the {client_addr} macro
// (milter_mail_macros in build/postfix-config.sh). The second is the one // (milter_mail_macros in build/postfix-config.sh). The second is the one
// that can be relied on: see MailFrom. Postfix 3.7.11, // that can be relied on: see MailFrom. Postfix 3.10.13,
// src/smtpd/smtpd_milter.c, smtpd_milter_eval(): {client_addr} is // src/smtpd/smtpd_milter.c, smtpd_milter_eval(): {client_addr} is
// state->rfc_addr (see clientAddr). // state->rfc_addr (see clientAddr).
clientIP string clientIP string
@@ -84,7 +84,7 @@ type session struct {
// Connect captures the client IP of a connection's first message. It is not // Connect captures the client IP of a connection's first message. It is not
// enough on its own - see MailFrom. // enough on its own - see MailFrom.
// //
// Postfix 3.7.11, src/smtpd/smtpd.c, smtpd_proto(): milter_conn_event() is // Postfix 3.10.13, src/smtpd/smtpd.c, smtpd_proto(): milter_conn_event() is
// given state->addr, the address without "IPv6:". go-milter v0.4.1, // given state->addr, the address without "IPv6:". go-milter v0.4.1,
// session.go, case CodeConn: net.ParseIP of it - nil for "unknown" and for a // session.go, case CodeConn: net.ParseIP of it - nil for "unknown" and for a
// family that sends no address. // family that sends no address.
@@ -115,7 +115,7 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// //
// go-milter v0.4.1, session.go, HandleMilterCommands(): after a reply whose // go-milter v0.4.1, session.go, HandleMilterCommands(): after a reply whose
// Continue() is false (response.go: accept, discard, reject, tempfail) the // Continue() is false (response.go: accept, discard, reject, tempfail) the
// backend is replaced by NewMilter(). Postfix 3.7.11, src/milter/milter8.c, // backend is replaced by NewMilter(). Postfix 3.10.13, src/milter/milter8.c,
// milter8_conn_event(): CONNECT goes once per milter connection. // milter8_conn_event(): CONNECT goes once per milter connection.
// {auth_authen}: src/smtpd/smtpd_milter.c, smtpd_milter_eval(), gives // {auth_authen}: src/smtpd/smtpd_milter.c, smtpd_milter_eval(), gives
// state->sasl_username, null without authentication, and then milter.c, // state->sasl_username, null without authentication, and then milter.c,
@@ -146,11 +146,11 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
// RcptTo records each recipient. Postfix calls this once per recipient, which // RcptTo records each recipient. Postfix calls this once per recipient, which
// is what lets the journal keep a separate row per (queue-id, recipient). // is what lets the journal keep a separate row per (queue-id, recipient).
// //
// Postfix 3.7.11, src/smtpd/smtpd.c, rcpt_cmd(): milter_rcpt_event() once per // Postfix 3.10.13, src/smtpd/smtpd.c, rcpt_cmd(): milter_rcpt_event() once per
// RCPT TO; a recipient Postfix refused goes only to a milter that asked for // RCPT TO; a recipient Postfix refused goes only to a milter that asked for
// SMFIP_RCPT_REJ, which Serve's Protocol does not. // SMFIP_RCPT_REJ, which Serve's Protocol does not.
// A recipient given twice - in any case - is one row: cleanup keeps the first // A recipient given twice - in any case - is one row: cleanup keeps the first
// and drops the rest (Postfix 3.7.11, src/cleanup/cleanup_out_recipient.c, // and drops the rest (Postfix 3.10.13, src/cleanup/cleanup_out_recipient.c,
// cleanup_out_recipient(), been_here() on a table made with BH_FLAG_FOLD in // cleanup_out_recipient(), been_here() on a table made with BH_FLAG_FOLD in
// cleanup_state.c), so only one delivery line will come (assumptions A-31). // cleanup_state.c), so only one delivery line will come (assumptions A-31).
// Its key also holds the DSN NOTIFY= and ORCPT= of the RCPT TO command, which // Its key also holds the DSN NOTIFY= and ORCPT= of the RCPT TO command, which
@@ -181,7 +181,7 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
// message through just as "accept" does - and, unlike it, leaves this session // message through just as "accept" does - and, unlike it, leaves this session
// in place for the connection's next message (see MailFrom). // in place for the connection's next message (see MailFrom).
// //
// go-milter v0.4.1, session.go, case CodeEOB: Body(). Postfix 3.7.11, // go-milter v0.4.1, session.go, case CodeEOB: Body(). Postfix 3.10.13,
// src/global/mail_params.h: DEF_MILT_EOD_MACROS "i", which // src/global/mail_params.h: DEF_MILT_EOD_MACROS "i", which
// src/cleanup/cleanup_milter.c, cleanup_milter_eval(), answers with the queue // src/cleanup/cleanup_milter.c, cleanup_milter_eval(), answers with the queue
// id. src/milter/milter8.c, milter8_event(): SMFIR_CONTINUE returns the // id. src/milter/milter8.c, milter8_event(): SMFIR_CONTINUE returns the
@@ -198,7 +198,7 @@ func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
// Postfix rejecting the message for its own reasons). No send-log row will be // Postfix rejecting the message for its own reasons). No send-log row will be
// written, so the level-2 slots this message held must go back. // written, so the level-2 slots this message held must go back.
// //
// Postfix 3.7.11, src/smtpd/smtpd.c: rset_cmd() and the refusals call // Postfix 3.10.13, src/smtpd/smtpd.c: rset_cmd() and the refusals call
// mail_reset(), which calls milter_abort() (src/milter/milter8.c, // mail_reset(), which calls milter_abort() (src/milter/milter8.c,
// milter8_abort(): SMFIC_ABORT); so do the end of every transaction and the // milter8_abort(): SMFIC_ABORT); so do the end of every transaction and the
// end of the session in smtpd_proto(). // end of the session in smtpd_proto().
@@ -214,7 +214,7 @@ func (s *session) Abort(m *milter.Modifier) error {
// single-character names (e.g. i) arrive bare. go-milter stores whatever name // single-character names (e.g. i) arrive bare. go-milter stores whatever name
// Postfix sends verbatim, so a lookup must try both forms. // Postfix sends verbatim, so a lookup must try both forms.
// //
// Postfix 3.7.11, src/milter/milter.c, milter_macro_lookup(): each name is // Postfix 3.10.13, src/milter/milter.c, milter_macro_lookup(): each name is
// sent as milter_*_macros spells it. go-milter v0.4.1, session.go, case // sent as milter_*_macros spells it. go-milter v0.4.1, session.go, case
// CodeMacro: m.macros[name] = value, the name as received. // CodeMacro: m.macros[name] = value, the name as received.
func macro(m *milter.Modifier, name string) string { func macro(m *milter.Modifier, name string) string {
@@ -228,7 +228,7 @@ func macro(m *milter.Modifier, name string) string {
// Postfix did not send one that reads as an address. An IPv6 address may come // Postfix did not send one that reads as an address. An IPv6 address may come
// in Sendmail's form, with "IPv6:" in front. // in Sendmail's form, with "IPv6:" in front.
// //
// Postfix 3.7.11, src/smtpd/smtpd_peer.c, smtpd_peer_sockaddr_to_hostaddr(): // Postfix 3.10.13, src/smtpd/smtpd_peer.c, smtpd_peer_sockaddr_to_hostaddr():
// rfc_addr is the dotted quad, "IPv6:" + the address for IPv6, a V4-mapped // rfc_addr is the dotted quad, "IPv6:" + the address for IPv6, a V4-mapped
// address rewritten to IPv4, or "unknown". // address rewritten to IPv4, or "unknown".
func clientAddr(m *milter.Modifier) string { func clientAddr(m *milter.Modifier) string {
@@ -270,7 +270,7 @@ func (s *session) record(queueID string) {
} }
// cleanAddress is an address as the milter gets it, without the angle // cleanAddress is an address as the milter gets it, without the angle
// brackets: Postfix 3.7.11 hands the milter the command's argument as the // brackets: Postfix 3.10.13 hands the milter the command's argument as the
// client wrote it, ESMTP parameters as separate strings (src/smtpd/smtpd.c, // client wrote it, ESMTP parameters as separate strings (src/smtpd/smtpd.c,
// mail_cmd()/rcpt_cmd(), milter_argv()), and go-milter v0.4.1 passes the first // mail_cmd()/rcpt_cmd(), milter_argv()), and go-milter v0.4.1 passes the first
// with "<>" trimmed (session.go, case CodeMail/CodeRcpt). A quoted local part // with "<>" trimmed (session.go, case CodeMail/CodeRcpt). A quoted local part
@@ -280,7 +280,7 @@ func cleanAddress(a string) string {
} }
// recipientAsLogged is a recipient in the form Postfix writes it in "to=<..>" // recipientAsLogged is a recipient in the form Postfix writes it in "to=<..>"
// and "orig_to=<..>", so a delivery line finds its row. Postfix 3.7.11 keeps // and "orig_to=<..>", so a delivery line finds its row. Postfix 3.10.13 keeps
// the address in internal form - a quoted local part unquoted, a backslash // the address in internal form - a quoted local part unquoted, a backslash
// dropped before the character it escapes, a blank in quotes made a space // dropped before the character it escapes, a blank in quotes made a space
// (src/smtpd/smtpd.c, extract_addr(), over src/global/tok822_parse.c, // (src/smtpd/smtpd.c, extract_addr(), over src/global/tok822_parse.c,
@@ -315,7 +315,7 @@ func recipientAsLogged(a string) string {
return quoteLocalPart(local) + domain return quoteLocalPart(local) + domain
} }
// quoteLocalPart is quote_822_local() of Postfix 3.7.11 // quoteLocalPart is quote_822_local() of Postfix 3.10.13
// (src/global/quote_822_local.c, with QUOTE_FLAG_8BITCLEAN, the default): a // (src/global/quote_822_local.c, with QUOTE_FLAG_8BITCLEAN, the default): a
// local part that is a dot-string - atoms of no blank, control character or // local part that is a dot-string - atoms of no blank, control character or
// one of ()<>@,;:\"[], joined by single dots, none first or last - is left as // one of ()<>@,;:\"[], joined by single dots, none first or last - is left as
@@ -366,7 +366,7 @@ func domainOf(addr string) string {
// gets a fresh session bound to rec. It returns nil on a clean shutdown. // gets a fresh session bound to rec. It returns nil on a clean shutdown.
// //
// go-milter v0.4.1, server.go: Close() closes the listener and Serve() then // go-milter v0.4.1, server.go: Close() closes the listener and Serve() then
// returns ErrServerClosed. Postfix 3.7.11, src/milter/milter8.c, // returns ErrServerClosed. Postfix 3.10.13, src/milter/milter8.c,
// milter8_message(): SMFIP_NOBODY leaves out only the body chunks - headers, // milter8_message(): SMFIP_NOBODY leaves out only the body chunks - headers,
// end-of-headers and end-of-body (milter8_eob()) still go; milter8_event() // end-of-headers and end-of-body (milter8_eob()) still go; milter8_event()
// takes a reply code (SMFIR_REPLYCODE) whatever the action mask. // takes a reply code (SMFIR_REPLYCODE) whatever the action mask.
+13 -13
View File
@@ -39,7 +39,7 @@ import (
// it up (Bindings). The send-log row of a refusal is written by another // it up (Bindings). The send-log row of a refusal is written by another
// goroutine and is not waited for. // goroutine and is not waited for.
// //
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): // Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with an owner by strcasecmp_utf8(); // the SASL login is compared with an owner by strcasecmp_utf8();
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers // src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2, // "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2,
@@ -103,7 +103,7 @@ func (b *Bindings) fresh() error {
return b.broken return b.broken
} }
// parseSenderMap reads the lines of a sender_login_maps file as Postfix 3.7.11 // parseSenderMap reads the lines of a sender_login_maps file as Postfix 3.10.13
// reads a texthash table (src/util/dict_thash.c, dict_thash_open(), over // reads a texthash table (src/util/dict_thash.c, dict_thash_open(), over
// src/util/readlline.c, readllines()): // src/util/readlline.c, readllines()):
// - a line that starts with a blank continues the one before; an empty // - a line that starts with a blank continues the one before; an empty
@@ -112,8 +112,8 @@ func (b *Bindings) fresh() error {
// escaping the next character; a line with an unbalanced quote, or // escaping the next character; a line with an unbalanced quote, or
// without a key or a value, is ignored (with a warning, in Postfix); // without a key or a value, is ignored (with a warning, in Postfix);
// - the key is folded to lower case (DICT_FLAG_FOLD_FIX, which // - the key is folded to lower case (DICT_FLAG_FOLD_FIX, which
// src/smtpd/smtpd_check.c, pre_jail_init(), gives // src/smtpd/smtpd_check.c, smtpd_check_init(), called from
// smtpd_sender_login_maps); // src/smtpd/smtpd.c, pre_jail_init(), gives smtpd_sender_login_maps);
// - a key given twice makes the whole table unusable: no DICT_FLAG_DUP_* // - a key given twice makes the whole table unusable: no DICT_FLAG_DUP_*
// flag is given, so dict_thash_open() returns a surrogate that fails // flag is given, so dict_thash_open() returns a surrogate that fails
// every lookup, and smtpd answers 451 (reject_server_error()). The check // every lookup, and smtpd answers 451 (reject_server_error()). The check
@@ -194,7 +194,7 @@ func splitTableLine(line string) (key, value string, ok bool) {
} }
// Allowed reports whether login may send as addr - the lookup Postfix makes // Allowed reports whether login may send as addr - the lookup Postfix makes
// for the envelope sender. Postfix 3.7.11, src/smtpd/smtpd_check.c, // for the envelope sender. Postfix 3.10.13, src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(), takes the owners of the first key // reject_auth_sender_login_mismatch(), takes the owners of the first key
// mail_addr_find() finds (src/global/mail_addr_find.c, mail_addr_find_opt() // mail_addr_find() finds (src/global/mail_addr_find.c, mail_addr_find_opt()
// with MA_FIND_DEFAULT): the address, then the address without its // with MA_FIND_DEFAULT): the address, then the address without its
@@ -242,7 +242,7 @@ const extensionDelimiter = "+"
// follows its first delimiter. "user+tag@example.org" is "user@example.org"; // follows its first delimiter. "user+tag@example.org" is "user@example.org";
// an address whose local part begins with the delimiter has no extension. // an address whose local part begins with the delimiter has no extension.
// //
// Postfix 3.7.11, src/global/strip_addr.c, strip_addr_internal(), and // Postfix 3.10.13, src/global/strip_addr.c, strip_addr_internal(), and
// split_addr.c, split_addr_internal(): the local part, up to the last "@", is // split_addr.c, split_addr_internal(): the local part, up to the last "@", is
// cut at its first delimiter, unless that is its first character. // cut at its first delimiter, unless that is its first character.
func withoutExtension(addr string) string { func withoutExtension(addr string) string {
@@ -322,7 +322,7 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
// Headers is the end of the headers, where the message is judged. // Headers is the end of the headers, where the message is judged.
// //
// go-milter v0.4.1, session.go, case CodeEOH: Headers(). Postfix 3.7.11, // go-milter v0.4.1, session.go, case CodeEOH: Headers(). Postfix 3.10.13,
// src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: a "4"/"5" reply // src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: a "4"/"5" reply
// whose enhanced code starts with the same digit is the message's reply; // whose enhanced code starts with the same digit is the message's reply;
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): a "5" rejects the // src/cleanup/cleanup_milter.c, cleanup_milter_apply(): a "5" rejects the
@@ -347,7 +347,7 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil
// cleanup has given the message a queue id by the end of its headers // cleanup has given the message a queue id by the end of its headers
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the // (milter_end_of_header_macros has {i}); Postfix's lines about it - the
// refusal among them - are found by it on the message's page. // refusal among them - are found by it on the message's page.
// Postfix 3.7.11, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i", // Postfix 3.10.13, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). // answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
s.record(shown, macro(m, "i")) s.record(shown, macro(m, "i"))
// A reply code of its own, so that the application is told what is wrong // A reply code of its own, so that the application is told what is wrong
@@ -367,7 +367,7 @@ func (s *senderSession) judge() (shown, why string, err error) {
if s.login == "" { if s.login == "" {
// Not reachable while the submission ports demand authentication; a // Not reachable while the submission ports demand authentication; a
// message nobody answers for is not signed either way. // message nobody answers for is not signed either way.
// Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): with // Postfix 3.10.13, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): with
// smtpd_delay_reject (default yes) smtpd_client_restrictions // smtpd_delay_reject (default yes) smtpd_client_restrictions
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT; // (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message. // src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
@@ -416,7 +416,7 @@ func (s *senderSession) judge() (shown, why string, err error) {
// line must also be one address in RFC 5322's grammar (net/mail), at the // line must also be one address in RFC 5322's grammar (net/mail), at the
// domain OpenDKIM signs for; a quoted display name may say anything. // domain OpenDKIM signs for; a quoted display name may say anything.
// And that address, its local part unquoted as Postfix holds the // And that address, its local part unquoted as Postfix holds the
// envelope's (Postfix 3.7.11, src/global/tok822_parse.c), is the one // envelope's (Postfix 3.10.13, src/global/tok822_parse.c), is the one
// held against the bindings: "x"@mine.com is x@mine.com, which may be // held against the bindings: "x"@mine.com is x@mine.com, which may be
// another application's (review of stage 2c, F1, F2). // another application's (review of stage 2c, F1, F2).
list, perr := fromParser.ParseList(unfold(raw)) list, perr := fromParser.ParseList(unfold(raw))
@@ -504,7 +504,7 @@ func (s *senderSession) record(shown, queueID string) {
// holds against nobody, the domain the login is bound to. Without it the row // holds against nobody, the domain the login is bound to. Without it the row
// would belong to no domain and its administrator would never see it. // would belong to no domain and its administrator would never see it.
// //
// Postfix 3.7.11, src/smtpd/smtpd_check.c, generic_checks(): // Postfix 3.10.13, src/smtpd/smtpd_check.c, generic_checks():
// reject_*_sender_login_mismatch run only "if (state->sender && // reject_*_sender_login_mismatch run only "if (state->sender &&
// *state->sender)". // *state->sender)".
func (s *senderSession) domain() string { func (s *senderSession) domain() string {
@@ -518,7 +518,7 @@ func (s *senderSession) domain() string {
// characters that mean something in an SMTP reply or a log line, and cuts it: // characters that mean something in an SMTP reply or a log line, and cuts it:
// it is said back to the client, logged and stored. // it is said back to the client, logged and stored.
// //
// Postfix 3.7.11, src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE: // Postfix 3.10.13, src/milter/milter8.c, milter8_event(), SMFIR_REPLYCODE:
// "%%" becomes "%" and a single "%" is dropped; src/cleanup/cleanup_milter.c, // "%%" becomes "%" and a single "%" is dropped; src/cleanup/cleanup_milter.c,
// cleanup_milter_apply(), logs "<id>: milter-reject: END-OF-MESSAGE from // cleanup_milter_apply(), logs "<id>: milter-reject: END-OF-MESSAGE from
// <client>: <text>; from=<...> to=<...> ...". // <client>: <text>; from=<...> to=<...> ...".
@@ -573,7 +573,7 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refus
// The headers decide; and each header value with its leading space, // The headers decide; and each header value with its leading space,
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(), // as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix // SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
// 3.7.11, src/milter/milter8.c: milter8_negotiate() keeps the event // 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps the event
// mask a milter of any version asks for (go-milter v0.4.1 says // mask a milter of any version asks for (go-milter v0.4.1 says
// version 2), and milter8_header() drops the space only for a milter // version 2), and milter8_header() drops the space only for a milter
// without SMFIP_HDR_LEADSPC. // without SMFIP_HDR_LEADSPC.
+1 -1
View File
@@ -230,7 +230,7 @@ func TestBindingsFollowTheFile(t *testing.T) {
// The owners of the first key Postfix finds decide, not any key that lists // The owners of the first key Postfix finds decide, not any key that lists
// the login: an address bound to one application is not open to another that // the login: an address bound to one application is not open to another that
// holds its whole domain (Postfix 3.7.11, smtpd_check.c, // holds its whole domain (Postfix 3.10.13, smtpd_check.c,
// reject_auth_sender_login_mismatch(), over mail_addr_find(); assumptions // reject_auth_sender_login_mismatch(), over mail_addr_find(); assumptions
// B-PS1). Composed by hand in internal/postfix's format. // B-PS1). Composed by hand in internal/postfix's format.
func TestBindingsFirstKeyWins(t *testing.T) { func TestBindingsFirstKeyWins(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@
* *
* test/upstream/mailparse-diff.sh <opendkim source dir> * test/upstream/mailparse-diff.sh <opendkim source dir>
* *
* builds it in debian:bookworm and runs TestMailParseMatchesOpenDKIM with it. * builds it in debian:trixie and runs TestMailParseMatchesOpenDKIM with it.
*/ */
#include <sys/types.h> #include <sys/types.h>
#include <stdio.h> #include <stdio.h>
+1 -1
View File
@@ -41,7 +41,7 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
return p.reload() return p.reload()
} }
// Postfix 3.7.11, man/man5/access.5: "OK" accepts the address the key // Postfix 3.10.13, man/man5/access.5: "OK" accepts the address the key
// matches; man/man5/transport.5: "user@domain transport:nexthop", and "a // matches; man/man5/transport.5: "user@domain transport:nexthop", and "a
// non-null transport field with a null nexthop field resets the nexthop // non-null transport field with a null nexthop field resets the nexthop
// information to the recipient domain"; relay_domains as in renderInboundMaps. // information to the recipient domain"; relay_domains as in renderInboundMaps.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
) )
// ParseTime reads a Postfix time value as Postfix 3.7.11 reads it: // ParseTime reads a Postfix time value as Postfix 3.10.13 reads it:
// src/global/conv_time.c, conv_time(), called by get_mail_conf_time() in // src/global/conv_time.c, conv_time(), called by get_mail_conf_time() in
// src/global/mail_conf_time.c. One number - strtol(), so leading blanks and a // src/global/mail_conf_time.c. One number - strtol(), so leading blanks and a
// "+" are taken, a negative number or one past an int is invalid - followed // "+" are taken, a negative number or one past an int is invalid - followed
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time" "time"
) )
// Postfix 3.7.11, src/global/conv_time.c, conv_time(): one number, one // Postfix 3.10.13, src/global/conv_time.c, conv_time(): one number, one
// lower-case unit, the default unit when there is none, the rest ignored. // lower-case unit, the default unit when there is none, the rest ignored.
func TestParseTime(t *testing.T) { func TestParseTime(t *testing.T) {
cases := []struct { cases := []struct {
+2 -2
View File
@@ -55,7 +55,7 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
return p.reload() return p.reload()
} }
// Postfix 3.7.11, man/man5/postconf.5: a relay_domains table "is matched when // Postfix 3.10.13, man/man5/postconf.5: a relay_domains table "is matched when
// a (parent) domain appears as lookup key", the value unused; relay_recipient_maps // a (parent) domain appears as lookup key", the value unused; relay_recipient_maps
// is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c, // is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c,
// MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain // MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain
@@ -104,7 +104,7 @@ func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tls
// what disable the MX lookup, so the operator's explicit upstream is used as // what disable the MX lookup, so the operator's explicit upstream is used as
// given; they also delimit a bare IPv6 address. // given; they also delimit a bare IPv6 address.
// //
// Postfix 3.7.11, src/smtp/smtp_connect.c: no MX lookup for a destination that // Postfix 3.10.13, src/smtp/smtp_connect.c: no MX lookup for a destination that
// starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host // starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host
// a name or an address ("ipv6:" in front is optional), checked by // a name or an address ("ipv6:" in front is optional), checked by
// valid_hostaddr(). // valid_hostaddr().
+4 -4
View File
@@ -62,7 +62,7 @@ type Binding struct {
// a comma-separated list, which is how Postfix expects multiple owners of one // a comma-separated list, which is how Postfix expects multiple owners of one
// sender. // sender.
// //
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): // Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the value is split into owners with mystrtok(&cp, CHARS_COMMA_SP). // the value is split into owners with mystrtok(&cp, CHARS_COMMA_SP).
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error { func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings) content, err := renderSenderLoginMaps(bindings)
@@ -105,7 +105,7 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
// One line per key as Postfix folds it: smtpd opens the map with // One line per key as Postfix folds it: smtpd opens the map with
// DICT_FLAG_FOLD_FIX and no DICT_FLAG_DUP_* flag, so two keys that // DICT_FLAG_FOLD_FIX and no DICT_FLAG_DUP_* flag, so two keys that
// differ only in case would make the whole table unusable (Postfix // differ only in case would make the whole table unusable (Postfix
// 3.7.11, src/util/dict_thash.c, dict_thash_open()). The panel stores // 3.10.13, src/util/dict_thash.c, dict_thash_open()). The panel stores
// addresses in lower case already; this keeps the file safe if a row // addresses in lower case already; this keeps the file safe if a row
// ever is not. // ever is not.
addr := strings.ToLower(b.Address) addr := strings.ToLower(b.Address)
@@ -141,7 +141,7 @@ func appendUnique(list []string, v string) []string {
// and the backslash. A login additionally may not carry "@" — that would make it // and the backslash. A login additionally may not carry "@" — that would make it
// look like an address. // look like an address.
// //
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key ends at // Postfix 3.10.13, src/util/dict_thash.c, dict_thash_open(): the key ends at
// the first blank outside quotes, a backslash escapes the next character; // the first blank outside quotes, a backslash escapes the next character;
// src/util/readlline.c: a newline ends the line (one that starts with a blank // src/util/readlline.c: a newline ends the line (one that starts with a blank
// continues the one before); the owners split on comma and blank (see // continues the one before); the owners split on comma and blank (see
@@ -180,7 +180,7 @@ func assertMapSafe(address, login string) error {
// Arguments are fixed literals — no user input is interpolated into the command, // Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md). // and it never goes through a shell (security.md).
// //
// Postfix 3.7.11, conf/postfix-script, start-fg: a process that is not PID 1 // Postfix 3.10.13, conf/postfix-script, start-fg: a process that is not PID 1
// runs "$daemon_directory/master -s" as its child, and src/master/master.c // runs "$daemon_directory/master -s" as its child, and src/master/master.c
// puts that master in a session of its own (setsid()). "reload" sends the // puts that master in a session of its own (setsid()). "reload" sends the
// master SIGHUP; src/master/master.c calls master_refresh(), and // master SIGHUP; src/master/master.c calls master_refresh(), and
+3 -3
View File
@@ -51,7 +51,7 @@ var postqueueP = func(ctx context.Context) *exec.Cmd {
// listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it; // listQueue runs `postqueue -p` and keeps at most maxQueueOutput bytes of it;
// truncated says whether there was more. // truncated says whether there was more.
// //
// Postfix 3.7.11, src/postqueue/postqueue.c, show_queue(): any user passes // Postfix 3.10.13, src/postqueue/postqueue.c, show_queue(): any user passes
// authorized_mailq_users (DEF_SHOWQ_ACL static:anyone), and a failure is // authorized_mailq_users (DEF_SHOWQ_ACL static:anyone), and a failure is
// msg_fatal_status() - a non-zero exit with the reason on stderr; // msg_fatal_status() - a non-zero exit with the reason on stderr;
// src/postqueue/showq_compat.c, showq_compat(): "Mail queue is empty". // src/postqueue/showq_compat.c, showq_compat(): "Mail queue is empty".
@@ -107,7 +107,7 @@ func Queue() (string, error) {
// cut are still in the queue, and a backlog that large is the outage the sweep // cut are still in the queue, and a backlog that large is the outage the sweep
// exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way. // exists for (audit 2026-10-12, H3). QueueMessages treats its cap the same way.
// //
// Postfix 3.7.11, src/showq/showq.c, showq_service(): maildrop, active, // Postfix 3.10.13, src/showq/showq.c, showq_service(): maildrop, active,
// incoming, deferred and hold are the queues it lists. // incoming, deferred and hold are the queues it lists.
func QueueIDs() (map[string]struct{}, error) { func QueueIDs() (map[string]struct{}, error) {
out, truncated, err := listQueue() out, truncated, err := listQueue()
@@ -126,7 +126,7 @@ func QueueIDs() (map[string]struct{}, error) {
// //
// The id is at the start of the line, optionally flagged '*' (in the active // The id is at the start of the line, optionally flagged '*' (in the active
// queue) or '!' (on hold), then '#' when the message was forced to expire // queue) or '!' (on hold), then '#' when the message was forced to expire
// (Postfix 3.7.11, src/postqueue/showq_compat.c, showq_message(): the flags // (Postfix 3.10.13, src/postqueue/showq_compat.c, showq_message(): the flags
// are appended to the id in that order; assumptions B-PQ1), and is followed // are appended to the id in that order; assumptions B-PQ1), and is followed
// by the message size. Requiring the // by the message size. Requiring the
// size is what separates an entry from the listing's other left-margin lines: // size is what separates an entry from the listing's other left-margin lines:
+2 -2
View File
@@ -12,7 +12,7 @@ import (
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests // TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of // below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
// queue entries composed by hand after the layout of Postfix 3.7.11's // queue entries composed by hand after the layout of Postfix 3.10.13's
// src/postqueue/showq_compat.c. // src/postqueue/showq_compat.c.
func TestFakePostqueue(t *testing.T) { func TestFakePostqueue(t *testing.T) {
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" { if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
@@ -76,7 +76,7 @@ func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
// The reconcile sweep decides whether a message is still Postfix's problem, so // The reconcile sweep decides whether a message is still Postfix's problem, so
// the parser must pick queue ids out of a listing and nothing else out of it: // the parser must pick queue ids out of a listing and nothing else out of it:
// not the header, not the byte-count trailer, and above all not a deferred // not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line. The listing is composed by hand. Postfix 3.7.11 // entry's reason line. The listing is composed by hand. Postfix 3.10.13
// (src/postqueue/showq_compat.c, showq_message()) right-aligns a reason to // (src/postqueue/showq_compat.c, showq_message()) right-aligns a reason to
// column 76, so it reaches the left margin only when it is 76 characters or // column 76, so it reaches the left margin only when it is 76 characters or
// longer; the one here is put at the margin as the hard case. // longer; the one here is put at the margin as the hard case.
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports // QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
// it: where it is, and since when. // it: where it is, and since when.
// //
// Postfix 3.7.11, src/postqueue/showq_json.c, format_json(): one object and a // Postfix 3.10.13, src/postqueue/showq_json.c, format_json(): one object and a
// newline per message - "queue_name", "queue_id", "arrival_time" (seconds, // newline per message - "queue_name", "queue_id", "arrival_time" (seconds,
// %ld), "message_size", "forced_expire", "sender", "recipients" [{"address", // %ld), "message_size", "forced_expire", "sender", "recipients" [{"address",
// "delay_reason"}]; the queue names are those of src/global/mail_queue.h that // "delay_reason"}]; the queue names are those of src/global/mail_queue.h that
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// The listing is composed by hand: its first line in the field order of // The listing is composed by hand: its first line in the field order of
// Postfix 3.7.11's src/postqueue/showq_json.c, format_json(); the others // Postfix 3.10.13's src/postqueue/showq_json.c, format_json(); the others
// short of fields or broken on purpose. // short of fields or broken on purpose.
func TestParseQueueJSON(t *testing.T) { func TestParseQueueJSON(t *testing.T) {
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]} out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
+33 -19
View File
@@ -29,7 +29,7 @@ type RetryPolicy struct {
// build/postfix-config.sh does not set. Used when postconf is missing (unit // build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts. // tests, a binary outside the container) so the panel still starts.
// //
// Postfix 3.7.11, src/global/mail_params.h: DEF_QUEUE_RUN_DELAY "300s", // Postfix 3.10.13, src/global/mail_params.h: DEF_QUEUE_RUN_DELAY "300s",
// DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s", // DEF_MIN_BACKOFF_TIME DEF_QUEUE_RUN_DELAY, DEF_MAX_BACKOFF_TIME "4000s",
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h". // DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME "0h".
func DefaultRetryPolicy() RetryPolicy { func DefaultRetryPolicy() RetryPolicy {
@@ -56,7 +56,7 @@ var retryConfKeys = []string{
} }
// retryConfUnits is each key's default unit: the last letter of its default // retryConfUnits is each key's default unit: the last letter of its default
// value (Postfix 3.7.11, src/global/mail_params.h, DEF_QUEUE_RUN_DELAY // value (Postfix 3.10.13, src/global/mail_params.h, DEF_QUEUE_RUN_DELAY
// "300s", DEF_MIN_BACKOFF_TIME, DEF_MAX_BACKOFF_TIME "4000s", // "300s", DEF_MIN_BACKOFF_TIME, DEF_MAX_BACKOFF_TIME "4000s",
// DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME // DEF_MAX_QUEUE_TIME "5d", DEF_DSN_QUEUE_TIME "5d", DEF_DELAY_WARN_TIME
// "0h"; src/global/mail_conf_time.c, get_def_time_unit()). // "0h"; src/global/mail_conf_time.c, get_def_time_unit()).
@@ -66,23 +66,37 @@ var retryConfUnits = []byte{'s', 's', 's', 'd', 'd', 'h'}
// the same way logtail stubs queueIDs. // the same way logtail stubs queueIDs.
var readRetryConf = postconfRetryValues var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) { // postconfRetryCmd builds the `postconf -h -x` command. It is a var so tests
// can put a fake postconf in its place, the way postqueueP is.
var postconfRetryCmd = func() *exec.Cmd {
// Fixed argv, no user input — same pattern as Queue (security.md). // Fixed argv, no user input — same pattern as Queue (security.md).
// Postfix 3.7.11, src/postconf/postconf_main.c, pcf_show_parameters(): return exec.Command("postconf", append([]string{"-h", "-x"}, retryConfKeys...)...)
// named parameters are printed in argument order, with -h one "%s\n" }
// value each; -x expands "$name" as Postfix itself does when it reads
// the value (mail_conf_eval()). // postconfRetryValues reads the values from postconf's standard output only.
cmd := exec.Command("postconf", "-h", "-x", // Postfix 3.10.13, src/postconf/postconf_main.c, pcf_show_parameters():
"queue_run_delay", // named parameters are printed to stdout in argument order, with -h one
"minimal_backoff_time", // "%s\n" value each, and an unknown one is a msg_warn() with no line;
"maximal_backoff_time", // -x expands "$name" (pcf_expand_parameter_value(),
"maximal_queue_lifetime", // src/postconf/postconf_lookup.c) as Postfix itself does when it reads the
"bounce_queue_lifetime", // value (mail_conf_eval()). Warnings go to stderr (src/postconf/postconf.c,
"delay_warning_time", // main(), msg_vstream_init(argv[0], VSTREAM_ERR)): an unknown name, and after
) // the values (src/postconf/postconf.c, main()) an unused
out, err := cmd.CombinedOutput() // main.cf parameter, and since 3.9 a deprecated one, "support for parameter
// \"%s\" will be removed; instead, %s" (src/postconf/postconf_unused.c,
// pcf_flag_unused_parameters(); RELEASE_NOTES-3.9). Read together with stdout,
// as CombinedOutput did until base-trixie, such a warning was one value too
// many and the panel fell back to the compiled-in defaults.
func postconfRetryValues() ([]string, error) {
cmd := postconfRetryCmd()
var errOut strings.Builder
cmd.Stderr = &errOut
out, err := cmd.Output()
if err != nil { if err != nil {
return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out))) return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(errOut.String()))
}
if why := strings.TrimSpace(errOut.String()); why != "" {
log.Printf("postfix: postconf -h: %s", why)
} }
lines := strings.Split(strings.TrimSpace(string(out)), "\n") lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(retryConfKeys) { if len(lines) != len(retryConfKeys) {
@@ -123,7 +137,7 @@ func parseRetryPolicy(lines []string) (RetryPolicy, error) {
} }
durs[i] = d durs[i] = d
} }
// Postfix 3.7.11, src/qmgr/qmgr.c, qmgr_post_init(): "bounce_queue_lifetime // Postfix 3.10.13, src/qmgr/qmgr.c, qmgr_post_init(): "bounce_queue_lifetime
// is larger than maximal_queue_lifetime - adjusting" - qmgr uses the // is larger than maximal_queue_lifetime - adjusting" - qmgr uses the
// smaller. // smaller.
if durs[4] > durs[3] { if durs[4] > durs[3] {
@@ -155,7 +169,7 @@ func (p RetryPolicy) BackoffCap() string {
// sender, which an application may submit too. It bounds which stored log // sender, which an application may submit too. It bounds which stored log
// lines can be about a send-log row (store.SendLogLines). // lines can be about a send-log row (store.SendLogLines).
// //
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic(): a // Postfix 3.10.13, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic(): a
// message expires after var_max_queue_time, or var_dsn_queue_time when its // message expires after var_max_queue_time, or var_dsn_queue_time when its
// sender is null - whoever submitted it; qmgr.c caps the second at the // sender is null - whoever submitted it; qmgr.c caps the second at the
// first, and so does parseRetryPolicy, which leaves the first the longer. // first, and so does parseRetryPolicy, which leaves the first the longer.
+44 -3
View File
@@ -2,12 +2,15 @@ package postfix
import ( import (
"errors" "errors"
"fmt"
"os"
"os/exec"
"testing" "testing"
"time" "time"
) )
// The postconf values in these tests are composed by hand: values main.cf // The postconf values in these tests are composed by hand: values main.cf
// could hold. Stock Postfix 3.7.11 prints "0h" for delay_warning_time // could hold. Stock Postfix 3.10.13 prints "0h" for delay_warning_time
// (src/global/mail_params.h, DEF_DELAY_WARN_TIME), not "0". // (src/global/mail_params.h, DEF_DELAY_WARN_TIME), not "0".
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) { func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf old := readRetryConf
@@ -29,7 +32,7 @@ func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
if p.MaximalQueueLifetime != 2*24*time.Hour { if p.MaximalQueueLifetime != 2*24*time.Hour {
t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime) t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime)
} }
// 5d over a maximal 2d: qmgr uses 2d (Postfix 3.7.11, src/qmgr/qmgr.c, // 5d over a maximal 2d: qmgr uses 2d (Postfix 3.10.13, src/qmgr/qmgr.c,
// qmgr_post_init(), "adjusting bounce_queue_lifetime"). // qmgr_post_init(), "adjusting bounce_queue_lifetime").
if p.BounceQueueLifetime != 2*24*time.Hour { if p.BounceQueueLifetime != 2*24*time.Hour {
t.Errorf("BounceQueueLifetime = %v, want 2d", p.BounceQueueLifetime) t.Errorf("BounceQueueLifetime = %v, want 2d", p.BounceQueueLifetime)
@@ -40,7 +43,7 @@ func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
} }
// A message lives at most maximal_queue_lifetime: qmgr caps // A message lives at most maximal_queue_lifetime: qmgr caps
// bounce_queue_lifetime at it (Postfix 3.7.11, src/qmgr/qmgr.c, // bounce_queue_lifetime at it (Postfix 3.10.13, src/qmgr/qmgr.c,
// qmgr_post_init()), so a larger bounce lifetime does not lengthen it. // qmgr_post_init()), so a larger bounce lifetime does not lengthen it.
func TestLongestLifetimeIsTheQueueLifetime(t *testing.T) { func TestLongestLifetimeIsTheQueueLifetime(t *testing.T) {
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "1d", "5d", "0h"}) p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "1d", "5d", "0h"})
@@ -95,3 +98,41 @@ func TestParseRetryPolicyStock(t *testing.T) {
t.Errorf("parsed = %+v", p) t.Errorf("parsed = %+v", p)
} }
} }
// TestFakePostconf is not a test: it is the fake `postconf -h -x` the test
// below runs in place of the real one. Its warning is composed after Postfix
// 3.10.13, src/postconf/postconf_unused.c, pcf_flag_unused_parameters(),
// written to stderr after the values (src/postconf/postconf.c, main()).
func TestFakePostconf(t *testing.T) {
if os.Getenv("SP_FAKE_POSTCONF") != "1" {
t.Skip("helper process")
}
fmt.Fprint(os.Stdout, "300s\n300s\n4000s\n2d\n5d\n0h\n")
fmt.Fprint(os.Stderr, "postconf: warning: /etc/postfix/main.cf: support for parameter \"smtpd_use_tls\" will be removed; instead, specify \"smtpd_tls_security_level\"\n")
os.Exit(0)
}
// postconf's warnings are not values: since Postfix 3.9 a deprecated
// main.cf parameter draws one on every run, and read with the values it made
// one line too many, so the panel showed the compiled-in defaults.
func TestPostconfRetryValuesReadsStdoutOnly(t *testing.T) {
old := postconfRetryCmd
postconfRetryCmd = func() *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=^TestFakePostconf$")
cmd.Env = append(os.Environ(), "SP_FAKE_POSTCONF=1")
return cmd
}
t.Cleanup(func() { postconfRetryCmd = old })
lines, err := postconfRetryValues()
if err != nil {
t.Fatalf("postconfRetryValues: %v", err)
}
p, err := parseRetryPolicy(lines)
if err != nil {
t.Fatalf("parse %q: %v", lines, err)
}
if p.MaximalQueueLifetime != 2*24*time.Hour || p.FromDefaults {
t.Errorf("policy = %+v, want the postconf values", p)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// login is globally unique because it is the SASL identity Postfix // login is globally unique because it is the SASL identity Postfix
// authenticates (one sasldb2 across all domains, architecture.md § Mail path). // authenticates (one sasldb2 across all domains, architecture.md § Mail path).
// It is unique without regard to case too (migration 0008's NOCASE index): // It is unique without regard to case too (migration 0008's NOCASE index):
// Postfix 3.7.11, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch() // Postfix 3.10.13, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch()
// compares the SASL login with the map's owners by strcasecmp_utf8. // compares the SASL login with the map's owners by strcasecmp_utf8.
var ErrLoginExists = errors.New("application login already exists") var ErrLoginExists = errors.New("application login already exists")
+2 -2
View File
@@ -100,7 +100,7 @@ func (s *Store) InsertRejected(e SendLogEntry) error {
// wrote, and the milter's row holds it as Postfix logs it // wrote, and the milter's row holds it as Postfix logs it
// (milter.recipientAsLogged); but a recipient repeated in another case is // (milter.recipientAsLogged); but a recipient repeated in another case is
// one recipient to Postfix - cleanup drops the second, comparing folded // one recipient to Postfix - cleanup drops the second, comparing folded
// (Postfix 3.7.11, src/cleanup/cleanup_out_recipient.c, been_here() with // (Postfix 3.10.13, src/cleanup/cleanup_out_recipient.c, been_here() with
// BH_FLAG_FOLD) - and one row to the milter, spelt as first given, and // BH_FLAG_FOLD) - and one row to the milter, spelt as first given, and
// log_adhoc() writes orig_to= only when it differs from to= other than in // log_adhoc() writes orig_to= only when it differs from to= other than in
// case (src/global/log_adhoc.c, strcasecmp_utf8()). Assumptions D-S20. It // case (src/global/log_adhoc.c, strcasecmp_utf8()). Assumptions D-S20. It
@@ -191,7 +191,7 @@ func (s *Store) CloseUnreported(id int64) (int64, error) {
// ExpireMessage closes the rows of a message Postfix gave up on at the end of // ExpireMessage closes the rows of a message Postfix gave up on at the end of
// its queue lifetime: each recipient's newest row with that queue id, if it // its queue lifetime: each recipient's newest row with that queue id, if it
// is still open ("queued" or "deferred"), becomes "bounced" - Postfix returns // is still open ("queued" or "deferred"), becomes "bounced" - Postfix returns
// the message to its sender. Postfix 3.7.11, src/qmgr/qmgr_active.c, // the message to its sender. Postfix 3.10.13, src/qmgr/qmgr_active.c,
// qmgr_active_done_25_generic(), logs expiry once per message, "<id>: // qmgr_active_done_25_generic(), logs expiry once per message, "<id>:
// from=<..>, status=expired, returned to sender", with no line per // from=<..>, status=expired, returned to sender", with no line per
// recipient: the recipients' last lines say "deferred" (assumptions A-8). It // recipient: the recipients' last lines say "deferred" (assumptions A-8). It
+1 -1
View File
@@ -20,7 +20,7 @@ const socketPath = "/run/supervisor.sock"
// runOnceFor bounds RunOnce: a reload that is still running after it is // runOnceFor bounds RunOnce: a reload that is still running after it is
// reported as a failure. "postfix reload" checks that master runs, moves the // reported as a failure. "postfix reload" checks that master runs, moves the
// incoming queue to active and sends master SIGHUP (Postfix 3.7.11, // incoming queue to active and sends master SIGHUP (Postfix 3.10.13,
// conf/postfix-script, "reload") - well under a second on the stand. // conf/postfix-script, "reload") - well under a second on the stand.
var runOnceFor = time.Minute var runOnceFor = time.Minute
+1 -1
View File
@@ -354,7 +354,7 @@ func decryptErrorMessage(err error) string {
func unreadableBackupErr(err error, deployRoot string) string { func unreadableBackupErr(err error, deployRoot string) string {
if name, ok := backup.UnreadableCert(err, deployRoot); ok { if name, ok := backup.UnreadableCert(err, deployRoot); ok {
return "The panel is not allowed to read " + name + ", and a backup without it would not bring the server back. " + return "The panel is not allowed to read " + name + ", and a backup without it would not bring the server back. " +
"On the host, give the file to group 998 and make it group-readable (chgrp 998, chmod 640), then try again. Nothing was downloaded." "On the host, give the file to group 10998 and make it group-readable (chgrp 10998, chmod 640), then try again. Nothing was downloaded."
} }
if errors.Is(err, backup.ErrNoCerts) { if errors.Is(err, backup.ErrNoCerts) {
return "The project's certs/ holds no certificate, so a backup would bring the server back without one. " + return "The project's certs/ holds no certificate, so a backup would bring the server back without one. " +
+1 -1
View File
@@ -232,7 +232,7 @@ var serverTopics = []HelpTopic{
". The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, "+ ". The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, "+
"so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server "+ "so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server "+
"starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in ", Code("certs/"), "starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in ", Code("certs/"),
" has to be readable by the panel: give it to group 998 on the host (", Code("chgrp 998"), ", ", Code("chmod 640"), " has to be readable by the panel: give it to group 10998 on the host (", Code("chgrp 10998"), ", ", Code("chmod 640"),
"). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"), "). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"),
", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ", ", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ",
Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"), Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"),
+1 -1
View File
@@ -289,7 +289,7 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
} }
if in.Recovered { if in.Recovered {
// cleanup's line names the message's last recipient only (Postfix // cleanup's line names the message's last recipient only (Postfix
// 3.7.11, src/cleanup/cleanup_milter.c, cleanup_milter_apply(), // 3.10.13, src/cleanup/cleanup_milter.c, cleanup_milter_apply(),
// " to=<%s>" of state->recip; assumptions A-15). // " to=<%s>" of state->recip; assumptions A-15).
facts = append(facts, Fact{Label: "Written", Value: Rich("from ", Code("mail.log"), " after a restart: the panel stopped before it wrote the refusal. Postfix's line names the last recipient only")}) facts = append(facts, Fact{Label: "Written", Value: Rich("from ", Code("mail.log"), " after a restart: the panel stopped before it wrote the refusal. Postfix's line names the last recipient only")})
} }
+4 -4
View File
@@ -55,7 +55,7 @@ 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 // (Postfix 3.10.13, conf/postfix-script: "reload" and "status" both test
// "master -t" and exit 1 while master does not hold its pid lock.) // "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 {
@@ -82,7 +82,7 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
// testExpiredMessageIsClosed: a message Postfix gives up on is closed as // testExpiredMessageIsClosed: a message Postfix gives up on is closed as
// bounced. Postfix logs expiry once per message, "<id>: from=<..>, // bounced. Postfix logs expiry once per message, "<id>: from=<..>,
// status=expired, returned to sender" (here "force-expired"), and no line per // status=expired, returned to sender" (here "force-expired"), and no line per
// recipient (Postfix 3.7.11, src/qmgr/qmgr_active.c, // recipient (Postfix 3.10.13, src/qmgr/qmgr_active.c,
// qmgr_active_done_25_generic(); assumptions A-8). The queue lifetime cannot // qmgr_active_done_25_generic(); assumptions A-8). The queue lifetime cannot
// be waited out, so the message is deferred by hand - defer_transports=smtp, // be waited out, so the message is deferred by hand - defer_transports=smtp,
// under which qmgr hands the recipient to the retry service, "4.3.2 deferred // under which qmgr hands the recipient to the retry service, "4.3.2 deferred
@@ -154,7 +154,7 @@ print("" if r is None else r[0]+" "+r[1])`, token)
// testRecipientsAsPostfixLogsThem: each recipient's row reaches "sent". The // testRecipientsAsPostfixLogsThem: each recipient's row reaches "sent". The
// row holds the recipient as Postfix logs it - "a b" stays quoted, "ab" loses // row holds the recipient as Postfix logs it - "a b" stays quoted, "ab" loses
// its quotes - and a recipient given again in other capitals is one row, as // its quotes - and a recipient given again in other capitals is one row, as
// cleanup keeps one (Postfix 3.7.11, src/global/quote_822_local.c, // cleanup keeps one (Postfix 3.10.13, src/global/quote_822_local.c,
// src/cleanup/cleanup_out_recipient.c; assumptions B-G5, A-31). Before, the // src/cleanup/cleanup_out_recipient.c; assumptions B-G5, A-31). Before, the
// first was cut at its blank and its row closed as bounced by the sweep. // first was cut at its blank and its row closed as bounced by the sweep.
func testRecipientsAsPostfixLogsThem(t *testing.T, sc *scenario) { func testRecipientsAsPostfixLogsThem(t *testing.T, sc *scenario) {
@@ -184,7 +184,7 @@ print("; ".join(r[0]+" "+r[1] for r in rows))`, token)
// testAddressLiteralIsNotLocal: an address literal of an interface the // testAddressLiteralIsNotLocal: an address literal of an interface the
// container listens on is a local destination to Postfix even with an empty // container listens on is a local destination to Postfix even with an empty
// mydestination (Postfix 3.7.11, src/global/resolve_local.c, // mydestination (Postfix 3.10.13, src/global/resolve_local.c,
// resolve_local()). Local delivery is Postfix's refusal now // resolve_local()). Local delivery is Postfix's refusal now
// (local_transport=error:..., assumptions C-P8), and smtpd refuses such a // (local_transport=error:..., assumptions C-P8), and smtpd refuses such a
// recipient at RCPT with its text: smtpd_reject_unlisted_recipient (on by // recipient at RCPT with its text: smtpd_reject_unlisted_recipient (on by
+41 -1
View File
@@ -3,9 +3,11 @@ package e2e
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net/mail"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-msgauth/dkim"
@@ -73,12 +75,50 @@ func verifyDKIM(raw []byte, domain string) error {
return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications)) return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications))
} }
// postfixReceived returns the Received: headers of raw that a Postfix smtpd
// wrote - "(Postfix)" after the host name, the only form it writes (Postfix
// 3.10.13, src/smtpd/smtpd.c:3627-3645, "by %s (%s)" with var_mail_name).
// In the stand only SelfPost's Postfix writes one: the sink is smtp-sink,
// which adds none, and the tests compose their messages without any.
func postfixReceived(raw []byte) ([]string, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse the delivered message: %w", err)
}
var out []string
for _, v := range msg.Header["Received"] {
if strings.Contains(v, "(Postfix)") {
out = append(out, v)
}
}
return out, nil
}
// checkReceivedHidden: the submission ports set smtpd_hide_client_session
// (build/postfix-config.sh), so SelfPost's Received: header is the redacted
// one - it opens with "by", names no client and no protocol (Postfix 3.10.13,
// src/smtpd/smtpd.c:3495, the "from" part written only when the parameter
// is off, and with_protocol emptied with it).
func checkReceivedHidden(raw []byte) error {
recv, err := postfixReceived(raw)
if err != nil {
return err
}
if len(recv) != 1 {
return fmt.Errorf("want one Received: header from SelfPost's Postfix, got %d: %q", len(recv), recv)
}
if !strings.HasPrefix(recv[0], "by ") || strings.Contains(recv[0], " with ") {
return fmt.Errorf("the Received: header names the client session: %q", recv[0])
}
return nil
}
// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log // connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log
// line, which is how the harness learns the source address Postfix itself // line, which is how the harness learns the source address Postfix itself
// 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 // Postfix 3.10.13, src/smtpd/smtpd.c, smtpd_service(): msg_info("connect from
// %s", namaddr), namaddr being "name[addr]". // %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.:]+)\]`)
+3
View File
@@ -274,6 +274,9 @@ func TestE2E(t *testing.T) {
if err := verifyDKIM(raw, senderDomain); err != nil { if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("DKIM did not verify against the record the panel published: %v", err) t.Fatalf("DKIM did not verify against the record the panel published: %v", err)
} }
if err := checkReceivedHidden(raw); err != nil {
t.Fatalf("port 465 should hide the application's session: %v", err)
}
if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) { if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
rows, err := sc.panel.sendLogRows(senderDomain, "") rows, err := sc.panel.sendLogRows(senderDomain, "")
+5 -5
View File
@@ -48,7 +48,7 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
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: // (Postfix 3.10.13, src/milter/milter8.c, milter8_event(), SMFIR_TEMPFAIL:
// "451 4.7.1 Service unavailable - try again later"). // "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)
@@ -60,7 +60,7 @@ 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). Postfix's half: 3.7.11, src/smtpd/smtpd.c, // client-address list). Postfix's half: 3.10.13, src/smtpd/smtpd.c,
// smtpd_proto(), calls milter_conn_event() once per connection. // 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 {
@@ -94,7 +94,7 @@ 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. (Postfix 3.7.11, src/global/mail_params.h, // only sent back at RCPT TO. (Postfix 3.10.13, src/global/mail_params.h,
// DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c, // DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected: // reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected:
// not owned by user".) // not owned by user".)
@@ -110,7 +110,7 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
// 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/ // The reply: smtps's client restrictions end in "reject" (build/
// postfix-config.sh), Postfix 3.7.11, src/smtpd/smtpd_check.c, // postfix-config.sh), Postfix 3.10.13, src/smtpd/smtpd_check.c,
// generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied", // generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied",
// delayed to RCPT by smtpd_delay_reject. // delayed to RCPT by smtpd_delay_reject.
func testNoAuthRejected(t *testing.T, sc *scenario) { func testNoAuthRejected(t *testing.T, sc *scenario) {
@@ -200,7 +200,7 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
// 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). (An incident // smtpd from before the panel's reload held (waitForSenderMap). (An incident
// on the stand, date not recorded.) Anvil's reply: Postfix 3.7.11, // on the stand, date not recorded.) Anvil's reply: Postfix 3.10.13,
// src/smtpd/smtpd.c, mail_cmd(): "450 4.7.1 Error: too much mail from %s". // 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", "")
+10 -5
View File
@@ -34,7 +34,7 @@ type port25Case struct {
// 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 (date not // 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()). // recorded; Postfix 3.10.13, 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{
@@ -51,8 +51,12 @@ var port25Cases = []port25Case{
// The log tailer tells this listener's refusals from the // The log tailer tells this listener's refusals from the
// submission ports' by the name they are logged under. // submission ports' by the name they are logged under.
{param: "syslog_name", contains: "postfix/inbound", wantExact: true}, {param: "syslog_name", contains: "postfix/inbound", wantExact: true},
// The SMTP-smuggling defence, off by default on this Postfix // The SMTP-smuggling defence. Postfix 3.10.13 has it by default
// (own-mta-builds, § 5); testInboundBareNewline drives it. // (src/global/mail_params.h, DEF_SMTPD_FORBID_BARE_LF "normalize");
// set explicitly since 2.6.1, when the image ran Postfix 3.7.11,
// whose default was "no" (own-mta-builds, § 5), and kept so the
// listener does not depend on the default. testInboundBareNewline
// drives it.
{param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true}, {param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true},
// No antispam milter configured: the inbound journal milter // No antispam milter configured: the inbound journal milter
// alone. The submission chain (strict OpenDKIM + journal-milter) // alone. The submission chain (strict OpenDKIM + journal-milter)
@@ -150,8 +154,9 @@ func checkPort25ConfigCombinations(s *stack) error {
// 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. (Seen in 1.9.1, // `postfix check` stays silent). Ask Postfix itself. (Seen in 1.9.1,
// date not recorded; Postfix 3.7.11, man/man5/master.5, "-o // date not recorded; Postfix 3.10.13, man/man5/master.5, "-o
// name=value (short form)", NOTE 2.) // name=value (short form)": "does not support whitespace
// around the "=" or in parameter values".)
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)
+15 -3
View File
@@ -272,7 +272,7 @@ func testInboundRelay(t *testing.T, sc *scenario) {
// Port 25's limit is the whole limit: a 12 MB message - over the // Port 25's limit is the whole limit: a 12 MB message - over the
// 10240000 bytes cleanup would hold it to on its own, under the 25 MiB the // 10240000 bytes cleanup would hold it to on its own, under the 25 MiB the
// listener says - is accepted and passed on (Postfix 3.7.11, // listener says - is accepted and passed on (Postfix 3.10.13,
// src/cleanup/cleanup_init.c, cleanup_post_jail(); assumptions C-P31). // src/cleanup/cleanup_init.c, cleanup_post_jail(); assumptions C-P31).
big := uniqueToken("inbound-12mb") big := uniqueToken("inbound-12mb")
line := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab\r\n" line := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab\r\n"
@@ -365,7 +365,7 @@ func testInboundRelay(t *testing.T, sc *scenario) {
t.Fatalf("the Inbound log holds %d rows, want the two relayed messages alone:\n%s", len(rows), strings.Join(rows, "\n")) t.Fatalf("the Inbound log holds %d rows, want the two relayed messages alone:\n%s", len(rows), strings.Join(rows, "\n"))
} }
// Postfix 3.7.11, src/smtpd/smtpd_check.c: "User unknown in relay recipient // Postfix 3.10.13, src/smtpd/smtpd_check.c: "User unknown in relay recipient
// table" (check_rcpt_maps(); unknown_relay_recipient_reject_code, // table" (check_rcpt_maps(); unknown_relay_recipient_reject_code,
// DEF_RELAY_RCPT_CODE 550) and "Relay access denied" // DEF_RELAY_RCPT_CODE 550) and "Relay access denied"
// (reject_unauth_destination(); DEF_RELAY_CODE 554). // (reject_unauth_destination(); DEF_RELAY_CODE 554).
@@ -464,7 +464,8 @@ func testInboundRelay(t *testing.T, sc *scenario) {
// smuggling sequence, with a second transaction's commands behind it - is // smuggling sequence, with a second transaction's commands behind it - is
// body, and the one message delivered carries all of it; nothing is read as // body, and the one message delivered carries all of it; nothing is read as
// a second transaction (smtpd_forbid_bare_newline=normalize on smtp/inet, // a second transaction (smtpd_forbid_bare_newline=normalize on smtp/inet,
// build/postfix-config.sh; Postfix 3.7.11, src/smtpd/smtpd.c:3649-3652). // build/postfix-config.sh; Postfix 3.10.13, src/smtpd/smtpd.c:3713-3716,
// receive_data_message()).
// Runs after testInboundRelay, whose domain, upstream and recipient it uses. // Runs after testInboundRelay, whose domain, upstream and recipient it uses.
func testInboundBareNewline(t *testing.T, sc *scenario) { func testInboundBareNewline(t *testing.T, sc *scenario) {
token := uniqueToken("inbound-bare") token := uniqueToken("inbound-bare")
@@ -484,6 +485,17 @@ func testInboundBareNewline(t *testing.T, sc *scenario) {
if !bytes.Contains(raw, []byte(smuggled)) { if !bytes.Contains(raw, []byte(smuggled)) {
t.Fatalf("the message was cut at the bare-newline \".\": what followed it is not in the delivered message\n%s", raw) t.Fatalf("the message was cut at the bare-newline \".\": what followed it is not in the delivered message\n%s", raw)
} }
// Port 25 keeps RFC 5321's trace: smtpd_hide_client_session is set on
// the submission ports only (build/postfix-config.sh; Postfix 3.10.13,
// proto/postconf.proto:19587-19589, "must be used for the port 25 MTA
// service"), so this Received: header opens with "from" and the HELO.
recv, err := postfixReceived(raw)
if err != nil {
t.Fatal(err)
}
if len(recv) != 1 || !strings.HasPrefix(recv[0], "from mx.stranger.e2e.test ") {
t.Fatalf("port 25's Received: header should name the sending server, got %q", recv)
}
// A second transaction, had Postfix read one, would be delivered right // A second transaction, had Postfix read one, would be delivered right
// behind the first: give it the time and count the files that carry the // behind the first: give it the time and count the files that carry the
// smuggled token. // smuggled token.
+2 -2
View File
@@ -73,7 +73,7 @@ func parseSupervisorStatus(out string) map[string]string {
// 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). For an absent service // removed when INBOUND_RELAY_ENABLE is not true). For an absent service
// postconf -M prints nothing on stdout and "warning: unmatched request" on // postconf -M prints nothing on stdout and "warning: unmatched request" on
// stderr (Postfix 3.7.11, src/postconf/postconf_master.c, // stderr (Postfix 3.10.13, src/postconf/postconf_master.c,
// pcf_show_master_entries()). // 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")
@@ -90,7 +90,7 @@ func checkInboundRelayOff(s *stack) error {
// checkCitedPackageVersions compares the Debian packages the image runs with // checkCitedPackageVersions compares the Debian packages the image runs with
// the versions their sources were read at (docs/development.md, the table // the versions their sources were read at (docs/development.md, the table
// between the cited-package-versions markers). The image installs unpinned // between the cited-package-versions markers). The image installs unpinned
// from a moving bookworm, so a point release changes them on a rebuild // from a moving trixie, so a point release changes them on a rebuild
// without anyone reading what changed (assumptions C-D1). // without anyone reading what changed (assumptions C-D1).
func checkCitedPackageVersions(s *stack) error { func checkCitedPackageVersions(s *stack) error {
doc, err := os.ReadFile(filepath.Join(s.repoRoot, "docs", "development.md")) doc, err := os.ReadFile(filepath.Join(s.repoRoot, "docs", "development.md"))
+2 -2
View File
@@ -24,7 +24,7 @@ import (
// record that was published before the backup. // record that was published before the backup.
func testFullBackupRestore(t *testing.T, sc *scenario) { func testFullBackupRestore(t *testing.T, sc *scenario) {
// A private key the panel may not read - the state a server is in until // A private key the panel may not read - the state a server is in until
// its operator gives the key to group 998. The panel does not make an // its operator gives the key to group 10998. The panel does not make an
// archive without it: it refuses, names the file and says what to do. // archive without it: it refuses, names the file and says what to do.
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem") keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
if err := os.Chmod(keyPath, 0o600); err != nil { if err := os.Chmod(keyPath, 0o600); err != nil {
@@ -34,7 +34,7 @@ func testFullBackupRestore(t *testing.T, sc *scenario) {
if err != nil { if err != nil {
t.Fatalf("full backup with an unreadable key: %v", err) t.Fatalf("full backup with an unreadable key: %v", err)
} }
if resp.StatusCode != 500 || !strings.Contains(body, "not allowed to read certs/privkey.pem") || !strings.Contains(body, "chgrp 998") { if resp.StatusCode != 500 || !strings.Contains(body, "not allowed to read certs/privkey.pem") || !strings.Contains(body, "chgrp 10998") {
t.Fatalf("full backup with an unreadable key: status %d, want a refusal that names the file and the way out:\n%.800s", resp.StatusCode, body) t.Fatalf("full backup with an unreadable key: status %d, want a refusal that names the file and the way out:\n%.800s", resp.StatusCode, body)
} }
if err := os.Chmod(keyPath, 0o644); err != nil { if err := os.Chmod(keyPath, 0o644); err != nil {
+3 -3
View File
@@ -53,7 +53,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
{"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 (Postfix // folded, and Postfix hands the milter both of its lines (Postfix
// 3.7.11, src/milter/milter8.c, milter8_header(): the value is the // 3.10.13, src/milter/milter8.c, milter8_header(): the value is the
// whole header as cleanup's MIME state machine collected it, // whole header as cleanup's MIME state machine collected it,
// continuation line included). // 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},
@@ -112,7 +112,7 @@ func testSenderCheck(t *testing.T, sc *scenario) {
// What the header case above holds to: Postfix itself refuses the // What the header case above holds to: Postfix itself refuses the
// wildcard application billing@ as envelope sender, at RCPT, because the // wildcard application billing@ as envelope sender, at RCPT, because the
// address's own key names app-list only (Postfix 3.7.11, // address's own key names app-list only (Postfix 3.10.13,
// src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): "553 // src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): "553
// 5.7.1 <%s>: Sender address rejected: not owned by user %s"). // 5.7.1 <%s>: Sender address rejected: not owned by user %s").
if res := attemptSend(sendAttempt{ if res := attemptSend(sendAttempt{
@@ -250,7 +250,7 @@ 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; Postfix 3.7.11, src/smtpd/smtpd.c, milter_send() to // smtpd's lines only; Postfix 3.10.13, src/smtpd/smtpd.c, milter_send() to
// cleanup, src/cleanup/cleanup_milter.c). The panel is frozen with SIGSTOP // cleanup, src/cleanup/cleanup_milter.c). The panel is frozen with SIGSTOP
// once DATA is answered, and thawed after the client has its reply. // 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) {
+2 -2
View File
@@ -3,7 +3,7 @@
# the raw received message (headers + body, including the DKIM-Signature # the raw received message (headers + body, including the DKIM-Signature
# Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix # Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix
# package itself — no new dependency. # package itself — no new dependency.
FROM debian:bookworm-slim FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
@@ -21,7 +21,7 @@ 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. (Postfix 3.7.11, src/smtpstone/smtp-sink.c: -d "Dump each mail # exposed. (Postfix 3.10.13, src/smtpstone/smtp-sink.c: -d "Dump each mail
# transaction to a single-message file" named by the template plus "a # transaction to a single-message file" named by the template plus "a
# pseudo-random hexadecimal number"; main(): "-u option is required if running # pseudo-random hexadecimal number"; main(): "-u option is required if running
# as root".) # as root".)
+1 -1
View File
@@ -237,7 +237,7 @@ func mailTwiceOnOneConnection(login, password, from string) [2]error {
// 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, date not recorded; two seconds after the save it no // (seen on the stand, date not recorded; two seconds after the save it no
// longer happened, at 0, 10, 40 or 110 s of waiting). Postfix 3.7.11: an smtpd // longer happened, at 0, 10, 40 or 110 s of waiting). Postfix 3.10.13: an smtpd
// reads the texthash map once, when it starts (html/DATABASE_README.html, // 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 // "texthash"), and one from before the reload exits only when it next sees
// master's status pipe closed (src/master/multi_server.c, // master's status pipe closed (src/master/multi_server.c,
+1 -1
View File
@@ -117,7 +117,7 @@ func writeSelfSignedCert(certPath, keyPath string) error {
} }
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
// Readable by everyone, which a real key must never be. On a server the // Readable by everyone, which a real key must never be. On a server the
// operator gives the key to the container's selfpost group (998) so the // operator gives the key to the container's selfpost group (10998) so the
// panel can put it into a full backup; the runner of this suite is not // panel can put it into a full backup; the runner of this suite is not
// always root and cannot hand a file to a group it is not in. The key is // always root and cannot hand a file to a group it is not in. The key is
// thrown away with the stage directory. // thrown away with the stage directory.
+1 -1
View File
@@ -45,7 +45,7 @@ func waitProgramState(t *testing.T, name, want string, within time.Duration) {
} }
// postfixStops counts the lines "postfix stop" writes to mail.log before it // postfixStops counts the lines "postfix stop" writes to mail.log before it
// sends master TERM (Postfix 3.7.11, conf/postfix-script, "stop": $INFO // sends master TERM (Postfix 3.10.13, conf/postfix-script, "stop": $INFO
// stopping the Postfix mail system; "... with force" when master had not // stopping the Postfix mail system; "... with force" when master had not
// gone after five seconds, which this does not count). // gone after five seconds, which this does not count).
func postfixStops(t *testing.T) int { func postfixStops(t *testing.T) int {
+3 -3
View File
@@ -1,17 +1,17 @@
#!/bin/sh #!/bin/sh
# The From: parser port (internal/milter/mailparse.go) against OpenDKIM's own # The From: parser port (internal/milter/mailparse.go) against OpenDKIM's own
# dkim_mail_parse(): builds internal/milter/testdata/mailparse_harness.c # dkim_mail_parse(): builds internal/milter/testdata/mailparse_harness.c
# against the libopendkim source the image runs, in a debian:bookworm # against the libopendkim source the image runs, in a debian:trixie
# container, and runs TestMailParseMatchesOpenDKIM with it. # container, and runs TestMailParseMatchesOpenDKIM with it.
# #
# test/upstream/mailparse-diff.sh <opendkim source dir> # test/upstream/mailparse-diff.sh <opendkim source dir>
# #
# The source dir is what `apt-get source opendkim` unpacks in debian:bookworm # The source dir is what `apt-get source opendkim` unpacks in debian:trixie
# (docs/development.md); it must contain libopendkim/. # (docs/development.md); it must contain libopendkim/.
set -eu set -eu
src=$(cd "$1" && pwd) src=$(cd "$1" && pwd)
repo=$(cd "$(dirname "$0")/../.." && pwd) repo=$(cd "$(dirname "$0")/../.." && pwd)
docker run --rm -v "$src":/src:ro -v "$repo/internal/milter/testdata":/h -w /h debian:bookworm sh -c ' docker run --rm -v "$src":/src:ro -v "$repo/internal/milter/testdata":/h -w /h debian:trixie sh -c '
apt-get update -qq >/dev/null && apt-get install -y -qq gcc libc6-dev >/dev/null 2>&1 apt-get update -qq >/dev/null && apt-get install -y -qq gcc libc6-dev >/dev/null 2>&1
gcc -O1 -static -o /h/mailparse_harness -I /src/libopendkim /h/mailparse_harness.c' gcc -O1 -static -o /h/mailparse_harness -I /src/libopendkim /h/mailparse_harness.c'
trap 'rm -f "$repo/internal/milter/testdata/mailparse_harness"' EXIT trap 'rm -f "$repo/internal/milter/testdata/mailparse_harness"' EXIT
+1 -1
View File
@@ -16,7 +16,7 @@ set -e
mkdir -p /tmp/odk && cd /tmp/odk mkdir -p /tmp/odk && cd /tmp/odk
for d in sender.e2e.test other.e2e.test mail.e2e.test; do for d in sender.e2e.test other.e2e.test mail.e2e.test; do
mkdir -p keys/$d mkdir -p keys/$d
opendkim-genkey -D keys/$d -d $d -s sel openssl genrsa -out keys/$d/sel.private 2048 2>/dev/null
echo "$d $d:sel:/tmp/odk/keys/$d/sel.private" >> KeyTable echo "$d $d:sel:/tmp/odk/keys/$d/sel.private" >> KeyTable
echo "*@$d $d" >> SigningTable echo "*@$d $d" >> SigningTable
done done