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<{ success: boolean; warning?: string }> { let password: string | undefined = dto.password; let username: string | undefined = dto.username; // Fall back to stored credentials (form never pre-fills password — T-07-17) if (!password || !username) { const stored = await this.getDecryptedSmtpConfig(tenantId); if (stored) { if (!password) password = stored.decryptedPassword ?? undefined; if (!username) username = stored.username ?? undefined; } } try { const transport = nodemailer.createTransport({ host: dto.host, port: dto.port, secure: dto.encryption === 'ssl-tls', requireTLS: dto.encryption === 'starttls', connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 10_000, auth: username ? { user: username, pass: password ?? '' } : undefined, }); if (dto.testTo) { await transport.sendMail({ from: dto.fromAddress, to: dto.testTo, subject: 'Tessera SMTP-Test', text: 'Diese E-Mail bestätigt, dass die SMTP-Konfiguration in Tessera funktioniert.', }); } else { await transport.verify(); } return { success: true }; } catch (error) { this.logger.warn( `SMTP connection test failed for tenant ${tenantId}: ${(error as Error).message}`, ); return { success: 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, }; } }