From 6a0c6a4a49b7c4424b8abda9d14453f5dd09a64f Mon Sep 17 00:00:00 2001 From: Alberto Martinez Date: Fri, 21 Aug 2026 20:02:27 -0500 Subject: [PATCH] =?UTF-8?q?Agregando=20dise=C3=B1o=20de=20correo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.api | 1 + api/assets/email/icon-lock.png | Bin 0 -> 280 bytes api/assets/email/icon-shield.png | Bin 0 -> 369 bytes api/assets/email/icon-user.png | Bin 0 -> 380 bytes api/assets/email/logo-footer.png | Bin 0 -> 2231 bytes api/assets/email/logo.png | Bin 0 -> 2806 bytes api/assets/email/petal.png | Bin 0 -> 656 bytes api/config.ts | 7 +- api/email_assets.ts | 9 ++ api/mail.ts | 218 ++++++++++++++++++++++++-- api/saas.ts | 11 +- api/smtp.ts | 10 ++ docs/mockups/panels-access-email.html | 56 +++++++ 13 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 api/assets/email/icon-lock.png create mode 100644 api/assets/email/icon-shield.png create mode 100644 api/assets/email/icon-user.png create mode 100644 api/assets/email/logo-footer.png create mode 100644 api/assets/email/logo.png create mode 100644 api/assets/email/petal.png create mode 100644 api/email_assets.ts create mode 100644 docs/mockups/panels-access-email.html diff --git a/Dockerfile.api b/Dockerfile.api index 392c58d..66bebce 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -15,6 +15,7 @@ WORKDIR /app COPY db/ ./db/ COPY api/ ./api/ +COPY web-panel/public/brand/panel-icon-symmetric.png ./api/assets/email/logo.png RUN chmod +x ./db/bootstrap-tools.sh \ && ./db/bootstrap-tools.sh \ diff --git a/api/assets/email/icon-lock.png b/api/assets/email/icon-lock.png new file mode 100644 index 0000000000000000000000000000000000000000..995734af5941b3f25ffa2baf5e2192d346647d1e GIT binary patch literal 280 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 literal 0 HcmV?d00001 diff --git a/api/config.ts b/api/config.ts index b636731..4ae9a8e 100644 --- a/api/config.ts +++ b/api/config.ts @@ -21,11 +21,12 @@ export const config = { seedPassword: Deno.env.get("SEED_PASSWORD") ?? "", vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=", panelLoginUrl: Deno.env.get("PANEL_LOGIN_URL") ?? "http://localhost:3000/login", - smtpHost: Deno.env.get("SMTP_HOST") ?? "", + smtpHost: (Deno.env.get("SMTP_HOST") ?? "").trim(), smtpPort: Number(Deno.env.get("SMTP_PORT") ?? "587"), - smtpUser: Deno.env.get("SMTP_USER") ?? "", + smtpUser: (Deno.env.get("SMTP_USER") ?? "").trim(), smtpPass: Deno.env.get("SMTP_PASS") ?? "", - smtpFrom: Deno.env.get("SMTP_FROM") ?? "PANELS ", + /** Compose puede pasar SMTP_FROM vacío; no pisar el default. */ + smtpFrom: (Deno.env.get("SMTP_FROM") ?? "").trim() || "PANELS ", /** En HTTPS (Coolify) poner COOKIE_SECURE=true */ cookieSecure: ["1", "true", "yes"].includes( (Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(), diff --git a/api/email_assets.ts b/api/email_assets.ts new file mode 100644 index 0000000..ad2ae33 --- /dev/null +++ b/api/email_assets.ts @@ -0,0 +1,9 @@ +/** Assets de correo: logo desde web-panel/public/brand/panel-icon-symmetric.png */ +export const emailAssets = { + logo: "data:image/png;base64,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", + logo_footer: "data:image/png;base64,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", + petal: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAACV0lEQVR4nO3XP2sUQRyH8ec7e3sxGAsFY5HGQrAJioJiI+TAVksLLay09xUkFmrhO8kRsEqbKxQtLGyu0VJUkqjgn2D+7M7X4iIEwh0Xk5gbmE957C2/Z2duloMsy46SDupGtjXXoZgBVmdwt9M5sHtnh8V2WFz02OI7jx31LP009voF22q3CQCSamAD4PmL1RNVefySCjVi9HoRetcctT0HCpjtYphjdmmp8ajVqgCqYvy6xFNqTwRpOdajsapDHwS21e5S3p7W5t/PFt78uhyr4q5kIU9i3UAqMWvGe354h2GoIebnXWxvx82Fl58nY3lqKsStGOvilsR9gRz1Efhg24hC4MMdfThDBZ6+Qmk7SrKLEzcVq4cm/JbYwv5kALkGjQWwD/D1s18DA20LQNK67bDweu1BDOEO9pQggr8AP3tXqwGEUYqDAcNsxwlw+9X3k6E8do0YnyFNOsb3QqVFKUbjtOyn73Bz7W4pKUqywrF7xPgEGBOsKIRxRHPU42DQgGvHg20tLbkhcQ48jVi3+YHdYMS2Yj99A0+d2bIkt1qqcFwBLdsUQJMROSGH0Tfw23K5c4U2scv/MM+B23WK2pYkz5w9W114+3NSG7pao4vCvyQi0SGNzdmzawXnOp0CoNVSFarivAmPkWckfcXUiOL/j/nvBp6CNWqAJ7Cbjun87nYa+KIvojdqtIJoQlor99fAFXTvL0+T3umZ5AqO/It6v3Jg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6nJg6v4ARcjZVhf1qQsAAAAASUVORK5CYII=", + icon_user: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABQ0lEQVR4nO1Z2w0CIRBEYwcWYmnWYmkWYg36YUgI2YV9zKIcTOKPBzO77IM7SGljY22c0IS3++vdev58XKGaELKe0RwQzpgJOKN7RlnncTBNqo2wiiN41BNKUVQ+ezjFgyMMR2ico4gtKLmljUHkACUQBXgK5ZXQEnsLVKorankaceRGJtFnU8iyOdW1Uv5QvKpJmonS8WheVRFPg6jVjxg/fQSO54C170ci20KlESQCLYEa6AU6XgpZUUaBikT5PzI9LyiilL6GZSPRX14coA6kxNdDVFOAO5AxqotNX8QktFt9NParxJT4lzQ6/PdA14FfRkGizTrA9fGo1GrxuvYUihjtRLhGpICXW1XE1BGj1RHu7VTL5T7c1b60tcZbzmAhx+veG5ohx+slpKtIwRM9CmteMdWY8pKPw+hr1o2N1fEB0r9JGMUsFy4AAAAASUVORK5CYII=", + icon_lock: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAA30lEQVR4nO2Y3Q2DMAyE3aobdBBGYxZG6yDMQF8t5IgkviNKe98jEPsuzi9mQggh/pkHI+iy7kf0/LO94fmgAUvCzyCNwAJ58SWBNd+08kQEqRXm39VW64q0gdZeRZuAVMCsbUgg5wDMwJQs635khkG2vdkPVGB6A2I0qeUMtRllltWuhijhZ3qMvO5OGMHqkGIyRsLeuNMvozIwGqoB1nzx0Ax44UwTFAORYJYJzYGIaINj/FIxI1bAC2aJN9MQGo8MjCZ1nL6COXlToI8ImXipCtx6ESkw/Z1YCCFEii9K4GqxBXxqhgAAAABJRU5ErkJggg==", + icon_shield: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABOElEQVR4nO1Z2w3CMAw0iA0YhNGYpaMxCDPAV6UqivzInZOCfJ9t4tw5bn1pRQqFQkHD4/n+ZMa/ZAXuEX9td/p61IAt6SNh7R4CSpAjOQ+x6HgN0GSUCEPI0CRmBtF47sFZNYyuoQ6YQRpd++YJNpN4u6bVR65z6OShBKzGcgGoV1oqYCePiFgmgNUMlwhgdvKlJcToL9MFsA84UwWwTaCIU4CVNU9WR8h74qoCtBNVe11bDCWvzTF3wBJh3c8kL+IsIVSEh0hvvmeO+yG2gvVEIG8cr+Cht5Any1kPbYuQgJFSySqdHeEdiIjIJi8CfFZhNSU0znAn9uyEBUYSICuBiDiNnR4RcTo7HRHBNnQ0NxolM9WNRmEZPyaoArRSyjgLiCTsgOWJ2J8pf/4XUyoYrrRQKPw5vti4AQNfqUCeAAAAAElFTkSuQmCC", +} as const; diff --git a/api/mail.ts b/api/mail.ts index e31f61f..2c15faa 100644 --- a/api/mail.ts +++ b/api/mail.ts @@ -1,11 +1,21 @@ +import { join } from "node:path"; import type { PlatformDb } from "./platform_db.ts"; -import { config } from "./config.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 { @@ -22,6 +32,14 @@ export async function sendMail( } 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 ", + }; + } try { const nodemailer = await import("npm:nodemailer@6.9.16"); const transporter = nodemailer.createTransport({ @@ -34,16 +52,23 @@ export async function sendMail( greetingTimeout: 20000, socketTimeout: 30000, tls: { - // Algunos hosting (cPanel) usan certs que fallan la validación estricta en Deno rejectUnauthorized: false, servername: s.host, }, }); const info = await transporter.sendMail({ - from: s.from_address, + 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}`); @@ -77,24 +102,197 @@ Destinatario: ${dest} }); } -export function accessEmailBody(opts: { +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export type AccessEmailOpts = { companyName: string; username: string; password: string; contactName?: string; -}): string { - const hi = opts.contactName ? `Hola ${opts.contactName},` : "Hola,"; +}; + +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} -Su empresa "${opts.companyName}" ya tiene acceso a PANELS. +Tu acceso a PANELS está listo. -Usuario (administrador): ${opts.username} +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} -Inicie sesión en: ${config.panelLoginUrl} +Ingresar: ${config.panelLoginUrl} -Por seguridad, al entrar por primera vez deberá cambiar la contraseña. +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 = + `PANELS`; + + const watermark = ` + + + + + + + + + +
 
 
 
 
`; + + const footerMark = ` + + + + + + + + + +
 
 
 
 
`; + + return ` + + + + + Tu acceso a PANELS está listo + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
${mark}PANELS${watermark}
+
+

${hi}

+

Tu acceso a PANELS está listo

+

La empresa ${company} ya tiene acceso a PANELS. Utiliza los siguientes datos para iniciar sesión:

+ + + + + + + + + + + +
+ + + + + + + +
👤Usuario administrador|${username}
+
+
 
+
+ + + + + + + +
🔒Contraseña temporal|${password}
+
+ + + + + +
+ Ingresar a PANELS +
+

+ ${loginUrl} +

+ + + + + + +
🛡Por seguridad, al ingresar por primera vez deberás crear una nueva contraseña.
+
+ + + + + +
+
— Equipo PANELS
+
Todas tus operaciones. Un solo sistema.
+
${footerMark}
+
+ +

Este correo fue enviado automáticamente. No respondas a este mensaje.

+
+ +`; +} diff --git a/api/saas.ts b/api/saas.ts index a95b783..f7dd69e 100644 --- a/api/saas.ts +++ b/api/saas.ts @@ -9,7 +9,7 @@ import { type PlatformDb, } from "./platform_db.ts"; import { companyByCode, normCompanyCode, normalizeCompanyProfile, validateCompanyProfile, type CompanyProfileInput } from "./companies.ts"; -import { accessEmailBody, sendMail } from "./mail.ts"; +import { accessEmailAttachments, accessEmailBody, accessEmailHtml, sendMail } from "./mail.ts"; import { config } from "./config.ts"; export type CreateTenantInput = CompanyProfileInput & { @@ -549,13 +549,20 @@ async function sendAccessEmail( ): Promise<{ sent: boolean; error?: string }> { const result = await sendMail(platformDb, { to: opts.contactEmail, - subject: `Acceso a PANELS — ${opts.companyName}`, + subject: `Tu acceso a PANELS está listo — ${opts.companyName}`, text: accessEmailBody({ companyName: opts.companyName, username: opts.username, password: opts.password, contactName: opts.contactName, }), + html: accessEmailHtml({ + companyName: opts.companyName, + username: opts.username, + password: opts.password, + contactName: opts.contactName, + }), + attachments: accessEmailAttachments(), }); if (result.sent) { platformDb.prepare( diff --git a/api/smtp.ts b/api/smtp.ts index 51060fd..19baf48 100644 --- a/api/smtp.ts +++ b/api/smtp.ts @@ -110,6 +110,11 @@ export type SaveSmtpInput = { keep_password?: boolean; }; +function looksLikeEmailFrom(value: string): boolean { + // Acepta "user@dom.com" o "Nombre " + return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value); +} + export function saveSmtpSettings( platformDb: PlatformDb, input: SaveSmtpInput, @@ -125,6 +130,11 @@ export function saveSmtpSettings( const enabled = input.enabled !== false; if (enabled && !host) return { error: "Host SMTP obligatorio si está habilitado" }; if (enabled && !fromAddress) return { error: "Remitente (From) obligatorio si está habilitado" }; + if (enabled && fromAddress && !looksLikeEmailFrom(fromAddress)) { + return { + error: 'Remitente inválido. Use un correo, ej. PANELS ', + }; + } const current = readRow(platformDb); let password = input.password ?? ""; diff --git a/docs/mockups/panels-access-email.html b/docs/mockups/panels-access-email.html new file mode 100644 index 0000000..8cec347 --- /dev/null +++ b/docs/mockups/panels-access-email.html @@ -0,0 +1,56 @@ + + + + + + +Acceso a PANELS — {{company_name}} + + + +
Tu acceso de administrador a PANELS está listo.
+ +
+ + + + + + + + +
+ + +