feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
|
||||
* 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
|
||||
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
|
||||
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
|
||||
* 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. */
|
||||
@@ -186,10 +216,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
|
||||
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
|
||||
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
|
||||
* bleibt fuer genau diese beiden Wege bestehen).
|
||||
* 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,
|
||||
@@ -275,46 +305,59 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user (optional — derzeit ohne
|
||||
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
|
||||
* Transport je Versand erhalten).
|
||||
* 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.
|
||||
*
|
||||
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
|
||||
* 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 sendWelcomeEmail(
|
||||
email: string,
|
||||
username: string,
|
||||
async sendWelcomeMail(
|
||||
tenantId: string,
|
||||
locale: string = 'de',
|
||||
to: string,
|
||||
mail: { subject: string; text: string; html: string },
|
||||
): Promise<void> {
|
||||
const isGerman = locale === 'de';
|
||||
const subject = isGerman
|
||||
? 'Willkommen bei Tessera'
|
||||
: 'Welcome to Tessera';
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
const text = isGerman
|
||||
? [
|
||||
`Hallo ${username},`,
|
||||
'',
|
||||
'Ihr Tessera-Account wurde erstellt.',
|
||||
'',
|
||||
`Sie können sich unter ${this.appUrl}/login anmelden.`,
|
||||
'',
|
||||
'Mit freundlichen Grüßen,',
|
||||
'Ihr Tessera-Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hello ${username},`,
|
||||
'',
|
||||
'Your Tessera account has been created.',
|
||||
'',
|
||||
`You can sign in at ${this.appUrl}/login.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Tessera Team',
|
||||
].join('\n');
|
||||
|
||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, '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() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user