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

@@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
import { resolveTheme } from '@/lib/funnel/themes';
import { env } from '@/lib/env';
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);
if (!lead) return false;
const tenant = await getTenantPerfilById(lead.tenantId);
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
return enviarEnlaceFormulario({
to: lead.email,
nombre: lead.nombre,
empresa: tenant.nombreEmpresa,
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
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
export async function enviarPresupuestoEmail(opts: {
@@ -31,24 +155,34 @@ export async function enviarPresupuestoEmail(opts: {
empresa: string;
pdf: Buffer;
filename: string;
brand?: EmailBrand;
cta?: { url: string; label: string } | null;
}): Promise<boolean> {
const transport = getTransport();
if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
const empresa = escapeHtml(opts.empresa);
const html = `<p>Hola ${nombre},</p>
<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>
<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>
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).',
headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`,
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 {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`,
html,
subject: 'Aquí está tu presupuesto de reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
});
return true;
@@ -65,25 +199,33 @@ export async function enviarEnlaceFormulario(opts: {
nombre: string;
empresa: string;
url: string;
brand?: EmailBrand;
}): Promise<boolean> {
const transport = getTransport();
if (!transport) return false;
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
const empresa = escapeHtml(opts.empresa);
const url = encodeURI(opts.url);
const html = `<p>Hola ${nombre},</p>
<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>
<p>👉 <a href="${url}">Subir mis fotos</a></p>
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p>
<p>—<br/>${empresa}</p>`;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.',
headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`,
parrafosHtml: [
`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 {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
html,
subject: 'Sube las fotos de tu reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
});
return true;
} catch (err) {
@@ -91,3 +233,5 @@ export async function enviarEnlaceFormulario(opts: {
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 { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
import { resolveTheme } from '@/lib/funnel/themes';
import { normalizarTelefonoEs } from '@/lib/voice/retell';
export type ResultadoFinalizar = {
ok: boolean;
@@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
.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([
enviarPresupuestoEmail({
to: lead.email,
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
empresa: tenant.nombreEmpresa,
pdf: buffer,
filename,
brand: {
primary: theme.primary,
primaryDark: theme.primaryDark,
contrast: theme.contrast,
logoUrl: tenant.logoUrl,
},
cta,
}),
notificarFlujoWhatsapp({
leadId,