Für die Webanwendungen in meinem Docker-Setup möchte ich Mailversand ermöglichen. Damit meine E-Mails nicht überall als Spam abgelehnt werden, will ich sie nicht über die IP-Adresse meiner privaten Internetleitung versenden. Das geht nie gut. Stattdessen möchte ich die Mails über ein Relay im Internet verschicken. Weil ich beim Relay nicht für jede Anwendung eigene Zugangsdaten anlegen möchte, brauche ich eine einfachere Lösung. Ich möchte lokal eine weitere Zwischenstation betreiben, die Mails der Anwendungen in den Containern annimmt und authentifiziert über das Relay im Internet an die Zieladressen schickt.

Zielsetzung ist, einen Container aufzusetzen, in dem Postfix via SMTP Mails annimmt, sich beim Relay-Host im Internet authentifiziert und die Mails darüber dann verschickt. Dabei verzichte ich auf Authentifizierung am lokalen Postfix und regle die Erlaubnis, Mails zu schicken, über die von Docker genutzten IP-Bereiche. Wer in diesem vertrauenswürdigen Netzbereich ist, darf Mails schicken.

Am Ende der Anleitung kann ein Container lokal gebaut, über eine Datei mit Umgebungsvariablen konfiguriert und mit docker-compose gestartet werden. Es gibt auch ein github-Repository mit dem Code - konfigurieren sollte man den aber schon.

Ausdrücklich nicht geplant ist die Bereitstellung lokaler Mail-Konten. Mails werden in diesem Container nicht gespeichert und können dort auch nicht abgeholt werden. Postfächer müssen woanders gepflegt werden. Hier geht es um reine Weiterleitung.

Voraussetzungen

Gebraucht wird ein Mail-Account bei einem Provider, der SMTP-Authentifizierung erlaubt. GMail benötigt Authentifizierung via OAUTH2, das wird in dieser Anleitung nicht behandelt. Stattdessen kämen etwa Uberspace oder Mailgun in Frage.

Relay-Container

Zum Einsatz kommt Postfix, ein Mail Transfer Agent. Der ist verhältnismäßig einfach zu konfigurieren und funktioniert auch in Docker-Containern. Darunter läuft Debian.

Container bauen - Dockerfile

FROM debian:bullseye-slim

ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y postfix bsd-mailx
COPY postfix.sh /
RUN chmod +x /postfix.sh
EXPOSE 25
CMD ["/postfix.sh"]

Genutzt wird das slim-Image von Debian Bullseye (11), installiert wird nichts außer Postfix und einem minimalen Mail-Client für die Kommandozeile, zum testen. Die notwendige Konfiguration von Postfix macht ein Shellscript. Konfigurationsdaten kommen über Umgebungsvariablen hinein.

Postfix konfigurieren und starten - Shellscript

#!/bin/bash

set -eu

echo "Configuring postfix"
echo "${relayhost} ${relayuser}:${relaypassword}" > /etc/postfix/sasl_password
postmap /etc/postfix/sasl_password

postconf -e "inet_protocols = ipv4"
postconf -e "maillog_file = /dev/stdout"
postconf -e "mydestination = localhost"
postconf -e "mydomain = ${mydomain}"
postconf -e "myhostname = ${myhostname:-mail}.${mydomain}"
postconf -e "mynetworks = ${mynetworks:-192.168.0.0/16,172.16.0.0/12}"
postconf -e "myorigin = ${mydomain}"
postconf -e "relayhost = [${relayhost}]:${relayport:-587}"
postconf -e "smtp_host_lookup = native,dns"
postconf -e "smtp_sasl_auth_enable = yes"
postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_password"
postconf -e "smtp_sasl_security_options = noanonymous"
postconf -e "smtp_use_tls = yes"
echo "nameserver 1.1.1.1" > /var/spool/postfix/etc/resolv.conf
echo "nameserver 1.1.1.1" > /etc/resolv.conf

echo "Starting postfix"
exec /usr/sbin/postfix start-fg

Ein paar Erklärungen:

  • Die Zugangsdaten braucht Postfix in einem speziellen Konfigurationsformat,
    danach wird nur noch reuläre Konfiguration nach /etc/postfix/main.cf geschrieben.
  • Die Konfiguration wird auf IPv4 begrenzt, da IPv6 mit Docker komplizierter ist.
  • Persistentes Logfile wird nicht innerhalb des Containers geschrieben, stattdessen in die Log-Ausgabe des Containers.
  • Wird mydestination auf localhost gesetzt, werden alle anderen Zieladressen über das Relay geleitet.
  • Mailserver brauchen an vielen Stellen Hostnames, deswegen wird einer gesetzt.
  • In mynetworks wird festgelegt, aus welchen Netzen Mails zur Weiterleitung entgegen genommen werden. Dann wird auch keine weitere Authentifizierung erwartet!
  • In myorigin wird die Domain festgelegt, die ausgehende Mails in der FROM-Zeile haben sollen.
  • Der relayhost wird mit Port angegeben. Wird kein Port festgelegt, wird 587 (submission) angenommen.
  • Beim DNS-Lookup bedeutet native, dass auch Einträge in der /etc/hosts berücksichtigt werden. Das ist hauptsächlich relevant, wenn die Host-Einträge anderer Container hier hinterlegt wurden.
  • Die weitere Konfiguration beschäftigt sich mit der Authentifizierung am Relay. Nicht konfigurierbar ist die Verwendung von TLS. Wer das wirklich ausmachen will, kann das gerne hardcoden.
  • Um Nameserver nicht durch den (nicht installierten) systemd-resolved erreichen zu müssen, werden sie einmal ins System und einmal in das von Postfix genutzte chroot-Verzeichnis hinterlegt.

