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 Postalgunei-network: red compartida con Caddy, ERP, Plane y otros servicios
Ubicacion y archivos
| Ruta | Descripcion |
|---|---|
/opt/infrastructure/postal/ | Directorio raiz |
/opt/infrastructure/postal/docker-compose.yml | Compose con todos los servicios |
/opt/infrastructure/postal/config/postal.yml | Config principal de Postal |
/opt/infrastructure/postal/.env | Variables de entorno (passwords) |
/opt/infrastructure/postal/config/signing.key | Clave DKIM para firma de emails |
Contenedores
| Contenedor | Imagen | Rol | Redes | Puertos |
|---|---|---|---|---|
postal-web | ghcr.io/postalserver/postal:latest | Panel web (Puma :5000) | postal-internal, gunei-network | - |
postal-smtp | ghcr.io/postalserver/postal:latest | Servidor SMTP | postal-internal, gunei-network | 25:25, 587:25 |
postal-worker | ghcr.io/postalserver/postal:latest | Worker de procesamiento | postal-internal | - |
postal-mariadb | mariadb:10.11 | Base de datos | postal-internal | - |
postal-rabbitmq | rabbitmq:3.12-alpine | Message queue | postal-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:
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:
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:
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).
| Tipo | Nombre | Valor | Proxy |
|---|---|---|---|
| A | postal | 154.53.36.180 | DNS only |
| MX | gunei.xyz | postal.gunei.xyz | DNS only |
| TXT (SPF) | spf.postal.gunei.xyz | v=spf1 ip4:154.53.36.180 ~all | DNS only |
| TXT (DKIM) | DNS only | ||
| CNAME | rp.postal.gunei.xyz | DNS only | |
| CNAME | routes.postal.gunei.xyz | DNS only | |
| CNAME | track.postal.gunei.xyz | DNS 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
- Ir a
postal.gunei.xyz→ login con cuenta admin - Click "Build a new mail server"
- Completar:
- Name: nombre descriptivo (ej:
Notificaciones Plane) - Short name: identificador corto sin espacios (ej:
plane-notif) — Postal lo usa para crear la DB interna
- Name: nombre descriptivo (ej:
- 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
- Dentro del mail server → Domains → Add a new domain
- Ingresar el dominio (ej:
gunei.xyz,orien.com.ar) - 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:
| Tipo | Proposito | Ejemplo de nombre |
|---|---|---|
| TXT | SPF | spf.postal.gunei.xyz |
| TXT | DKIM | postal-[mailserver]._domainkey.gunei.xyz |
| CNAME | Return Path | rp.postal.gunei.xyz |
| CNAME | Route | routes.postal.gunei.xyz |
| CNAME | Tracking | track.postal.gunei.xyz |
| MX | Recepcion (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
- Volver a Domains → click en el dominio
- Postal muestra el estado de cada registro (verde = OK, rojo = falta o incorrecto)
- La verificacion puede tardar unos minutos despues de agregar los registros
- Si algun registro no verifica, comparar el valor exacto entre Cloudflare y lo que Postal espera
Paso 5: Crear credencial SMTP
- Dentro del mail server → Credentials → Add a new credential
- Completar:
- Type: SMTP
- Name: nombre descriptivo (ej:
erp-backend,plane-api) - Hold: dejar en blanco (no retener emails)
- Postal genera un key (usuario SMTP) y un password
- Guardar ambos — el password no se puede ver despues
Paso 6: Configurar la aplicacion
Usar la credencial creada para conectar la app al SMTP:
# 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:25porgunei-network(trafico interno Docker, sin TLS) - Credencial SMTP:
gunei-erp(creada en panel Postal) - Auth SMTP requerida
Plane
- Servicios
api,worker,beat-workerconectados agunei-network - Config en
.envde Plane:bashEMAIL_HOST=postal-smtp EMAIL_PORT=25 EMAIL_FROM=plane@gunei.xyz EMAIL_USE_TLS=0 - Proxy interno de Plane deshabilitado (
replicas: 0), Caddy manejaplane.gunei.xyz - Pendiente: crear credencial SMTP dedicada
plane(actualmente compartegunei-erp)
Comandos utiles
# 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/0en postal.yml - ignorado- Headers
X-Forwarded-Proto/X-Forwarded-Hosten Caddy - ignorado BIND_ADDRESS=0.0.0.0env var - arreglo el binding de Puma pero no el 403- Montar custom
environments/production.rbconRails.application.config.hosts.clear- crashea Postal v3 porqueActionDispatch::HostAuthorizationmiddleware 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:
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
planeen panel Postal (actualmente compartegunei-erp) - [ ] Evaluar habilitar TLS para SMTP (
smtp_server.tls_enableden postal.yml)