Incrusta el logo del reformista en los emails (CID) + tema de marca

- prepararLogo: el logo (data URI base64, URL http o ruta /public con APP_URL)
  se adjunta como imagen inline con Content-ID y se referencia con cid:, que
  es la forma fiable de mostrarlo en Gmail/Apple Mail/Outlook (el data URI
  directo lo bloquea Gmail). Fallback al nombre de la empresa si no hay logo.
- El acento (barra, botón) ya usa el color de marca del tenant (resolveTheme).

Probado: envío real con el logo de Reformas Ejemplo embebido y tema pizarra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-04 14:41:59 +02:00
parent 2bc34d4017
commit 4d464d40ef

View File

@@ -43,18 +43,42 @@ type EmailParts = {
parrafosHtml: string[]; // HTML seguro: las variables ya vienen escapadas
cta?: { url: string; label: string } | null;
footer: string;
logoCid?: string | null; // si hay logo, se incrusta como adjunto inline (cid:)
};
type LogoAdjunto = { filename: string; content?: Buffer; path?: string; cid: string; contentType?: string };
const LOGO_CID = 'logoempresa';
// Prepara el logo como adjunto inline (CID) para que se vea en todos los clientes (Gmail bloquea
// las imágenes en data URI directas). Acepta data URI (base64), URL http(s), o ruta /public
// (se vuelve absoluta con APP_URL). Devuelve null si no hay logo usable.
function prepararLogo(logoUrl: string | null | undefined): LogoAdjunto | null {
if (!logoUrl) return null;
if (logoUrl.startsWith('data:')) {
const coma = logoUrl.indexOf(',');
if (coma === -1) return null;
const mime = logoUrl.slice(5, coma).split(';')[0] || 'image/png';
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
const content = Buffer.from(logoUrl.slice(coma + 1), 'base64');
return { filename: `logo.${ext}`, content, cid: LOGO_CID, contentType: mime };
}
if (/^https?:\/\//.test(logoUrl)) return { filename: 'logo', path: logoUrl, cid: LOGO_CID };
if (logoUrl.startsWith('/') && env.APP_URL) {
return { filename: 'logo', path: `${env.APP_URL}${logoUrl}`, cid: LOGO_CID };
}
return null;
}
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 logo = p.logoCid
? `<img src="cid:${p.logoCid}" alt="${empresa}" height="40" style="max-height:40px;max-width:200px;display:block;border:0;" />`
: `<span class="email-logo-name" 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>
@@ -110,7 +134,7 @@ function construirEmailHtml(p: EmailParts): string {
<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>
${logo}
</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>
@@ -162,6 +186,7 @@ export async function enviarPresupuestoEmail(opts: {
if (!transport) return false;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const logo = prepararLogo(opts.brand?.logoUrl);
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
@@ -174,8 +199,18 @@ export async function enviarPresupuestoEmail(opts: {
],
cta: opts.cta ?? null,
footer: `Presupuesto orientativo. El precio final puede variar según la visita técnica. · ${opts.empresa}`,
logoCid: logo ? logo.cid : null,
};
const attachments: Array<{
filename: string;
content?: Buffer;
path?: string;
cid?: string;
contentType?: string;
}> = [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }];
if (logo) attachments.push(logo);
try {
await transport.sendMail({
from: env.EMAIL_FROM,
@@ -183,7 +218,7 @@ export async function enviarPresupuestoEmail(opts: {
subject: 'Aquí está tu presupuesto de reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
attachments,
});
return true;
} catch (err) {
@@ -205,6 +240,7 @@ export async function enviarEnlaceFormulario(opts: {
if (!transport) return false;
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
const logo = prepararLogo(opts.brand?.logoUrl);
const parts: EmailParts = {
brand: opts.brand ?? BRAND_DEFECTO,
empresa: opts.empresa,
@@ -217,6 +253,7 @@ export async function enviarEnlaceFormulario(opts: {
],
cta: { url: encodeURI(opts.url), label: 'Subir mis fotos' },
footer: opts.empresa,
logoCid: logo ? logo.cid : null,
};
try {
@@ -226,6 +263,7 @@ export async function enviarEnlaceFormulario(opts: {
subject: 'Sube las fotos de tu reforma',
html: construirEmailHtml(parts),
text: construirEmailText(parts),
attachments: logo ? [logo] : undefined,
});
return true;
} catch (err) {