Am Ende startet das Script Postfix im Vordergrund. Zum einen gehen die Log-Ausgaben dann von stdout in die Logs des Containers, zum anderen ist der Postfix-Prozess dann der Hauptprozess des Containers. Einen Service Manager wie systemd oder s6 gibt es nicht.

Konfiguration hinterlegen - Datei mit Umgebungsvariablen

Die Konfiguration des Containers erfolgt mit Umgebungsvariablen. Die können entweder alle auf der Kommandozeile mit docker -e angegeben werden oder - besser - in einer Datei gespeichert werden, aus der sie beim Starten von docker-compose gelesen werden.

mydomain=# enter the domain you append to mails here
relayhost=# fqdn for yor relay host
relayuser=# username for your relay host
relaypassword=# password for your relay host

Diese Datei kann nach dem Ausfüllen der Werte dann z.B. mit Dateinamen env in das gleiche Verzeichnis gespeichert werden.

Container bauen und starten - mit docker-compose

---
version: "3.5"
services:
  postfix:
    image: postfixdocker
    build:
      context: .
      dockerfile: Dockerfile
    env_file: env
    networks:
      - mail
networks:
  mail:
    name: mail

Mit dieser docker-compose.yml kann sowohl das Bauen des Containers als auch dessen Start behandelt werden.

  • Zum Bauen: docker-compose build Das resultierende Docker-Image wird einfach postfixdocker heißen.
  • Zum Starten docker-compose up -d Dazu wird die Datei mit Namen env gelesen, um die Umgebungsvariablen des Containers festzulegen. Der Container startet das postfix.sh-Script, was mit diesen Variablen dann die lokale Konfigurationsdatei von Postfix schreibt.

Verwendung in anderen Containern

Beim Starten des Postfix-Containers wird auch ein Netzwerk mail angelegt. Sollen andere Container in der Lage sein, Mails zu schicken, müssen diese ebenfalls Mitglied in diesem Netzwerk sein, um den Postfix-Container erreichen zu können.

Als Minimalbeispiel dient hier ein busybox-Container in einer fiktiven eigenen docker-compose.yml:

---
version: "3.5"
services:
  busybox:
    image: busybox
  networks:
    - mail
networks:
  mail:
    external: true
    name: mail

Wichtig ist, dass sowohl auf der obersten Ebene das Netzwerk als extern angegeben als auch das Netzwerk ausdrüklich weiter oben dem Container zugewordnet wird. Eine Anwendung in einem so konfigurierten Container kann dann Mails verschicken, wenn sie selbst wie folgt konfiguriert wird:

  • SMTP-Host: mail_postfix_1 (der von `docker-compose automatisch generierte Hostname des Postfix-Containers)
  • SMTP-Port: 25 (sonst ist keiner exposed, spielt aber auch keine Rolle)
  • SMTP-Authentifizierung: keine
  • SSL/TLS: StartTLS oder keine - das ist in Ordnung, alle Container auf dem selben Host laufen und die Kommunikation den einen Host nicht verlässt
  • SSL-Zertifikat validieren: off, da wir kein gültiges Zertifikat für mail_postfix_1 erzeugt haben.

Logging und Debugging

Logfiles von Postfix fallen einfach im Container an:

$ docker-compose logs
postfix_1  | Sep 20 14:03:52 mail postfix/smtpd[119]: connect from unknown[192.168.32.3]
postfix_1  | Sep 20 14:03:52 mail postfix/smtpd[119]: 4A49CE0A9C: client=unknown[192.168.32.3]
postfix_1  | Sep 20 14:03:52 mail postfix/cleanup[123]: 4A49CE0A9C: message-id=<614894c7d924b_3115a64686b@6ba07816fe00.mail>
postfix_1  | Sep 20 14:03:52 mail postfix/qmgr[117]: 4A49CE0A9C: from=<test@testdomain.de>, size=757, nrcpt=1 (queue active)
postfix_1  | Sep 20 14:03:52 mail postfix/smtpd[119]: disconnect from unknown[192.168.32.3] ehlo=2 starttls=1 mail=1 rcpt=1 data=1 quit=1 commands=7
postfix_1  | Sep 20 14:03:54 mail postfix/smtp[124]: 4A49CE0A9C: to=<florian@testdomain.de>, relay=mein_mail_relay.invalid[1.2.3.4]:587, delay=1.9, delays=0.06/0.08/1.7/0.07, dsn=2.0.0, status=sent (250 ok 1632146634 qp 21824)
postfix_1  | Sep 20 14:03:54 mail postfix/qmgr[117]: 4A49CE0A9C: removed

Um eine Mail auszulösen, ohne dafür eigens eine Anwendung konfigurieren zu müssen, kann im Container einfach eine von der Shell aus geschickt werden:

$ docker exec -i -t mail_postfix_1 /bin/bash
mail_postfix_1 $ echo "Testmail" | mail -s "Test-Betreff" sinnvollemailadresse@example.com

Der Erfolg lässt sich in den Logfiles und durch Eingang der Testmail beurteilen.