Servidor SMTP relay en Docker

Nacho - - Tiempo de lectura 9 mins

Hace tiempo que venía usando msmtp como servidor SMTP en mi stack Docker, con la imagen de crazy-max. La idea es que en lugar de tener que configurar usuario y contraseña y conexión en todos los servicios que tengo corriendo, sólo lo configuraba en msmtp y los demás mandaban el mail a través de ese servicio.

El problema

El problema es que en un momento empecé a tener problemas con varios servicios. Me puse a mirar, y estaban mandando un comando RSET o NOOP antes de empezar el envío del correo (o, en algunos casos, después de terminar de enviarlo), y msmtp estaba respondiendo con un error.

RSET y NOOP son parte del estándar SMTP. Se supone que RSET sirve para cancelar el envío de un email. Tipo, mandás el MAIL FROM, el RCPT TO, y ahí te arrepentís por lo que sea y mandás un RSET para arrancar de cero. No sé qué tanto sentido tiene en un mundo en el que el email se manda automáticamente, no sé en qué caso usarías RSET en ese contexto, pero bueno, es parte del estándar, está ahí. msmtp soporta RSET durante la transacción, que es donde tiene sentido, pero el RFC-5321 dice que se puede mandar en cualquier momento (y fuera de transacción equivale a un NOOP). NOOP no hace nada, y de nuevo, no sé para qué sirve en un mundo en el cual el envío de emails es un proceso automatizado. Sólo hacés telnet server 25 cuando querés probar algo o para jugar, no para mandar un email en serio. De nuevo, msmtp lo soporta, pero sólo dentro de una transacción. Para la mayoría de los casos, la implementación de msmtp está bien, porque no tiene sentido andar mandando RSET o NOOP por ahí porque sí.

Pero bueno, esto es internet, no todo tiene sentido. Resulta que servicios como Authelia o Vikunja mandan RSET o NOOP en momentos random. Investigando un poco más, en el caso específico de Vikunja, llegué a que la librería de email que usa, go-mail, manda un NOOP apenas se conecta, como forma de validar que la conexión está andando. Lo cual, si me preguntás a mí, no tiene sentido. Ninguno. Ridículo. Pero el caso es que los mails de Vikunja no salían, y el problema no es del lado de Vikunja, y técnicamente no es un bug de go-mail porque NOOP es parte del estándar y por más que no tenga sentido lo que está haciendo go-mail, msmtp es el que no está compliendo el estándar.

Así que fui con toda esta información y creé un issue en msmtp. El maintainer lo arregló al toque, y yo ya estaba listo para actualizar en cuanto hubiese una imagen nueva. Pero pasó el tiempo y nada. No había release nuevo, y entonces no había imagen nueva. Entonces intenté reproducir la imagen de crazy-max usando el código de msmtp en git, pero me encontré con un montón de problemas, y perdí pila de tiempo intentando hacerlo funcionar, y no pude. No quería crear algo dese cero porque me iba a demorar más tiempo, pensé. Pero al final me armé de valor y lo hice, y resultó mucho más fácil de lo que pensé.

Postfix en Docker

El plan era usar Postfix, configurarlo para mandar todos los emails que reciba a través de un “smart relay” (ForwardEmail en mi caso), y reemplazar msmtp con eso, sin tener que cambiar la configuración de ningún otro servicio.

Configuración

Postfix tiene dos archivos principales de configuración: master.cf, y main.cf. En master.cf posiblemente no quieras cambiar nada, excepto potencialmente el puerto en el que escuchar. La configuración que querés posiblemente va en main.cf.

La mayoría (todas?) de las distribuciones incluyen master.cf.proto y main.cf.proto que podés usar como base para configurar.

master.cf

Yo usé directamente master.cf.proto. La única línea que toqué fue, justamente, la del puerto en el que escucha.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (no)    (never) (100)
# ==========================================================================
smtp      inet  n       -       n       -       -       smtpd
#smtp      inet  n       -       n       -       1       postscreen
#smtpd     pass  -       -       n       -       -       smtpd
#dnsblog   unix  -       -       n       -       0       dnsblog
#tlsproxy  unix  -       -       n       -       0       tlsproxy
#submission inet n       -       n       -       -       smtpd

