Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
17 changes: 17 additions & 0 deletions backend/src/lib/notifications/providers.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return smtpService.sendMail({
to,
subject: "AnchorPoint Notification",
text: message,
});
}
}

export class ConsoleEmailProvider implements NotificationProvider {
async send(to: string, message: string): Promise<boolean> {
logger.info(`[MOCK EMAIL] To: ${to} | Message: ${message}`);
Expand All @@ -21,3 +32,9 @@ export class ConsolePushProvider implements NotificationProvider {
return true;
}
}

export function createEmailProvider(): NotificationProvider {
return smtpService.isConfigured()
? new SmtpEmailProvider()
: new ConsoleEmailProvider();
}
127 changes: 127 additions & 0 deletions backend/src/lib/smtp.service.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof nodemailer>;
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',
})
);
});
});
83 changes: 83 additions & 0 deletions backend/src/lib/smtp.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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();
2 changes: 2 additions & 0 deletions backend/src/services/admin-email.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,6 +17,7 @@ export class SmtpAdminEmailService implements AdminEmailService {
async sendPasswordResetEmail(input: PasswordResetEmailInput): Promise<void> {
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,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/services/hot-wallet-monitor.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -301,6 +302,25 @@ export class HotWalletMonitorService {
}

private async sendEmailAlert(recipients: string, alert: AlertPayload): Promise<void> {
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 });
}
Expand Down
Loading