Skip to content
On this page

Postal (Mail Server)

Postal v3.3.5 - Mail server self-hostedReemplazo de Resend


Overview

Postal es un mail server open-source self-hosted que reemplaza a Resend (que estaba configurado para facturacion.gunei.xyz).

Ventajas sobre Resend:

  • Self-hosted, sin dependencia de terceros ni limites de envio
  • Soporte multi-dominio
  • Panel web con tracking de emails
  • Credenciales SMTP separadas por aplicacion
  • Sin costo por volumen de envio

Uso actual:

  • Envio de emails desde el ERP (facturas, notificaciones)
  • Envio de emails desde Plane (project management)

Arquitectura

                        Internet


              ┌────────────────────────┐
              │   Caddy Shared (SSL)   │
              │   caddy-shared:443     │
              └──────────┬─────────────┘

          ┌──────────────┼──────────── gunei-network ──────────────┐
          │              │                                         │
          │              ▼                                         │
          │    ┌──────────────────┐                                 │
          │    │   postal-web     │◄─── postal.gunei.xyz            │
          │    │   :5000          │                                 │
          │    └────────┬─────────┘                                 │
          │             │                                          │
          │    ┌────────┼──── postal-internal ────────────┐        │
          │    │        │                                 │        │
          │    │   ┌────┴────────┐  ┌──────────────────┐  │        │
          │    │   │postal-worker│  │ postal-mariadb   │  │        │
          │    │   └─────────────┘  └──────────────────┘  │        │
          │    │                                          │        │
          │    │   ┌─────────────┐  ┌──────────────────┐  │        │
          │    │   │postal-smtp  │  │ postal-rabbitmq  │  │        │
          │    │   │ :25, :587   │  └──────────────────┘  │        │
          │    │   └──────┬──────┘                        │        │
          │    └──────────┼──────────────────────────────┘        │
          │               │                                       │
          │         ┌─────┴──────────────────┐                    │
          │         │                        │                    │
          │    ┌────┴─────┐           ┌──────┴──────┐             │
          │    │ Gunei ERP│           │    Plane    │             │
          │    │(SMTP :25)│           │ (SMTP :25)  │             │
          │    └──────────┘           └─────────────┘             │
          └───────────────────────────────────────────────────────┘

Redes Docker:

  • postal-internal: comunicacion interna entre contenedores Postal
  • gunei-network: red compartida con Caddy, ERP, Plane y otros servicios

Ubicacion y archivos

RutaDescripcion
/opt/infrastructure/postal/Directorio raiz
/opt/infrastructure/postal/docker-compose.ymlCompose con todos los servicios
/opt/infrastructure/postal/config/postal.ymlConfig principal de Postal
/opt/infrastructure/postal/.envVariables de entorno (passwords)
/opt/infrastructure/postal/config/signing.keyClave DKIM para firma de emails

Contenedores

ContenedorImagenRolRedesPuertos
postal-webghcr.io/postalserver/postal:latestPanel web (Puma :5000)postal-internal, gunei-network-
postal-smtpghcr.io/postalserver/postal:latestServidor SMTPpostal-internal, gunei-network25:25, 587:25
postal-workerghcr.io/postalserver/postal:latestWorker de procesamientopostal-internal-
postal-mariadbmariadb:10.11Base de datospostal-internal-
postal-rabbitmqrabbitmq:3.12-alpineMessage queuepostal-internal-

Nota sobre puertos SMTP:

  • Puerto 25: trafico SMTP normal
  • Puerto 587 mapeado a 25 interno (587:25): para dev local, ya que el ISP bloquea outbound puerto 25

Docker Compose

/opt/infrastructure/postal/docker-compose.yml:

yaml
services:
  postal-mariadb:
    image: mariadb:10.11
    container_name: postal-mariadb
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MYSQL_DATABASE: postal
      MYSQL_USER: postal
      MYSQL_PASSWORD: ${MARIADB_PASSWORD}
    volumes:
      - postal-mariadb-data:/var/lib/mysql
    networks:
      - postal-internal
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5

  postal-rabbitmq:
    image: rabbitmq:3.12-alpine
    container_name: postal-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: postal
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
      RABBITMQ_DEFAULT_VHOST: postal
    volumes:
      - postal-rabbitmq-data:/var/lib/rabbitmq
    networks:
      - postal-internal
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  postal:
    image: ghcr.io/postalserver/postal:latest
    container_name: postal-web
    command: postal web-server
    restart: unless-stopped
    depends_on:
      postal-mariadb:
        condition: service_healthy
      postal-rabbitmq:
        condition: service_healthy
    volumes:
      - ./config:/config
    networks:
      - postal-internal
      - gunei-network
    environment:
      - TZ=America/Argentina/Buenos_Aires
      - BIND_ADDRESS=0.0.0.0
      - RAILS_LOG_TO_STDOUT=1
      - RAILS_LOG_LEVEL=debug

  postal-smtp:
    image: ghcr.io/postalserver/postal:latest
    container_name: postal-smtp
    restart: unless-stopped
    command: postal smtp-server
    depends_on:
      - postal
    volumes:
      - ./config:/config
    networks:
      - postal-internal
      - gunei-network
    ports:
      - "25:25"
      - "587:25"
    environment:
      - TZ=America/Argentina/Buenos_Aires

  postal-worker:
    image: ghcr.io/postalserver/postal:latest
    container_name: postal-worker
    restart: unless-stopped
    command: postal worker
    depends_on:
      - postal
    volumes:
      - ./config:/config
    networks:
      - postal-internal
    environment:
      - TZ=America/Argentina/Buenos_Aires

