feat(07-03): SettingsModule — SMTP config backend + connection test (DKV-05)
- SmtpConfigDto: host/port/encryption/username/password/fromAddress with class-validator - SettingsService: getSmtpConfig (SMTP_SAFE_SELECT, no password), saveSmtpConfig (AES-256-GCM encryption via CalendarCryptoService, preserve existing password on empty), getDecryptedSmtpConfig (internal, used by DkvMailService), testSmtpConfig (nodemailer.verify(), returns boolean, T-07-16), getStartupSmtpConfig (tenant-agnostic, used by MailModule factory, D-06) - SettingsController: GET/PUT /settings/smtp + POST /settings/smtp/test, all @Roles(ADMIN, SUPER_ADMIN) - SettingsModule: imports CalendarModule, exports SettingsService - AppModule: imports SettingsModule
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
/**
|
||||
* Safe select for SmtpConfig rows — never returns the encrypted password to API callers.
|
||||
* T-07-07: encryptedPassword is excluded from all GET responses.
|
||||
*/
|
||||
const SMTP_SAFE_SELECT = {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
host: true,
|
||||
port: true,
|
||||
encryption: true,
|
||||
username: true,
|
||||
// encryptedPassword: NEVER included — T-07-07
|
||||
fromAddress: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly crypto: CalendarCryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the SMTP config for a tenant — safe (no password field).
|
||||
* Returns null when no config row exists for the tenant.
|
||||
*/
|
||||
async getSmtpConfig(tenantId: string) {
|
||||
return this.prisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: {
|
||||
...SMTP_SAFE_SELECT,
|
||||
// Include encryptedPassword presence for hasPassword boolean only
|
||||
encryptedPassword: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the SMTP config for a tenant.
|
||||
* Encrypts the password with AES-256-GCM when a new password is provided.
|
||||
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
||||
*
|
||||
* T-07-08: Encryption via CalendarCryptoService. Never logs the plaintext password.
|
||||
*/
|
||||
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
||||
// Determine the encrypted password to store
|
||||
let encryptedPassword: string | undefined;
|
||||
|
||||
if (dto.password && dto.password.length > 0) {
|
||||
encryptedPassword = this.crypto.encrypt(dto.password);
|
||||
// T-07-10: Never log the plaintext password
|
||||
}
|
||||
|
||||
const data = {
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
encryption: dto.encryption,
|
||||
username: dto.username ?? null,
|
||||
fromAddress: dto.fromAddress,
|
||||
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
|
||||
};
|
||||
|
||||
const result = await this.prisma.smtpConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
select: SMTP_SAFE_SELECT,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Get the decrypted SMTP config for a tenant.
|
||||
* Used by DkvMailService to build a nodemailer transport at send time.
|
||||
* NEVER log the decrypted password (T-07-10 / T-05-13).
|
||||
*/
|
||||
async getDecryptedSmtpConfig(tenantId: string): Promise<{
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: string;
|
||||
username: string | null;
|
||||
fromAddress: string;
|
||||
decryptedPassword: string | null;
|
||||
} | null> {
|
||||
const config = await this.prisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
let decryptedPassword: string | null = null;
|
||||
if (config.encryptedPassword) {
|
||||
// T-05-13: Never log this value
|
||||
decryptedPassword = this.crypto.decrypt(config.encryptedPassword);
|
||||
}
|
||||
|
||||
return {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
encryption: config.encryption,
|
||||
username: config.username,
|
||||
fromAddress: config.fromAddress,
|
||||
decryptedPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an SMTP connection using the submitted DTO.
|
||||
* When `dto.password` is empty, uses the stored decrypted password instead.
|
||||
* Returns true on success, false on failure.
|
||||
*
|
||||
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
|
||||
*/
|
||||
async testSmtpConfig(tenantId: string, dto: SmtpConfigDto): Promise<boolean> {
|
||||
let password: string | undefined = dto.password;
|
||||
|
||||
// If no password submitted, fall back to the stored password
|
||||
if (!password) {
|
||||
const stored = await this.getDecryptedSmtpConfig(tenantId);
|
||||
password = stored?.decryptedPassword ?? undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
secure: dto.encryption === 'ssl-tls',
|
||||
requireTLS: dto.encryption === 'starttls',
|
||||
auth: dto.username
|
||||
? { user: dto.username, pass: password ?? '' }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transport.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
// T-07-16: Log only generic message, never credentials
|
||||
this.logger.warn(
|
||||
`SMTP connection test failed for tenant ${tenantId}: ${(error as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-agnostic startup accessor for the MailModule factory.
|
||||
* Returns the first SmtpConfig row in the DB (single-tenant deployments) with
|
||||
* the password decrypted. Returns null when no row exists (env-var fallback path).
|
||||
*
|
||||
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
|
||||
* T-07-11: Decrypted password is used only to build the transport — never logged.
|
||||
*/
|
||||
async getStartupSmtpConfig(): Promise<{
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
requireTLS: boolean;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
fromAddress: string;
|
||||
} | null> {
|
||||
const config = await this.prisma.smtpConfig.findFirst();
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
let password: string | null = null;
|
||||
if (config.encryptedPassword) {
|
||||
// T-07-11: Used only to build transport at startup; never logged
|
||||
password = this.crypto.decrypt(config.encryptedPassword);
|
||||
}
|
||||
|
||||
return {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.encryption === 'ssl-tls',
|
||||
requireTLS: config.encryption === 'starttls',
|
||||
username: config.username,
|
||||
password,
|
||||
fromAddress: config.fromAddress,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user