From 1bec0e76ee1e4735f80d820b4f3d34757ad92c77 Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 27 Jun 2026 00:09:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-03):=20SettingsModule=20=E2=80=94=20SMT?= =?UTF-8?q?P=20config=20backend=20+=20connection=20test=20(DKV-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/app.module.ts | 2 + apps/api/src/settings/dto/smtp-config.dto.ts | 47 +++++ apps/api/src/settings/settings.controller.ts | 79 ++++++++ apps/api/src/settings/settings.module.ts | 23 +++ apps/api/src/settings/settings.service.ts | 193 +++++++++++++++++++ 5 files changed, 344 insertions(+) create mode 100644 apps/api/src/settings/dto/smtp-config.dto.ts create mode 100644 apps/api/src/settings/settings.controller.ts create mode 100644 apps/api/src/settings/settings.module.ts create mode 100644 apps/api/src/settings/settings.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6e8d364..0a8e927 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; +import { SettingsModule } from './settings/settings.module'; import { TenantMiddleware } from './tenant/tenant.middleware'; import { TenantModule } from './tenant/tenant.module'; import { UserModule } from './user/user.module'; @@ -33,6 +34,7 @@ import { UserModule } from './user/user.module'; DomaincheckModule, DashboardModule, CalendarModule, + SettingsModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/settings/dto/smtp-config.dto.ts b/apps/api/src/settings/dto/smtp-config.dto.ts new file mode 100644 index 0000000..7cdd3dc --- /dev/null +++ b/apps/api/src/settings/dto/smtp-config.dto.ts @@ -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; +} diff --git a/apps/api/src/settings/settings.controller.ts b/apps/api/src/settings/settings.controller.ts new file mode 100644 index 0000000..9f4ff35 --- /dev/null +++ b/apps/api/src/settings/settings.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts new file mode 100644 index 0000000..25c15d5 --- /dev/null +++ b/apps/api/src/settings/settings.module.ts @@ -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 {} diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts new file mode 100644 index 0000000..0e5d76c --- /dev/null +++ b/apps/api/src/settings/settings.service.ts @@ -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 { + 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, + }; + } +}