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:
@@ -14,6 +14,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
|||||||
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
||||||
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { TenantMiddleware } from './tenant/tenant.middleware';
|
import { TenantMiddleware } from './tenant/tenant.middleware';
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
@@ -33,6 +34,7 @@ import { UserModule } from './user/user.module';
|
|||||||
DomaincheckModule,
|
DomaincheckModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
|
SettingsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for saving or testing an SMTP configuration.
|
||||||
|
*
|
||||||
|
* Security: T-07-08 — host/port/encryption validated to prevent open-relay abuse.
|
||||||
|
* Password field is optional on PUT (empty string = preserve existing stored password).
|
||||||
|
*/
|
||||||
|
export class SmtpConfigDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
host!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
port!: number;
|
||||||
|
|
||||||
|
/** 'none' | 'starttls' | 'ssl-tls' */
|
||||||
|
@IsIn(['none', 'starttls', 'ssl-tls'])
|
||||||
|
encryption!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plaintext password submitted by the client.
|
||||||
|
* Empty string means "keep existing stored password" on PUT.
|
||||||
|
* Never returned in GET responses.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
fromAddress!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings Controller — SMTP configuration and connection test.
|
||||||
|
*
|
||||||
|
* All endpoints require ADMIN or SUPER_ADMIN role (V4 access control — T-07-08).
|
||||||
|
* Config is per-tenant (D-05).
|
||||||
|
*/
|
||||||
|
@Controller('settings')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /settings/smtp
|
||||||
|
* Returns the SMTP config for the current tenant.
|
||||||
|
* Never returns `encryptedPassword` — adds `hasPassword` boolean instead (T-07-07).
|
||||||
|
*/
|
||||||
|
@Get('smtp')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async getSmtpConfig(@Req() req: any) {
|
||||||
|
const tenantId = req.tenantId as string | undefined;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('No tenant context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.settingsService.getSmtpConfig(tenantId);
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
// Strip encrypted password, add hasPassword boolean (T-07-07)
|
||||||
|
const { encryptedPassword, ...safe } = config;
|
||||||
|
return { ...safe, hasPassword: !!encryptedPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /settings/smtp
|
||||||
|
* Upsert the SMTP config for the current tenant.
|
||||||
|
* Password is encrypted at rest. Empty password preserves the existing stored password.
|
||||||
|
*/
|
||||||
|
@Put('smtp')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
|
||||||
|
const tenantId = req.tenantId as string | undefined;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('No tenant context');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.settingsService.saveSmtpConfig(tenantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /settings/smtp/test
|
||||||
|
* Tests an SMTP connection with the submitted config.
|
||||||
|
* Returns { success: boolean } — no credentials or transport details in the response (T-07-16).
|
||||||
|
* Backs the UI-SPEC Surface C "Verbindung testen" button (Plan 07-06).
|
||||||
|
*/
|
||||||
|
@Post('smtp/test')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
|
||||||
|
const tenantId = req.tenantId as string | undefined;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new BadRequestException('No tenant context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await this.settingsService.testSmtpConfig(tenantId, dto);
|
||||||
|
return { success };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CalendarModule } from '../calendar/calendar.module';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsModule — manages general application settings shared across modules.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - SettingsService: SmtpConfig CRUD (encrypted), connection test, startup accessor
|
||||||
|
* - SettingsController: REST endpoints GET/PUT /settings/smtp, POST /settings/smtp/test
|
||||||
|
*
|
||||||
|
* Imports CalendarModule to get CalendarCryptoService for AES-256-GCM encryption.
|
||||||
|
* PrismaModule is global — no explicit import needed.
|
||||||
|
* Exports SettingsService so other modules (e.g. MailModule, DkvModule) can inject it.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [CalendarModule],
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
@@ -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