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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(' — ');
|
||||
}
|
||||
Reference in New Issue
Block a user