Postfix Configuration¶
How Mailyte's SMTP server is set up — virtual domains, TLS, authentication, rate limiting, and the tracking filter.
Postfix handles all inbound and outbound email. It talks to MySQL for domain and mailbox lookups, hands off authentication to Dovecot, and pipes outgoing mail through the tracking content filter. The config is baked into the image from mailer/postfix/config/ (main.cf, master.cf, the MySQL lookup files, and header/body checks); at container start, entrypoint.sh runs envsubst over the MySQL lookup files and applies environment-driven overrides with postconf -e.
main.cf — The Core Config¶
The main Postfix config lives at /etc/postfix/main.cf (source: mailer/postfix/config/main.cf). Here are the key sections as actually shipped.
Server Identity¶
myhostname = mail.example.com # overridden at start: postconf -e "myhostname=${HOSTNAME}"
mydomain = example.com # derived from $HOSTNAME by the entrypoint
myorigin = $mydomain
mydestination = localhost
mynetworks = 127.0.0.0/8 [::1]/128 # entrypoint appends ${POSTFIX_MYNETWORKS:-172.25.0.0/16}
compatibility_level = 3.6
smtpd_helo_required = yes
disable_vrfy_command = yes
disable_etrn_command = yes
smtpd_forbid_bare_newline = reject
mydestination is set to localhost because all real domains are handled as virtual domains through MySQL. This is intentional — don't add your domain here.
Virtual Domain Setup¶
Postfix queries MySQL through the proxy: prefix (the proxymap daemon keeps persistent connections; it's also required because smtpd runs chrooted):
virtual_mailbox_domains = proxy:mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
virtual_mailbox_maps = proxy:mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
virtual_alias_maps = proxy:mysql:/etc/postfix/mysql-virtual-alias-maps.cf
smtpd_sender_login_maps = proxy:mysql:/etc/postfix/mysql-sender-login-maps.cf
virtual_transport = lmtp:inet:dovecot:24
virtual_mailbox_base = /var/mail/vhosts
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
Mail for virtual domains is delivered to the Dovecot container over LMTP on TCP port 24 (a Unix socket can't cross container boundaries).
TLS Settings¶
# Inbound TLS (other servers connecting to us)
smtpd_tls_cert_file = /etc/ssl/certs/server.crt
smtpd_tls_key_file = /etc/ssl/private/server.key
tls_server_sni_maps = hash:/etc/postfix/sni_certs.map
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1
smtpd_tls_ciphers = high
smtpd_tls_exclude_ciphers = aNULL, MD5, DES, RC4, 3DES, ADH, EXPORT, LOW
# Outbound TLS (us connecting to other servers)
smtp_tls_security_level = may
smtp_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1
smtp_tls_loglevel = 1
smtp_tls_note_starttls_offer = yes
The server.crt/server.key pair and the SNI map are produced by cert_manager (see SSL Certificates) — the entrypoint copies them into place and runs postmap -F hash: on the SNI map at every start.
[!NOTE]
smtpd_tls_security_level = maymeans TLS is offered but not required for server-to-server mail. This is the standard for email — requiring TLS would break delivery to many servers that don't support it. For user authentication,smtpd_tls_auth_only = yesensures credentials are never sent in the clear.
SASL Authentication (via Dovecot)¶
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:dovecot:24100
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
Postfix doesn't do authentication itself. It delegates to Dovecot's auth service over TCP port 24100 (containers can't share a Unix socket). Dovecot checks the email_accounts table for mailbox logins and the smtp_credentials table for SMTP API keys (see Dovecot Configuration).
Sender Login Enforcement¶
smtpd_sender_login_maps maps a MAIL FROM address to the SASL logins allowed to use it — a mailbox owns its own address, an alias's destinations own the alias, and a domain-scoped SMTP credential owns every address in its domain:
SELECT email FROM email_accounts WHERE email='%s' AND status = 'active'
UNION
SELECT destination FROM aliases WHERE source='%s' AND active = 1
UNION
SELECT sc.username FROM smtp_credentials sc
JOIN domains d ON sc.domain_id = d.id
WHERE d.domain = SUBSTRING_INDEX('%s', '@', -1) AND sc.active = 1
[!WARNING]
reject_sender_login_mismatchis enforced only in the port 587/465master.cfoverrides (placed beforepermit_sasl_authenticated), never in the globalsmtpd_sender_restrictions. Putting it in the global value makes port 25 demand a SASL login from every connecting MTA and breaks all inbound mail — this happened in production on 2026-08-22.
Message Size Limit¶
The 50 MB limit is baked into main.cf (it is not env-configurable — the POSTFIX_MESSAGE_SIZE_LIMIT entry in .env.example is not read by the entrypoint).
Rspamd Integration¶
milter_protocol = 6
milter_default_action = accept
smtpd_milters = inet:rspamd:11332
non_smtpd_milters = inet:rspamd:11332
milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}
All incoming and outgoing mail passes through Rspamd for spam checking and DKIM signing.
[!WARNING] The
milter_default_action = acceptline means that if Rspamd goes down, mail is accepted without scanning. Change this totempfailin high-security environments so mail gets deferred instead.
Postscreen (Port 25)¶
Port 25 is fronted by postscreen, which weeds out botnet clients before they reach an smtpd process:
postscreen_greet_action = enforce
postscreen_dnsbl_sites = zen.spamhaus.org*3, b.barracudacentral.org*2,
bl.spamcop.net*2, dnsbl.sorbs.net*1, psbl.surriel.com*1
postscreen_dnsbl_threshold = 3
postscreen_dnsbl_action = enforce
postscreen_access_list = permit_mynetworks
The deeper protocol tests (postscreen_non_smtp_command_enable, postscreen_bare_newline_enable, postscreen_pipelining_enable) are deliberately off — enabling them delayed and broke delivery from large providers.
Logging¶
Postfix logs to a file via its built-in postlogd (the postlog service in master.cf) — there is no syslog daemon in the container. The log_ingestor service tails this file to populate the mail_logs and delivery_events tables.
master.cf — Service Definitions¶
master.cf is baked into the image and never modified at runtime. The listening services:
| Service | Port | Purpose |
|---|---|---|
smtp (postscreen) | 25 | Server-to-server mail; postscreen in front, no auth |
submission | 587 | Authenticated submission (STARTTLS required) |
smtps | 465 | Authenticated submission (implicit TLS) |
10587 | 10587 (internal only) | Unauthenticated submission for trusted internal services (permit_mynetworks,reject); not published to the host |
127.0.0.1:10026 | 10026 | Re-injection listener for the tracking filter (content_filter= cleared to avoid loops) |
Port 587/465 overrides (both identical apart from smtpd_tls_wrappermode=yes on 465):
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_reject_unlisted_recipient=yes
-o smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o content_filter=tracking-filter:
Plus tighter per-client limits on submission: connection rate 10, connection count 20, message rate 50, recipient rate 100.
Tracking Filter¶
Outgoing mail on 587/465 (and the internal 10587 listener) passes through the tracking-filter pipe service:
tracking-filter unix - n n - 25 pipe
flags=Rq user=vmail argv=/usr/local/bin/tracking_injector.py -f ${sender} -- ${recipient}
The injector rewrites links, adds an open-tracking pixel, then re-injects the message via SMTP to 127.0.0.1:10026. It reads its settings (TRACKING_ENABLED, TRACKING_SERVICE_URL, DB credentials, …) from /etc/postfix/runtime.env, a file the entrypoint writes at start — Postfix's import_environment does not pass env vars to pipe/spawn children.
Two policy services also run from master.cf:
policy-rate-limit(rate_limit_policy.py) — consulted at exactly one point,smtpd_data_restrictions(adding it elsewhere double-counts messages).policy-ip-access(ip_access_policy.py) — consulted fromsmtpd_recipient_restrictions.
An inbound webhook-filter pipe service (webhook_sender.py) emits inbound-mail webhooks.
MySQL Lookup Files¶
These live in /etc/postfix/ and get their credentials substituted from DB_USER/DB_PASSWORD/DB_HOST/DB_PORT/DB_NAME at container start:
| File | Query |
|---|---|
mysql-virtual-mailbox-domains.cf | SELECT 1 FROM domains WHERE domain='%s' AND active = 1 |
mysql-virtual-mailbox-maps.cf | SELECT 1 FROM email_accounts WHERE email='%s' AND status = 'active' |
mysql-virtual-alias-maps.cf | SELECT destination FROM aliases WHERE source='%s' AND active = 1 |
mysql-sender-login-maps.cf | 3-branch UNION shown above |
[!TIP] These files contain database credentials after substitution. The entrypoint sets them to
640, ownerroot:postfix— keep them that way.
Rate Limiting¶
Per-client (per-IP) limits at the SMTP level, from main.cf:
smtpd_client_connection_rate_limit = 30
smtpd_client_connection_count_limit = 50
smtpd_client_message_rate_limit = 100
smtpd_client_recipient_rate_limit = 200
anvil_rate_time_unit = 60s
All four are overridable via POSTFIX_CONNECTION_RATE_LIMIT, POSTFIX_CONNECTION_COUNT_LIMIT, POSTFIX_MESSAGE_RATE_LIMIT, POSTFIX_RECIPIENT_RATE_LIMIT, and POSTFIX_RATE_TIME_UNIT.
[!NOTE] These are per-client limits at the SMTP level. Mailyte also enforces per-tenant and per-SMTP-credential rate limits through the
rate_limiterservice (consulted by thepolicy-rate-limitpolicy daemon at DATA time) — see Multi-Tenant Configuration.
Restrictions¶
The full chains as shipped (first matching rule wins):
smtpd_client_restrictions =
permit_mynetworks, permit_sasl_authenticated, reject_unauth_pipelining,
reject_rbl_client zen.spamhaus.org, warn_if_reject reject_unknown_client_hostname
smtpd_recipient_restrictions =
permit_mynetworks, permit_sasl_authenticated, reject_non_fqdn_recipient,
reject_unknown_recipient_domain, reject_unauth_destination,
reject_unlisted_recipient, reject_rbl_client zen.spamhaus.org,
check_policy_service unix:private/policy-ip-access, permit
smtpd_sender_restrictions =
permit_mynetworks, permit_sasl_authenticated,
reject_non_fqdn_sender, reject_unknown_sender_domain
smtpd_relay_restrictions =
permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_data_restrictions =
reject_unauth_pipelining, reject_multi_recipient_bounce,
check_policy_service unix:private/policy-rate-limit
Only one RBL (zen.spamhaus.org) is consulted at smtpd time — several others were removed after they blocked legitimate senders. permit_mynetworks in smtpd_relay_restrictions is required: the webmail container submits unauthenticated over the Docker network.
Transport Cutover Map¶
config/mailer/postfix/transport_cutover (bind-mounted to /etc/postfix/custom/) lists domains that are provisioned on this server but whose MX still points elsewhere; smtp: with no nexthop routes their mail by public MX instead of delivering locally. Because it's a texthash: map, edits need only postfix reload — no postmap.
[!WARNING] Remove a domain's line the moment its MX cuts over to this server, or its inbound mail will loop-bounce (
5.4.6 ... loops back to myself). A stale entry here silently destroyed 167 inbound messages across 13 domains before it was caught on 2026-08-27.
Environment Variables Read by the Entrypoint¶
HOSTNAME, POSTFIX_MYNETWORKS, POSTFIX_DEV_MODE, POSTFIX_TLS_SECURITY_LEVEL, POSTFIX_SMTP_TLS_SECURITY_LEVEL, POSTFIX_TLS_AUTH_ONLY, POSTFIX_TLS_PROTOCOLS, POSTFIX_TLS_CIPHERS, POSTFIX_TLS_EXCLUDE_CIPHERS, POSTFIX_TLS_LOG_LEVEL, POSTFIX_TLS_CACHE_TIMEOUT, the rate-limit overrides listed above, the timeout/error-limit overrides (POSTFIX_SMTP_TIMEOUT, POSTFIX_HELO_TIMEOUT, POSTFIX_MAIL_TIMEOUT, POSTFIX_RCPT_TIMEOUT, POSTFIX_DATA_TIMEOUT, POSTFIX_SOFT_ERROR_LIMIT, POSTFIX_HARD_ERROR_LIMIT, POSTFIX_JUNK_COMMAND_LIMIT, POSTFIX_DNS_TIMEOUT), plus the set written into /etc/postfix/runtime.env for pipe/spawn children (DB_*, REDIS_*, TRACKING_*, DELIVERY_OPTIMIZER_*, DEFAULT_ORGANIZATION_ID, DEFAULT_DOMAIN_ID, WEBHOOK_SECRET).
[!WARNING] The
POSTFIX_MYHOSTNAME,POSTFIX_MYDOMAIN,POSTFIX_VIRTUAL_*,POSTFIX_MESSAGE_SIZE_LIMIT,POSTFIX_CONTENT_FILTER, andPOSTFIX_HEADER_CHECKSentries in.env.exampleare not read by anything — setting them has no effect. The entrypoint reads only the names listed above.
Testing Your Configuration¶
After making changes, always check for syntax errors:
docker exec postfix postconf -n # Show non-default settings
docker exec postfix postfix check # Validate configuration
To reload Postfix without restarting the container:
Remember that main.cf is baked into the image and re-applied by the entrypoint: postconf -e edits inside a running container vanish when the container is recreated. Persistent changes belong in mailer/postfix/config/ (rebuild) or in an env var the entrypoint reads.