feat(07-03): DkvMailService — runtime nodemailer transport with xlsx attachment (DKV-04)
- 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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user