El viaje de un push, y en qué se diferencia
El push recorre el mismo camino que el email —validar, encolar, entregar, registrar— pero no es simétrico. Las diferencias no son cosméticas y conviene conocerlas antes de tocar cualquiera de los dos.
Diferencia 1 · Nadie aloja la plantilla
En email, Resend guarda el cuerpo y solo viaja un templateId. En push no hay proveedor que
aloje nada, así que la plantilla guarda su contenido en la propia fila, en la columna content
(un JSON con title, body y data), y es el worker quien renderiza.
Por eso la validación de plantilla es la contraria según el canal:
| Canal | Obligatorio | Por qué |
|---|---|---|
EMAIL |
templateId |
el cuerpo lo aloja Resend |
PUSH |
content |
el cuerpo lo renderiza el worker |
Ese assertChannelPayload corre al crear y al actualizar la plantilla, no al enviar — así una
plantilla inválida ni siquiera llega a guardarse.
En PushService, título y cuerpo salen de la plantilla salvo que la llamada los pise
(dto.title ?? templateContent.title). Y el data se mezcla por clave, ganando el que llega
en la petición:
data: { ...templateContent.data, ...dto.data }
El comentario explica para qué: un código de verificación de un solo uso no puede vivir en la fila estática de la plantilla.
El render es deliberadamente tonto
Que el worker renderice abre una pregunta que el email no tiene: con qué motor. La respuesta es
que no hay motor. core/render/interpolate.ts es un replace de doce líneas sobre {{variable}}:
template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, key) => ...)
Sin condicionales, sin bucles, sin helpers. Su comentario dice por qué: templates stay data, not
code. Las plantillas se guardan en base de datos y se editan desde el panel, así que un motor con
lógica —Handlebars con helpers, por ejemplo— convertiría una fila de una tabla en algo ejecutable.
Un push no necesita un if, y ese es todo el argumento para no poder tenerlo.
Dos consecuencias prácticas:
- Una variable que falta no es un error: es cadena vacía. El
replacedevuelve''paraundefinedy paranull, así que un{{ciudad}}mal escrito entrega “Nuevo acceso desde “ y el envío consta como correcto. No hay validación que cace el typo; el síntoma es un push publicado con un hueco. - Solo se interpolan
titleybody. El processor llama ainterpolatesobre esos dos campos y nada más, así que poner{{algo}}dentro dedatano hace nada: llega literal al dispositivo.
Diferencia 2 · Quién resuelve la credencial
Es la asimetría interna más fácil de tropezar:
- Email:
email.processor→MailerService(resuelve la clave) →ResendService. - Push:
push.processorresuelve las credenciales él mismo y se las pasa aFcmProvider.
O sea, MailerService no es el homólogo de FcmProvider aunque estén en la misma carpeta. El
homólogo de FcmProvider es ResendService: los dos implementan ChannelProvider.
Y las credenciales se comportan distinto: la clave de Resend tiene respaldo global
(RESEND_API_KEY), pero las de FCM no — sin cuenta de servicio del tenant, el push falla.
Diferencia 3 · El destinatario puede dejar de existir
Un email a una dirección muerta rebota y te enteras por el webhook. Un push a un token desinstalado lo rechaza FCM en el acto, y ese token no volverá a servir jamás.
Por eso el push tiene un camino de vuelta que el email no necesita: tokens de push muertos.
Lo que sí es igual
- Mismos
attempts: 3ybackoff: 5000. - Mismo
idempotencyKey→jobIdpara deduplicar. - Mismo ciclo de vida del log, con una salvedad: un push va a varios tokens a la vez, así que un trabajo escribe varias filas de log, una por destinatario. El email es el caso degenerado de “un destinatario”.
- El nombre del trabajo, en cambio, aquí es fijo (
send-push), no dinámico como en email.