panels-origin/api/mail.ts

298 lines
13 KiB
TypeScript

import { join } from "node:path";
import type { PlatformDb } from "./platform_db.ts";
import { config, ROOT } from "./config.ts";
import { resolveSmtp, smtpConfigured as smtpConfiguredFromDb } from "./smtp.ts";
export type MailAttachment = {
filename: string;
content: Uint8Array;
cid: string;
contentType?: string;
};
export type MailPayload = {
to: string;
subject: string;
text: string;
html?: string;
attachments?: MailAttachment[];
};
export function smtpConfigured(platformDb: PlatformDb): boolean {
return smtpConfiguredFromDb(platformDb);
}
/** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */
export async function sendMail(
platformDb: PlatformDb,
payload: MailPayload,
): Promise<{ sent: boolean; error?: string; message_id?: string }> {
if (!smtpConfigured(platformDb)) {
return { sent: false, error: "SMTP no configurado o deshabilitado" };
}
const s = resolveSmtp(platformDb);
const from = (s.from_address ?? "").trim();
if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) {
return {
sent: false,
error:
"Remitente (From) inválido o vacío. Debe ser un correo, ej. PANELS <noreply@tudominio.com>",
};
}
try {
const nodemailer = await import("npm:nodemailer@6.9.16");
const transporter = nodemailer.createTransport({
host: s.host,
port: s.port,
secure: s.port === 465,
requireTLS: s.port === 587,
auth: s.username ? { user: s.username, pass: s.password } : undefined,
connectionTimeout: 20000,
greetingTimeout: 20000,
socketTimeout: 30000,
tls: {
rejectUnauthorized: false,
servername: s.host,
},
});
const info = await transporter.sendMail({
from,
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
attachments: payload.attachments?.map((a) => ({
filename: a.filename,
content: a.content,
cid: a.cid,
contentType: a.contentType ?? "image/png",
contentDisposition: "inline" as const,
})),
});
const messageId = typeof info?.messageId === "string" ? info.messageId : undefined;
console.log(`[smtp] sent to=${payload.to} id=${messageId ?? "—"} host=${s.host}:${s.port}`);
return { sent: true, message_id: messageId };
} catch (e) {
const msg = e instanceof Error ? e.message : "Error al enviar correo";
console.error(`[smtp] fail to=${payload.to}:`, msg);
return { sent: false, error: msg };
}
}
/** Prueba de conexión / envío a un destinatario. */
export async function testSmtp(
platformDb: PlatformDb,
to: string,
): Promise<{ sent: boolean; error?: string; message_id?: string }> {
const dest = (to ?? "").trim();
if (!dest || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dest)) {
return { sent: false, error: "Indique un correo de prueba válido" };
}
return sendMail(platformDb, {
to: dest,
subject: "PANELS — prueba SMTP",
text: `Este es un correo de prueba de la configuración SMTP de PANELS.
Si recibió este mensaje, el SMTP está funcionando.
Enviado: ${new Date().toISOString()}
Destinatario: ${dest}
`,
});
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export type AccessEmailOpts = {
companyName: string;
username: string;
password: string;
contactName?: string;
};
const LOGO_CID = "panels-logo@panels";
export function accessEmailAttachments(): MailAttachment[] {
const path = join(ROOT, "api", "assets", "email", "logo.png");
try {
const content = Deno.readFileSync(path);
return [{
filename: "logo.png",
content,
cid: LOGO_CID,
contentType: "image/png",
}];
} catch {
console.warn("[mail] logo.png no encontrado en", path);
return [];
}
}
export function accessEmailBody(opts: AccessEmailOpts): string {
const hi = opts.contactName ? `Hola, ${opts.contactName}` : "Hola";
return `${hi}
Tu acceso a PANELS está listo.
La empresa ${opts.companyName} ya tiene acceso a PANELS. Utiliza los siguientes datos para iniciar sesión:
Usuario administrador: ${opts.username}
Contraseña temporal: ${opts.password}
Ingresar: ${config.panelLoginUrl}
Por seguridad, al ingresar por primera vez deberás crear una nueva contraseña.
— Equipo PANELS
Todas tus operaciones. Un solo sistema.
`;
}
/** HTML del correo de acceso (diseño original + logo real por CID). */
export function accessEmailHtml(opts: AccessEmailOpts): string {
const name = (opts.contactName ?? "").trim();
const hi = name ? `Hola, ${escapeHtml(name)}` : "Hola";
const company = escapeHtml(opts.companyName);
const username = escapeHtml(opts.username);
const password = escapeHtml(opts.password);
const loginUrl = escapeHtml(config.panelLoginUrl);
const mark =
`<img src="cid:${LOGO_CID}" width="40" height="40" alt="PANELS" style="display:block;border:0;outline:none;" />`;
const watermark = `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="120" height="120" style="border-collapse:collapse;opacity:0.22;">
<tr>
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;">&nbsp;</div></td>
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;">&nbsp;</div></td>
</tr>
<tr>
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;">&nbsp;</div></td>
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;">&nbsp;</div></td>
</tr>
</table>`;
const footerMark = `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="40" height="40" style="border-collapse:separate;">
<tr>
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;">&nbsp;</div></td>
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;">&nbsp;</div></td>
</tr>
<tr>
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;">&nbsp;</div></td>
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;">&nbsp;</div></td>
</tr>
</table>`;
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tu acceso a PANELS está listo</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f9;font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:100%;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f6f9;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:560px;background-color:#ffffff;border-radius:12px;overflow:hidden;">
<tr>
<td style="padding:28px 32px 8px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle" width="48">${mark}</td>
<td valign="middle" style="padding-left:12px;font-size:22px;font-weight:700;color:#0B1F4D;letter-spacing:0.5px;">PANELS</td>
<td align="right" valign="top" width="100" style="overflow:hidden;">${watermark}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:8px 32px 8px 32px;">
<p style="margin:0 0 16px 0;font-size:16px;line-height:1.5;color:#4B5563;">${hi}</p>
<h1 style="margin:0 0 12px 0;font-size:26px;line-height:1.25;font-weight:700;color:#0B1F4D;">Tu acceso a PANELS está listo</h1>
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.55;color:#4B5563;">La empresa <strong style="color:#0B1F4D;">${company}</strong> ya tiene acceso a PANELS. Utiliza los siguientes datos para iniciar sesión:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#EEF4FC;border-radius:12px;margin:0 0 24px 0;">
<tr>
<td style="padding:16px 18px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="28" valign="middle" style="font-size:18px;color:#2F6FED;">&#128100;</td>
<td width="160" valign="middle" style="width:160px;font-size:14px;color:#4B5563;padding-left:8px;">Usuario administrador</td>
<td width="16" align="center" valign="middle" style="color:#CBD5E1;">|</td>
<td valign="middle" style="font-size:15px;font-weight:700;color:#0B1F4D;font-family:Consolas,Monaco,monospace;">${username}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 18px;">
<div style="height:1px;background-color:#D6E4F5;line-height:1px;font-size:0;">&nbsp;</div>
</td>
</tr>
<tr>
<td style="padding:16px 18px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="28" valign="middle" style="font-size:18px;color:#2F6FED;">&#128274;</td>
<td width="160" valign="middle" style="width:160px;font-size:14px;color:#4B5563;padding-left:8px;">Contraseña temporal</td>
<td width="16" align="center" valign="middle" style="color:#CBD5E1;">|</td>
<td valign="middle" style="font-size:15px;font-weight:700;color:#0B1F4D;font-family:Consolas,Monaco,monospace;">${password}</td>
</tr>
</table>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 12px 0;">
<tr>
<td align="center" style="background-color:#2F6FED;border-radius:10px;">
<a href="${loginUrl}" style="display:block;padding:14px 24px;font-size:16px;font-weight:700;color:#ffffff;text-decoration:none;">Ingresar a PANELS</a>
</td>
</tr>
</table>
<p style="margin:0 0 28px 0;text-align:center;">
<a href="${loginUrl}" style="font-size:13px;color:#2F6FED;text-decoration:underline;word-break:break-all;">${loginUrl}</a>
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 28px 0;">
<tr>
<td width="28" valign="top" style="font-size:16px;color:#2F6FED;padding-top:1px;">&#128737;</td>
<td valign="top" style="font-size:13px;line-height:1.5;color:#4B5563;padding-left:8px;">Por seguridad, al ingresar por primera vez deberás crear una nueva contraseña.</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background-color:#0B1F4D;padding:22px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle" style="color:#ffffff;">
<div style="font-size:14px;font-weight:700;margin-bottom:4px;">— Equipo PANELS</div>
<div style="font-size:12px;opacity:0.85;">Todas tus operaciones. Un solo sistema.</div>
</td>
<td width="56" align="right" valign="middle">${footerMark}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0;font-size:11px;line-height:1.4;color:#9CA3AF;text-align:center;">Este correo fue enviado automáticamente. No respondas a este mensaje.</p>
</td>
</tr>
</table>
</body>
</html>`;
}