From 2ca39e96ecac62991246ddfc41f4d19b08c13b12 Mon Sep 17 00:00:00 2001 From: josunday002 Date: Wed, 29 Jul 2026 20:05:40 +0100 Subject: [PATCH] feat(mailer): pluggable email mailer with prefs, templates & failure isolation Replaces token-logging password reset and no-op subscriber prefs with a real MailerModule: MailerPort interface, ConsoleMailerAdapter (dev, redacts in production-like NODE_ENV) and SmtpMailerAdapter stub, selected via MAILER_DRIVER=console|smtp. MailerService wraps sends with a timeout, in-memory outbox, and metrics stub, and never throws so mail failures can't fail the forgot-password or subscribe HTTP responses. Reset and new-subscriber flows now send HTML+text templates with escaped user-controlled fields; new-subscriber mail is gated on preferences.newSubscriber. --- .env.example | 15 ++ src/auth/auth.module.ts | 2 + src/auth/auth.service.ts | 20 ++- src/auth/password-reset.spec.ts | 48 ++++++- src/config/env.validation.ts | 8 ++ .../adapters/console-mailer.adapter.spec.ts | 51 +++++++ src/mailer/adapters/console-mailer.adapter.ts | 26 ++++ src/mailer/adapters/smtp-mailer.adapter.ts | 82 +++++++++++ src/mailer/email-outbox.port.ts | 21 +++ src/mailer/email-outbox.service.ts | 24 ++++ src/mailer/mailer-metrics.service.ts | 52 +++++++ src/mailer/mailer.module.spec.ts | 42 ++++++ src/mailer/mailer.module.ts | 61 ++++++++ src/mailer/mailer.port.ts | 27 ++++ src/mailer/mailer.service.spec.ts | 131 +++++++++++++++++ src/mailer/mailer.service.ts | 133 ++++++++++++++++++ src/mailer/mailer.util.spec.ts | 35 +++++ src/mailer/mailer.util.ts | 29 ++++ src/mailer/templates/email-content.type.ts | 5 + .../templates/new-subscriber.template.ts | 32 +++++ .../templates/reset-password.template.ts | 35 +++++ src/mailer/templates/templates.spec.ts | 49 +++++++ src/subscriptions/subscriptions.module.ts | 8 +- .../subscriptions.service.spec.ts | 109 ++++++++++++++ src/subscriptions/subscriptions.service.ts | 32 +++++ 25 files changed, 1069 insertions(+), 8 deletions(-) create mode 100644 src/mailer/adapters/console-mailer.adapter.spec.ts create mode 100644 src/mailer/adapters/console-mailer.adapter.ts create mode 100644 src/mailer/adapters/smtp-mailer.adapter.ts create mode 100644 src/mailer/email-outbox.port.ts create mode 100644 src/mailer/email-outbox.service.ts create mode 100644 src/mailer/mailer-metrics.service.ts create mode 100644 src/mailer/mailer.module.spec.ts create mode 100644 src/mailer/mailer.module.ts create mode 100644 src/mailer/mailer.port.ts create mode 100644 src/mailer/mailer.service.spec.ts create mode 100644 src/mailer/mailer.service.ts create mode 100644 src/mailer/mailer.util.spec.ts create mode 100644 src/mailer/mailer.util.ts create mode 100644 src/mailer/templates/email-content.type.ts create mode 100644 src/mailer/templates/new-subscriber.template.ts create mode 100644 src/mailer/templates/reset-password.template.ts create mode 100644 src/mailer/templates/templates.spec.ts diff --git a/.env.example b/.env.example index 8881d2a..f1cd9ac 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,21 @@ UPLOAD_DIR=./uploads # For local dev this should match your server's host + port PUBLIC_BASE_URL=http://localhost:3000 +# ─── Mailer ─────────────────────────────────────────── +# Driver: "console" (dev, logs the email) or "smtp" (real delivery) +MAILER_DRIVER=console + +# Only used when MAILER_DRIVER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=no-reply@myfans.local + +# Milliseconds before a send attempt is treated as failed +MAILER_TIMEOUT_MS=10000 + # ─── Tips ───────────────────────────────────────────── # Platform fee in basis points (1/100th of a percent). 500 = 5%. TIP_PLATFORM_FEE_BPS=500 diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 422c00e..799ef61 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -12,6 +12,7 @@ import { UsersModule } from '../users/users.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoggerModule } from '../logger/logger.module'; import { AuditModule } from '../audit/audit.module'; +import { MailerModule } from '../mailer/mailer.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { AuditModule } from '../audit/audit.module'; PassportModule, LoggerModule, AuditModule, + MailerModule, TypeOrmModule.forFeature([RefreshToken, PasswordResetToken]), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6c0646c..56fa5ec 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { UsersService } from '../users/users.service'; import { SignupDto } from './dto/signup.dto'; import { TokenService, TokenPair } from './token.service'; @@ -15,6 +16,7 @@ import { Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { UserRole } from './enums/role.enum'; +import { MailerService } from '../mailer/mailer.service'; export interface AuthResponse extends TokenPair { user: { id: number; name: string; email: string; role: UserRole }; @@ -35,6 +37,8 @@ export class AuthService { private readonly tokenService: TokenService, @InjectRepository(PasswordResetToken) private readonly passwordResetTokenRepository: Repository, + private readonly mailerService: MailerService, + private readonly configService: ConfigService, ) {} async forgotPassword(email: string): Promise { @@ -53,8 +57,20 @@ export class AuthService { expiresAt, }); - // Log token in dev mode (stub email delivery) - console.log(`Password reset token for ${email}: ${token}`); + const baseUrl = + this.configService.get('PUBLIC_BASE_URL') ?? + 'http://localhost:3000'; + const resetUrl = `${baseUrl}/reset-password?token=${token}`; + + // Mail failures must never fail the (always-200) forgot-password request. + try { + await this.mailerService.sendPasswordReset(email, { + name: user.name, + resetUrl, + }); + } catch { + // MailerService already logs/records failures internally. + } } async resetPassword(token: string, newPassword: string): Promise { diff --git a/src/auth/password-reset.spec.ts b/src/auth/password-reset.spec.ts index 0d61389..1d06379 100644 --- a/src/auth/password-reset.spec.ts +++ b/src/auth/password-reset.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; @@ -7,6 +8,7 @@ import { TokenService } from './token.service'; import { PasswordResetToken } from './entities/password-reset-token.entity'; import { AuthController } from './auth.controller'; import { AuditService } from '../audit/audit.service'; +import { MailerService } from '../mailer/mailer.service'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; @@ -30,12 +32,21 @@ const mockAuditService = () => ({ log: jest.fn(), }); +const mockMailerService = () => ({ + sendPasswordReset: jest.fn().mockResolvedValue({ accepted: true }), +}); + +const mockConfigService = () => ({ + get: jest.fn().mockReturnValue('http://localhost:3000'), +}); + describe('Password Reset Flow (Unit)', () => { let authService: AuthService; let authController: AuthController; let resetTokenRepo: any; let usersService: any; let tokenService: any; + let mailerService: any; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -58,6 +69,14 @@ describe('Password Reset Flow (Unit)', () => { provide: AuditService, useFactory: mockAuditService, }, + { + provide: MailerService, + useFactory: mockMailerService, + }, + { + provide: ConfigService, + useFactory: mockConfigService, + }, ], }).compile(); @@ -66,15 +85,15 @@ describe('Password Reset Flow (Unit)', () => { resetTokenRepo = module.get(getRepositoryToken(PasswordResetToken)); usersService = module.get(UsersService); tokenService = module.get(TokenService); + mailerService = module.get(MailerService); }); describe('forgotPassword', () => { - it('should generate a token, save its SHA-256 hash, and log it to the console when user exists', async () => { + it('should generate a token, save its SHA-256 hash, and send exactly one reset email when user exists', async () => { const email = 'existing@example.com'; const mockUser = { id: 42, email, name: 'Test User' }; usersService.findByEmail.mockResolvedValue(mockUser); resetTokenRepo.save.mockResolvedValue({}); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); await authService.forgotPassword(email); @@ -86,11 +105,17 @@ describe('Password Reset Flow (Unit)', () => { expiresAt: expect.any(Date), }), ); - expect(consoleLogSpy).toHaveBeenCalled(); - consoleLogSpy.mockRestore(); + expect(mailerService.sendPasswordReset).toHaveBeenCalledTimes(1); + expect(mailerService.sendPasswordReset).toHaveBeenCalledWith( + email, + expect.objectContaining({ + name: mockUser.name, + resetUrl: expect.stringContaining('/reset-password?token='), + }), + ); }); - it('should return early without generating or saving a token if the user does not exist (no user enumeration)', async () => { + it('should return early without generating a token or sending mail if the user does not exist (no user enumeration)', async () => { const email = 'nonexistent@example.com'; usersService.findByEmail.mockResolvedValue(null); @@ -98,6 +123,19 @@ describe('Password Reset Flow (Unit)', () => { expect(usersService.findByEmail).toHaveBeenCalledWith(email); expect(resetTokenRepo.save).not.toHaveBeenCalled(); + expect(mailerService.sendPasswordReset).not.toHaveBeenCalled(); + }); + + it('still resolves (HTTP 200 path) even if the mailer throws', async () => { + const email = 'existing@example.com'; + const mockUser = { id: 42, email, name: 'Test User' }; + usersService.findByEmail.mockResolvedValue(mockUser); + resetTokenRepo.save.mockResolvedValue({}); + mailerService.sendPasswordReset.mockRejectedValue( + new Error('SMTP down'), + ); + + await expect(authService.forgotPassword(email)).resolves.toBeUndefined(); }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 1b59c54..739822b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -19,6 +19,14 @@ const envSchema = Joi.object({ TIP_PLATFORM_FEE_BPS: Joi.number().integer().min(0).max(10000).default(500), TIP_MIN_AMOUNT_CENTS: Joi.number().integer().min(1).default(100), TIP_MAX_AMOUNT_CENTS: Joi.number().integer().min(1).default(100000), + MAILER_DRIVER: Joi.string().valid('console', 'smtp').default('console'), + SMTP_HOST: Joi.string().optional(), + SMTP_PORT: Joi.number().port().optional(), + SMTP_SECURE: Joi.string().valid('true', 'false').optional(), + SMTP_USER: Joi.string().allow('').optional(), + SMTP_PASS: Joi.string().allow('').optional(), + SMTP_FROM: Joi.string().optional(), + MAILER_TIMEOUT_MS: Joi.number().integer().min(1).default(10000), }); function assertJwtConfig(value: Record): void { diff --git a/src/mailer/adapters/console-mailer.adapter.spec.ts b/src/mailer/adapters/console-mailer.adapter.spec.ts new file mode 100644 index 0000000..456efe1 --- /dev/null +++ b/src/mailer/adapters/console-mailer.adapter.spec.ts @@ -0,0 +1,51 @@ +import { ConsoleMailerAdapter } from './console-mailer.adapter'; +import { AppLogger } from '../../logger/app-logger.service'; + +describe('ConsoleMailerAdapter', () => { + const originalNodeEnv = process.env.NODE_ENV; + let logger: jest.Mocked>; + + beforeEach(() => { + logger = { log: jest.fn() }; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('logs the full email content in non-production environments', async () => { + process.env.NODE_ENV = 'development'; + const adapter = new ConsoleMailerAdapter(logger as unknown as AppLogger); + const token = 'a'.repeat(64); + + const result = await adapter.send({ + to: 'jeremiah@example.com', + subject: 'Reset your password', + html: '

