feat(07-03): Migrate MailModule to DB-sourced SMTP transport with env fallback (D-06)
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Waiting to run

- MailerModule.forRootAsync factory now async; injects SettingsService + ConfigService
- Priority 1: getStartupSmtpConfig() reads first SmtpConfig DB row (single-tenant default)
  — T-07-11: decrypted password used only to build transport, never logged
- Priority 2: env vars MAIL_HOST/MAIL_PORT/MAIL_USER/MAIL_PASS
- Priority 3: legacy TESSERA_SMTP_* env vars (backward compat)
- Priority 4: localhost:1025 hardcoded final fallback (Mailhog dev default)
- imports SettingsModule; no circular import (MailModule → SettingsModule → CalendarModule)
- mail.service.ts unchanged — still injects @nestjs-modules/mailer MailerService
This commit is contained in:
2026-06-27 00:12:43 +02:00
parent 4deefb52de
commit de48e35c74
+80 -17
View File
@@ -1,32 +1,95 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer'; import { MailerModule } from '@nestjs-modules/mailer';
import { SettingsModule } from '../settings/settings.module';
import { SettingsService } from '../settings/settings.service';
import { MailService } from './mail.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({ @Module({
imports: [ imports: [
SettingsModule,
MailerModule.forRootAsync({ MailerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({ imports: [SettingsModule],
transport: { useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
host: configService.get<string>('TESSERA_SMTP_HOST', 'localhost'), // Priority 1: DB SmtpConfig (getStartupSmtpConfig uses findFirst — single-tenant default)
port: configService.get<number>('TESSERA_SMTP_PORT', 1025), const db = await settingsService.getStartupSmtpConfig();
secure: configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true',
auth: { if (db) {
user: configService.get<string>('TESSERA_SMTP_USER', ''), // T-07-11: DB password used only to build transport; never logged
pass: configService.get<string>('TESSERA_SMTP_PASSWORD', ''), 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<string>('MAIL_HOST') ??
configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
configService.get<number>('MAIL_PORT') ??
configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
configService.get<string>('MAIL_USER') ??
configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
configService.get<string>('MAIL_PASS') ??
configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
transport: {
host,
port,
secure,
auth: { user, pass },
}, },
}, defaults: { from },
defaults: { };
from: configService.get<string>( },
'TESSERA_SMTP_FROM', inject: [SettingsService, ConfigService],
'Tessera <tessera@tessera.local>',
),
},
}),
inject: [ConfigService],
}), }),
], ],
providers: [MailService], providers: [MailService],
exports: [MailService], exports: [MailService],
}) })
export class MailModule {} export class MailModule {}