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