No description
  • Python 82%
  • Smarty 11.2%
  • Shell 3.5%
  • Dockerfile 3.3%
Find a file
2026-03-16 17:51:44 +01:00
files chore: opensmtpd multi domain ready 2026-03-13 22:34:44 +01:00
mail-sample chore: doc samples 2026-03-13 23:16:19 +01:00
tests Add mailctl parser hardening and end-to-end tests 2026-03-16 17:51:44 +01:00
tools Add mailctl parser hardening and end-to-end tests 2026-03-16 17:51:44 +01:00
Containerfile fix: entrypoint 2026-01-07 03:22:05 +01:00
mailcontainer-sample.container feat: doc and mailctl tool 2026-03-13 23:09:53 +01:00
README.md chore: doc samples 2026-03-13 23:16:19 +01:00
roundcube-sample.container feat: doc and mailctl tool 2026-03-13 23:09:53 +01:00

Mail Container with Podman Quadlet

This repository builds a mail container with:

  • OpenSMTPD for SMTP submission and relay
  • Dovecot for IMAPS and ManageSieve
  • Rspamd for filtering and DKIM signing

It is designed to run with Podman Quadlet. Sample Quadlet units are included in:

  • mailcontainer-sample.container
  • roundcube-sample.container

Table of contents

What the container expects

The mail container mounts a host directory on /etc/mail and keeps mailboxes in /var/vmail.

The sample Quadlet uses:

  • /opt/containers/mail/volumes/mail:/etc/mail
  • /opt/containers/mail/volumes/vmail:/var/vmail

Inside the /etc/mail volume, create at least:

  • passwd
  • virtuals
  • aliases
  • senders
  • certs/

On first start, the container will:

TLS certificate naming

The SMTP and IMAP configs are generated from templates and require these exact file names:

  • /etc/mail/certs/${MAIL_DOMAIN}.pem
  • /etc/mail/certs/${MAIL_DOMAIN}-key.pem

This is taken directly from files/templates/smtpd.conf.tpl and files/templates/dovecot.conf.tpl.

For MAIL_DOMAIN=example.com, the files must therefore be:

  • /etc/mail/certs/example.com.pem
  • /etc/mail/certs/example.com-key.pem

If you use Let's Encrypt, the usual mapping is:

  • fullchain.pem -> ${MAIL_DOMAIN}.pem
  • privkey.pem -> ${MAIL_DOMAIN}-key.pem

You can copy or symlink them into the mounted mail volume. Example:

install -d /opt/containers/mail/volumes/mail/certs
cp /etc/letsencrypt/live/example.com/fullchain.pem \
  /opt/containers/mail/volumes/mail/certs/example.com.pem
cp /etc/letsencrypt/live/example.com/privkey.pem \
  /opt/containers/mail/volumes/mail/certs/example.com-key.pem
chmod 600 /opt/containers/mail/volumes/mail/certs/example.com-key.pem

If you prefer symlinks:

install -d /opt/containers/mail/volumes/mail/certs
ln -sf /etc/letsencrypt/live/example.com/fullchain.pem \
  /opt/containers/mail/volumes/mail/certs/example.com.pem
ln -sf /etc/letsencrypt/live/example.com/privkey.pem \
  /opt/containers/mail/volumes/mail/certs/example.com-key.pem

Getting certificates with Let's Encrypt

How you obtain the certificate is up to your environment. A common approach is certbot.

Example with standalone validation:

certbot certonly --standalone -d example.com

If your MX, IMAP, and submission hostnames are separate, request certificates for the names clients will use, then place the resulting files under the names expected by MAIL_DOMAIN.

Build the image

Build the local image referenced by the sample Quadlet:

podman build -t mail-container .

Prepare the mail volume

Create the required directories:

install -d /opt/containers/mail/volumes/mail
install -d /opt/containers/mail/volumes/mail/certs
install -d /opt/containers/mail/volumes/vmail
touch /opt/containers/mail/volumes/mail/passwd
touch /opt/containers/mail/volumes/mail/virtuals
touch /opt/containers/mail/volumes/mail/aliases
touch /opt/containers/mail/volumes/mail/senders

You can also start from the example files in mail-sample/.

Mail users, aliases, and sender permissions

This setup uses flat files mounted into /etc/mail:

  • passwd for SMTP/IMAP authentication
  • virtuals for mailbox delivery and aliases
  • senders for submission sender authorization

For routine management, use tools/mailctl.py:

uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail add-user user@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail set-password user@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail add-alias alias@example.com user@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail remove-alias alias@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail allow-sender user@example.com @example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail remove-sender user@example.com @example.com

If you do not want to install anything into the host Python environment, use uv run or uvx so the inline script dependency metadata pulls bcrypt automatically.

Warning: tools/mailctl.py is vibecoded. Read it before using it on production mail data and keep backups of your /etc/mail directory.

mailctl.py uses Python bcrypt with cost 12 to generate password hashes for passwd.

add-user prompts for a password, writes the bcrypt hash to passwd, creates the real mailbox mapping in virtuals, and grants the user permission to send as their own address in senders.

For example:

uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail add-user paolo@example.com

