ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todo el contenido

El viaje de un email

notification-stack
último cambio 2026-09-25

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:

  1. Comprueba el tenant. Si no existe, 404 al llamante.
  2. Resuelve la plantilla con resolveForChannel(tenantId, code, EMAIL). La clave es la terna (tenantId, code, channel), así que un mismo code puede tener plantilla de email y de push a la vez y no se pisan.
  3. 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:

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:

  1. ¿Respondió 2xx la llamada? Si fue 400/404, ni se encoló: plantilla o tenant.
  2. ¿Existe el trabajo en Bull-Board? Si falló sus 3 intentos, el fallo es del worker: credencial o Resend.
  3. ¿Qué dice la fila de NotificationLog? PENDING sin terminar es que el worker murió a medias; SENT es que Resend lo aceptó y el problema está más allá; BOUNCED/SPAM lo puso el webhook y el problema es la dirección de destino.