From 798a7a8140314975152aabe47af22b52b4f358b2 Mon Sep 17 00:00:00 2001 From: wheval Date: Sat, 30 May 2026 13:12:48 +0100 Subject: [PATCH] fix: add shared Nodemailer SMTP service for backend email delivery Centralize SMTP transport setup and wire admin password resets, notification emails, and hot-wallet alerts through it. Closes ceejaylaboratory/AnchorPoint#364 --- backend/.env.example | 8 ++ backend/README.md | 10 ++ backend/src/config/env.ts | 2 - backend/src/index.ts | 4 +- backend/src/lib/notifications/providers.ts | 17 +++ backend/src/lib/smtp.service.test.ts | 127 ++++++++++++++++++ backend/src/lib/smtp.service.ts | 83 ++++++++++++ backend/src/services/admin-email.service.ts | 29 +--- .../services/hot-wallet-monitor.service.ts | 30 +++-- 9 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 backend/src/lib/smtp.service.test.ts create mode 100644 backend/src/lib/smtp.service.ts diff --git a/backend/.env.example b/backend/.env.example index 624aed9..a93798f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -92,6 +92,14 @@ REDIS_URL=redis://localhost:6379 # ALERT_WEBHOOK_URL=https://your-webhook.com/alerts # ALERT_EMAIL_RECIPIENTS=admin@example.com +# SMTP (optional — logs emails in development when unset) +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=your-smtp-user +# SMTP_PASS=your-smtp-password +# SMTP_FROM=noreply@example.com +# ADMIN_PASSWORD_RESET_URL_BASE=http://localhost:3000/admin/reset-password + # Feature Flags # FEATURE_FLAG_MULTISIG=true # FEATURE_FLAG_BATCH_PAYMENTS=true diff --git a/backend/README.md b/backend/README.md index 3c6280e..f308842 100644 --- a/backend/README.md +++ b/backend/README.md @@ -99,3 +99,13 @@ Optional SMTP environment variables: - `SMTP_PASS` - `SMTP_FROM` - `ADMIN_PASSWORD_RESET_URL_BASE` + +When SMTP is not configured, emails are logged locally for development instead of being sent. + +## SMTP Integration +The backend uses a shared Nodemailer transport (`src/lib/smtp.service.ts`) for: +- Admin password reset emails +- User notification emails (when SMTP is configured) +- Hot wallet low-balance alerts + +Configure the SMTP variables above to enable delivery in staging or production. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index d1e9d88..abd188a 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -34,13 +34,11 @@ const envSchema = z.object({ .pipe(z.number().int().min(0)), STELLAR_NETWORK: z.enum(['testnet', 'public', 'futurenet']).default('testnet'), RECURRING_PAYMENTS_WORKER_CRON: z.string().default('*/1 * * * *'), - STELLAR_NETWORK: z.enum(['testnet', 'public']).default('testnet'), STELLAR_NETWORK_PASSPHRASE: z .string() .default('Test SDF Network ; September 2015'), STELLAR_HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), HORIZON_URL: z.string().url().default('https://horizon-testnet.stellar.org'), - STELLAR_NETWORK_PASSPHRASE: z.string().default('Test SDF Network ; September 2015'), STELLAR_FEE_BUMP_SECRET: z.string().optional(), STELLAR_DISTRIBUTION_SECRET: z.string().optional(), STELLAR_BASE_FEE: z.string().default('100'), diff --git a/backend/src/index.ts b/backend/src/index.ts index abee4a6..26d07e2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,11 +27,11 @@ import { errorHandler } from './api/middleware/error.middleware'; import { metricsMiddleware, connectionTracker } from './api/middleware/metrics.middleware'; import { publicLimiter } from './api/middleware/rate-limit.middleware'; import { notificationService } from './services/notification.service'; -import { ConsoleEmailProvider, ConsoleSmsProvider, ConsolePushProvider } from './lib/notifications/providers'; +import { createEmailProvider, ConsoleSmsProvider, ConsolePushProvider } from './lib/notifications/providers'; import { NotificationType } from '@prisma/client'; // Initialize Notification Engine -notificationService.registerProvider(NotificationType.EMAIL, new ConsoleEmailProvider()); +notificationService.registerProvider(NotificationType.EMAIL, createEmailProvider()); notificationService.registerProvider(NotificationType.SMS, new ConsoleSmsProvider()); notificationService.registerProvider(NotificationType.PUSH, new ConsolePushProvider()); diff --git a/backend/src/lib/notifications/providers.ts b/backend/src/lib/notifications/providers.ts index f33e429..6d77d93 100644 --- a/backend/src/lib/notifications/providers.ts +++ b/backend/src/lib/notifications/providers.ts @@ -1,6 +1,17 @@ import { NotificationProvider } from "../../services/notification.service"; +import { smtpService } from "../smtp.service"; import logger from "../../utils/logger"; +export class SmtpEmailProvider implements NotificationProvider { + async send(to: string, message: string): Promise { + return smtpService.sendMail({ + to, + subject: "AnchorPoint Notification", + text: message, + }); + } +} + export class ConsoleEmailProvider implements NotificationProvider { async send(to: string, message: string): Promise { logger.info(`[MOCK EMAIL] To: ${to} | Message: ${message}`); @@ -21,3 +32,9 @@ export class ConsolePushProvider implements NotificationProvider { return true; } } + +export function createEmailProvider(): NotificationProvider { + return smtpService.isConfigured() + ? new SmtpEmailProvider() + : new ConsoleEmailProvider(); +} diff --git a/backend/src/lib/smtp.service.test.ts b/backend/src/lib/smtp.service.test.ts new file mode 100644 index 0000000..0f7e903 --- /dev/null +++ b/backend/src/lib/smtp.service.test.ts @@ -0,0 +1,127 @@ +const mockConfig = { + SMTP_HOST: undefined as string | undefined, + SMTP_PORT: undefined as number | undefined, + SMTP_USER: undefined as string | undefined, + SMTP_PASS: undefined as string | undefined, + SMTP_FROM: undefined as string | undefined, +}; + +jest.mock('../config/env', () => ({ + config: mockConfig, +})); + +jest.mock('nodemailer'); +jest.mock('../utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +import nodemailer from 'nodemailer'; +import logger from '../utils/logger'; +import { SmtpService } from './smtp.service'; + +const mockedNodemailer = nodemailer as jest.Mocked; +const sendMail = jest.fn(); + +describe('SmtpService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (SmtpService as any).instance = undefined; + mockedNodemailer.createTransport.mockReturnValue({ sendMail } as any); + sendMail.mockResolvedValue({ messageId: 'test-id' }); + + mockConfig.SMTP_HOST = undefined; + mockConfig.SMTP_PORT = undefined; + mockConfig.SMTP_USER = undefined; + mockConfig.SMTP_PASS = undefined; + mockConfig.SMTP_FROM = undefined; + }); + + it('reports unconfigured when required SMTP env vars are missing', () => { + const service = SmtpService.getInstance(); + + expect(service.isConfigured()).toBe(false); + }); + + it('logs and skips delivery when SMTP is not configured', async () => { + const service = SmtpService.getInstance(); + + const sent = await service.sendMail({ + to: 'user@example.com', + subject: 'Test', + text: 'Hello', + }); + + expect(sent).toBe(false); + expect(logger.info).toHaveBeenCalledWith( + 'SMTP not configured; email logged for development', + expect.objectContaining({ + to: 'user@example.com', + subject: 'Test', + }) + ); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it('sends mail when SMTP is configured', async () => { + mockConfig.SMTP_HOST = 'smtp.example.com'; + mockConfig.SMTP_PORT = 587; + mockConfig.SMTP_FROM = 'noreply@example.com'; + mockConfig.SMTP_USER = 'smtp-user'; + mockConfig.SMTP_PASS = 'smtp-pass'; + + const service = SmtpService.getInstance(); + expect(service.isConfigured()).toBe(true); + + const sent = await service.sendMail({ + to: 'user@example.com', + subject: 'AnchorPoint Alert', + text: 'Balance is low', + }); + + expect(sent).toBe(true); + expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ + host: 'smtp.example.com', + port: 587, + secure: false, + auth: { + user: 'smtp-user', + pass: 'smtp-pass', + }, + }); + expect(sendMail).toHaveBeenCalledWith({ + from: 'noreply@example.com', + to: 'user@example.com', + subject: 'AnchorPoint Alert', + text: 'Balance is low', + html: undefined, + }); + }); + + it('logs and rethrows when SMTP delivery fails', async () => { + mockConfig.SMTP_HOST = 'smtp.example.com'; + mockConfig.SMTP_PORT = 465; + mockConfig.SMTP_FROM = 'noreply@example.com'; + sendMail.mockRejectedValueOnce(new Error('SMTP unavailable')); + + const service = SmtpService.getInstance(); + + await expect( + service.sendMail({ + to: 'user@example.com', + subject: 'Failure', + text: 'Hello', + }) + ).rejects.toThrow('SMTP unavailable'); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to send email via SMTP', + expect.objectContaining({ + to: 'user@example.com', + subject: 'Failure', + }) + ); + }); +}); diff --git a/backend/src/lib/smtp.service.ts b/backend/src/lib/smtp.service.ts new file mode 100644 index 0000000..3d00d60 --- /dev/null +++ b/backend/src/lib/smtp.service.ts @@ -0,0 +1,83 @@ +import nodemailer, { Transporter } from 'nodemailer'; + +import { config } from '../config/env'; +import logger from '../utils/logger'; + +export interface SendMailOptions { + to: string | string[]; + subject: string; + text?: string; + html?: string; +} + +export class SmtpService { + private static instance: SmtpService; + private transporter: Transporter | null = null; + + private constructor() {} + + static getInstance(): SmtpService { + if (!SmtpService.instance) { + SmtpService.instance = new SmtpService(); + } + return SmtpService.instance; + } + + isConfigured(): boolean { + return Boolean(config.SMTP_HOST && config.SMTP_PORT && config.SMTP_FROM); + } + + private getTransporter(): Transporter { + if (!this.transporter) { + this.transporter = nodemailer.createTransport({ + host: config.SMTP_HOST, + port: config.SMTP_PORT, + secure: config.SMTP_PORT === 465, + auth: + config.SMTP_USER && config.SMTP_PASS + ? { + user: config.SMTP_USER, + pass: config.SMTP_PASS, + } + : undefined, + }); + } + + return this.transporter; + } + + async sendMail(options: SendMailOptions): Promise { + if (!this.isConfigured()) { + logger.info('SMTP not configured; email logged for development', { + to: options.to, + subject: options.subject, + }); + return false; + } + + try { + await this.getTransporter().sendMail({ + from: config.SMTP_FROM, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }); + + logger.info('Email sent via SMTP', { + to: options.to, + subject: options.subject, + }); + return true; + } catch (error) { + logger.error('Failed to send email via SMTP', { + to: options.to, + subject: options.subject, + error, + }); + throw error; + } + } +} + +export const smtpService = SmtpService.getInstance(); diff --git a/backend/src/services/admin-email.service.ts b/backend/src/services/admin-email.service.ts index 230d504..7af83e9 100644 --- a/backend/src/services/admin-email.service.ts +++ b/backend/src/services/admin-email.service.ts @@ -1,7 +1,5 @@ -import nodemailer from 'nodemailer'; - import { config } from '../config/env'; -import logger from '../utils/logger'; +import { smtpService } from '../lib/smtp.service'; export interface PasswordResetEmailInput { to: string; @@ -17,30 +15,7 @@ export class SmtpAdminEmailService implements AdminEmailService { async sendPasswordResetEmail(input: PasswordResetEmailInput): Promise { const resetUrl = `${config.ADMIN_PASSWORD_RESET_URL_BASE}?token=${encodeURIComponent(input.token)}`; - if (!config.SMTP_HOST || !config.SMTP_PORT || !config.SMTP_FROM) { - logger.info('SMTP not configured; password reset email logged for development', { - to: input.to, - resetUrl, - expiresAt: input.expiresAt.toISOString(), - }); - return; - } - - const transporter = nodemailer.createTransport({ - host: config.SMTP_HOST, - port: config.SMTP_PORT, - secure: config.SMTP_PORT === 465, - auth: - config.SMTP_USER && config.SMTP_PASS - ? { - user: config.SMTP_USER, - pass: config.SMTP_PASS, - } - : undefined, - }); - - await transporter.sendMail({ - from: config.SMTP_FROM, + await smtpService.sendMail({ to: input.to, subject: 'AnchorPoint Admin Password Reset', text: [ diff --git a/backend/src/services/hot-wallet-monitor.service.ts b/backend/src/services/hot-wallet-monitor.service.ts index 531616f..f989146 100644 --- a/backend/src/services/hot-wallet-monitor.service.ts +++ b/backend/src/services/hot-wallet-monitor.service.ts @@ -2,6 +2,7 @@ import { Horizon } from '@stellar/stellar-sdk'; import { stellarService } from './stellar.service'; import { metricsService } from './metrics.service'; import { redis } from '../lib/redis'; +import { smtpService } from '../lib/smtp.service'; import logger from '../utils/logger'; import promClient, { Gauge } from 'prom-client'; @@ -302,20 +303,25 @@ export class HotWalletMonitorService { } private async sendEmailAlert(recipients: string, alert: AlertPayload): Promise { - // This project has no email transport wired up yet. - // Log the intent so an operator / future SMTP integration can act on it. - logger.warn('[HotWalletMonitor] Email alert (no transport configured — log only)', { - recipients, - alert, + const text = [ + 'Hot Wallet Low Balance Alert', + `Wallet: ${alert.walletLabel}`, + `Asset: ${alert.assetCode}`, + `Current Balance: ${alert.currentBalance}`, + `Threshold: ${alert.thresholdAmount}`, + `Public Key: ${alert.publicKey}`, + `Detected At: ${alert.checkedAt}`, + ].join('\n'); + + const sent = await smtpService.sendMail({ + to: recipients.split(',').map((recipient) => recipient.trim()).filter(Boolean), + subject: `[AnchorPoint] Low Balance: ${alert.walletLabel}`, + text, }); - // TODO: wire up nodemailer / SendGrid / SES here when an SMTP service is added. - // Example: - // await mailer.sendMail({ - // to: recipients, - // subject: `[AnchorPoint] Low Balance: ${alert.walletLabel}`, - // text: formatEmailBody(alert), - // }); + if (sent) { + logger.info('[HotWalletMonitor] Email alert sent', { wallet: alert.walletLabel, recipients }); + } } private async sendCustomWebhook(url: string, alert: AlertPayload): Promise {