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:
@@ -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",
|
||||
|
||||
79
apps/api/src/services/email/email.service.ts
Normal file
79
apps/api/src/services/email/email.service.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
35
apps/api/src/services/email/templates/base.ts
Normal file
35
apps/api/src/services/email/templates/base.ts
Normal 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;">© ${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>`;
|
||||
}
|
||||
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
14
apps/api/src/services/email/templates/payment-failed.ts
Normal file
14
apps/api/src/services/email/templates/payment-failed.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
@@ -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>
|
||||
`);
|
||||
}
|
||||
@@ -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>
|
||||
`);
|
||||
}
|
||||
15
apps/api/src/services/email/templates/welcome.ts
Normal file
15
apps/api/src/services/email/templates/welcome.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -68,6 +68,9 @@ importers:
|
||||
node-forge:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
nodemailer:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
pg:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
@@ -99,6 +102,9 @@ importers:
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.14
|
||||
version: 1.3.14
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.11
|
||||
version: 7.0.11
|
||||
'@types/pg':
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
@@ -1109,6 +1115,9 @@ packages:
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
@@ -1961,6 +1970,10 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
nodemailer@8.0.2:
|
||||
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3376,6 +3389,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/nodemailer@7.0.11':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/pg@8.18.0':
|
||||
@@ -4277,6 +4294,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nodemailer@8.0.2: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
Reference in New Issue
Block a user