import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as nodemailer from 'nodemailer'; import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import { SettingsService } from '../settings/settings.service'; /** * MailService — Systemmails (Kennwort-Zuruecksetzung, Willkommensmail). * * TRANSPORT JE VERSAND NACH MANDANT DES EMPFAENGERS (Etappe 3c, 260914-eym, * WINDOWS #30 GESCHLOSSEN): * * Vorher baute `mail.module.ts` beim Start EINEN Transport aus einer * beliebigen SmtpConfig (`findFirst()` ohne Bedingung) und alle * Systemmails aller Mandanten liefen ueber den SMTP-Server und die * Absenderadresse DIESES einen Mandanten (T-GWH-03). Zwei Gruende, warum * der Transport jetzt JE VERSAND entsteht: * * 1. Pitfall 3 (Research): ein Start-Transport kann nicht wechseln — eine * Aenderung der SMTP-Einstellungen im UI griff erst nach einem Neustart. * 2. Mandantentrennung: der Mandant des EMPFAENGERS entscheidet, welche * Zugangsdaten benutzt werden — nie ein beliebiger. Der Mandant ist an * der einzigen produktiven Versandstelle bekannt * (`AuthService.requestPasswordReset`: `user.tenantId` steht eine Zeile * vor dem Versand). Vorlage: `DkvMailService`/`TenderMailService` * (`getDecryptedSmtpConfig(tenantId)`, gebunden, `nodemailer.createTransport`, * `transport.close()` im `finally`). * * Die Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) ist NUR * noch der Rueckfall fuer Mandanten OHNE eigene SmtpConfig — nicht mehr * der Ersatz fuer einen verstummten Startpfad. Es gibt keinen Startpfad * mehr, deshalb braucht `SmtpConfig` auch keine `system_read_policy`. * * Was mit EINEM Mandanten identisch bleibt (mail.service.spec.ts): Mandant * MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren * fromAddress; Mandant OHNE -> dieselbe Umgebungs-Kette wie bisher; * Transportfehler werden weiter verschluckt und protokolliert (T-02-12 — * der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration). * * Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von * `resolveTransport`/`deliver` und wird nie protokolliert * (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und * Empfaenger. * * Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern * `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt * Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer * den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und * `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt, * ob die Mail ging. */ /** Inhaltskennung des Kopfbilds der Willkommensmail (``). */ export const WELCOME_HEADER_CID = 'welcome-header@tessera'; let welcomeHeaderCache: Buffer | null | undefined; /** * Laedt das Kopfbild der Willkommensmail einmal je Prozess * (apps/api/assets/mail/welcome-header.png, erzeugt von * scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter * dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter * apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt * dann einen dunklen Textkopf statt abzubrechen. */ export function loadWelcomeHeaderPng(): Buffer | null { if (welcomeHeaderCache !== undefined) return welcomeHeaderCache; const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png'); try { welcomeHeaderCache = fs.readFileSync(file); } catch { new Logger('MailService').warn(`Welcome header image missing: ${file}`); welcomeHeaderCache = null; } return welcomeHeaderCache; } /** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */ export interface OutgoingAttachment { filename: string; content: Buffer; contentType: string; /** Inhaltskennung fuer eingebettete Bilder (``). */ cid?: string; } /** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */ export interface OutgoingMail { to: string; subject: string; text: string; html?: string; attachments?: OutgoingAttachment[]; } /** Was `BugReportsService` liefert — Empfaenger und Mandant kommen getrennt. */ export type BugReportMail = Pick; interface ResolvedTransport { source: 'tenant' | 'env'; /** * SMTPTransport.Options statt TransportOptions & Record: * beide Zweige von resolveTransport() bauen reine SMTP-Optionen (host, * port, secure, requireTLS, auth) — und genau das nimmt createTransport() * ohne Zusicherung entgegen. Die bisherige Kombination war zu weit und * brauchte deshalb ein `as any` an der Uebergabe. */ options: SMTPTransport.Options; from: string; } @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); private readonly appUrl: string; constructor( private readonly settingsService: SettingsService, private readonly configService: ConfigService, ) { this.appUrl = this.configService.get( 'TESSERA_APP_URL', 'http://localhost:3000', ); } /** * Transport-Optionen fuer den Mandanten des Empfaengers: die SmtpConfig * des Mandanten (gebunden ueber `getDecryptedSmtpConfig(tenantId)`), * sonst die bisherige Umgebungs-Kette aus `mail.module.ts` unveraendert. */ private async resolveTransport(tenantId: string): Promise { const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId); if (smtpConfig) { return { source: 'tenant', options: { host: smtpConfig.host, port: smtpConfig.port, secure: smtpConfig.encryption === 'ssl-tls', requireTLS: smtpConfig.encryption === 'starttls', auth: smtpConfig.username ? { user: smtpConfig.username, // T-07-10/T-07-11: entschluesseltes Kennwort nur hier, nie protokolliert pass: smtpConfig.decryptedPassword ?? '', } : undefined, }, from: smtpConfig.fromAddress, }; } // Rueckfall: Umgebungsvariablen (neue Namen zuerst, TESSERA_SMTP_* als // zweite Stufe, zuletzt localhost:1025 — Mailhog / dev default). const host = this.configService.get('MAIL_HOST') ?? this.configService.get('TESSERA_SMTP_HOST') ?? 'localhost'; const port = this.configService.get('MAIL_PORT') ?? this.configService.get('TESSERA_SMTP_PORT') ?? 1025; const user = this.configService.get('MAIL_USER') ?? this.configService.get('TESSERA_SMTP_USER') ?? ''; const pass = this.configService.get('MAIL_PASS') ?? this.configService.get('TESSERA_SMTP_PASSWORD') ?? ''; const from = this.configService.get('TESSERA_SMTP_FROM') ?? 'Tessera '; const secure = this.configService.get('TESSERA_SMTP_SECURE', 'false') === 'true'; return { source: 'env', options: { host, port, secure, auth: { user, pass } }, from, }; } /** * Der eine Versandkern: Transport je Versand aus `resolveTransport`, * `sendMail` mit optionalem HTML und Anhaengen, `close()` im `finally` * (WR-01 — keine offenen Verbindungen). WIRFT bei Transportfehlern — * ob der Fehler nach aussen geht, entscheidet der Aufrufer. */ private async deliver(tenantId: string, mail: OutgoingMail, kind: string): Promise { let transport: nodemailer.Transporter | null = null; try { const resolved = await this.resolveTransport(tenantId); transport = nodemailer.createTransport(resolved.options); await transport.sendMail({ from: resolved.from, to: mail.to, subject: mail.subject, text: mail.text, ...(mail.html !== undefined ? { html: mail.html } : {}), ...(mail.attachments !== undefined ? { attachments: mail.attachments } : {}), }); this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`); } finally { transport?.close(); } } /** * Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler * werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter * 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt * ihn seit dem Versand aus der Benutzerverwaltung nicht mehr. */ private async sendViaTenantTransport( tenantId: string, mail: { to: string; subject: string; text: string }, kind: string, ): Promise { try { await this.deliver(tenantId, mail, kind); } catch (error) { // Log but don't throw -- caller returns 200 regardless (T-02-12) this.logger.error( `Failed to send ${kind} email to ${mail.to}`, error instanceof Error ? error.stack : String(error), ); } } /** * Fehlermeldung eines Anwenders (quick-260914-m97) mit PNG-Anhang an das * eingestellte Postfach des Mandanten. Fehler gehen BEWUSST nach aussen — * anders als bei T-02-12: hier gibt es nichts zu verbergen (kein * Anmeldeweg, kein Enumerationsrisiko), und der Anwender soll wissen, ob * sein Bericht angekommen ist. `BugReportsService` uebersetzt den Fehler * in eine 502-Antwort. */ async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise { await this.deliver(tenantId, { to, ...report }, 'Bug report'); } /** * Send a password reset email with a time-limited token link. * T-02-12: The caller always returns 200 regardless of whether this succeeds * (no email enumeration). * * @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport) */ async sendPasswordResetEmail( email: string, token: string, tenantId: string, locale: string = 'de', ): Promise { const resetLink = `${this.appUrl}/reset-password/${token}`; const isGerman = locale === 'de'; const subject = isGerman ? 'Passwort zurücksetzen - Tessera' : 'Reset your password - Tessera'; const text = isGerman ? [ 'Hallo,', '', 'Sie haben eine Passwortzurücksetzung für Ihren Tessera-Account angefordert.', '', `Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:`, resetLink, '', 'Dieser Link ist 1 Stunde gültig und kann nur einmal verwendet werden.', '', 'Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.', '', 'Mit freundlichen Grüßen,', 'Ihr Tessera-Team', ].join('\n') : [ 'Hello,', '', 'You have requested a password reset for your Tessera account.', '', 'Click the following link to reset your password:', resetLink, '', 'This link is valid for 1 hour and can only be used once.', '', 'If you did not request this, you can safely ignore this email.', '', 'Best regards,', 'The Tessera Team', ].join('\n'); await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset'); } /** * Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer, * "Willkommensmail senden"). Inhalt und HTML baut * `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber * den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt * nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein * Nachladen von aussen) und versendet ueber den Transport des Mandanten * des EMPFAENGERS. * * Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein * Administrator loest den Versand gezielt aus und muss erfahren, ob die * Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung * verraten koennte (T-02-12 betrifft nur Kennwort-Reset). */ async sendWelcomeMail( tenantId: string, to: string, mail: { subject: string; text: string; html: string }, ): Promise { const header = loadWelcomeHeaderPng(); await this.deliver( tenantId, { to, ...mail, attachments: header ? [ { filename: 'tessera.png', content: header, contentType: 'image/png', cid: WELCOME_HEADER_CID, }, ] : undefined, }, 'Welcome', ); } /** * Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine * eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST / * TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in * der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen * Versandweg, der im Betrieb ins Leere geht. */ async hasConfiguredTransport(tenantId: string): Promise { const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId); if (smtpConfig) return true; const envHost = this.configService.get('MAIL_HOST') ?? this.configService.get('TESSERA_SMTP_HOST'); return typeof envHost === 'string' && envHost.trim() !== ''; } /** * Erinnerungs-E-Mail (quick-260929-if2): eine Mail je faelliger Erinnerung an * die eigene Adresse des Besitzers, ueber den SMTP-Transport seines Mandanten. * Gibt `true` zurueck, wenn der Versand gelang, `false` bei einem * Transportfehler — der Planer (`ReminderMailScheduler`) entscheidet daran, * ob er den Anspruch wieder freigibt (E-04). Wirft nie. * * Nur Text, kein HTML (T-IF2-05, kein HTML-Einschleusen). Der Betreff hat * Zeilenumbrueche durch Leerzeichen ersetzt (Header-Einschleusung) und ist * auf 150 Zeichen gekuerzt. Die Zeit steht in `Europe/Berlin` (E-07): im * Benutzer ist keine Zeitzone gespeichert, das Haus arbeitet in deutscher Zeit. */ async sendReminderEmail( tenantId: string, to: string, reminder: { title: string; description: string; dueAt: Date }, ): Promise { const when = `${new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'full', timeStyle: 'short', }).format(reminder.dueAt)} Uhr`; const subject = `Erinnerung: ${reminder.title}`.replace(/[\r\n]+/g, ' ').slice(0, 150); const lines = [ 'Guten Tag,', '', `Sie haben in Tessera eine Erinnerung für ${when} gesetzt:`, '', reminder.title, ]; if (reminder.description.trim() !== '') { lines.push('', reminder.description); } lines.push('', this.appUrl); try { await this.deliver(tenantId, { to, subject, text: lines.join('\n') }, 'Reminder'); return true; } catch (error) { this.logger.error( `Failed to send Reminder email to ${to}`, error instanceof Error ? error.stack : String(error), ); return false; } } }