feat(07-03): Migrate MailModule to DB-sourced SMTP transport with env fallback (D-06)
- 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:
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user