diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts index 11a4496..f25a3d3 100644 --- a/apps/api/src/mail/mail.module.ts +++ b/apps/api/src/mail/mail.module.ts @@ -1,32 +1,95 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MailerModule } from '@nestjs-modules/mailer'; +import { SettingsModule } from '../settings/settings.module'; +import { SettingsService } from '../settings/settings.service'; import { MailService } from './mail.service'; +/** + * MailModule — system email delivery (password reset, welcome emails). + * + * D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1) + * with an env-var fallback (priority 2) when no DB row exists. + * + * Transport priority: + * 1. DB SmtpConfig (first row — single-tenant default; set via /settings/smtp) + * 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS + * 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD + * 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default) + * + * The factory is async because getStartupSmtpConfig() reads from the DB. + * No circular import risk: MailModule → SettingsModule → CalendarModule (no reverse edges). + */ @Module({ imports: [ + SettingsModule, MailerModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - transport: { - host: configService.get('TESSERA_SMTP_HOST', 'localhost'), - port: configService.get('TESSERA_SMTP_PORT', 1025), - secure: configService.get('TESSERA_SMTP_SECURE', 'false') === 'true', - auth: { - user: configService.get('TESSERA_SMTP_USER', ''), - pass: configService.get('TESSERA_SMTP_PASSWORD', ''), + imports: [SettingsModule], + useFactory: async (settingsService: SettingsService, configService: ConfigService) => { + // Priority 1: DB SmtpConfig (getStartupSmtpConfig uses findFirst — single-tenant default) + const db = await settingsService.getStartupSmtpConfig(); + + if (db) { + // T-07-11: DB password used only to build transport; never logged + return { + transport: { + host: db.host, + port: db.port, + secure: db.secure, + requireTLS: db.requireTLS, + auth: db.username + ? { user: db.username, pass: db.password ?? '' } + : undefined, + }, + defaults: { + from: db.fromAddress, + }, + }; + } + + // Priority 2: Env vars (new names first, legacy TESSERA_SMTP_* as secondary fallback) + const host = + configService.get('MAIL_HOST') ?? + configService.get('TESSERA_SMTP_HOST') ?? + 'localhost'; + + const port = + configService.get('MAIL_PORT') ?? + configService.get('TESSERA_SMTP_PORT') ?? + 1025; + + const user = + configService.get('MAIL_USER') ?? + configService.get('TESSERA_SMTP_USER') ?? + ''; + + const pass = + configService.get('MAIL_PASS') ?? + configService.get('TESSERA_SMTP_PASSWORD') ?? + ''; + + const from = + configService.get('TESSERA_SMTP_FROM') ?? + 'Tessera '; + + const secure = + configService.get('TESSERA_SMTP_SECURE', 'false') === 'true'; + + return { + transport: { + host, + port, + secure, + auth: { user, pass }, }, - }, - defaults: { - from: configService.get( - 'TESSERA_SMTP_FROM', - 'Tessera ', - ), - }, - }), - inject: [ConfigService], + defaults: { from }, + }; + }, + inject: [SettingsService, ConfigService], }), ], providers: [MailService], exports: [MailService], }) export class MailModule {} +