volumes:
  postal-mariadb-data:
  postal-rabbitmq-data:

networks:
  postal-internal:
    driver: bridge
  gunei-network:
    external: true

postal.yml config

/opt/infrastructure/postal/config/postal.yml:

yaml
version: 2

postal:
  web_hostname: postal.gunei.xyz
  web_protocol: https
  smtp_hostname: postal.gunei.xyz

web_server:
  default_bind_address: "0.0.0.0"
  default_port: 5000

rails:
  secret_key: <REDACTED>

main_db:
  host: postal-mariadb
  port: 3306
  username: postal
  password: <REDACTED>
  database: postal

message_db:
  host: postal-mariadb
  port: 3306
  username: postal
  password: <REDACTED>
  table_name_prefix: postal_msg_

smtp_server:
  default_port: 25
  tls_enabled: false

dns:
  mx_records:
    - postal.gunei.xyz
  smtp_server_hostname: postal.gunei.xyz
  spf_include: spf.postal.gunei.xyz
  return_path_domain: rp.postal.gunei.xyz
  route_domain: routes.postal.gunei.xyz
  track_domain: track.postal.gunei.xyz

logging:
  stdout: true

Importante

El archivo usa version: 2. Las keys deben ser formato v2 (ej: postal.web_hostname, NO web.host). Ver Troubleshooting para mas detalle.


Variables de entorno

/opt/infrastructure/postal/.env:

bash
MARIADB_ROOT_PASSWORD=<REDACTED>
MARIADB_PASSWORD=<REDACTED>
RABBITMQ_PASSWORD=<REDACTED>

DNS en Cloudflare

Todos los registros deben estar en DNS-only (nube gris, sin proxy de Cloudflare).

TipoNombreValorProxy
Apostal154.53.36.180DNS only
MXgunei.xyzpostal.gunei.xyzDNS only
TXT (SPF)spf.postal.gunei.xyzv=spf1 ip4:154.53.36.180 ~allDNS only
TXT (DKIM)DNS only
CNAMErp.postal.gunei.xyzDNS only
CNAMEroutes.postal.gunei.xyzDNS only
CNAMEtrack.postal.gunei.xyzDNS only

Los registros se verifican desde el panel de Postal en la seccion de cada dominio.


Caddy config

Entry esperada para postal.gunei.xyz apuntando a postal-web:5000:

postal.gunei.xyz {
    reverse_proxy postal-web:5000
}

Agregar un nuevo mail server

Un "mail server" en Postal es un contenedor logico que agrupa dominios, credenciales y tracking. Se crea uno por aplicacion o por caso de uso (ej: facturacion-erp, notificaciones-plane).

Paso 1: Crear mail server en panel Postal

  1. Ir a postal.gunei.xyz → login con cuenta admin
  2. Click "Build a new mail server"
  3. Completar:
    • Name: nombre descriptivo (ej: Notificaciones Plane)
    • Short name: identificador corto sin espacios (ej: plane-notif) — Postal lo usa para crear la DB interna
  4. Click Create

Nota sobre MariaDB

Postal crea una database por mail server (prefijo postal-). Si falla, verificar que el user postal tenga los grants necesarios. Ver Troubleshooting.

Paso 2: Agregar dominio de envio

  1. Dentro del mail server → DomainsAdd a new domain
  2. Ingresar el dominio (ej: gunei.xyz, orien.com.ar)
  3. Postal mostrara los registros DNS requeridos

Paso 3: Configurar DNS en Cloudflare

Postal muestra una tabla con los registros que necesita. Agregar todos en Cloudflare con proxy deshabilitado (DNS only / nube gris).

Registros tipicos que Postal pide:

TipoPropositoEjemplo de nombre
TXTSPFspf.postal.gunei.xyz
TXTDKIMpostal-[mailserver]._domainkey.gunei.xyz
CNAMEReturn Pathrp.postal.gunei.xyz
CNAMERouteroutes.postal.gunei.xyz
CNAMETrackingtrack.postal.gunei.xyz
MXRecepcion (opcional)gunei.xyz

Importante

Todos los registros de email deben estar en DNS only (nube gris). Cloudflare Proxy (nube naranja) rompe el email.

Paso 4: Verificar DNS en Postal

  1. Volver a Domains → click en el dominio
  2. Postal muestra el estado de cada registro (verde = OK, rojo = falta o incorrecto)
  3. La verificacion puede tardar unos minutos despues de agregar los registros
  4. Si algun registro no verifica, comparar el valor exacto entre Cloudflare y lo que Postal espera

