diff --git a/apps/api/src/tenders/tender-mail.service.ts b/apps/api/src/tenders/tender-mail.service.ts new file mode 100644 index 0000000..1fb19f3 --- /dev/null +++ b/apps/api/src/tenders/tender-mail.service.ts @@ -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, + ): 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(' — '); +}