La línea marcada es la que podés querer cambiar. Controla el puerto en el que Postfix espera conexiones. Lo primero que dice (smtp por defecto) es el puerto. Si es un nombre de un puerto conocido, lo sustituye (poner smtp es igual a poner 25). Si querés un puerto raro, podés poner el número que quieras.

Lo demás, lo dejé como venía.

main.cf

La configuración del servicio SMTP viene acá.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
compatibility_level = 3.10

smtp_tls_wrappermode = yes
smtp_tls_security_level = encrypt
smtp_sasl_auth_enable = yes
smtp_sasl_security_options = noanonymous
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt

relayhost = [relay.example.com]:487
smtp_sasl_password_maps = static:usuario:password
mynetworks = 192.168.1.0/24

inet_interfaces = all
inet_protocols = ipv4

# Disable local delivery
mydestination = $myhostname, localhost.$mydomain, localhost
local_transport = error:local delivery disabled

maillog_file = /dev/stdout

Tiene un montón de pedacitos. Vamos por partes.

compatibility_level = 3.10

Si no ponés esto, Postfix se queja un poco. Es la versión del formato de configuración.

smtp_tls_wrappermode = yes
smtp_tls_security_level = encrypt
smtp_sasl_auth_enable = yes
smtp_sasl_security_options = noanonymous
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt

Esto configura la habilidad de usar conexiones seguras. Posiblemente lo quieras usar más o menos así.

relayhost = [relay.example.com]:587
smtp_sasl_password_maps = static:usuario:password
mynetworks = 192.168.1.0/24

Esta es la parte más importante: a dónde mandar los correos, cómo autenticarse, y las redes que querés usar. Ahí hay un par de datos un poco sensibles que capaz que no querés meter en la imagen Docker. Más abajo lo voy a cambiar para generar la configuración.

inet_interfaces = all
inet_protocols = ipv4

Escuchá en todas las interfaces de red en IPv4.

mydestination = $myhostname, localhost.$mydomain, localhost
local_transport = error:local delivery disabled

No mandes mails locales

maillog_file = /dev/stdout

Mostrame los mensajes de log en la salida de error estándar.

Cambiar la configuración

No quiero meter toda la configuración adentro de la imagen. Primero, porque hay cosas que querés mantener medio seguras (contraseña por ejemplo). Segundo, porque si vamos a hacer las cosas, vamos a hacerlas bien, ¿no?

Para esto, hice un archivo init.sh que se encarga de crear la configuración en base a variables de entorno, y luego iniciar el servicio. La idea es que en el Dockerfile después le podemos decir que el comando es init.sh.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/sh

# Leer variables de entorno y asignar valores por defecto
host=${SMTP_RELAY_HOST:-localhost}
port=${SMTP_RELAY_PORT:-587}
user=${SMTP_RELAY_USER:-''}
pass=${SMTP_RELAY_PASS:-''}
allowhosts=${SMTP_RELAY_ALLOW:-127.0.0.1}
listenport=${SMTP_PORT:-smtp}

# Los archivos de configuración que vamos a crear
mainconfigfile=/etc/postfix/main.cf
masterconfigfile=/etc/postfix/master.cf

# Qué dice la línea en el template que queremos modificar
insertkey='{{--INSERT--}}'

# Un par de archivos temporales
tmpmain=$(mktemp)
tmpmaster=$(mktemp)

# Escribimos configuraciones a los archivos temporales: para main.cf...
echo "
relayhost = [$host]:$port
smtp_sasl_password_maps = static:${user}:${pass}
mynetworks = $allowhosts
" > $tmpmain
# ... y para master.cf
echo "
${listenport}      inet  n       -       n       -       -       smtpd
" > $tmpmaster

# Reemplazamos las líneas con el marcador por el contenido de los archivos temporales
sed -e "/${insertkey}/r ${tmpmain}" -e "/${insertkey}/d" $mainconfigfile.template > $mainconfigfile
sed -e "/${insertkey}/r ${tmpmaster}" -e "/${insertkey}/d" $masterconfigfile.template > $masterconfigfile

# Limpiamos los archivos temporales porque no somos desprolijos
rm $tmpmain
rm $tmpmaster

# Y arrancamos Postfix
postfix start-fg

