feat: add email service with Nodemailer and 6 HTML templates

EmailService with mock fallback when SMTP not configured.
Templates: welcome, fiel-notification, payment-confirmed,
payment-failed, subscription-expiring, subscription-cancelled.
Uses Google Workspace SMTP (STARTTLS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-03-15 23:34:49 +00:00
parent bcabbd4959
commit 6fc81b1c0d
10 changed files with 220 additions and 0 deletions

View File

@@ -30,6 +30,7 @@
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pg": "^8.18.0",
"zod": "^3.23.0"
},
@@ -42,6 +43,7 @@
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"prisma": "^5.22.0",
"tsx": "^4.19.0",

View File

@@ -0,0 +1,79 @@
import { createTransport, type Transporter } from 'nodemailer';
import { env } from '../../config/env.js';
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (!transporter) {
if (!env.SMTP_USER || !env.SMTP_PASS) {
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
return {
sendMail: async (opts: any) => {
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
return { messageId: 'mock' };
},
} as any;
}
transporter = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false, // STARTTLS
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
}
return transporter;
}
async function sendEmail(to: string, subject: string, html: string) {
const transport = getTransporter();
try {
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject,
html,
text: html.replace(/<[^>]*>/g, ''),
});
} catch (error) {
console.error('[EMAIL] Error sending email:', error);
// Don't throw — email failure shouldn't break the main flow
}
}
export const emailService = {
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
const { welcomeEmail } = await import('./templates/welcome.js');
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
},
sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
const { fielNotificationEmail } = await import('./templates/fiel-notification.js');
await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
},
sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js');
await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
},
sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
const { paymentFailedEmail } = await import('./templates/payment-failed.js');
await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
},
sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js');
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
},
};

View File

@@ -0,0 +1,35 @@
export function baseTemplate(content: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background-color:#1e293b;padding:24px 32px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;">Horux360</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
${content}
</td>
</tr>
<tr>
<td style="background-color:#f8fafc;padding:16px 32px;text-align:center;font-size:12px;color:#94a3b8;">
<p style="margin:0;">&copy; ${new Date().getFullYear()} Horux360 - Plataforma Fiscal Inteligente</p>
<p style="margin:4px 0 0;">Consultoria Alcaraz Salazar</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">FIEL Subida</h2>
<p style="color:#475569;line-height:1.6;">El cliente <strong>${data.clienteNombre}</strong> ha subido su e.firma (FIEL).</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Empresa:</strong> ${data.clienteNombre}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>RFC:</strong> ${data.clienteRfc}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${new Date().toLocaleString('es-MX')}</p>
</div>
<p style="color:#475569;line-height:1.6;">Ya puedes iniciar la sincronización de CFDIs para este cliente.</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Pago Confirmado</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Hemos recibido tu pago correctamente.</p>
<div style="background-color:#f0fdf4;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #22c55e;">
<p style="margin:0;color:#334155;"><strong>Monto:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${data.date}</p>
</div>
<p style="color:#475569;line-height:1.6;">Tu suscripción está activa. Gracias por confiar en Horux360.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Problema con tu Pago</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">No pudimos procesar tu pago. Por favor verifica tu método de pago.</p>
<div style="background-color:#fef2f2;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #ef4444;">
<p style="margin:0;color:#334155;"><strong>Monto pendiente:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
</div>
<p style="color:#475569;line-height:1.6;">Si necesitas ayuda, contacta a soporte respondiendo a este correo.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Suscripción Cancelada</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> ha sido cancelada.</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;">Tu acceso continuará activo hasta el final del período actual de facturación.</p>
<p style="margin:8px 0 0;color:#334155;">Después de eso, solo tendrás acceso de lectura a tus datos.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si deseas reactivar tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,13 @@
import { baseTemplate } from './base.js';
export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Tu Suscripción Vence Pronto</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> vence el <strong>${data.expiresAt}</strong>.</p>
<div style="background-color:#fffbeb;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #f59e0b;">
<p style="margin:0;color:#334155;">Para evitar interrupciones en el servicio, asegúrate de que tu método de pago esté actualizado.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si tienes alguna pregunta sobre tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Bienvenido a Horux360</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Email:</strong> ${data.email}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Contraseña temporal:</strong> ${data.tempPassword}</p>
</div>
<p style="color:#475569;line-height:1.6;">Te recomendamos cambiar tu contraseña después de iniciar sesión.</p>
<a href="https://horux360.consultoria-as.com/login" style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:16px;">Iniciar sesión</a>
`);
}