diff --git a/apps/api/package.json b/apps/api/package.json index 1830326..ba0f0ab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts new file mode 100644 index 0000000..d5e4186 --- /dev/null +++ b/apps/api/src/services/email/email.service.ts @@ -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)); + }, +}; diff --git a/apps/api/src/services/email/templates/base.ts b/apps/api/src/services/email/templates/base.ts new file mode 100644 index 0000000..156989d --- /dev/null +++ b/apps/api/src/services/email/templates/base.ts @@ -0,0 +1,35 @@ +export function baseTemplate(content: string): string { + return ` + +
+ + + + +| + + | +
El cliente ${data.clienteNombre} ha subido su e.firma (FIEL).
+Empresa: ${data.clienteNombre}
+RFC: ${data.clienteRfc}
+Fecha: ${new Date().toLocaleString('es-MX')}
+Ya puedes iniciar la sincronización de CFDIs para este cliente.
+ `); +} diff --git a/apps/api/src/services/email/templates/payment-confirmed.ts b/apps/api/src/services/email/templates/payment-confirmed.ts new file mode 100644 index 0000000..e612620 --- /dev/null +++ b/apps/api/src/services/email/templates/payment-confirmed.ts @@ -0,0 +1,15 @@ +import { baseTemplate } from './base.js'; + +export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string { + return baseTemplate(` +Hola ${data.nombre},
+Hemos recibido tu pago correctamente.
+Monto: $${data.amount.toLocaleString('es-MX')} MXN
+Plan: ${data.plan}
+Fecha: ${data.date}
+Tu suscripción está activa. Gracias por confiar en Horux360.
+ `); +} diff --git a/apps/api/src/services/email/templates/payment-failed.ts b/apps/api/src/services/email/templates/payment-failed.ts new file mode 100644 index 0000000..5ba7666 --- /dev/null +++ b/apps/api/src/services/email/templates/payment-failed.ts @@ -0,0 +1,14 @@ +import { baseTemplate } from './base.js'; + +export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string { + return baseTemplate(` +Hola ${data.nombre},
+No pudimos procesar tu pago. Por favor verifica tu método de pago.
+Monto pendiente: $${data.amount.toLocaleString('es-MX')} MXN
+Plan: ${data.plan}
+Si necesitas ayuda, contacta a soporte respondiendo a este correo.
+ `); +} diff --git a/apps/api/src/services/email/templates/subscription-cancelled.ts b/apps/api/src/services/email/templates/subscription-cancelled.ts new file mode 100644 index 0000000..ae9ad71 --- /dev/null +++ b/apps/api/src/services/email/templates/subscription-cancelled.ts @@ -0,0 +1,14 @@ +import { baseTemplate } from './base.js'; + +export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string { + return baseTemplate(` +Hola ${data.nombre},
+Tu suscripción al plan ${data.plan} ha sido cancelada.
+Tu acceso continuará activo hasta el final del período actual de facturación.
+Después de eso, solo tendrás acceso de lectura a tus datos.
+Si deseas reactivar tu suscripción, contacta a soporte.
+ `); +} diff --git a/apps/api/src/services/email/templates/subscription-expiring.ts b/apps/api/src/services/email/templates/subscription-expiring.ts new file mode 100644 index 0000000..adec334 --- /dev/null +++ b/apps/api/src/services/email/templates/subscription-expiring.ts @@ -0,0 +1,13 @@ +import { baseTemplate } from './base.js'; + +export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string { + return baseTemplate(` +Hola ${data.nombre},
+Tu suscripción al plan ${data.plan} vence el ${data.expiresAt}.
+Para evitar interrupciones en el servicio, asegúrate de que tu método de pago esté actualizado.
+Si tienes alguna pregunta sobre tu suscripción, contacta a soporte.
+ `); +} diff --git a/apps/api/src/services/email/templates/welcome.ts b/apps/api/src/services/email/templates/welcome.ts new file mode 100644 index 0000000..bde490c --- /dev/null +++ b/apps/api/src/services/email/templates/welcome.ts @@ -0,0 +1,15 @@ +import { baseTemplate } from './base.js'; + +export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string { + return baseTemplate(` +Hola ${data.nombre},
+Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:
+Email: ${data.email}
+Contraseña temporal: ${data.tempPassword}
+Te recomendamos cambiar tu contraseña después de iniciar sesión.
+ Iniciar sesión + `); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 243e083..ebae2a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}