feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s

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:
2026-09-30 15:28:52 +02:00
parent 0b34e82b21
commit 32441d77c7
24 changed files with 1540 additions and 115 deletions
+50 -2
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service';
import { MailService, WELCOME_HEADER_CID } from './mail.service';
/**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Guten Tag bob,',
html: '<p>bob</p>',
});
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Text',
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
});
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
expect(sent.text).toBe('Text');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
expect(sent.attachments[0].contentType).toBe('image/png');
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED');
});
await expect(
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
).rejects.toThrow('ECONNREFUSED');
});
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
expect(
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(false);
});
});
+84 -41
View File
@@ -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() !== '';
}
/**
+241
View File
@@ -0,0 +1,241 @@
/**
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
*
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
* Anmeldehinweis" `WelcomeMailService`.
*
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
* Mail sehr unterschiedlich darstellen:
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
* - der Kopf im Stil des Dashboard-Hintergrunds "Duenen" ist ein PNG
* (`headerImageSrc`, im Versand `cid:`), weil SVG und CSS-Hintergruende in
* Outlook nicht erscheinen; fehlt das Bild, steht dort ein dunkler
* Textkopf;
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
*
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
* Konten bekommen einen Link zum Festlegen (Token wie beim
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
* Windows-Passwort.
*/
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
const C = {
page: '#eceef1',
card: '#ffffff',
ink: '#1a1d21',
body: '#3d4450',
muted: '#6b7280',
line: '#e3e5e9',
well: '#f7f7f5',
yellow: '#ffed00',
link: '#1d5fc2',
header: '#1a1c20',
} as const;
const FONT =
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
export type WelcomeMailAccount =
| { kind: 'directory' }
| { kind: 'local'; setPasswordUrl: string; validHours: number }
| { kind: 'local-no-link' };
export interface WelcomeMailInput {
/** Anzeigename, sonst Benutzername. */
name: string;
username: string;
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
appUrl: string;
account: WelcomeMailAccount;
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
headerImageSrc: string | null;
}
export interface RenderedWelcomeMail {
subject: string;
text: string;
html: string;
}
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
const INTRO =
'für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.';
const DESKTOP_HINT =
'Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.';
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
function safeUrl(value: string): string {
return /^https?:\/\//i.test(value) ? value : '';
}
function loginHintText(account: WelcomeMailAccount): string {
switch (account.kind) {
case 'directory':
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
case 'local':
return 'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.';
case 'local-no-link':
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
}
}
function validityText(hours: number): string {
const span =
hours % 24 === 0 && hours >= 24
? hours === 24
? '1 Tag'
: `${hours / 24} Tage`
: hours === 1
? '1 Stunde'
: `${hours} Stunden`;
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
}
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
function button(href: string, label: string, bg: string, fg: string): string {
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
</td></tr></table>`;
}
function headerRow(src: string | null): string {
if (src) {
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;line-height:0;font-size:0;">
<img src="${escapeHtml(src)}" width="600" height="150" alt="Tessera" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;border-radius:12px 12px 0 0;color:#ffffff;font-family:${FONT};font-size:28px;font-weight:700;">
</td></tr>`;
}
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:40px 40px 36px;font-family:${FONT};font-size:30px;line-height:36px;font-weight:700;color:#ffffff;border-bottom:3px solid ${C.yellow};">Tessera</td></tr>`;
}
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const hint = loginHintText(input.account);
// ── Text-Alternative ───────────────────────────────────────────────────
const textLines = [
`Guten Tag ${input.name},`,
'',
INTRO,
'',
'Ihre Zugangsdaten',
`Adresse: ${base}`,
`Benutzername: ${input.username}`,
'',
hint,
];
if (input.account.kind === 'local') {
textLines.push(
'',
'Passwort festlegen:',
input.account.setPasswordUrl,
validityText(input.account.validHours),
);
}
textLines.push(
'',
'Zu Tessera:',
loginUrl,
'',
DESKTOP_HINT,
'',
'Viel Erfolg mit Tessera!',
'',
'--',
FOOTER,
);
const text = textLines.join('\n');
// ── HTML ───────────────────────────────────────────────────────────────
const name = escapeHtml(input.name);
const username = escapeHtml(input.username);
const baseHtml = escapeHtml(base);
const p = (content: string, extra = '') =>
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
const label = (content: string) =>
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
let accountBlock = p(escapeHtml(hint), 'margin:24px 0 16px;');
if (input.account.kind === 'local') {
const setUrl = safeUrl(input.account.setPasswordUrl);
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
}
const html = `<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${escapeHtml(WELCOME_MAIL_SUBJECT)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<style>
a { color: ${C.link}; }
@media only screen and (max-width: 620px) {
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
<tr><td align="center" style="padding:32px 12px;">
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
${headerRow(input.headerImageSrc)}
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:36px 40px 12px;">
<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">Willkommen bei Tessera, ${name}!</h1>
${p(escapeHtml(INTRO.charAt(0).toUpperCase() + INTRO.slice(1)))}
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
${label('Adresse')}
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
${label('Benutzername')}
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
</td></tr></table>
${accountBlock}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
${p(`<strong style="color:${C.ink};">Tipp:</strong> ${escapeHtml(DESKTOP_HINT)}`, `font-size:14px;line-height:22px;margin:0 0 12px;`)}
${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
</td></tr></table>
</td></tr>
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>`;
return { subject: WELCOME_MAIL_SUBJECT, text, html };
}