diff --git a/backend/.env.example b/backend/.env.example index 9294abe..b9db779 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -104,6 +104,14 @@ REDIS_URL=redis://localhost:6379 # SMTP_PASS= # SMTP_FROM=alerts@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 cab3df3..4c4bf63 100644 --- a/backend/README.md +++ b/backend/README.md @@ -100,4 +100,13 @@ Optional SMTP environment variables: - `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. When SMTP is configured, the backend also sends HTML alert emails (for example hot-wallet low-balance notifications) using the same transport. Set `ALERT_EMAIL_RECIPIENTS` to a comma-separated list of operator addresses. If SMTP is not configured, alert content is logged at `info` level for local development. diff --git a/backend/src/index.ts b/backend/src/index.ts index 755da48..6b3ae7b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,12 +25,12 @@ import eventRouter from './api/routes/event.route'; import notificationsRouter from './api/routes/notifications.route'; 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'; import { validateKmsConfigOnStartup } from './lib/key-management.service'; // 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 ce7c131..9c880e3 100644 --- a/backend/src/services/admin-email.service.ts +++ b/backend/src/services/admin-email.service.ts @@ -1,4 +1,5 @@ import { config } from '../config/env'; +import { smtpService } from '../lib/smtp.service'; import { createSmtpTransporter, isSmtpConfigured } from '../lib/smtp/create-transporter'; import logger from '../utils/logger'; @@ -16,6 +17,7 @@ export class SmtpAdminEmailService implements AdminEmailService { async sendPasswordResetEmail(input: PasswordResetEmailInput): Promise { const resetUrl = `${config.ADMIN_PASSWORD_RESET_URL_BASE}?token=${encodeURIComponent(input.token)}`; + await smtpService.sendMail({ if (!isSmtpConfigured()) { logger.info('SMTP not configured; password reset email logged for development', { to: input.to, diff --git a/backend/src/services/hot-wallet-monitor.service.ts b/backend/src/services/hot-wallet-monitor.service.ts index 3b02539..941e9ce 100644 --- a/backend/src/services/hot-wallet-monitor.service.ts +++ b/backend/src/services/hot-wallet-monitor.service.ts @@ -1,6 +1,7 @@ import { stellarService } from './stellar.service'; import { metricsService } from './metrics.service'; import { redis } from '../lib/redis'; +import { smtpService } from '../lib/smtp.service'; import type { AlertPayload } from '../types/alerts'; import logger from '../utils/logger'; import promClient, { Gauge } from 'prom-client'; @@ -301,6 +302,25 @@ export class HotWalletMonitorService { } private async sendEmailAlert(recipients: string, alert: AlertPayload): Promise { + 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, + }); + + if (sent) { + logger.info('[HotWalletMonitor] Email alert sent', { wallet: alert.walletLabel, recipients }); + } await this.alertEmailService.sendHotWalletLowBalanceAlert(recipients, alert); logger.info('[HotWalletMonitor] Email alert dispatched', { wallet: alert.walletLabel }); }