Mejora los emails transaccionales (diseño premium + marca)

Reescribe los emails del funnel (entrega del presupuesto + enlace al
formulario) con plantilla HTML mobile-first de una columna, dark mode, botón
"bulletproof" en tabla, tipografías de sistema y versión en texto plano.

- mailer.ts: construirEmailHtml/construirEmailText compartidos; acento en el
  color de marca del reformista (resolveTheme) + logo si es URL absoluta; copy
  con jerarquía y CTA orientado a resultado.
- finalizar.ts: pasa la marca del tenant y un CTA de contacto (wa.me del
  reformista o mailto) al email de entrega.
- actions.ts: pasa la marca al email con el enlace al formulario.
- COPY-GUIDE §6.b: asuntos (+alternativas), preheader, headline y cuerpo
  mejorados.

Probado: render visual (light) de ambos emails y envío real por SMTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-04 14:35:02 +02:00
parent 2e3cd78216
commit 2bc34d4017
4 changed files with 221 additions and 44 deletions

View File

@@ -582,47 +582,54 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo, Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente. igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof"
(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva
asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano.
### Email de entrega del presupuesto (PDF adjunto) ### Email de entrega del presupuesto (PDF adjunto)
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el
protagonista, cálido pero claro.
- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo* - **Asunto (elegido):** *Aquí está tu presupuesto de reforma*
- **Asunto (alt. A):** *Tu reforma, en números y en imágenes*
- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo*
- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).*
- **Headline:** *Aquí está tu presupuesto, [Nombre]*
- **Cuerpo:** - **Cuerpo:**
> Hola [Nombre], > Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de
> cómo quedaría tu espacio y el desglose por partidas.
> >
> Aquí tienes tu **presupuesto orientativo de reforma**, preparado por [Reformista]. Lo encontrarás > *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas. > tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.
> >
> ⚠️ Es una **estimación**. El precio definitivo lo confirma [Reformista] en una visita gratuita en > ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
> tu casa, donde mide todo con detalle y lo ajusta.
> - **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin - **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
> compromiso.
>
> —
> [Reformista]
### Email con enlace al formulario (subir imágenes) ### Email con enlace al formulario (subir imágenes)
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
del espacio. `[url]` apunta a su formulario personal del funnel. del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara.
- **Asunto:** *Sube las fotos de tu reforma para [Reformista]* - **Asunto (elegido):** *Sube las fotos de tu reforma*
- **Asunto (alt. A):** *Un paso más para tu presupuesto*
- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio*
- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.*
- **Headline:** *Enséñanos tu espacio, [Nombre]*
- **Cuerpo:** - **Cuerpo:**
> Hola [Nombre], > Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio.
> >
> Para preparar tu render y tu presupuesto, [Reformista] necesita ver el espacio. Sube unas fotos > Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
> de cada zona desde este enlace, cuando te venga bien: > quieras.
> >
> 👉 [Subir mis fotos]([url]) > En cuanto las tengamos, seguimos con tu presupuesto.
>
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu - **CTA:** *Subir mis fotos*`[url]`
> presupuesto. - **Footer:** *[Reformista]*
>
> —
> [Reformista]
--- ---

View File