Luego, creé main.cf.template y master.cf.template con la configuración de arriba pero con las partes que genero con init.sh reemplazadas por {{--INSERT--}}.

La solución no es particularmente prolija ni robusta, pero es fácil de entender exactamente qué hace en cada paso. Sé que hay herramientas que hacen esto pero mucho mejor, pero por esta vez me quedé con lo más fácil y sencillo de implementar y entender.

Dockerfile

Ahora hay que poner todo junto. Lo que queremos es un Dockerfile que haga esto:

  • Instalar Postfix.
  • Copiar los templates a la imagen
  • Copiar init.sh a la imagen, y hacerlo ejecutable
  • Configurar el comando como init.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM alpine:latest

RUN apk add --no-cache postfix

COPY main.cf.template /etc/postfix/main.cf.template
COPY master.cf.template /etc/postfix/master.cf.template
COPY init.sh /init.sh
RUN chmod +x /init.sh

CMD ["/init.sh"]

Sencillo y directo al punto.

Crear la imagen en Forgejo

Obviamente no voy a crear la imagen yo mismo cada vez que haga un cambio, como un cavernícola. Ya hice alguna vez esto de generar la imagen en un workflow, así que básicamente copié, pegué, y adapté.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
on:
  push:
    branches:
      - main

enable-email-notifications: true

env:
  REGISTRY: url.de.forgejo.example.com

jobs:
  build-docker-images:
    strategy:
      matrix:
        arch: ['amd64', 'arm64']
    runs-on: alpine-${{ matrix.arch }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Instalar Docker CLI y tar
        run: |
          apk update
          apk add --no-cache docker-cli tar
      - name: Preparar Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Login en Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ forgejo.actor }}
          password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}

      - name: Crear imagen
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile
          push: true
          provenance: false
          tags: ${{ env.REGISTRY }}/${{forgejo.actor}}/smtp-relay-${{matrix.arch}}:latest

  merge-final-images:
    runs-on: alpine
    needs: build-docker-images
    steps:
      - name: Instalar Docker CLI y tar
        run: |
          apk update
          apk add --no-cache docker-cli tar
      - name: Login en Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ forgejo.actor }}
          password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
      - name: Generate and push multi-arch manifest
        run: |
          docker manifest create ${{ env.REGISTRY }}/${{forgejo.actor}}/smtp-relay:latest ${{ env.REGISTRY }}/${{forgejo.actor}}/smtp-relay-amd64:latest ${{ env.REGISTRY }}/${{forgejo.actor}}/smtp-relay-arm64:latest
          docker manifest push ${{ env.REGISTRY }}/${{forgejo.actor}}/smtp-relay:latest

Después de eso, push a Forgejo, esperar un par de minutos, y listo el Postfix.

Cómo se usa

Después, fue sólo modificar el docker-compose.yml, y cambiar msmtp por mi nueva imagen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  smtp-relay:
    image:   url.de.forgejo.example.com/smtp-relay:latest
    container_name: smtp-relay
    restart: unless-stopped
    environment:
      SMTP_RELAY_HOST: "smtp.example.com"
      SMTP_RELAY_PORT: "465"
      SMTP_RELAY_USER: "usuario"
      SMTP_RELAY_PASS: "password"
      SMTP_RELAY_ALLOW: "10.0.0.0/8 192.168.1.0/24"
      SMTP_PORT: "2500"

Las variables de entorno lo configuran como quiero. Lo puse en el puerto 2500 porque la imagen de msmtp que estaba usando escuchaba en el 2500, y mi objetivo era simplemente sacar msmtp y poner esta imagen sin cambiar nada más.

Y listo, después de reiniciar el servicio y un par de pruebas para confirmar que no se rompió, quedó todo andando.

Conclusiones

La verdad estaba convencido de que me iba a llevar mucho más tiempo hacer esto. Terminé gastando bastante menos tiempo de lo que ya había gastado en intentar hacer andar msmtp desde el código del repositorio.

La contra, por supuesto, es que Postfix es mucho más grande y complejo, con todos los temas de uso de recursos que eso significa. Pero así sé que no debería volver a tener problemas con incompatibilidades: dudo que alguien publique una versión de una librería de email que falle con uno de los servidores smtp más usados del mundo.