Añade envío de email SMTP del presupuesto y del enlace al formulario

- mailer.ts: transport nodemailer perezoso desde env; enviarPresupuestoEmail
  (adjunta el PDF) y enviarEnlaceFormulario. Best-effort: sin SMTP configurado
  o ante error devuelven false sin lanzar.
- COPY-GUIDE §6.b: copy literal de ambos emails al cliente final.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:05:17 +02:00
parent bcc882e37d
commit ec09267d99
2 changed files with 142 additions and 0 deletions

View File

@@ -536,6 +536,55 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel
--- ---
## 6.b Emails al cliente final (funnel B2C)
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.
### Email de entrega del presupuesto (PDF adjunto)
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto.
- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo*
- **Cuerpo:**
> Hola [Nombre],
>
> Aquí tienes tu **presupuesto orientativo de reforma**, preparado por [Reformista]. Lo encontrarás
> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.
>
> ⚠️ Es una **estimación**. El precio definitivo lo confirma [Reformista] en una visita gratuita en
> tu casa, donde mide todo con detalle y lo ajusta.
>
> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin
> compromiso.
>
> —
> [Reformista]
### 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
del espacio. `[url]` apunta a su formulario personal del funnel.
- **Asunto:** *Sube las fotos de tu reforma para [Reformista]*
- **Cuerpo:**
> Hola [Nombre],
>
> Para preparar tu render y tu presupuesto, [Reformista] necesita ver el espacio. Sube unas fotos
> de cada zona desde este enlace, cuando te venga bien:
>
> 👉 [Subir mis fotos]([url])
>
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu
> presupuesto.
>
> —
> [Reformista]
---
## 7. Microcopy del panel del reformista ## 7. Microcopy del panel del reformista
| Elemento | Texto | | Elemento | Texto |

View File

@@ -0,0 +1,93 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { env, emailConfigurado } from '@/lib/env';
let _transport: Transporter | null = null;
// Transport perezoso: solo se crea cuando hay SMTP configurado y se va a enviar de verdad.
function getTransport(): Transporter | null {
if (!emailConfigurado()) return null;
if (_transport) return _transport;
const port = Number(env.SMTP_PORT ?? 587);
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port,
secure: port === 465, // 465 = TLS implícito; 587/1025 = STARTTLS/none
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
});
return _transport;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;',
);
}
// 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: {
to: string;
nombre: string;
empresa: string;
pdf: Buffer;
filename: string;
}): 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>`;
try {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`,
html,
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
});
return true;
} catch (err) {
console.error('enviarPresupuestoEmail error:', err);
return false;
}
}
// Email con el enlace al formulario para subir imágenes (COPY-GUIDE §6.b), usado en el canal
// llamada. Best-effort, mismas garantías que el anterior.
export async function enviarEnlaceFormulario(opts: {
to: string;
nombre: string;
empresa: string;
url: string;
}): 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>`;
try {
await transport.sendMail({
from: env.EMAIL_FROM,
to: opts.to,
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
html,
});
return true;
} catch (err) {
console.error('enviarEnlaceFormulario error:', err);
return false;
}
}