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; } finally { // Always close the transport to release the SMTP connection pool (WR-01). // Without this, repeated sends (especially with retry backoff) accumulate // open connections and can exhaust OS socket limits. transport.close(); } } }