El viaje de un email
Recorrido completo de un email, con el detalle que importa: qué se resuelve en cada lado.
1 · El productor valida y encola
POST /notifications/email llega a EmailService.sendEmail(), que hace tres cosas antes de
aceptar el trabajo:
- Comprueba el tenant. Si no existe, 404 al llamante.
- Resuelve la plantilla con
resolveForChannel(tenantId, code, EMAIL). La clave es la terna(tenantId, code, channel), así que un mismocodepuede tener plantilla de email y de push a la vez y no se pisan. - Exige que la plantilla tenga
templateId, el id de la plantilla alojada en Resend. En email es obligatorio: el cuerpo lo aloja el proveedor.
Luego resuelve el remitente, tenant.senderEmail ?? DEFAULT_SENDER_EMAIL, y encola:
const jobData: EmailData = {
tenantId, sourceTenantId, to, subject, code,
from, // ya resuelto
templateId, // ya resuelto
variables,
};
await this.emailQueue.add(`send-email-${dto.code}`, jobData, {
attempts: 3,
backoff: 5000,
...(dto.idempotencyKey ? { jobId: dto.idempotencyKey } : {}),
});
Dos detalles que suelen sorprender:
- El nombre del trabajo es dinámico (
send-email-<code>) y solo sirve de etiqueta en Bull-Board. El worker procesa igual todos los trabajos de la cola, mire el nombre o no. Añadir un código nuevo no toca este fichero. idempotencyKeyse convierte en eljobIdde BullMQ. Es el mecanismo de deduplicación: dos peticiones con la misma clave son un único trabajo. Si no la mandas, no hay dedupe.
La respuesta es inmediata: { jobId, channel, status: 'queued' }. Encolado no es entregado.
2 · Qué NO va en el trabajo
from y templateId viajan ya resueltos porque la api tuvo que cargar tenant y plantilla de
todas formas para validar; que el worker los releyera serían dos consultas más por email.
La excepción es la credencial, y está comentada en el propio contrato:
La clave de Resend es la excepción: se queda detrás de la base de datos y el descifrado en vez de viajar por Redis.
Una carga de Redis no debe contener secretos. El worker la resuelve él mismo — ver credenciales por tenant.
3 · El worker entrega
email.processor → MailerService → ResendService. MailerService resuelve la clave del tenant
y delega; ResendService es quien implementa ChannelProvider<EmailMessage> y habla con Resend.
Antes de enviar nada, el processor escribe el log como PENDING — y ese paso también decide si hay
que enviar siquiera. Ver la vida de un log.
4 · Después de la entrega
Resend acepta el mensaje y devuelve un providerMessageId, que se guarda en la fila del log. Ahí
termina el worker: el resultado final —entregado, rebotado, marcado como spam— llega más tarde y
por otro camino, al webhook de notification-api.
Dónde mirar cuando un email no llega
En orden, porque cada paso descarta el anterior:
- ¿Respondió 2xx la llamada? Si fue 400/404, ni se encoló: plantilla o tenant.
- ¿Existe el trabajo en Bull-Board? Si falló sus 3 intentos, el fallo es del worker: credencial o Resend.
- ¿Qué dice la fila de
NotificationLog?PENDINGsin terminar es que el worker murió a medias;SENTes que Resend lo aceptó y el problema está más allá;BOUNCED/SPAMlo puso el webhook y el problema es la dirección de destino.