Paso 5: Crear credencial SMTP

  1. Dentro del mail server → CredentialsAdd a new credential
  2. Completar:
    • Type: SMTP
    • Name: nombre descriptivo (ej: erp-backend, plane-api)
    • Hold: dejar en blanco (no retener emails)
  3. Postal genera un key (usuario SMTP) y un password
  4. Guardar ambos — el password no se puede ver despues

Paso 6: Configurar la aplicacion

Usar la credencial creada para conectar la app al SMTP:

bash
# Conexion desde contenedor en gunei-network
SMTP_HOST=postal-smtp
SMTP_PORT=25
SMTP_USERNAME=<key generado por Postal>
SMTP_PASSWORD=<password generado por Postal>
SMTP_TLS=false    # comunicacion interna Docker, sin TLS
EMAIL_FROM=noreply@tudominio.xyz

No se necesitan cambios en Caddy ni en Docker Compose — el mail server es una entidad logica dentro de Postal, no un contenedor nuevo.

Checklist rapido

  • [ ] Mail server creado en panel Postal
  • [ ] Dominio agregado
  • [ ] Registros DNS en Cloudflare (todos DNS only)
  • [ ] DNS verificado OK en panel Postal
  • [ ] Credencial SMTP creada
  • [ ] App configurada con credenciales
  • [ ] Email de prueba enviado y recibido

Aplicaciones configuradas

Gunei ERP

  • Conexion via postal-smtp:25 por gunei-network (trafico interno Docker, sin TLS)
  • Credencial SMTP: gunei-erp (creada en panel Postal)
  • Auth SMTP requerida

Plane

  • Servicios api, worker, beat-worker conectados a gunei-network
  • Config en .env de Plane:
    bash
    EMAIL_HOST=postal-smtp
    EMAIL_PORT=25
    EMAIL_FROM=plane@gunei.xyz
    EMAIL_USE_TLS=0
    
  • Proxy interno de Plane deshabilitado (replicas: 0), Caddy maneja plane.gunei.xyz
  • Pendiente: crear credencial SMTP dedicada plane (actualmente comparte gunei-erp)

Comandos utiles

bash
# Logs
docker logs -f postal-web
docker logs -f postal-smtp
docker logs -f postal-worker

# Restart de todos los servicios
cd /opt/infrastructure/postal && docker compose restart

# Restart individual
docker restart postal-web

# Inicializar Postal (primera vez o despues de upgrade)
docker compose run --rm postal postal initialize

# Crear usuario admin
docker compose run --rm postal postal make-user

# Ver estado de los contenedores
docker compose ps

# Verificar DNS desde el VPS
dig +short postal.gunei.xyz
dig +short MX gunei.xyz
dig +short TXT spf.postal.gunei.xyz

# Test SMTP basico
telnet postal-smtp 25

Troubleshooting

403 Forbidden en panel web

Estado: RESUELTO

Sintoma: Postal levantaba OK, Caddy llegaba a postal-web:5000, pero devolvía 403 Forbidden con body vacío.

Root cause: Mismatch de formato en postal.yml. El archivo tenia version: 2 pero usaba keys de v1 (ej: web.host). El middleware Rails HostAuthorization solo permite el hostname leido de Postal::Config.postal.web_hostname, que defaulteaba a postal.example.com porque la key v2 correcta no se estaba leyendo.

Fix: Corregir postal.yml para usar keys v2: postal.web_hostname, postal.smtp_hostname, etc.

Lo que NO funciono (descartado):

  • trusted_proxies: 0.0.0.0/0 en postal.yml - ignorado
  • Headers X-Forwarded-Proto / X-Forwarded-Host en Caddy - ignorado
  • BIND_ADDRESS=0.0.0.0 env var - arreglo el binding de Puma pero no el 403
  • Montar custom environments/production.rb con Rails.application.config.hosts.clear - crashea Postal v3 porque ActionDispatch::HostAuthorization middleware ya no existe como tal en v3

MariaDB permisos insuficientes

Estado: RESUELTO

Sintoma: Postal falla al intentar crear databases para mail servers (necesita crear una DB por cada mail server configurado).

Root cause: El user postal creado por Docker no tenia permisos para crear databases con prefijo postal-.

Fix:

sql
GRANT ALL PRIVILEGES ON `postal-%`.* TO 'postal'@'%';
FLUSH PRIVILEGES;

ISP bloquea puerto 25

El ISP de la conexion local puede bloquear outbound al puerto 25. Por eso el compose mapea 587:25, permitiendo conectar al puerto 587 desde fuera del VPS, que internamente redirige al 25 de Postal.


Pendientes

  • [ ] Crear credencial SMTP dedicada plane en panel Postal (actualmente comparte gunei-erp)
  • [ ] Evaluar habilitar TLS para SMTP (smtp_server.tls_enabled en postal.yml)