action se puede activar email junto a push, in_app, tools_inbox y chat. La acción usa la misma EmailService y soporta {{variable}} interpolation desde el payload del evento.
Proveedor
SMTP2GO — servicio transaccional con tracking de delivery, opens y clicks. Configurado víaSMTP2GO_USER + SMTP2GO_API_KEY. Sender por defecto: EMAIL_FROM_DEFAULT + EMAIL_FROM_NAME_DEFAULT. La integración Resend anterior se deprecó en la migración del epic notification-automation-system; el alias FROM_EMAIL se mantiene transitoriamente para entornos que aún no completaron la actualización de variables.
Templates disponibles
| Template | Uso | Variables |
|---|---|---|
welcome | Onboarding de usuario | firstName, lastName, loginUrl |
password-reset | Recuperacion de password | firstName, resetUrl, expiresIn |
subscription-confirmation | Suscripcion completada | firstName, planName, amount, currency, nextBillingDate, billingPortalUrl |
subscription-cancelled | Cancelacion de suscripcion | firstName, planName, cancellationDate, accessUntil |
payment-failed | Fallo de pago | firstName, planName, amount, currency, retryDate, updatePaymentUrl |
invoice | Envio de factura | firstName, invoiceNumber, amount, currency, dueDate, downloadUrl |
trial-ending | Aviso fin de trial | firstName, trialEndDate, upgradeUrl |
subscription-renewal | Renovacion confirmada | firstName, planName, renewalDate, amount, currency |
Builder de plantillas de email
Las plantillas de email se crean y editan visualmente en/app/notifications/email (editor basado en @react-email/editor / TipTap). El editor compila el contenido a HTML email-safe (<table role="presentation">, secciones, botones) y guarda tanto el html_body como el editor_state (JSON de TipTap) para reabrir la plantilla sin pérdida.
Borrador vs publicado
Cada plantilla tiene un campostatus (draft | published, migración 142). El builder ofrece dos acciones de guardado en la barra superior:
- Guardar borrador (
status='draft') — guarda trabajo en curso sin exigir asunto ni cuerpo (solo nombre). El borrador se queda en el editor para seguir trabajando. La validación del payload de email se relaja server-side (emailDraftPayloadSchema). - Publicar (
status='published') — aplica la validación completa (nombre + asunto + cuerpo) y vuelve al listado.
published, y los paths de envío en producción (sending.service, engine.service) fuerzan status='published'. El test-send sí permite borradores para que el autor pruebe antes de publicar. Al duplicar una plantilla, la copia siempre nace como draft.
La API expone el filtro vía query param: GET /v1/notifications/templates?status=published.
Librería de bloques
El panel izquierdo del editor incluye una librería de bloques reutilizables (notification_email_blocks):
- Estructura — inserts nativos del editor (columnas, secciones, botón, separador, títulos, listas, imagen).
- Sistema — 9 bloques
is_systemlistos para usar (header navy, hero, sección, CTA, separador, firma, pasos, footer básico, footer legal), regenerados con HTML table-based (migración 143). - Míos — bloques que el usuario guarda desde el editor (selección activa o documento completo). Cada bloque guarda su
json_content(TipTap) y unhtml_previewpara el thumbnail.
API Endpoints
Todos los endpoints requieren roladmin.
| Metodo | Endpoint | Descripcion |
|---|---|---|
| GET | /email/templates | Listar templates disponibles |
| GET | /email/templates/:name | Detalle de un template |
| POST | /email/test | Enviar email de prueba con variables custom |
| POST | /email/welcome | Enviar email de bienvenida a usuario |
Opciones de envio
Estructura de modulo
Best practices transaccionales (Ronda D)
El motor aplica una serie de transformaciones alhtml_body antes de mandarlo a SMTP2GO. Cada una es opcional y idempotente; viven en apps/backend/src/notifications/engine/transactional-email.util.ts.
Preview text (preheader)
Campo opcionalpreheader en channel_payload. El motor inyecta un <div> oculto justo después de <body> con el texto preheader interpolado y ~30 pares ‌ de padding para empujar fuera del preview cualquier texto del cuerpo. Sweet spot 80–120 chars; cap duro 200.
Lo edita el creador de la plantilla en el editor (/app/notifications/email/{id} → Configuración → Preview text).
Locale en <html lang>
Sustituido en runtime: 'en' cuando recipient.locale === 'en', 'es' en cualquier otro caso. La plantilla siempre se guarda con lang="en" (el editor no lo customiza), pero el destinatario recibe la suya.
Color scheme (force light)
Inyecta<meta name="color-scheme" content="light"> y supported-color-schemes para evitar que Gmail/Apple Mail apliquen su algoritmo de inversión a los colores Vivla (cream/navy → café/gris). Forzar light es más predecible que diseñar dark mode bien; revisar como mejora futura si CX/Marketing lo pide.
Title
<title>{{subject}}</title> en <head>. Algunos webmail clients lo muestran en print/forward; lectura cero coste.
List-Unsubscribe (RFC 8058 one-click)
CuandoUNSUBSCRIBE_TOKEN_SECRET está configurado y el target es un userId, el motor añade dos headers:
/v1/notifications/unsubscribe (Public, token-auth) → upsert idempotente en notification_suppressions. El motor consulta esta tabla antes de cada send y skipea si hay match. Ver Suppressions / preferences abajo.
Default scope = category (template.category). Coherente con la percepción del usuario (“no quiero más recordatorios”). Granularidad por template requiere editar el row manualmente.
UTM injection
SiEMAIL_UTM_DOMAINS=app.vivla.com,vivla.com está configurada, todo <a href="..."> cuyo hostname matchee se enriquece con:
mailto:, tel:, anchors #, URLs ya con utm_* (el editor pudo elegir tracking explícito), y URLs con {{...}} sin interpolar.
Open / click tracking (SMTP2GO native)
Cada send llevatrack_opens: true, track_clicks: true (overridable con SMTP2GO_TRACK_OPENS=false / SMTP2GO_TRACK_CLICKS=false). El dashboard de SMTP2GO debe tenerlo activado a nivel cuenta — la flag per-mensaje no enciende algo apagado a nivel cuenta.
Eventos webhook se postean a POST /v1/notifications/email/webhook/smtp2go (Public, HMAC verify), normalizan a 7 tipos canónicos (delivered, opened, clicked, bounced, spam, rejected, unsubscribed), y se persisten en notification_email_events. Setup paso a paso en docs/internal/tools/notifications/runbooks/smtp2go-tracking-setup.md.
Caveat Apple MPP: desde iOS 15, Apple Mail pre-fetcha imágenes del email — todos los emails parecen “abiertos” inmediatamente. Las métricas de open son inflacionarias; clicks son más fiables.
Schema.org structured data
Eventos enDOMAIN_EVENT_CATALOG con campo schema_org emiten un <script type="application/ld+json"> describiendo un EmailMessage con potentialAction. Gmail muestra un botón inline al lado del sender (sin abrir el email). Cobertura inicial:
| event_type | Action | URL |
|---|---|---|
user.welcomed | ViewAction “Abrir Vivla” | {appUrl}/app/dashboard |
booking.created | ViewAction “Ver mi reserva” | {appUrl}/app/bookings/{bookingId} |
booking.rent_approved | ConfirmAction “Ver alquiler aprobado” | {appUrl}/app/bookings/{bookingId} |
packages/common/src/types/automation-flow.ts y agrega schema_org al DomainEventDefinition correspondiente. actionName es I18nString (es+en mínimo); handlerUrl se interpola contra el payload del evento.
Tags por mensaje (analítica)
El motor adjunta tres tags a cada email send:X-Vivla-Tag-* (no tiene campo nativo tags en su API v3). El webhook los echo back y notification_email_events.metadata los preserva — analytics filtrables por cualquiera.
Suppressions
Tablanotification_suppressions (migración 089). El motor consulta PreferencesService.findActiveSuppression() antes de cada send y skipea con log si hay match.
Schema:
scope=all mata todo el canal → scope=category → scope=template.
Endpoints:
POST /v1/notifications/unsubscribe?token=...→ One-Click (Public, RFC 8058)GET /v1/notifications/unsubscribe?token=...→ landing HTML con confirmación + lista de suppressions actualesGET /v1/notifications/preferences/me→ autenticado, devuelve las suppressions del userDELETE /v1/notifications/preferences/me/:id→ re-subscribe (borra una suppression)
UNSUBSCRIBE_TOKEN_SECRET), TTL default 30 días, soporte de rotación con UNSUBSCRIBE_TOKEN_SECRETS_ACCEPTED (lista CSV de secrets antiguos que aún verifican).
Variables expuestas en email render
Más allá deevent.payload y SYSTEM_PROPERTIES:
{{unsubscribeUrl}}— URL firmada con token, lista para<a href>. Vacía si no hay secret configurado.{{manageNotificationsUrl}}—${APP_PUBLIC_URL or app.vivla.com}/app/settings/notifications.
Configuración
Resumen de env vars relacionadas con email (verapps/backend/.env.example):
BIMI (futura mejora)
BIMI hace que el logo Vivla aparezca al lado del sender en Gmail/Apple Mail/Yahoo. Requiere DMARC enforcement (ya tenemos), un VMC certificado (~$1,500/año via DigiCert/Entrust), y un registro DNSdefault._bimi.vivla.com TXT v=BIMI1;l=https://...logo.svg;a=https://...vmc.pem. Excluido de la Ronda D por costo; revisar cuando el volumen de email justifique el ROI de marca.