This creates or updates:

  • passwd: paolo@example.com:<smtpctl-hash>
  • virtuals: paolo@example.com vmail
  • senders: paolo@example.com paolo@example.com

passwd

The helper script hashes passwords directly with Python bcrypt:

uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail add-user user@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail set-password user@example.com

If you want to manage the file manually, put the mailbox and the resulting bcrypt hash into /etc/mail/passwd.

Example:

user@example.com:$2b$12$.....................................................
admin@example.com:$2b$12$....................................................

Format:

email-address:encrypted-password

These users are used for authenticated SMTP submission and IMAP login. The hashes written by mailctl.py look like $2b$12$....

virtuals

Use /etc/mail/virtuals to map recipient addresses.

Examples:

user@example.com vmail
alias@example.com user@example.com

Meaning:

  • user@example.com vmail creates a real mailbox account delivered into the virtual mail store
  • alias@example.com user@example.com makes alias@example.com an alias that forwards locally to user@example.com

You can define multiple aliases pointing to the same destination mailbox.

With the helper script:

uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail add-alias ciao@example.com paolo@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail remove-alias ciao@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail remove-alias ciao@example.com paolo@example.com

senders

Use /etc/mail/senders to define which authenticated user may send with which envelope sender addresses on the submission ports.

Examples:

user@example.com user@example.com
user@example.com @example.com
paolo@example.com paolo@example.com
paolo@example.com info@example.com
paolo@example.com hello@example.com

Meaning:

  • user@example.com user@example.com allows user@example.com to send as user@example.com
  • user@example.com @example.com allows user@example.com to send as any address in @example.com
  • the three paolo@example.com ... entries allow paolo@example.com to send only as that explicit list of accounts: paolo@example.com, info@example.com, and hello@example.com

For a user limited to a small set of sender identities, add one line per allowed address.

With the helper script:

uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail allow-sender paolo@example.com paolo@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail allow-sender paolo@example.com @example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail allow-sender paolo@example.com info@example.com hello@example.com ciao@example.com
uv run tools/mailctl.py --mail-dir /opt/containers/mail/volumes/mail remove-sender paolo@example.com hello@example.com

Install the Quadlet unit

Install the unit system-wide:

sudo install -d /etc/containers/systemd
sudo cp mailcontainer-sample.container /etc/containers/systemd/mail.container

Edit the file and set at least:

  • Environment=MAIL_DOMAIN=example.com
  • Volume= paths for your host
  • ports you want to publish

The sample publishes privileged ports (25, 465, 587, 993), so the documented setup assumes a system-wide Quadlet in /etc/containers/systemd/.

The sample unit exposes:

  • 25 SMTP
  • 587 submission
  • 465 SMTPS
  • 993 IMAPS
  • 4190 ManageSieve

Start with Quadlet

Reload system units and start the container:

sudo systemctl daemon-reload
sudo systemctl enable --now mail.service

Check logs:

sudo systemctl status mail.service
sudo journalctl -u mail.service -f

DNS records

This container signs outgoing mail with DKIM using selector main. That comes from files/templates/dkim_signing.conf.tpl.

On first boot, the container generates:

  • /etc/mail/dkim/${MAIL_DOMAIN}.key

You must publish the matching DKIM public key in DNS under:

  • main._domainkey.${MAIL_DOMAIN}

For MAIL_DOMAIN=example.com, the DNS record name is:

  • main._domainkey.example.com

The TXT value must look like this:

main._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=BASE64_PUBLIC_KEY"

To derive BASE64_PUBLIC_KEY from the generated private key:

openssl rsa -in /opt/containers/mail/volumes/mail/dkim/example.com.key -pubout \
  | grep -v '-----' \
  | tr -d '\n'

Then insert that single-line value after p=.

Example:

main._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."

SPF

At minimum, publish an SPF record that authorizes your mail host to send mail for the domain.

If the same host that serves mail for example.com is also the MX, a simple starter record is:

example.com. IN TXT "v=spf1 mx -all"

If you prefer to authorize a specific IPv4 address directly:

example.com. IN TXT "v=spf1 ip4:203.0.113.10 -all"

Choose one SPF policy for the domain, not both in separate TXT SPF records.

DMARC

A conservative DMARC starting point is:

_dmarc.example.com. IN TXT "v=DMARC1; p=none; adkim=s; aspf=s; rua=mailto:dmarc@example.com"

That records failures without immediately quarantining or rejecting mail. Once DKIM and SPF alignment are verified, you can tighten the policy, for example:

_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; adkim=s; aspf=s; rua=mailto:dmarc@example.com"

Roundcube sample

If you also want webmail, copy roundcube-sample.container into /etc/containers/systemd/ and adjust:

  • ROUNDCUBEMAIL_DEFAULT_HOST
  • ROUNDCUBEMAIL_SMTP_SERVER
  • published port
  • persistent volume paths

Notes

  • MAIL_DOMAIN is mandatory. The container exits if it is missing.
  • The TLS files must already exist before first successful startup.
  • DKIM keys are generated automatically on first boot under /etc/mail/dkim/.
  • The generated configs are only created if they do not already exist, so local edits in the mounted /etc/mail volume are preserved.