feat(12-02): TenderMailService — mandanten-SMTP-Versand (DkvMailService-Klon)

Structural clone of DkvMailService (RESEARCH.md Pattern F / D-08): a
fresh nodemailer transport is built from
settingsService.getDecryptedSmtpConfig(tenantId) on every send, never
a cached/global mailer, and transport.close() always runs in finally
(WR-01 socket-leak guard).

Unlike DkvMailService, sendDigest/sendInstant never throw — a missing
SmtpConfig or a send failure both resolve to false so the digest
scheduler (Task 2) can decide whether to stamp TenderMatch.notifiedAt
without a per-caller try/catch, and a cron run never crashes because
one tenant lacks SMTP config (Pitfall 6).

sendDigest builds ONE mail sectioned by saved-search profile name
(D-02); sendInstant builds ONE collective mail per profile (D-05).
estimatedValue is formatted via String() only, never Number()-coerced
(mostly-null Decimal field). Tender titles/profile names are
HTML-escaped before interpolation (T-12-08).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:11:57 +02:00
parent fbcc108341
commit 53e72dfa05
+290
View File
@@ -0,0 +1,290 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { SettingsService } from '../settings/settings.service';
/**
* Minimal shape a tender needs to be rendered in a digest/instant email body.
* Deliberately a structural subset of the Prisma `Tender` model so real
* Prisma rows (with far more fields) can be passed in directly.
*
* `estimatedValue` is typed `unknown` on purpose: it arrives as a Prisma
* `Decimal`, a plain string, or null — it is NEVER blindly `Number()`-coerced
* (Phase-11 hint: mostly null, and coercion risks precision loss / NaN on
* non-numeric strings). Formatting uses `String()` only, which is safe for
* Decimal (has `toString()`), string, and number alike.
*/
export interface TenderMailItem {
title: string;
buyerName?: string | null;
deadlineAt?: Date | string | null;
estimatedValue?: unknown;
sourceUrl?: string | null;
}
/** Minimal shape of the recipient — a subset of the Prisma `User` model. */
export interface TenderMailRecipient {
email: string;
}
/** Minimal shape of a saved-search profile — a subset of `TenderSavedSearch`. */
export interface TenderMailSearchProfile {
name: string;
}
/**
* TenderMailService — sends tender-radar digest/instant notification emails
* via the tenant-specific SMTP configuration.
*
* Structural clone of `DkvMailService` (RESEARCH.md Pattern F / D-08): a
* fresh `nodemailer` transport is built from `settingsService.getDecryptedSmtpConfig(tenantId)`
* on EVERY send — never a cached/global mailer — so an admin SMTP config
* change takes effect immediately without a restart (Pitfall 4). The
* transport is always `close()`d in `finally` (WR-01 socket-leak guard).
*
* Unlike `DkvMailService` (which rethrows so its caller can retry), both
* public methods here NEVER throw: a missing SmtpConfig or a send failure
* both resolve to `false` ("skipped"/"failed", not sent). This lets the
* digest scheduler (12-02, Task 2) and the future instant dispatcher (12-03)
* use a single boolean success signal to decide whether to stamp
* `TenderMatch.notifiedAt` — a failed/skipped send must leave `notifiedAt`
* NULL so the pair is retried on the next run (RESEARCH Pitfall 6), and a
* cron/tick must never crash because one tenant/user has no SMTP configured.
*
* Security: T-07-10 pattern — the decrypted SMTP password only ever exists
* inside `resolveTransport`'s method scope and is never logged. Tender
* titles / saved-search profile names are escaped before HTML interpolation
* (T-12-08 — email-injection guard); the plain-text body needs no escaping.
*/
@Injectable()
export class TenderMailService {
private readonly logger = new Logger(TenderMailService.name);
constructor(private readonly settingsService: SettingsService) {}
/**
* Sends ONE digest mail to `user.email`, sectioned by saved-search profile
* name (D-02 — one mail per user, never one mail per profile).
*
* @param sections - profile name -> matched tenders, in display order
* @returns true if the mail was sent, false if skipped (no SMTP config) or
* the send failed — callers must not stamp `notifiedAt` on false.
*/
async sendDigest(
user: TenderMailRecipient,
tenantId: string,
sections: Record<string, TenderMailItem[]>,
): Promise<boolean> {
const resolved = await this.resolveTransport(tenantId);
if (!resolved) return false;
const { transport, smtpConfig } = resolved;
const { subject, text, html } = this.buildDigestBody(sections);
try {
await transport.sendMail({
from: smtpConfig.fromAddress,
to: user.email,
subject,
text,
html,
});
this.logger.log(`Tender digest email sent to ${user.email}`);
return true;
} catch (error) {
// Generic log message — no SMTP credentials/host details (T-07-10)
this.logger.error(
`Failed to send tender digest to ${user.email}: ${(error as Error).message}`,
);
return false;
} finally {
// WR-01: always release the SMTP connection, success or failure.
transport.close();
}
}
/**
* Sends ONE collective instant-alert mail for a single saved-search
* profile, covering all of its freshly-matched tenders in this poll tick
* (D-05 — bundled per profile per tick, never one mail per match).
*
* @returns true if the mail was sent, false if skipped/failed — callers
* must not stamp `notifiedAt` on false.
*/
async sendInstant(
user: TenderMailRecipient,
tenantId: string,
search: TenderMailSearchProfile,
tenders: TenderMailItem[],
): Promise<boolean> {
const resolved = await this.resolveTransport(tenantId);
if (!resolved) return false;
const { transport, smtpConfig } = resolved;
const { subject, text, html } = this.buildInstantBody(search, tenders);
try {
await transport.sendMail({
from: smtpConfig.fromAddress,
to: user.email,
subject,
text,
html,
});
this.logger.log(
`Tender instant alert sent to ${user.email} for profile "${search.name}"`,
);
return true;
} catch (error) {
this.logger.error(
`Failed to send tender instant alert to ${user.email}: ${(error as Error).message}`,
);
return false;
} finally {
transport.close();
}
}
/**
* Loads the decrypted SMTP config for `tenantId` and builds a fresh
* `nodemailer` transport from it — built fresh on every call, never
* cached (Pitfall 4). Returns null (no throw) when the tenant has no
* SmtpConfig row: the caller treats this as "skip this send".
*/
private async resolveTransport(tenantId: string): Promise<{
transport: ReturnType<typeof nodemailer.createTransport>;
smtpConfig: { fromAddress: string };
} | null> {
// Decrypted only within this method's scope — never logged (T-07-10).
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (!smtpConfig) {
this.logger.warn(
`No SMTP configuration for tenant ${tenantId} — skipping tender mail send (will retry next run)`,
);
return null;
}
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,
});
return { transport, smtpConfig };
}
private buildDigestBody(
sections: Record<string, TenderMailItem[]>,
): { subject: string; text: string; html: string } {
const subject = 'Ausschreibungs-Radar: neue Treffer';
const textParts: string[] = [
'Es gibt neue Treffer in Ihren Suchprofilen:',
'',
];
const htmlParts: string[] = ['<p>Es gibt neue Treffer in Ihren Suchprofilen:</p>'];
for (const [profileName, tenders] of Object.entries(sections)) {
textParts.push(`## ${profileName}`);
htmlParts.push(`<h3>${escapeHtml(profileName)}</h3>`, '<ul>');
for (const tender of tenders) {
textParts.push(...formatTenderTextLines(tender));
htmlParts.push(`<li>${tenderToHtmlLine(tender)}</li>`);
}
textParts.push('');
htmlParts.push('</ul>');
}
textParts.push('Mit freundlichen Grüßen,', 'Ihr Tessera-System');
return { subject, text: textParts.join('\n'), html: htmlParts.join('\n') };
}
private buildInstantBody(
search: TenderMailSearchProfile,
tenders: TenderMailItem[],
): { subject: string; text: string; html: string } {
const subject = `Ausschreibungs-Radar: neuer Treffer in „${search.name}"`;
const intro =
tenders.length === 1
? `Es gibt einen neuen Treffer im Suchprofil „${search.name}":`
: `Es gibt ${tenders.length} neue Treffer im Suchprofil „${search.name}":`;
const textParts: string[] = [intro, '', ...tenders.flatMap(formatTenderTextLines)];
textParts.push('', 'Mit freundlichen Grüßen,', 'Ihr Tessera-System');
const htmlIntro = `<p>${escapeHtml(intro)}</p>`;
const htmlParts: string[] = [
htmlIntro,
'<ul>',
...tenders.map((tender) => `<li>${tenderToHtmlLine(tender)}</li>`),
'</ul>',
];
return { subject, text: textParts.join('\n'), html: htmlParts.join('\n') };
}
}
/** Escapes the five HTML-special characters — email-injection guard (T-12-08). */
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Formats `deadlineAt` for display, or null if absent/unparseable.
* Deliberately does not throw on malformed input.
*/
function formatDeadline(deadlineAt: unknown): string | null {
if (!deadlineAt) return null;
const date = deadlineAt instanceof Date ? deadlineAt : new Date(deadlineAt as string);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString('de-DE');
}
/**
* Formats `estimatedValue` for display, or null if absent.
* Uses `String()` ONLY — never `Number()` — so a Prisma `Decimal` (has its
* own `toString()`), a plain numeric string, or any other representation is
* preserved verbatim without precision loss or a silent `NaN`.
*/
function formatEstimatedValue(estimatedValue: unknown): string | null {
if (estimatedValue === null || estimatedValue === undefined) return null;
return `${String(estimatedValue)} €`;
}
function formatTenderTextLines(tender: TenderMailItem): string[] {
const lines: string[] = [`- ${tender.title}`];
if (tender.buyerName) lines.push(` Auftraggeber: ${tender.buyerName}`);
const deadline = formatDeadline(tender.deadlineAt);
if (deadline) lines.push(` Frist: ${deadline}`);
const value = formatEstimatedValue(tender.estimatedValue);
if (value) lines.push(` Geschätzter Wert: ${value}`);
if (tender.sourceUrl) lines.push(` Link: ${tender.sourceUrl}`);
return lines;
}
function tenderToHtmlLine(tender: TenderMailItem): string {
const bits: string[] = [`<strong>${escapeHtml(tender.title)}</strong>`];
if (tender.buyerName) bits.push(escapeHtml(tender.buyerName));
const deadline = formatDeadline(tender.deadlineAt);
if (deadline) bits.push(`Frist: ${deadline}`);
const value = formatEstimatedValue(tender.estimatedValue);
if (value) bits.push(escapeHtml(value));
if (tender.sourceUrl) {
bits.push(`<a href="${escapeHtml(tender.sourceUrl)}">Zur Ausschreibung</a>`);
}
return bits.join(' &mdash; ');
}