diff --git a/apps/api/src/dkv/dkv-mail.service.ts b/apps/api/src/dkv/dkv-mail.service.ts new file mode 100644 index 0000000..60c28fe --- /dev/null +++ b/apps/api/src/dkv/dkv-mail.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { SettingsService } from '../settings/settings.service'; + +/** + * DkvMailService — sends DKV export files via SMTP with a runtime transport. + * + * Key design decision (Research Pitfall 3): @nestjs-modules/mailer cannot change its + * SMTP transport after startup. DkvMailService solves this by calling + * nodemailer.createTransport() fresh on every send — reflecting any SMTP config change + * made in the UI immediately without a service restart. + * + * This service uses nodemailer directly — NOT the @nestjs-modules/mailer abstraction. + * + * Security: T-07-10 — decrypted SMTP credentials are used only inside this method + * scope and never logged. Generic error messages are emitted on failure. + */ +@Injectable() +export class DkvMailService { + private readonly logger = new Logger(DkvMailService.name); + + constructor(private readonly settingsService: SettingsService) {} + + /** + * Send a DKV export xlsx file as an email attachment to the configured recipient. + * + * Transport is created fresh per send using the decrypted SMTP config from DB. + * This ensures that any admin SMTP config change takes effect on the next send + * without restarting the API (Pitfall 3 mitigation). + * + * On failure: logs a generic error message (never credentials — T-07-10) and + * rethrows so the orchestrator (Plan 04) can execute 3-retry exponential backoff (D-16). + * Error is NOT swallowed here — unlike MailService (which swallows for T-02-12 reasons). + * + * @param tenantId - Tenant whose SmtpConfig to use + * @param recipient - Export recipient email address (from DkvModuleConfig.exportRecipient) + * @param attachmentBuffer - xlsx Buffer from DkvExportService.buildExcelBuffer() + * @param filename - Attachment filename (e.g. "DKV_2026-04_26-651566449-001.xlsx") + */ + async sendExportEmail( + tenantId: string, + recipient: string, + attachmentBuffer: Buffer, + filename: string, + ): Promise { + // Load decrypted SMTP config — used only within this method scope (T-07-10) + const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId); + + if (!smtpConfig) { + throw new Error( + `No SMTP configuration found for tenant ${tenantId}. Configure SMTP in Settings first.`, + ); + } + + // Build transport at send time (NOT at module startup — Pitfall 3) + const transport = nodemailer.createTransport({ + host: smtpConfig.host, + port: smtpConfig.port, + secure: smtpConfig.encryption === 'ssl-tls', + requireTLS: smtpConfig.encryption === 'starttls', + auth: smtpConfig.username + ? { + user: smtpConfig.username, + // T-07-10: decryptedPassword used only here, never logged + pass: smtpConfig.decryptedPassword ?? '', + } + : undefined, + }); + + const subject = `DKV Flottenabrechnung: ${filename}`; + const text = [ + 'Sehr geehrte Damen und Herren,', + '', + 'anbei erhalten Sie die aktuelle DKV-Flottenabrechnung als Excel-Datei.', + '', + `Datei: ${filename}`, + '', + 'Mit freundlichen Grüßen,', + 'Ihr Tessera-System', + ].join('\n'); + + try { + await transport.sendMail({ + from: smtpConfig.fromAddress, + to: recipient, + subject, + text, + attachments: [ + { + filename, + content: attachmentBuffer, + contentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + }); + + this.logger.log(`DKV export email sent to ${recipient}: ${filename}`); + } catch (error) { + // T-07-10: Generic log message — no SMTP credentials, host, or transport details + this.logger.error( + `Failed to send DKV export to ${recipient} (file: ${filename}): ${(error as Error).message}`, + ); + // RETHROW — caller (DkvService) handles exponential backoff retries (D-16) + throw error; + } + } +}