Email transaccional multi-tenant: dominios, marca y links de acción sin duplicar servicios

El email transaccional parece una pieza pequeña hasta que tienes más de un producto.

Con un solo producto, la configuración suele ser global:

Bloque de codigo - text
from = Producto <no-reply@mail.producto.example>
appUrl = https://app.producto.example
tenantId = producto-slug

Pero en cuanto el mismo sistema de auth crea usuarios para otro producto, esa configuración global empieza a fallar.

El caso concreto: Ultimate Porra.

Si invito a alguien a una porra del Mundial y recibe un email del producto equivocado, con un link que acaba en un portal compartido en vez del producto que espera, el flujo puede ser técnicamente válido y aun así estar mal. El usuario no sabe dónde está, no reconoce la marca y pierde confianza en el enlace.

Important

En email transaccional, la marca no es decoración. Es parte de la seguridad percibida del flujo.

El problema real

El flujo inicial tenía este riesgo:

flowchart LR admin["Admin crea usuario de producto"] --> auth["servicio de auth"] auth --> global["tenant compartido"] global --> notif["servicio de notificaciones"] notif --> email["email del producto incorrecto"] email --> portal["portal compartido"]

El bug no estaba en Resend ni en la plantilla. Estaba en el contrato entre productores y servicio de notificaciones.

El servicio de notificaciones ya podía enviar por tenant. Pero el servicio de auth necesitaba decir qué tenant correspondía a cada acción.

La arquitectura correcta

La separación que quiero es esta:

flowchart LR producer["servicio de auth"] --> product["metadata de producto<br/>tenant + codigo de producto"] product --> queue["servicio de notificaciones<br/>cola email"] queue --> tenant["config tenant<br/>remitente + app URL + referencia de key"] tenant --> resend["Resend<br/>dominio verificado"] resend --> inbox["usuario"]

Cada pieza tiene una responsabilidad:

Pieza Responsabilidad
Servicio de auth Saber para qué producto se crea la acción
Token de acción Guardar metadata de producto para password setup/recovery
Servicio de notificaciones Renderizar y encolar según tenant
Resend API key Autorizar envío para un dominio
DNS del dominio Probar SPF, DKIM y DMARC
appUrl Llevar al usuario al producto correcto

La clave es no duplicar el servicio entero por producto si sólo cambia la marca.

El contrato mínimo por producto

Cada producto necesita una entrada de configuración explícita:

Bloque de codigo - json
{
  "tenantId": "producto-slug",
  "productCode": "producto-slug",
  "brandName": "Nombre del producto",
  "fromAddress": "Nombre del producto <no-reply@mail.producto.example>",
  "replyTo": "soporte@producto.example",
  "appUrl": "https://producto.example",
  "authActionBaseUrl": "https://auth.example",
  "sendingKeySecretRef": "clave de envio acotada al dominio"
}

El detalle importante: authActionBaseUrl puede seguir siendo compartido.

No necesito un servicio de auth por producto si la página de acción sabe qué producto representa el token.

Tokens de acción con metadata

El flujo bueno queda así:

Bloque de codigo - text
accion de crear usuario
  productCode = producto-slug

servicio de auth
  -> crea usuario pending
  -> crea token password_setup
  -> guarda metadata:
       tenantId = producto-slug
       productCode = producto-slug
       brandName = Nombre del producto
       appUrl = URL del producto
  -> encola email en el servicio de notificaciones con el tenant del producto

Después, cuando el usuario abre el link:

Bloque de codigo - text
lectura del token de accion
  -> lee metadata del token
  -> renderiza página con marca del producto

accion de completar
  -> consume token
  -> activa password
  -> devuelve la URL de redirect del producto

Esto resuelve dos problemas a la vez:

  • el email sale desde el tenant correcto;
  • la página de password y el redirect final pertenecen al producto correcto.

Por qué no basta con cambiar el remitente

Cambiar sólo fromAddress deja el flujo inconsistente.

Elemento Si queda global Qué rompe
fromAddress Notipad El usuario no reconoce la invitación
plantilla Notipad La promesa de producto es incorrecta
action page Notipad El usuario cree estar creando otra cuenta
redirect final portal compartido El usuario aterriza fuera de la app
support URL Notipad Soporte y confianza quedan mezclados

