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