@@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell'; import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
import { iniciarConversacionWhatsapp } from '@/lib/webhooks'; import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
import { enviarEnlaceFormulario } from '@/lib/email/mailer'; import { enviarEnlaceFormulario } from '@/lib/email/mailer';
import { resolveTheme } from '@/lib/funnel/themes';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
const MAX_ZONAS = 6; const MAX_ZONAS = 6;
@@ -256,12 +257,19 @@ export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boole
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return false; if (!lead) return false;
const tenant = await getTenantPerfilById(lead.tenantId); const tenant = await getTenantPerfilById(lead.tenantId);
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`; const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
return enviarEnlaceFormulario({ return enviarEnlaceFormulario({
to: lead.email, to: lead.email,
nombre: lead.nombre, nombre: lead.nombre,
empresa: tenant.nombreEmpresa, empresa: tenant.nombreEmpresa,
url, url,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
}); });
} }

View File

@@ -23,6 +23,130 @@ function escapeHtml(s: string): string {
); );
} }
const primerNombre = (nombre: string) => nombre.split(' ')[0] || nombre;
// Color de marca del reformista para el acento del email (botón, barra, enlaces).
export type EmailBrand = {
primary: string;
primaryDark: string;
contrast: string;
logoUrl?: string | null;
};
const BRAND_DEFECTO: EmailBrand = { primary: '#0a0a0a', primaryDark: '#1a1a1a', contrast: '#ffffff' };
type EmailParts = {
brand: EmailBrand;
empresa: string;
preheader: string;
headline: string;
parrafosHtml: string[]; // HTML seguro: las variables ya vienen escapadas
cta?: { url: string; label: string } | null;
footer: string;
};
const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// Email transaccional: una columna (máx 600px), mobile-first, dark mode, botón bulletproof (tabla),
// estilos inline como base + <style> para dark/responsive. Compatible con Gmail/Apple Mail/Outlook.
function construirEmailHtml(p: EmailParts): string {
const empresa = escapeHtml(p.empresa);
const logo =
p.brand.logoUrl && /^https?:\/\//.test(p.brand.logoUrl)
? `<img src="${escapeHtml(p.brand.logoUrl)}" alt="${empresa}" height="34" style="max-height:34px;max-width:180px;display:block;border:0;" />`
: `<span style="font-size:18px;font-weight:700;color:#1a1a1a;letter-spacing:-0.2px;">${empresa}</span>`;
const cta = p.cta
? `<tr><td style="height:28px;line-height:28px;">&nbsp;</td></tr>
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">
<tr><td style="border-radius:10px;background:${p.brand.primary};">
<a class="btn-a" href="${escapeHtml(p.cta.url)}" target="_blank"
style="display:inline-block;padding:15px 30px;font-family:${FONT};font-size:16px;font-weight:600;line-height:1;color:${p.brand.contrast};text-decoration:none;border-radius:10px;">
${escapeHtml(p.cta.label)}
</a>
</td></tr>
</table>
</td></tr>`
: '';
const parrafos = p.parrafosHtml
.map(
(html) =>
`<tr><td class="email-text" style="font-family:${FONT};font-size:16px;line-height:1.65;color:#3f3f46;padding-bottom:16px;">${html}</td></tr>`,
)
.join('');
return `<!DOCTYPE html>
<html lang="es" style="margin:0;padding:0;">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${empresa}</title>
<style>
@media (prefers-color-scheme: dark) {
.email-body { background:#0b0b0c !important; }
.email-card { background:#18181b !important; }
.email-head { background:#18181b !important; }
.email-title { color:#fafafa !important; }
.email-text { color:#d4d4d8 !important; }
.email-muted { color:#a1a1aa !important; }
.email-rule { border-color:#27272a !important; }
.email-logo-name { color:#fafafa !important; }
}
@media (max-width:600px) {
.email-card { width:100% !important; border-radius:0 !important; }
.email-pad { padding-left:24px !important; padding-right:24px !important; }
.btn-a { display:block !important; padding-left:0 !important; padding-right:0 !important; width:100%; box-sizing:border-box; text-align:center; }
}
</style>
</head>
<body class="email-body" style="margin:0;padding:0;background:#f4f4f5;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(p.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f4f5;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="email-card" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;">
<tr><td style="height:4px;line-height:4px;background:${p.brand.primary};font-size:0;">&nbsp;</td></tr>
<tr><td class="email-head email-pad" style="padding:24px 40px 8px 40px;">
<span class="email-logo-name">${logo}</span>
</td></tr>
<tr><td class="email-pad" style="padding:8px 40px 0 40px;">
<h1 class="email-title" style="margin:0 0 18px 0;font-family:${FONT};font-size:26px;line-height:1.25;font-weight:800;color:#18181b;letter-spacing:-0.4px;">${escapeHtml(p.headline)}</h1>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${parrafos}
${cta}
</table>
</td></tr>
<tr><td class="email-pad" style="padding:28px 40px 0 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="email-rule" style="border-top:1px solid #e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="email-pad email-muted" style="padding:16px 40px 32px 40px;font-family:${FONT};font-size:13px;line-height:1.5;color:#71717a;">${escapeHtml(p.footer)}</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function construirEmailText(p: EmailParts): string {
const lineas = [p.headline, '', ...p.parrafosHtml.map(stripHtml), ''];
if (p.cta) lineas.push(`${p.cta.label}: ${p.cta.url}`, '');
lineas.push('—', p.footer);
return lineas.join('\n');
}
const stripHtml = (html: string) =>
html
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
// Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay // Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline. // SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
export async function enviarPresupuestoEmail(opts: { export async function enviarPresupuestoEmail(opts: {
@@ -31,24 +155,34 @@ export async function enviarPresupuestoEmail(opts: {
empresa: string; empresa: string;
pdf: Buffer; pdf: Buffer;
filename: string; filename: string;
brand?: EmailBrand;
cta?: { url: string; label: string } | null;
}): Promise<boolean> { }): Promise<boolean> {
const transport = getTransport(); const transport = getTransport();
if (!transport) return false; if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre); const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const empresa = escapeHtml(opts.empresa); const parts: EmailParts = {
const html = `<p>Hola ${nombre},</p> brand: opts.brand ?? BRAND_DEFECTO,
<p>Aquí tienes tu <strong>presupuesto orientativo de reforma</strong>, preparado por ${empresa}. Lo encontrarás adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.</p> empresa: opts.empresa,
<p>⚠️ Es una <strong>estimación</strong>. El precio definitivo lo confirma ${empresa} en una visita gratuita en tu casa, donde mide todo con detalle y lo ajusta.</p> preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).',
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p> headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`,
<p>—<br/>${empresa}</p>`; parrafosHtml: [
`Hemos preparado el <strong>presupuesto orientativo</strong> de tu reforma. En el PDF adjunto tienes el render de cómo quedaría tu espacio y el desglose por partidas.`,
`<em>Es una estimación.</em> El precio definitivo lo confirma ${empresaB} en una <strong>visita gratuita</strong> en tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.`,
`¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.`,
],
cta: opts.cta ?? null,
footer: `Presupuesto orientativo. El precio final puede variar según la visita técnica. · ${opts.empresa}`,
};
try { try {
await transport.sendMail({ await transport.sendMail({
from: env.EMAIL_FROM, from: env.EMAIL_FROM,
to: opts.to, to: opts.to,
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`, subject: 'Aquí está tu presupuesto de reforma',
html, html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }], attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
}); });
return true; return true;
@@ -65,25 +199,33 @@ export async function enviarEnlaceFormulario(opts: {
nombre: string; nombre: string;
empresa: string; empresa: string;
url: string; url: string;
brand?: EmailBrand;
}): Promise<boolean> { }): Promise<boolean> {
const transport = getTransport(); const transport = getTransport();
if (!transport) return false; if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre); const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const empresa = escapeHtml(opts.empresa); const parts: EmailParts = {
const url = encodeURI(opts.url); brand: opts.brand ?? BRAND_DEFECTO,
const html = `<p>Hola ${nombre},</p> empresa: opts.empresa,
<p>Para preparar tu render y tu presupuesto, ${empresa} necesita ver el espacio. Sube unas fotos de cada zona desde este enlace, cuando te venga bien:</p> preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.',
<p>👉 <a href="${url}">Subir mis fotos</a></p> headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`,
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p> parrafosHtml: [
<p>—<br/>${empresa}</p>`; `Para preparar tu render y tu presupuesto, ${empresaB} necesita ver cómo está ahora tu espacio.`,
`Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que quieras.`,
`En cuanto las tengamos, seguimos con tu presupuesto.`,
],
cta: { url: encodeURI(opts.url), label: 'Subir mis fotos' },
footer: opts.empresa,
};
try { try {
await transport.sendMail({ await transport.sendMail({
from: env.EMAIL_FROM, from: env.EMAIL_FROM,
to: opts.to, to: opts.to,
subject: `Sube las fotos de tu reforma para ${opts.empresa}`, subject: 'Sube las fotos de tu reforma',
html, html: construirEmailHtml(parts),
text: construirEmailText(parts),
}); });
return true; return true;
} catch (err) { } catch (err) {
@@ -91,3 +233,5 @@ export async function enviarEnlaceFormulario(opts: {
return false; return false;
} }
} }
export { construirEmailHtml, construirEmailText };

View File

@@ -4,6 +4,8 @@ import { leads, leadPipelineEventos } from '@/db/schema';
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto'; import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
import { enviarPresupuestoEmail } from '@/lib/email/mailer'; import { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks'; import { notificarFlujoWhatsapp } from '@/lib/webhooks';
import { resolveTheme } from '@/lib/funnel/themes';
import { normalizarTelefonoEs } from '@/lib/voice/retell';
export type ResultadoFinalizar = { export type ResultadoFinalizar = {
ok: boolean; ok: boolean;
@@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() }) .set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
.where(eq(leads.id, leadId)); .where(eq(leads.id, leadId));
// Marca del reformista para el acento del email + CTA de contacto (WhatsApp o email).
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const tel = tenant.telefono ? normalizarTelefonoEs(tenant.telefono) : null;
const cta = tel
? { url: `https://wa.me/${tel.replace('+', '')}`, label: 'Agendar mi visita gratuita' }
: tenant.email
? { url: `mailto:${tenant.email}`, label: 'Agendar mi visita gratuita' }
: null;
const [emailEnviado, whatsappSenal] = await Promise.all([ const [emailEnviado, whatsappSenal] = await Promise.all([
enviarPresupuestoEmail({ enviarPresupuestoEmail({
to: lead.email, to: lead.email,
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
empresa: tenant.nombreEmpresa, empresa: tenant.nombreEmpresa,
pdf: buffer, pdf: buffer,
filename, filename,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
cta,
}), }),
notificarFlujoWhatsapp({ notificarFlujoWhatsapp({
leadId, leadId,