Apariencia
SMTP local — correos desde el propio servidor
HUMAE envía todos los correos transaccionales (verificación, password reset, notificaciones) a través de un servidor SMTP instalado en el mismo host del backend (típicamente Postfix). No se usa ningún SaaS de correo (ni Resend, ni SendGrid, ni SES gestionado por un tercero).
Por qué self-hosted
Es un requisito arquitectónico del proyecto: la infra de HUMAE debe ser operable sin depender de servicios SaaS para envío de correo. Eso implica que el equipo de operaciones es responsable de mantener IP reputation, PTR, SPF/DKIM/DMARC y el monitoreo de mail.log.
Variables de entorno
env
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=25
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@humae.com.mx"
MAIL_FROM_NAME="HUMAE"
MAIL_REPLY_TO="soporte@humae.com.mx"En dev (docker-compose) apuntamos a MailHog:
env
MAIL_HOST=mailhog
MAIL_PORT=1025Si el Postfix local requiere autenticación (no es lo común cuando sólo escucha en 127.0.0.1), rellena MAIL_USERNAME / MAIL_PASSWORD.
Arquitectura de envío
[ Laravel (Mail + Queue) ]
│ SMTP 127.0.0.1:25
▼
[ Postfix local ]
│ DNS MX lookup + SMTP saliente por puerto 25
▼
[ Servidor de correo destino (Gmail, Outlook, ...) ]- El backend nunca habla con un MTA externo — sólo con el Postfix de la misma máquina.
- Postfix mantiene cola local (
/var/spool/postfix) y reintenta solo. - No hay key API: la autenticación ante los MTA destino se hace por reputación de IP + DKIM.
Postfix en producción
Instalación (Debian/Ubuntu)
bash
sudo apt update
sudo apt install -y postfix opendkim opendkim-tools mailutils
# Durante el setup elegir "Internet Site" y como hostname: mail.humae.com.mxmain.cf mínimo
conf
myhostname = mail.humae.com.mx
myorigin = humae.com.mx
mydestination = $myhostname, localhost.$mydomain, localhost
inet_interfaces = loopback-only # sólo escucha en 127.0.0.1
mynetworks = 127.0.0.0/8 [::1]/128
# TLS saliente
smtp_tls_security_level = may
smtp_tls_CApath = /etc/ssl/certs
# Cola y reintentos
maximal_queue_lifetime = 1d
bounce_queue_lifetime = 1d
# DKIM (via milter)
milter_default_action = accept
smtpd_milters = local:/run/opendkim/opendkim.sock
non_smtpd_milters = local:/run/opendkim/opendkim.sockReiniciar: sudo systemctl restart postfix.
DKIM con OpenDKIM
- Generar claves:
sudo opendkim-genkey -s default -d humae.com.mx -D /etc/opendkim/keys/humae.com.mx. - Copiar
default.txtal DNS como TXT endefault._domainkey.humae.com.mx. - En
/etc/opendkim.conf:Selector default,KeyTable,SigningTable,TrustedHosts. sudo systemctl restart opendkim postfix.
Firewall
- Puerto 25 saliente: muchos proveedores cloud (AWS, GCP, DigitalOcean free tier) bloquean 25 por defecto. Antes de provisionar, abrir ticket al provider o elegir provider que no bloquee.
- Puerto 25 entrante: cerrado. No queremos recibir correo — sólo enviar.
Registros DNS (crítico para deliverability)
Todos apuntan a la IP del servidor del backend, no a Resend.
| Tipo | Host | Valor |
|---|---|---|
A | mail.humae.com.mx | IP del servidor |
MX | humae.com.mx | 10 mail.humae.com.mx (opcional, sólo si recibes correo) |
TXT (SPF) | humae.com.mx | v=spf1 ip4:<IP-DEL-SERVIDOR> ~all |
TXT (DKIM) | default._domainkey.humae.com.mx | valor generado por opendkim-genkey |
TXT (DMARC) | _dmarc.humae.com.mx | v=DMARC1; p=none; rua=mailto:dmarc@humae.com.mx |
| PTR (reverse DNS) | IP → mail.humae.com.mx | se configura en el panel del provider de IP |
PTR es obligatorio
Sin PTR válido, Gmail/Outlook mandan todo a spam o rechazan. Pedirlo al provider al aprovisionar el servidor.
Uso desde Laravel
No se invoca al MTA directamente — se usan los canales de notificación estándar:
php
$user->notify(new MembershipActivatedNotification($membership));Laravel enruta al driver smtp, que abre una conexión TCP a 127.0.0.1:25 y entrega el correo a Postfix. Postfix lo firma con DKIM (si está el milter) y lo pone en cola para envío saliente.
Todas las Notification classes tienen Queueable → el envío sucede en un worker (php artisan queue:work), no en el request HTTP.
Desarrollo con MailHog
El docker-compose.yml ya trae un servicio MailHog:
yaml
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UIEn dev, .env apunta a MAIL_HOST=mailhog MAIL_PORT=1025. Los correos NO salen a internet — se inspeccionan en http://localhost:8025.
Plantillas
Blade markdown en resources/views/emails/* (ver Correos transaccionales).
Cada plantilla se renderiza server-side a HTML + texto plano antes de entregarse al MTA local.
Reintentos y fallos
- Cada
Notificationcorre como job. Laravel reintenta 3 veces con backoff ante excepciones del driver. - Si el SMTP local está caído o responde 4xx/5xx, el job queda en
failed_jobs→ admin puede reintentar conphp artisan queue:retry all. - Postfix mantiene su propia cola después de aceptar el mensaje: si un destinatario externo no acepta, Postfix reintenta durante
maximal_queue_lifetimey luego rebota con un NDR.
Monitoreo
tail -f /var/log/mail.log→ log del MTA.mailq→ cola pendiente de Postfix (debería estar casi vacía).- Alertas recomendadas:
failed_jobscon más de N filas.mailqcon más de 50 items (signo de que algo bloquea el envío).- Reputation check periódico contra
talosintelligence.com/mxtoolbox.com.
Deliverability tips
- Warm-up: las primeras semanas mandar volumen bajo y creciente. Una IP nueva sin historia entra a spam en Gmail.
- Mantener bounce rate < 2% y complaint rate < 0.1%. Procesar bounces en cola local.
Reply-Todebe ir a una dirección monitoreada (soporte@humae.com.mx).- Evitar subject en MAYÚSCULAS o emojis excesivos.
- No mandar desde dominios genéricos (gmail, hotmail). Todo va con
@humae.com.mx. - Incluir link de unsubscribe (Fase 2) para correos marketing; en transaccionales no es requerido.
Seguridad
- Postfix sólo escucha en
127.0.0.1— no está expuesto a internet para SMTP entrante. - Firewall cierra 25 entrante.
- Logs con PII (correo destinatario) rotan con
logrotate→ retención 14 días. - DKIM keys: rotar cada 12 meses.
Siguiente
Webhooks entrantes (Stripe, etc.): Webhooks →

