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, ): Promise { 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 { 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; 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, ): { 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[] = ['

Es gibt neue Treffer in Ihren Suchprofilen:

']; for (const [profileName, tenders] of Object.entries(sections)) { textParts.push(`## ${profileName}`); htmlParts.push(`

${escapeHtml(profileName)}

`, '
    '); for (const tender of tenders) { textParts.push(...formatTenderTextLines(tender)); htmlParts.push(`
  • ${tenderToHtmlLine(tender)}
  • `); } textParts.push(''); htmlParts.push('
'); } 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 = `

${escapeHtml(intro)}

`; const htmlParts: string[] = [ htmlIntro, '
    ', ...tenders.map((tender) => `
  • ${tenderToHtmlLine(tender)}
  • `), '
', ]; 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, '''); } /** * 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[] = [`${escapeHtml(tender.title)}`]; 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(`Zur Ausschreibung`); } return bits.join(' — '); }