hi

', + text: `Use this token: ${token}`, + }); + + expect(result.accepted).toBe(true); + const loggedMessage = logger.log.mock.calls[0][0] as string; + expect(loggedMessage).toContain('jeremiah@example.com'); + expect(loggedMessage).toContain(token); + }); + + it('redacts the recipient email and long tokens in production', async () => { + process.env.NODE_ENV = 'production'; + const adapter = new ConsoleMailerAdapter(logger as unknown as AppLogger); + const token = 'a'.repeat(64); + + await adapter.send({ + to: 'jeremiah@example.com', + subject: 'Reset your password', + html: '

hi

', + text: `Use this token: ${token}`, + }); + + const loggedMessage = logger.log.mock.calls[0][0] as string; + expect(loggedMessage).not.toContain('jeremiah@example.com'); + expect(loggedMessage).not.toContain(token); + expect(loggedMessage).toContain('[REDACTED]'); + }); +}); diff --git a/src/mailer/adapters/console-mailer.adapter.ts b/src/mailer/adapters/console-mailer.adapter.ts new file mode 100644 index 0000000..19db5f4 --- /dev/null +++ b/src/mailer/adapters/console-mailer.adapter.ts @@ -0,0 +1,26 @@ +import { randomUUID } from 'node:crypto'; +import { AppLogger } from '../../logger/app-logger.service'; +import { MailerPort, SendMailInput, SendMailResult } from '../mailer.port'; +import { isProductionLike, maskEmail, redactSensitive } from '../mailer.util'; + +/** Dev-mode mailer — logs the email instead of sending it. */ +export class ConsoleMailerAdapter implements MailerPort { + constructor(private readonly logger: AppLogger) {} + + send(input: SendMailInput): Promise { + const redact = isProductionLike(); + const to = redact ? maskEmail(input.to) : input.to; + const subject = redact ? redactSensitive(input.subject) : input.subject; + const body = redact ? redactSensitive(input.text) : input.text; + + this.logger.log( + `[ConsoleMailer] to=${to} subject="${subject}" tags=${JSON.stringify(input.tags ?? {})}\n${body}`, + ConsoleMailerAdapter.name, + ); + + return Promise.resolve({ + accepted: true, + messageId: `console-${randomUUID()}`, + }); + } +} diff --git a/src/mailer/adapters/smtp-mailer.adapter.ts b/src/mailer/adapters/smtp-mailer.adapter.ts new file mode 100644 index 0000000..8e8ce5b --- /dev/null +++ b/src/mailer/adapters/smtp-mailer.adapter.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; +import { AppLogger } from '../../logger/app-logger.service'; +import { MailerPort, SendMailInput, SendMailResult } from '../mailer.port'; + +/** Shape mirrors nodemailer's createTransport(SMTPTransport.Options) config. */ +export interface SmtpMailerConfig { + host: string; + port: number; + secure: boolean; + auth?: { user: string; pass: string }; + from: string; +} + +interface SmtpTransportLike { + sendMail(message: { + from: string; + to: string; + subject: string; + html: string; + text: string; + }): Promise<{ messageId: string }>; +} + +/** + * SMTP adapter stub. Lazily requires `nodemailer` so the dependency stays + * optional until a real deploy sets MAILER_DRIVER=smtp; in test/no-package + * environments it falls back to an in-memory no-op transport so `connect` + * never touches the network. + */ +export class SmtpMailerAdapter implements MailerPort { + private transport: SmtpTransportLike | null = null; + + constructor( + private readonly config: SmtpMailerConfig, + private readonly logger: AppLogger, + ) {} + + private getTransport(): SmtpTransportLike { + if (this.transport) return this.transport; + + if (process.env.NODE_ENV === 'test') { + this.transport = this.buildNoopTransport(); + return this.transport; + } + + try { + // Optional peer dependency — only required for real SMTP delivery. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodemailer = require('nodemailer') as { + createTransport: (opts: SmtpMailerConfig) => SmtpTransportLike; + }; + this.transport = nodemailer.createTransport(this.config); + } catch { + this.logger.warn( + '[SmtpMailer] nodemailer package not installed — falling back to no-op transport', + SmtpMailerAdapter.name, + ); + this.transport = this.buildNoopTransport(); + } + + return this.transport; + } + + private buildNoopTransport(): SmtpTransportLike { + return { + sendMail: () => + Promise.resolve({ messageId: `smtp-noop-${randomUUID()}` }), + }; + } + + async send(input: SendMailInput): Promise { + const transport = this.getTransport(); + const result = await transport.sendMail({ + from: this.config.from, + to: input.to, + subject: input.subject, + html: input.html, + text: input.text, + }); + return { accepted: true, messageId: result.messageId }; + } +} diff --git a/src/mailer/email-outbox.port.ts b/src/mailer/email-outbox.port.ts new file mode 100644 index 0000000..16c7723 --- /dev/null +++ b/src/mailer/email-outbox.port.ts @@ -0,0 +1,21 @@ +export interface EmailOutboxRecord { + id: string; + to: string; + status: 'sent' | 'failed'; + tags?: Record; + error?: string; + createdAt: Date; +} + +/** + * Outbox row writer. In-process/sync today (InMemoryEmailOutbox); swap for a + * TypeORM-backed implementation to enable real async/retry delivery later + * without touching MailerService or any call site. + */ +export interface EmailOutbox { + record( + entry: Omit, + ): Promise; +} + +export const EMAIL_OUTBOX = Symbol('EMAIL_OUTBOX'); diff --git a/src/mailer/email-outbox.service.ts b/src/mailer/email-outbox.service.ts new file mode 100644 index 0000000..e38a6cf --- /dev/null +++ b/src/mailer/email-outbox.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { EmailOutbox, EmailOutboxRecord } from './email-outbox.port'; + +@Injectable() +export class InMemoryEmailOutbox implements EmailOutbox { + private readonly rows: EmailOutboxRecord[] = []; + + async record( + entry: Omit, + ): Promise { + const row: EmailOutboxRecord = { + id: randomUUID(), + createdAt: new Date(), + ...entry, + }; + this.rows.push(row); + return Promise.resolve(row); + } + + getAll(): EmailOutboxRecord[] { + return [...this.rows]; + } +} diff --git a/src/mailer/mailer-metrics.service.ts b/src/mailer/mailer-metrics.service.ts new file mode 100644 index 0000000..bad63e6 --- /dev/null +++ b/src/mailer/mailer-metrics.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; + +export interface MailerMetricsSnapshot { + sent: number; + failed: number; + byTemplate: Record; +} + +/** + * Stub metrics collector — in-memory counters so failure isolation is + * observable in tests without pulling in the Prometheus registry. Swap the + * body for @willsoto/nestjs-prometheus counters when this needs to be scraped. + */ +@Injectable() +export class MailerMetrics { + private sent = 0; + private failed = 0; + private readonly byTemplate = new Map< + string, + { sent: number; failed: number } + >(); + + incrementSent(template?: string): void { + this.sent += 1; + this.bump(template, 'sent'); + } + + incrementFailed(template?: string): void { + this.failed += 1; + this.bump(template, 'failed'); + } + + snapshot(): MailerMetricsSnapshot { + return { + sent: this.sent, + failed: this.failed, + byTemplate: Object.fromEntries( + [...this.byTemplate.entries()].map(([key, value]) => [ + key, + { ...value }, + ]), + ), + }; + } + + private bump(template: string | undefined, kind: 'sent' | 'failed'): void { + if (!template) return; + const entry = this.byTemplate.get(template) ?? { sent: 0, failed: 0 }; + entry[kind] += 1; + this.byTemplate.set(template, entry); + } +} diff --git a/src/mailer/mailer.module.spec.ts b/src/mailer/mailer.module.spec.ts new file mode 100644 index 0000000..fe97a8d --- /dev/null +++ b/src/mailer/mailer.module.spec.ts @@ -0,0 +1,42 @@ +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { MailerModule } from './mailer.module'; +import { MAILER_PORT, MailerPort } from './mailer.port'; +import { ConsoleMailerAdapter } from './adapters/console-mailer.adapter'; +import { SmtpMailerAdapter } from './adapters/smtp-mailer.adapter'; + +describe('MailerModule driver selection', () => { + async function buildMailerPort( + driver: string | undefined, + ): Promise { + const configServiceMock: Pick = { + get: jest.fn((key: string) => + key === 'MAILER_DRIVER' ? driver : undefined, + ) as ConfigService['get'], + }; + + const moduleRef = await Test.createTestingModule({ + imports: [MailerModule], + }) + .overrideProvider(ConfigService) + .useValue(configServiceMock) + .compile(); + + return moduleRef.get(MAILER_PORT, { strict: false }); + } + + it('defaults to ConsoleMailerAdapter when MAILER_DRIVER is unset', async () => { + const port = await buildMailerPort(undefined); + expect(port).toBeInstanceOf(ConsoleMailerAdapter); + }); + + it('selects ConsoleMailerAdapter for MAILER_DRIVER=console', async () => { + const port = await buildMailerPort('console'); + expect(port).toBeInstanceOf(ConsoleMailerAdapter); + }); + + it('selects SmtpMailerAdapter for MAILER_DRIVER=smtp', async () => { + const port = await buildMailerPort('smtp'); + expect(port).toBeInstanceOf(SmtpMailerAdapter); + }); +}); diff --git a/src/mailer/mailer.module.ts b/src/mailer/mailer.module.ts new file mode 100644 index 0000000..2d5a06b --- /dev/null +++ b/src/mailer/mailer.module.ts @@ -0,0 +1,61 @@ +import { Module, Provider } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LoggerModule } from '../logger/logger.module'; +import { AppLogger } from '../logger/app-logger.service'; +import { ConsoleMailerAdapter } from './adapters/console-mailer.adapter'; +import { SmtpMailerAdapter } from './adapters/smtp-mailer.adapter'; +import { MAILER_PORT, MailerPort } from './mailer.port'; +import { EMAIL_OUTBOX } from './email-outbox.port'; +import { InMemoryEmailOutbox } from './email-outbox.service'; +import { MailerMetrics } from './mailer-metrics.service'; +import { MailerService } from './mailer.service'; + +export type MailerDriver = 'console' | 'smtp'; + +function resolveDriver(configService: ConfigService): MailerDriver { + const raw = (configService.get('MAILER_DRIVER') ?? 'console') + .trim() + .toLowerCase(); + return raw === 'smtp' ? 'smtp' : 'console'; +} + +const mailerPortProvider: Provider = { + provide: MAILER_PORT, + useFactory: (configService: ConfigService, logger: AppLogger): MailerPort => { + const driver = resolveDriver(configService); + + if (driver === 'smtp') { + return new SmtpMailerAdapter( + { + host: configService.get('SMTP_HOST') ?? 'localhost', + port: configService.get('SMTP_PORT') ?? 587, + secure: configService.get('SMTP_SECURE') === 'true', + auth: configService.get('SMTP_USER') + ? { + user: configService.get('SMTP_USER') ?? '', + pass: configService.get('SMTP_PASS') ?? '', + } + : undefined, + from: + configService.get('SMTP_FROM') ?? 'no-reply@myfans.local', + }, + logger, + ); + } + + return new ConsoleMailerAdapter(logger); + }, + inject: [ConfigService, AppLogger], +}; + +@Module({ + imports: [ConfigModule, LoggerModule], + providers: [ + MailerMetrics, + mailerPortProvider, + { provide: EMAIL_OUTBOX, useClass: InMemoryEmailOutbox }, + MailerService, + ], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/src/mailer/mailer.port.ts b/src/mailer/mailer.port.ts new file mode 100644 index 0000000..31956ca --- /dev/null +++ b/src/mailer/mailer.port.ts @@ -0,0 +1,27 @@ +export interface MailTags { + [key: string]: string | number | boolean | undefined; +} + +export interface SendMailInput { + to: string; + subject: string; + html: string; + text: string; + tags?: MailTags; +} + +export interface SendMailResult { + accepted: boolean; + messageId?: string; +} + +/** + * Port every mailer adapter (console, smtp, future providers) must implement. + * Consumed only through MailerService — adapters never get called directly + * by feature code so failure isolation stays centralized. + */ +export interface MailerPort { + send(input: SendMailInput): Promise; +} + +export const MAILER_PORT = Symbol('MAILER_PORT'); diff --git a/src/mailer/mailer.service.spec.ts b/src/mailer/mailer.service.spec.ts new file mode 100644 index 0000000..4ee13f7 --- /dev/null +++ b/src/mailer/mailer.service.spec.ts @@ -0,0 +1,131 @@ +import { ConfigService } from '@nestjs/config'; +import { MailerService } from './mailer.service'; +import { MailerPort, SendMailResult } from './mailer.port'; +import { EmailOutbox } from './email-outbox.port'; +import { MailerMetrics } from './mailer-metrics.service'; +import { AppLogger } from '../logger/app-logger.service'; + +describe('MailerService', () => { + let mailerPort: jest.Mocked; + let outbox: jest.Mocked; + let metrics: MailerMetrics; + let logger: jest.Mocked>; + let configService: jest.Mocked>; + let service: MailerService; + + beforeEach(() => { + mailerPort = { send: jest.fn() }; + outbox = { record: jest.fn().mockResolvedValue(undefined) }; + metrics = new MailerMetrics(); + logger = { error: jest.fn() }; + configService = { get: jest.fn().mockReturnValue(undefined) }; + + service = new MailerService( + mailerPort, + outbox, + metrics, + logger as unknown as AppLogger, + configService as unknown as ConfigService, + ); + }); + + it('sends mail and records a "sent" outbox row on success', async () => { + const okResult: SendMailResult = { accepted: true, messageId: 'abc' }; + mailerPort.send.mockResolvedValue(okResult); + + const result = await service.send({ + to: 'user@example.com', + subject: 'Hi', + html: '

hi

', + text: 'hi', + tags: { template: 'password-reset' }, + }); + + expect(result).toEqual(okResult); + expect(outbox.record).toHaveBeenCalledWith( + expect.objectContaining({ status: 'sent' }), + ); + expect(metrics.snapshot().sent).toBe(1); + }); + + it('swallows a mailer throw, records a "failed" outbox row, and returns accepted:false', async () => { + mailerPort.send.mockRejectedValue(new Error('SMTP down')); + + const result = await service.send({ + to: 'user@example.com', + subject: 'Hi', + html: '

hi

', + text: 'hi', + }); + + expect(result).toEqual({ accepted: false }); + expect(outbox.record).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', error: 'SMTP down' }), + ); + expect(metrics.snapshot().failed).toBe(1); + expect(logger.error).toHaveBeenCalled(); + }); + + it('treats a send that exceeds the timeout as a failure', async () => { + configService.get.mockImplementation((key: string) => + key === 'MAILER_TIMEOUT_MS' ? 10 : undefined, + ); + service = new MailerService( + mailerPort, + outbox, + metrics, + logger as unknown as AppLogger, + configService as unknown as ConfigService, + ); + mailerPort.send.mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve({ accepted: true }), 200), + ), + ); + + const result = await service.send({ + to: 'user@example.com', + subject: 'Hi', + html: '

hi

', + text: 'hi', + }); + + expect(result).toEqual({ accepted: false }); + expect(metrics.snapshot().failed).toBe(1); + }); + + it('builds and sends the password reset template', async () => { + mailerPort.send.mockResolvedValue({ accepted: true }); + + await service.sendPasswordReset('user@example.com', { + name: 'Jane', + resetUrl: 'https://app.test/reset?token=abc', + }); + + expect(mailerPort.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'user@example.com', + subject: 'Reset your password', + tags: { template: 'password-reset' }, + }), + ); + }); + + it('builds and sends the new subscriber template', async () => { + mailerPort.send.mockResolvedValue({ accepted: true }); + + await service.sendNewSubscriberNotification('creator@example.com', { + creatorName: 'Creator', + subscriberName: 'Fan', + }); + + expect(mailerPort.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'creator@example.com', + subject: 'You have a new subscriber', + tags: { template: 'new-subscriber' }, + }), + ); + }); +}); diff --git a/src/mailer/mailer.service.ts b/src/mailer/mailer.service.ts new file mode 100644 index 0000000..60c2f99 --- /dev/null +++ b/src/mailer/mailer.service.ts @@ -0,0 +1,133 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppLogger } from '../logger/app-logger.service'; +import { EMAIL_OUTBOX, EmailOutbox } from './email-outbox.port'; +import { + MAILER_PORT, + MailerPort, + SendMailInput, + SendMailResult, +} from './mailer.port'; +import { MailerMetrics } from './mailer-metrics.service'; +import { maskEmail } from './mailer.util'; +import { + ResetPasswordTemplateParams, + buildResetPasswordEmail, +} from './templates/reset-password.template'; +import { + NewSubscriberTemplateParams, + buildNewSubscriberEmail, +} from './templates/new-subscriber.template'; + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Single entry point feature code should call to send mail. Wraps the + * underlying MailerPort with a timeout, records the attempt to the outbox, + * and swallows/logs failures so a broken mail driver never fails the + * originating HTTP request (password reset always 200s, subscribe always + * 201s, etc). + * + * Hook points for future flows: add a `sendX(to, params)` method following + * the same pattern as sendPasswordReset/sendNewSubscriberNotification below + * — build template content, then delegate to `send()`. Tip/message + * notifications should plug in here the same way once their templates land. + */ +@Injectable() +export class MailerService { + private readonly timeoutMs: number; + + constructor( + @Inject(MAILER_PORT) private readonly mailerPort: MailerPort, + @Inject(EMAIL_OUTBOX) private readonly outbox: EmailOutbox, + private readonly metrics: MailerMetrics, + private readonly logger: AppLogger, + private readonly configService: ConfigService, + ) { + this.timeoutMs = + this.configService.get('MAILER_TIMEOUT_MS') ?? DEFAULT_TIMEOUT_MS; + } + + async sendPasswordReset( + to: string, + params: ResetPasswordTemplateParams, + ): Promise { + const { subject, html, text } = buildResetPasswordEmail(params); + return this.send({ + to, + subject, + html, + text, + tags: { template: 'password-reset' }, + }); + } + + async sendNewSubscriberNotification( + to: string, + params: NewSubscriberTemplateParams, + ): Promise { + const { subject, html, text } = buildNewSubscriberEmail(params); + return this.send({ + to, + subject, + html, + text, + tags: { template: 'new-subscriber' }, + }); + } + + async send(input: SendMailInput): Promise { + const template = + typeof input.tags?.template === 'string' + ? input.tags.template + : undefined; + const maskedTo = maskEmail(input.to); + + try { + const result = await this.withTimeout(this.mailerPort.send(input)); + this.metrics.incrementSent(template); + await this.outbox.record({ + to: maskedTo, + tags: input.tags, + status: 'sent', + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.metrics.incrementFailed(template); + this.logger.error( + `Mail send failed to=${maskedTo} template=${template ?? 'unknown'}: ${message}`, + undefined, + MailerService.name, + ); + await this.outbox.record({ + to: maskedTo, + tags: input.tags, + status: 'failed', + error: message, + }); + return { accepted: false }; + } + } + + private withTimeout( + promise: Promise, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Mailer send timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + } +} diff --git a/src/mailer/mailer.util.spec.ts b/src/mailer/mailer.util.spec.ts new file mode 100644 index 0000000..4a4205f --- /dev/null +++ b/src/mailer/mailer.util.spec.ts @@ -0,0 +1,35 @@ +import { escapeHtml, maskEmail, redactSensitive } from './mailer.util'; + +describe('mailer.util', () => { + describe('maskEmail', () => { + it('keeps the domain and masks most of the local part', () => { + expect(maskEmail('jeremiah@example.com')).toBe('je******@example.com'); + }); + + it('returns a redacted placeholder for malformed input', () => { + expect(maskEmail('not-an-email')).toBe('[REDACTED]'); + }); + }); + + describe('redactSensitive', () => { + it('redacts long hex-looking tokens', () => { + const token = 'a'.repeat(64); + const input = `Reset link: https://app/reset?token=${token}`; + expect(redactSensitive(input)).toBe( + 'Reset link: https://app/reset?token=[REDACTED]', + ); + }); + + it('leaves short/non-hex strings untouched', () => { + expect(redactSensitive('hello world 123')).toBe('hello world 123'); + }); + }); + + describe('escapeHtml', () => { + it('escapes HTML special characters', () => { + expect(escapeHtml(` & "quoted"`)).toBe( + '<script>alert('x')</script> & "quoted"', + ); + }); + }); +}); diff --git a/src/mailer/mailer.util.ts b/src/mailer/mailer.util.ts new file mode 100644 index 0000000..fed121f --- /dev/null +++ b/src/mailer/mailer.util.ts @@ -0,0 +1,29 @@ +/** PII-safe masking for logs — keeps first 2 chars of the local part, redacts the rest. */ +export function maskEmail(email: string): string { + const atIndex = email.indexOf('@'); + if (atIndex <= 0) return '[REDACTED]'; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const visible = local.slice(0, 2); + return `${visible}${'*'.repeat(Math.max(local.length - visible.length, 1))}@${domain}`; +} + +// Reset tokens are 64 hex chars (32 random bytes); redact any long hex run defensively. +const SENSITIVE_HEX_PATTERN = /[a-f0-9]{24,}/gi; + +export function redactSensitive(value: string): string { + return value.replace(SENSITIVE_HEX_PATTERN, '[REDACTED]'); +} + +export function isProductionLike(): boolean { + return process.env.NODE_ENV === 'production'; +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/mailer/templates/email-content.type.ts b/src/mailer/templates/email-content.type.ts new file mode 100644 index 0000000..14f1771 --- /dev/null +++ b/src/mailer/templates/email-content.type.ts @@ -0,0 +1,5 @@ +export interface EmailContent { + subject: string; + html: string; + text: string; +} diff --git a/src/mailer/templates/new-subscriber.template.ts b/src/mailer/templates/new-subscriber.template.ts new file mode 100644 index 0000000..cd28fc7 --- /dev/null +++ b/src/mailer/templates/new-subscriber.template.ts @@ -0,0 +1,32 @@ +import { escapeHtml } from '../mailer.util'; +import { EmailContent } from './email-content.type'; + +export interface NewSubscriberTemplateParams { + creatorName: string; + subscriberName: string; +} + +export function buildNewSubscriberEmail( + params: NewSubscriberTemplateParams, +): EmailContent { + const safeCreatorName = escapeHtml(params.creatorName); + const safeSubscriberName = escapeHtml(params.subscriberName); + const subject = 'You have a new subscriber'; + + const html = ` + + +

Hi ${safeCreatorName},

+

${safeSubscriberName} just subscribed to your page.

+

Keep posting to keep your subscribers engaged!

+ +`; + + const text = `Hi ${params.creatorName}, + +${params.subscriberName} just subscribed to your page. + +Keep posting to keep your subscribers engaged!`; + + return { subject, html, text }; +} diff --git a/src/mailer/templates/reset-password.template.ts b/src/mailer/templates/reset-password.template.ts new file mode 100644 index 0000000..e3e96b0 --- /dev/null +++ b/src/mailer/templates/reset-password.template.ts @@ -0,0 +1,35 @@ +import { escapeHtml } from '../mailer.util'; +import { EmailContent } from './email-content.type'; + +export interface ResetPasswordTemplateParams { + name: string; + resetUrl: string; +} + +export function buildResetPasswordEmail( + params: ResetPasswordTemplateParams, +): EmailContent { + const safeName = escapeHtml(params.name); + const safeUrl = escapeHtml(params.resetUrl); + const subject = 'Reset your password'; + + const html = ` + + +

Hi ${safeName},

+

We received a request to reset your password. Click the button below to choose a new one. This link expires in 15 minutes.

+

Reset password

+

If you didn't request this, you can safely ignore this email.

+ +`; + + const text = `Hi ${params.name}, + +We received a request to reset your password. Use the link below to choose a new one. This link expires in 15 minutes. + +${params.resetUrl} + +If you didn't request this, you can safely ignore this email.`; + + return { subject, html, text }; +} diff --git a/src/mailer/templates/templates.spec.ts b/src/mailer/templates/templates.spec.ts new file mode 100644 index 0000000..dce0d13 --- /dev/null +++ b/src/mailer/templates/templates.spec.ts @@ -0,0 +1,49 @@ +import { buildResetPasswordEmail } from './reset-password.template'; +import { buildNewSubscriberEmail } from './new-subscriber.template'; + +describe('email templates', () => { + describe('buildResetPasswordEmail', () => { + it('escapes user-controlled name and url in the html body', () => { + const { html, text } = buildResetPasswordEmail({ + name: ``, + resetUrl: 'https://app.test/reset?token=abc">', + }); + + expect(html).not.toContain(''); + expect(html).toContain('<img src=x onerror=alert(1)>'); + expect(html).not.toContain(''); + // Plain text is not HTML-rendered, so it stays unescaped/raw. + expect(text).toContain(''); + }); + + it('includes the reset link and subject', () => { + const { subject, text } = buildResetPasswordEmail({ + name: 'Jane', + resetUrl: 'https://app.test/reset?token=xyz', + }); + expect(subject).toBe('Reset your password'); + expect(text).toContain('https://app.test/reset?token=xyz'); + }); + }); + + describe('buildNewSubscriberEmail', () => { + it('escapes user-controlled names in the html body', () => { + const { html } = buildNewSubscriberEmail({ + creatorName: 'Creator', + subscriberName: ``, + }); + + expect(html).not.toContain(``); + expect(html).toContain('<script>'); + }); + + it('mentions both creator and subscriber in the text body', () => { + const { text } = buildNewSubscriberEmail({ + creatorName: 'Creator', + subscriberName: 'Fan One', + }); + expect(text).toContain('Creator'); + expect(text).toContain('Fan One'); + }); + }); +}); diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts index 14a3271..12f27a5 100644 --- a/src/subscriptions/subscriptions.module.ts +++ b/src/subscriptions/subscriptions.module.ts @@ -5,9 +5,15 @@ import { User } from '../users/user.entity'; import { SubscriptionsService } from './subscriptions.service'; import { SubscriptionsController } from './subscriptions.controller'; import { CreatorsController } from './creators.controller'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { MailerModule } from '../mailer/mailer.module'; @Module({ - imports: [TypeOrmModule.forFeature([Subscription, User])], + imports: [ + TypeOrmModule.forFeature([Subscription, User]), + NotificationsModule, + MailerModule, + ], providers: [SubscriptionsService], controllers: [SubscriptionsController, CreatorsController], exports: [SubscriptionsService], diff --git a/src/subscriptions/subscriptions.service.spec.ts b/src/subscriptions/subscriptions.service.spec.ts index d777423..d6693f4 100644 --- a/src/subscriptions/subscriptions.service.spec.ts +++ b/src/subscriptions/subscriptions.service.spec.ts @@ -9,13 +9,28 @@ import { Repository } from 'typeorm'; import { SubscriptionsService } from './subscriptions.service'; import { Subscription } from './subscription.entity'; import { User } from '../users/user.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { MailerService } from '../mailer/mailer.service'; describe('SubscriptionsService', () => { let service: SubscriptionsService; let subscriptionRepo: jest.Mocked>; let userRepo: jest.Mocked>; + let notificationsService: jest.Mocked< + Pick + >; + let mailerService: jest.Mocked< + Pick + >; beforeEach(async () => { + notificationsService = { shouldNotify: jest.fn().mockResolvedValue(true) }; + mailerService = { + sendNewSubscriberNotification: jest + .fn() + .mockResolvedValue({ accepted: true }), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ SubscriptionsService, @@ -34,6 +49,8 @@ describe('SubscriptionsService', () => { findOne: jest.fn(), }, }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: MailerService, useValue: mailerService }, ], }).compile(); @@ -125,6 +142,98 @@ describe('SubscriptionsService', () => { }); }); + describe('new subscriber mail notification', () => { + const creator = { + id: 2, + name: 'Creator', + email: 'creator@example.com', + } as User; + + it('sends creator mail when preferences.newSubscriber is true', async () => { + userRepo.findOne.mockResolvedValue(creator); + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({ + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + } as Subscription); + subscriptionRepo.save.mockResolvedValue({ + id: 'uuid-1', + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + subscribedAt: new Date(), + } as Subscription); + notificationsService.shouldNotify.mockResolvedValue(true); + + await service.subscribe(1, { creatorId: 2 }); + + expect(notificationsService.shouldNotify).toHaveBeenCalledWith( + 2, + 'newSubscriber', + ); + expect(mailerService.sendNewSubscriberNotification).toHaveBeenCalledWith( + creator.email, + expect.objectContaining({ creatorName: creator.name }), + ); + }); + + it('does not send creator mail when preferences.newSubscriber is false', async () => { + userRepo.findOne.mockResolvedValue(creator); + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({ + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + } as Subscription); + subscriptionRepo.save.mockResolvedValue({ + id: 'uuid-1', + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + subscribedAt: new Date(), + } as Subscription); + notificationsService.shouldNotify.mockResolvedValue(false); + + await service.subscribe(1, { creatorId: 2 }); + + expect( + mailerService.sendNewSubscriberNotification, + ).not.toHaveBeenCalled(); + }); + + it('still returns the subscription when the mailer throws', async () => { + userRepo.findOne.mockResolvedValue(creator); + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({ + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + } as Subscription); + subscriptionRepo.save.mockResolvedValue({ + id: 'uuid-1', + fanId: 1, + creatorId: 2, + status: 'active', + cancelledAt: null, + subscribedAt: new Date(), + } as Subscription); + notificationsService.shouldNotify.mockResolvedValue(true); + mailerService.sendNewSubscriberNotification.mockRejectedValue( + new Error('SMTP down'), + ); + + const result = await service.subscribe(1, { creatorId: 2 }); + + expect(result).toMatchObject({ id: 'uuid-1', status: 'active' }); + }); + }); + describe('cancel', () => { it('cancels an active subscription', async () => { const active = { diff --git a/src/subscriptions/subscriptions.service.ts b/src/subscriptions/subscriptions.service.ts index d40388f..8fc3d5c 100644 --- a/src/subscriptions/subscriptions.service.ts +++ b/src/subscriptions/subscriptions.service.ts @@ -16,6 +16,8 @@ import { PaginatedResponseDto, PaginationMetaDto, } from '../users/dtos/paginated-response.dto'; +import { NotificationsService } from '../notifications/notifications.service'; +import { MailerService } from '../mailer/mailer.service'; @Injectable() export class SubscriptionsService { @@ -24,6 +26,8 @@ export class SubscriptionsService { private readonly subscriptionRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly mailerService: MailerService, ) {} /** @@ -68,6 +72,7 @@ export class SubscriptionsService { existing.cancelledAt = null; existing.subscribedAt = new Date(); const reactivated = await this.subscriptionRepository.save(existing); + await this.notifyCreatorOfNewSubscriber(fanId, creator); return this.toResponse(reactivated); } @@ -78,9 +83,36 @@ export class SubscriptionsService { cancelledAt: null, }); const saved = await this.subscriptionRepository.save(subscription); + await this.notifyCreatorOfNewSubscriber(fanId, creator); return this.toResponse(saved); } + /** + * Best-effort creator notification — gated on preferences.newSubscriber. + * Any failure here (pref lookup or mail send) is caught and logged so it + * never turns a successful subscribe into a failed HTTP response. + */ + private async notifyCreatorOfNewSubscriber( + fanId: number, + creator: User, + ): Promise { + try { + const shouldNotify = await this.notificationsService.shouldNotify( + creator.id, + 'newSubscriber', + ); + if (!shouldNotify) return; + + const fan = await this.userRepository.findOne({ where: { id: fanId } }); + await this.mailerService.sendNewSubscriberNotification(creator.email, { + creatorName: creator.name, + subscriberName: fan?.name ?? 'A fan', + }); + } catch { + // Swallowed intentionally: mailer/prefs failures must not affect subscribe. + } + } + /** * Fan cancels an active subscription to a creator. */