32441d77c7
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar), GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile, Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig). Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste: Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert apps/api/assets. Lokal per MailHog nachgewiesen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
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 (`<img src="cid:...">`). */
|
|
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 (`<img src="cid:...">`). */
|
|
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<OutgoingMail, 'subject' | 'text' | 'attachments'>;
|
|
|
|
interface ResolvedTransport {
|
|
source: 'tenant' | 'env';
|
|
/**
|
|
* SMTPTransport.Options statt TransportOptions & Record<string, unknown>:
|
|
* 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<string>(
|
|
'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<ResolvedTransport> {
|
|
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<string>('MAIL_HOST') ??
|
|
this.configService.get<string>('TESSERA_SMTP_HOST') ??
|
|
'localhost';
|
|
|
|
const port =
|
|
this.configService.get<number>('MAIL_PORT') ??
|
|
this.configService.get<number>('TESSERA_SMTP_PORT') ??
|
|
1025;
|
|
|
|
const user =
|
|
this.configService.get<string>('MAIL_USER') ??
|
|
this.configService.get<string>('TESSERA_SMTP_USER') ??
|
|
'';
|
|
|
|
const pass =
|
|
this.configService.get<string>('MAIL_PASS') ??
|
|
this.configService.get<string>('TESSERA_SMTP_PASSWORD') ??
|
|
'';
|
|
|
|
const from =
|
|
this.configService.get<string>('TESSERA_SMTP_FROM') ??
|
|
'Tessera <tessera@tessera.local>';
|
|
|
|
const secure =
|
|
this.configService.get<string>('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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
|
|
if (smtpConfig) return true;
|
|
const envHost =
|
|
this.configService.get<string>('MAIL_HOST') ??
|
|
this.configService.get<string>('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<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
}
|