Email multi-tenant no significa "varios remitentes". Significa un flujo completo por producto.

Dominios de envío

Para producción, prefiero un subdominio de envío por producto:

Bloque de codigo - text
mail.producto-a.example
mail.producto-b.example

Eso permite aislar reputación, intención y DNS.

La verificación mínima:

Registro Para qué sirve
SPF Autoriza servidores a enviar por el dominio
DKIM Firma el email y prueba que no fue alterado
DMARC Define política y reporting si SPF/DKIM fallan

Resend recomienda verificar dominios propios y usar subdominios de envío para aislar la reputación. Una vez SPF y DKIM están verificados, DMARC añade otra capa de confianza.

Free vs Pro no es sólo volumen

Para desarrollo o una beta muy pequeña, Free puede bastar.

Pero cuando hay dos productos reales, usuarios reales y emails de password/recovery, el coste de que el email no salga o llegue con límites incómodos pesa más que el precio del plan.

Mi regla:

Bloque de codigo - text
Free -> desarrollo, pruebas, beta cerrada muy pequeña
Pro  -> producción con usuarios reales y más de un dominio/producto

No es porque Pro sea mágicamente más seguro. Es porque producción necesita menos fricción: más margen mensual, sin límite diario del plan Free y con espacio para crecer sin cambiar arquitectura en mitad de un lanzamiento.

La cifra exacta puede cambiar; por eso la decisión operativa debe revisar la página de pricing antes de comprar.

n8n y la cola de emails

Si notifications-api trabaja en modo cola, n8n puede actuar como worker simple:

flowchart LR producer["auth-api"] --> enqueue["enqueue email job"] enqueue --> db["email_jobs"] n8n["n8n scheduled worker"] --> next["process next job"] next --> resend["Resend"] resend --> status["sent / failed / retry"]

La ventaja es separar la experiencia de usuario del envío:

  • crear usuario no depende de esperar a Resend;
  • los fallos se reintentan;
  • los jobs pueden auditarse;
  • los tokens internos no llegan al browser.

La regla: n8n puede operar jobs, pero no debe conocer más secretos de los necesarios.

Checklist para añadir un producto nuevo

Checklist multi-tenant de email
  • Crear subdominio de envío: mail.<producto>.com.
  • Verificar dominio en Resend.
  • Añadir SPF y DKIM en Cloudflare DNS.
  • Añadir DMARC, aunque sea con política gradual.
  • Crear una API key de envío acotada al dominio.
  • Guardar la key en el almacén de secretos server-side.
  • Añadir la configuración del tenant en ajustes server-side.
  • Añadir el producto a la configuración permitida de acciones de auth.
  • Hacer que el productor envíe productCode o tenantId.
  • Probar auth_password_setup.
  • Probar password_recovery.
  • Confirmar que el link de password muestra la marca correcta.
  • Confirmar que complete redirige al appUrl correcto.
  • Revisar logs de cola y estado en Resend.

El patrón que queda

No quiero un servicio de email por producto.

Quiero un servicio compartido con límites claros:

Bloque de codigo - text
producto selecciona tenant
tenant selecciona dominio, marca y provider key
token conserva contexto
email y redirect mantienen continuidad

Eso mantiene la arquitectura pequeña sin hacerla confusa.

La señal de que el diseño funciona es sencilla: un usuario de Ultimate Porra nunca tiene que preguntarse por qué Notipad le está pidiendo una contraseña.

Fuentes técnicas

  • Resend documenta que la verificación de dominio requiere SPF y DKIM, y recomienda subdominios para aislar reputación e intención de envío: Managing Domains.
  • Resend permite crear API keys con Sending access y restringirlas a un dominio concreto: API Keys.
  • Resend explica DMARC como una capa sobre SPF y DKIM para proteger contra spoofing y mejorar confianza: Implementing DMARC.
  • Resend mantiene la información de pricing actualizada en su documentación y remite a la página pública de precios para cifras vigentes: What is Resend Pricing y Pricing.

Este artículo forma parte de mis notas de arquitectura y operación. Si te interesa el tema, sigue el blog o escríbeme.