Skip to main content

Email

El módulo de Email gestiona el envío de correos electrónicos transaccionales. Incluye 8 templates HTML profesionales con soporte para variables dinámicas. Email también está disponible como canal del builder de automaciones — al definir un step de tipo 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ía SMTP2GO_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

TemplateUsoVariables
welcomeOnboarding de usuariofirstName, lastName, loginUrl
password-resetRecuperacion de passwordfirstName, resetUrl, expiresIn
subscription-confirmationSuscripcion completadafirstName, planName, amount, currency, nextBillingDate, billingPortalUrl
subscription-cancelledCancelacion de suscripcionfirstName, planName, cancellationDate, accessUntil
payment-failedFallo de pagofirstName, planName, amount, currency, retryDate, updatePaymentUrl
invoiceEnvio de facturafirstName, invoiceNumber, amount, currency, dueDate, downloadUrl
trial-endingAviso fin de trialfirstName, trialEndDate, upgradeUrl
subscription-renewalRenovacion confirmadafirstName, planName, renewalDate, amount, currency
Cada template incluye version HTML (responsive con CSS inline) y version texto plano.

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 campo status (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.
Los borradores se marcan con un badge «Borrador» en el header del editor y en las tarjetas del listado. Quedan ocultos a quien envía: los selectores de envío piden solo plantillas 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_system listos 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 un html_preview para el thumbnail.
Al insertar un bloque se desempaqueta su nodo raíz y se inserta como array de nodos, de modo que la estructura del email no se corrompe.

API Endpoints

Todos los endpoints requieren rol admin.
MetodoEndpointDescripcion
GET/email/templatesListar templates disponibles
GET/email/templates/:nameDetalle de un template
POST/email/testEnviar email de prueba con variables custom
POST/email/welcomeEnviar email de bienvenida a usuario

Opciones de envio

{
  to: string          // destinatario
  subject: string     // asunto
  html: string        // contenido HTML
  text?: string       // texto plano (fallback)
  from?: string       // remitente
  replyTo?: string    // reply-to
  cc?: string[]       // copia
  bcc?: string[]      // copia oculta
  attachments?: []    // adjuntos
  tags?: string[]     // categorizacion
}

Estructura de modulo

apps/backend/src/email/
  email.module.ts
  email.controller.ts
  email.service.ts
  templates/           # Templates HTML

Best practices transaccionales (Ronda D)

El motor aplica una serie de transformaciones al html_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 opcional preheader en channel_payload. El motor inyecta un <div> oculto justo después de <body> con el texto preheader interpolado y ~30 pares &zwnj;&nbsp; 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)

Cuando UNSUBSCRIBE_TOKEN_SECRET está configurado y el target es un userId, el motor añade dos headers:
List-Unsubscribe: <https://api.vivla.com/v1/notifications/unsubscribe?token=...>, <mailto:abuse@vivla.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Gmail/Yahoo lo exigen para bulk desde feb-2024; transaccional también se beneficia. Click → POST al endpoint /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

Si EMAIL_UTM_DOMAINS=app.vivla.com,vivla.com está configurada, todo <a href="..."> cuyo hostname matchee se enriquece con:
utm_source=email
utm_medium={template.category}     # reminder | announcement | feedback | promotional | transactional
utm_campaign=tpl_{template_id_short}
utm_content=evt_{event_id_short}
Skipea 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 lleva track_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 en DOMAIN_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_typeActionURL
user.welcomedViewAction “Abrir Vivla”{appUrl}/app/dashboard
booking.createdViewAction “Ver mi reserva”{appUrl}/app/bookings/{bookingId}
booking.rent_approvedConfirmAction “Ver alquiler aprobado”{appUrl}/app/bookings/{bookingId}
Añadir más eventos: edita 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:
template_id={uuid}
template_category={reminder|announcement|...}
event_type={booking.created|...}
SMTP2GO los serializa como custom_headers 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

Tabla notification_suppressions (migración 089). El motor consulta PreferencesService.findActiveSuppression() antes de cada send y skipea con log si hay match. Schema:
user_id uuid       -- FK a users
channel text       -- email | push | in_app | tools_inbox | chat
scope text         -- all | category | template
scope_value text   -- category name o template_id (NULL si scope=all)
source text        -- list_unsubscribe | preferences_ui | manual_admin
reason text        -- libre
Resolución de matches en orden: scope=all mata todo el canal → scope=categoryscope=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 actuales
  • GET /v1/notifications/preferences/me → autenticado, devuelve las suppressions del user
  • DELETE /v1/notifications/preferences/me/:id → re-subscribe (borra una suppression)
Tokens HMAC-SHA256 (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á de event.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.
Ambas se inyectan solo en el canal email — push/in_app/tools_inbox/chat mantienen su contexto sin tocar.

Configuración

Resumen de env vars relacionadas con email (ver apps/backend/.env.example):
# Provider
SMTP2GO_API_KEY=...
EMAIL_FROM_DEFAULT=noreply@vivla.com
EMAIL_FROM_NAME_DEFAULT=Vivla
SMTP2GO_TRACK_OPENS=true             # default true
SMTP2GO_TRACK_CLICKS=true            # default true
SMTP2GO_WEBHOOK_SECRET=...           # set after configuring webhook in dashboard

# Compliance / unsubscribe
UNSUBSCRIBE_TOKEN_SECRET=...                   # required to emit List-Unsubscribe
UNSUBSCRIBE_TOKEN_SECRETS_ACCEPTED=oldA,oldB   # rotation grace
PUBLIC_API_URL=https://api.vivla.com           # used in unsubscribe links
APP_PUBLIC_URL=https://app.vivla.com           # used in {{appUrl}} + manageNotificationsUrl

# UTM
EMAIL_UTM_DOMAINS=app.vivla.com,vivla.com      # empty disables injection

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 DNS default._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.