panels-origin/api/mail.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

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 async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
return await 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 (!await smtpConfigured(platformDb)) {
return { sent: false, error: "SMTP no configurado o deshabilitado" };
}
const s = await 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>`;
}