feat(07-03): DkvMailService — runtime nodemailer transport with xlsx attachment (DKV-04)
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Waiting to run

- createTransport() called per-send from DB SmtpConfig (Pitfall 3 mitigation — not at startup)
- Injects SettingsService to load decrypted SMTP config per tenant
- secure/requireTLS mapped from encryption field (ssl-tls / starttls / none)
- Auth omitted when username absent (anonymous relay support)
- Attachment contentType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
- Error path rethrows after generic log (T-07-10) so DkvService can run 3-retry backoff (D-16)
- Does not import @nestjs-modules/mailer abstractions
This commit is contained in:
2026-06-27 00:11:59 +02:00
parent 6b76ca9633
commit 4deefb52de
+108
View File
@@ -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<void> {
// 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;
}
}
}