feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s

Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:49:17 +02:00
parent 31d514b7ca
commit e10da76259
27 changed files with 2471 additions and 82 deletions
@@ -0,0 +1,139 @@
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
import { describe, expect, it } from 'vitest';
import {
applyWelcomeMailPlaceholders,
firstNameOf,
renderWelcomeMail,
type WelcomeMailInput,
} from './welcome-mail.template';
/**
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
* feste Bausteine.
*/
const base: WelcomeMailInput = {
name: 'Max Muster',
username: 'max.muster',
email: 'max@example.invalid',
tenantName: 'Muster & Co',
appUrl: 'https://tessera.example.invalid/',
account: { kind: 'directory' },
headerImageSrc: null,
};
const values = {
name: 'Max Muster',
vorname: 'Max',
benutzername: 'max.muster',
email: 'max@example.invalid',
adresse: 'https://t.example.invalid',
firma: 'Muster & Co',
};
describe('Platzhalter', () => {
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
expect(
applyWelcomeMailPlaceholders(
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
values,
),
).toBe(
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
);
});
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
'{{a}}',
'{{b}}',
'{{}}',
]);
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
});
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
expect(firstNameOf(null, 'erika')).toBe('erika');
expect(firstNameOf(' ', 'erika')).toBe('erika');
});
});
describe('renderWelcomeMail mit Vorlage', () => {
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
const mail = renderWelcomeMail(base);
expect(mail.subject).toBe('Willkommen bei Tessera');
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
});
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
const mail = renderWelcomeMail({
...base,
name: '<script>x</script>',
texts: {
subject: 'Hallo\r\n{{firma}}',
heading: '<h2>{{name}}</h2>',
intro: '<a href="javascript:alert(1)">klick</a>',
closing: '',
},
});
expect(mail.subject).toBe('Hallo Muster & Co');
expect(mail.html).not.toContain('<script>x</script>');
expect(mail.html).not.toContain('<h2>');
expect(mail.html).not.toContain('href="javascript:');
expect(mail.html).toContain('&lt;h2&gt;&lt;script&gt;x&lt;/script&gt;&lt;/h2&gt;');
expect(mail.html).toContain('<title>Hallo Muster &amp; Co</title>');
});
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
const mail = renderWelcomeMail({
...base,
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', closing: ' ' },
});
expect(mail.html).toContain('>A1<br>A2</p>');
expect(mail.html).toContain('>B</p>');
expect(mail.html).not.toContain('Viel Erfolg');
expect(mail.text).toContain('A1\nA2\n\nB');
});
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
const mail = renderWelcomeMail({
...base,
account: {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
},
texts: { subject: 'x', heading: 'y', intro: '', closing: '' },
});
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
expect(mail.html).toContain('Passwort festlegen');
expect(mail.html).toContain('7 Tage');
expect(mail.html).toContain('Zu Tessera');
expect(mail.html).toContain('max.muster');
expect(mail.html).toContain('im Auftrag Ihres Administrators');
});
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
const mail = renderWelcomeMail({
...base,
account: { kind: 'local-example' },
notice: 'Testmail',
});
expect(mail.html).toContain('Beispiel-Link');
expect(mail.html).not.toContain('reset-password');
expect(mail.html).toContain('Testmail');
expect(mail.text.startsWith('[Testmail]')).toBe(true);
});
});
describe('Kopf (Outlook ohne CID-Bild)', () => {
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
expect(mail.html).toMatch(
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
);
expect(mail.html).toContain('padding:24px 40px 12px;');
});
});
+159 -40
View File
@@ -7,6 +7,14 @@
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
* Anmeldehinweis" `WelcomeMailService`.
*
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
* Einleitung und Abschluss kommen als `texts` herein (Vorlage des Mandanten
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
*
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
* Mail sehr unterschiedlich darstellen:
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
@@ -25,6 +33,14 @@
* Windows-Passwort.
*/
import {
DEFAULT_WELCOME_MAIL_TEXTS,
isWelcomeMailPlaceholder,
WELCOME_MAIL_PLACEHOLDER_RE,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
const C = {
page: '#eceef1',
@@ -46,17 +62,31 @@ const FONT =
export type WelcomeMailAccount =
| { kind: 'directory' }
| { kind: 'local'; setPasswordUrl: string; validHours: number }
| { kind: 'local-no-link' };
| { kind: 'local-no-link' }
/**
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
* Hinweis sagt, was er in der echten Mail tut.
*/
| { kind: 'local-example' };
export interface WelcomeMailInput {
/** Anzeigename, sonst Benutzername. */
name: string;
username: string;
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
email: string;
/** Name des Mandanten (Platzhalter `{{firma}}`). */
tenantName: string;
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
appUrl: string;
account: WelcomeMailAccount;
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
headerImageSrc: string | null;
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
texts?: WelcomeMailTexts | null;
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
notice?: string | null;
}
export interface RenderedWelcomeMail {
@@ -65,13 +95,55 @@ export interface RenderedWelcomeMail {
html: string;
}
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
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.';
const EXAMPLE_LINK_NOTE =
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
export function firstNameOf(displayName: string | null | undefined, username: string): string {
const first = (displayName ?? '').trim().split(/\s+/)[0];
return first || username;
}
/**
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
* wird erst beim Einbau ins HTML escaped.
*/
export function applyWelcomeMailPlaceholders(
text: string,
values: WelcomeMailPlaceholderValues,
): string {
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
);
}
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
function normalizeText(value: string): string {
return value.replace(/\r\n?/g, '\n').trim();
}
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
function singleLine(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
function paragraphsOf(value: string): string[] {
const text = normalizeText(value);
if (!text) return [];
return text
.split(/\n[ \t]*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
export function escapeHtml(value: string): string {
return value
@@ -92,6 +164,7 @@ function loginHintText(account: WelcomeMailAccount): string {
case 'directory':
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
case 'local':
case 'local-example':
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.';
@@ -154,8 +227,10 @@ ${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${r
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
* niedrigen dunklen Leiste — erscheinen immer;
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
* `cid:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
* Abstand, kein dunkler Block.
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
*/
function headerRow(src: string | null): string {
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
@@ -165,30 +240,56 @@ function headerRow(src: string | null): string {
</tr></table>
</td></tr>`;
const wave = src
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
</td></tr>`
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};">&nbsp;</td></tr>`;
return bar + wave;
}
/**
* Baut die Willkommensmail. Die vier Texte (Betreff, Ueberschrift,
* Einleitung, Abschluss) kommen aus der eigenen Vorlage des Mandanten, sonst
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
* Kopf, Kasten Adresse/Benutzername, Anmeldehinweis, Knopf "Zu Tessera" und
* Fusszeile sind feste Bausteine und nicht Teil der Vorlage.
*/
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const hint = loginHintText(input.account);
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
const values: WelcomeMailPlaceholderValues = {
name: input.name,
vorname: firstNameOf(input.name, input.username),
benutzername: input.username,
email: input.email,
adresse: base,
firma: input.tenantName,
};
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
const heading = singleLine(fill(texts.heading));
const introParagraphs = paragraphsOf(fill(texts.intro));
const closingParagraphs = paragraphsOf(fill(texts.closing));
const notice = input.notice ? singleLine(input.notice) : '';
// ── Text-Alternative ───────────────────────────────────────────────────
const textLines = [
`Guten Tag ${input.name},`,
'',
INTRO,
'',
const textLines: string[] = [];
if (notice) textLines.push(`[${notice}]`, '');
if (heading) textLines.push(heading, '');
for (const para of introParagraphs) textLines.push(para, '');
textLines.push(
'Ihre Zugangsdaten',
`Adresse: ${base}`,
`Benutzername: ${input.username}`,
'',
hint,
];
);
if (input.account.kind === 'local') {
textLines.push(
'',
@@ -196,29 +297,24 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
input.account.setPasswordUrl,
validityText(input.account.validHours),
);
} else if (input.account.kind === 'local-example') {
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
}
textLines.push(
'',
'Zu Tessera:',
loginUrl,
'',
DESKTOP_HINT,
'',
'Viel Erfolg mit Tessera!',
'',
'--',
FOOTER,
);
textLines.push('', 'Zu Tessera:', loginUrl);
for (const para of closingParagraphs) textLines.push('', para);
textLines.push('', '--', 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>`;
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
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>`;
@@ -227,8 +323,35 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
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>`;
} else if (input.account.kind === 'local-example') {
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
}
const noticeRow = notice
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
`
: '';
const headingHtml = heading
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
`
: '';
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
const closingHtml = closingParagraphs.length
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
${closingParagraphs
.map((part, i) =>
p(
para(part),
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
),
)
.join('\n')}
</td></tr></table>`
: '';
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>
@@ -237,7 +360,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
<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>
<title>${escapeHtml(subject)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<style>
a { color: ${C.link}; }
@@ -252,10 +375,9 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail
<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)))}
${noticeRow}${headerRow(input.headerImageSrc)}
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
${headingHtml}${introHtml}
<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')}
@@ -269,10 +391,7 @@ ${accountBlock}
${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>
${closingHtml}
</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>
@@ -282,5 +401,5 @@ ${p('Viel Erfolg mit Tessera!', 'font-size:14px;line-height:22px;margin:0;')}
</body>
</html>`;
return { subject: WELCOME_MAIL_SUBJECT, text, html };
return { subject, text, html };
}
@@ -0,0 +1,83 @@
import {
findUnknownWelcomeMailPlaceholders,
WELCOME_MAIL_LIMITS,
type WelcomeMailTexts,
} from '@tessera/shared';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
Validate,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
subject: 'Betreff',
heading: 'Überschrift',
intro: 'Einleitungstext',
closing: 'Abschlusstext',
};
/**
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
* statt spaeter woertlich in der Mail zu stehen.
*/
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
}
defaultMessage(args: ValidationArguments): string {
const unknown =
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
return `${label}: ${noun} ${unknown.join(', ')}`;
}
}
/**
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
* Betreff und Ueberschrift sind Pflicht, Einleitung und Abschluss duerfen
* leer sein (der Abschnitt faellt dann weg).
*/
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.subject)
@Validate(NurBekanntePlatzhalterConstraint)
subject!: string;
@IsString()
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
@MaxLength(WELCOME_MAIL_LIMITS.heading)
@Validate(NurBekanntePlatzhalterConstraint)
heading!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.intro)
@Validate(NurBekanntePlatzhalterConstraint)
intro!: string;
@IsString()
@MaxLength(WELCOME_MAIL_LIMITS.closing)
@Validate(NurBekanntePlatzhalterConstraint)
closing!: string;
}
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
@IsOptional()
@IsIn(['directory', 'local'])
account?: 'directory' | 'local';
}
+10 -2
View File
@@ -5,6 +5,8 @@ import { AdminSeedService } from './admin-seed.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { WelcomeMailService } from './welcome-mail.service';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* Importiert GroupsModule für UserService.create's Standardgruppen-
@@ -15,11 +17,17 @@ import { WelcomeMailService } from './welcome-mail.service';
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
* UserModule.
*
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
*/
@Module({
imports: [GroupsModule, MailModule],
controllers: [UserController],
providers: [UserService, AdminSeedService, WelcomeMailService],
controllers: [UserController, WelcomeMailTemplateController],
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
exports: [UserService],
})
export class UserModule {}
@@ -0,0 +1,151 @@
import 'reflect-metadata';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
/**
* Administrator → Willkommensmail (eigene Vorlage).
*
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
*/
function makeTemplates() {
return {
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
};
}
function makeWelcome() {
return {
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
};
}
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
const proto = WelcomeMailTemplateController.prototype as any;
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
return pipe.transform(body, { type: 'body', metatype });
}
async function messagesOf(body: unknown): Promise<string[]> {
try {
await validateBody(body);
} catch (error) {
expect(error).toBeInstanceOf(BadRequestException);
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
}
throw new Error('erwartete 400');
}
describe('WelcomeMailTemplateController — Rechte', () => {
it.each([
'get',
'save',
'reset',
'preview',
'sendTest',
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
});
it('haengt an Pfad welcome-mail-template', () => {
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
'welcome-mail-template',
);
});
});
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
const templates = makeTemplates();
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
await controller.get(admin);
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
await controller.reset(admin);
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
const result = await controller.sendTest(admin, valid as any, undefined);
expect(templates.getState).toHaveBeenCalledWith('t1');
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
expect(templates.reset).toHaveBeenCalledWith('t1');
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
});
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
const welcome = makeWelcome();
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
await controller.preview(admin, { ...valid });
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
});
});
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
const out: any = await validateBody({
...valid,
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
tenantId: 'evil',
});
expect(out).not.toHaveProperty('tenantId');
});
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
});
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
});
it('Laengen: Betreff/Ueberschrift 200, Texte 4000', async () => {
await expect(
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
).resolves.toBeTruthy();
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
});
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
'Bitte geben Sie einen Betreff ein.',
);
expect(await messagesOf({ ...valid, heading: '' })).toContain(
'Bitte geben Sie eine Überschrift ein.',
);
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
});
it('fehlendes Feld → 400', async () => {
const { closing: _closing, ...rest } = valid;
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
});
it('Vorschau: nur directory/local als Kontoart', async () => {
await expect(
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
).resolves.toBeTruthy();
await expect(
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Put,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthUser } from '../auth/types/auth-user';
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
import { WelcomeMailService } from './welcome-mail.service';
import {
WelcomeMailTemplateService,
type WelcomeMailTemplateState,
} from './welcome-mail-template.service';
/** Nur die vier Texte aus dem DTO — nie weitere Felder in die Vorlage. */
function textsOf(dto: WelcomeMailTemplateDto) {
return { subject: dto.subject, heading: dto.heading, intro: dto.intro, closing: dto.closing };
}
/**
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
*
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
*
* - GET /welcome-mail-template wirksame Texte + Standardtexte
* - PUT /welcome-mail-template eigene Vorlage speichern
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
*
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
* Reihenfolge-Falle.
*/
@Controller('welcome-mail-template')
export class WelcomeMailTemplateController {
constructor(
private readonly templateService: WelcomeMailTemplateService,
private readonly welcomeMailService: WelcomeMailService,
) {}
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.getState(currentUser.tenantId);
}
@Put()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async save(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
): Promise<WelcomeMailTemplateState> {
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
}
@Delete()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
return this.templateService.reset(currentUser.tenantId);
}
@Post('preview')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async preview(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailPreviewDto,
@Headers('origin') origin?: string,
): Promise<{ subject: string; html: string; text: string }> {
return this.welcomeMailService.preview(
currentUser.tenantId,
textsOf(dto),
dto.account ?? 'directory',
origin,
);
}
@Post('test')
@HttpCode(HttpStatus.OK)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async sendTest(
@CurrentUser() currentUser: AuthUser,
@Body() dto: WelcomeMailTemplateDto,
@Headers('origin') origin?: string,
): Promise<{ success: true; to: string }> {
const result = await this.welcomeMailService.sendTest(
currentUser.tenantId,
currentUser.id,
textsOf(dto),
origin,
);
return { success: true, ...result };
}
}
@@ -0,0 +1,125 @@
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
*
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
* `tenantId`, Zuruecksetzen loescht die Vorlage.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
}));
type Row = {
tenantId: string;
subject: string;
heading: string;
intro: string;
closing: string;
updatedBy: string | null;
updatedAt: Date;
};
function makePrisma(rows: Row[] = []) {
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
return {
__log: log,
__rows: rows,
__bound(tenantId: string) {
return {
welcomeMailTemplate: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
return (
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
null
);
}),
upsert: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
else rows.push({ ...args.create, updatedAt: new Date() });
return {};
}),
deleteMany: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
const before = rows.length;
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
}
return { count: before - rows.length };
}),
},
tenant: {
findUnique: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
}),
},
};
},
};
}
const texts = { subject: 'S {{name}}', heading: 'H', intro: 'I', closing: 'C' };
let prisma: ReturnType<typeof makePrisma>;
let service: WelcomeMailTemplateService;
beforeEach(() => {
prisma = makePrisma();
service = new WelcomeMailTemplateService(prisma as any);
});
describe('WelcomeMailTemplateService', () => {
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
const state = await service.getState('t1');
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
expect(await service.getCustomTexts('t1')).toBeNull();
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
});
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
const state = await service.save('t1', texts, 'ada');
const upsert = prisma.__log.find((c) => c.method === 'upsert');
expect(upsert?.tenantId).toBe('t1');
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
expect(state.custom).toBe(true);
expect(state.texts).toEqual(texts);
expect(state.updatedBy).toBe('ada');
expect(await service.getCustomTexts('t1')).toEqual(texts);
});
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
await service.save('t1', texts, 'ada');
expect(await service.getCustomTexts('t2')).toBeNull();
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
});
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
await service.save('t1', texts, 'ada');
const state = await service.reset('t1');
const del = prisma.__log.find((c) => c.method === 'deleteMany');
expect(del?.tenantId).toBe('t1');
expect(del?.args.where).toEqual({ tenantId: 't1' });
expect(state.custom).toBe(false);
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
});
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
expect(await service.getTenantName('tx')).toBe('');
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
't1',
'tx',
]);
});
});
@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
* (Administrator → Willkommensmail).
*
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
* `tenantId` eindeutig). Gespeichert werden nur die vier Texte; Kopf,
* Zugangsdaten, Anmeldehinweis, Knoepfe und Fusszeile bleiben fest in
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`.
*
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
*/
/** Antwort von GET /welcome-mail-template. */
export interface WelcomeMailTemplateState {
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
custom: boolean;
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
texts: WelcomeMailTexts;
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
defaults: WelcomeMailTexts;
updatedAt: Date | null;
updatedBy: string | null;
}
const TEXT_SELECT = {
subject: true,
heading: true,
intro: true,
closing: true,
updatedAt: true,
updatedBy: true,
} as const;
@Injectable()
export class WelcomeMailTemplateService {
constructor(private readonly prisma: PrismaService) {}
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
where: { tenantId },
select: TEXT_SELECT,
});
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
if (!row) {
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
}
return {
custom: true,
texts: { subject: row.subject, heading: row.heading, intro: row.intro, closing: row.closing },
defaults,
updatedAt: row.updatedAt,
updatedBy: row.updatedBy,
};
}
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
const state = await this.getState(tenantId);
return state.custom ? state.texts : null;
}
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
async getTenantName(tenantId: string): Promise<string> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenant = await tenantPrisma.tenant.findUnique({
where: { id: tenantId },
select: { name: true },
});
return tenant?.name ?? '';
}
async save(
tenantId: string,
texts: WelcomeMailTexts,
updatedBy: string,
): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = {
subject: texts.subject,
heading: texts.heading,
intro: texts.intro,
closing: texts.closing,
updatedBy,
};
await tenantPrisma.welcomeMailTemplate.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return this.getState(tenantId);
}
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
return this.getState(tenantId);
}
}
+209 -11
View File
@@ -1,6 +1,11 @@
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
import type { WelcomeMailTexts } from '@tessera/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
import {
normalizeOrigin,
WelcomeMailService,
type WelcomeMailTarget,
} from './welcome-mail.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
@@ -34,6 +39,10 @@ function makePrisma() {
log.push({ tenantId, model: 'user', method: 'update', args });
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
}),
findFirst: vi.fn(async (args: any) => {
log.push({ tenantId, model: 'user', method: 'findFirst', args });
return selfRow;
}),
},
};
},
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
};
let prisma: ReturnType<typeof makePrisma>;
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
let selfRow: {
username: string;
displayName: string | null;
email: string | null;
ldapDn: string | null;
} | null;
beforeEach(() => {
prisma = makePrisma();
selfRow = {
username: 'admin.lokal',
displayName: 'Ada Admin',
email: 'ada@example.invalid',
ldapDn: null,
};
});
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
return {
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
};
}
function make(
mail = makeMail(),
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
templates = makeTemplates(),
) {
const service = new WelcomeMailService(
prisma as any,
mail as any,
config as any,
templates as any,
);
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { service, mail };
return { service, mail, templates };
}
describe('WelcomeMailService.send — Vorbedingungen', () => {
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
const { service, mail } = make();
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN' } as WelcomeMailTarget);
await service.send({
...localUser,
lastLoginAt: new Date(),
role: 'SUPER_ADMIN',
} as WelcomeMailTarget);
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
});
it('deaktiviertes Konto → ConflictException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(ConflictException);
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
ConflictException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne E-Mail-Adresse → BadRequestException', async () => {
const { service, mail } = make();
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
describe('WelcomeMailService.resolveAppUrl', () => {
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }));
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe('https://tessera.example.invalid');
const { service } = make(
makeMail(),
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
);
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
'https://tessera.example.invalid',
);
});
it('ohne Konfiguration: Origin der Anfrage', () => {
const { service } = make(makeMail(), makeConfig({}));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
'https://alpha.example.invalid',
);
});
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe('https://alpha.example.invalid');
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
'https://alpha.example.invalid',
);
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
});
@@ -203,3 +259,145 @@ describe('WelcomeMailService.resolveAppUrl', () => {
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
});
});
describe('WelcomeMailService.send — eigene Vorlage', () => {
const custom: WelcomeMailTexts = {
subject: 'Hallo {{vorname}} bei {{firma}}',
heading: 'Schön, dass Sie da sind, {{name}}!',
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
closing: 'Grüße vom <b>IT-Team</b>',
};
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
const { service, mail, templates } = make();
await service.send({ ...ldapUser, tenantId: 't9' });
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
});
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
expect(rendered.html).toContain(
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
);
expect(rendered.html).toContain('Grüße vom &lt;b&gt;IT-Team&lt;/b&gt;');
expect(rendered.html).not.toContain('<b>IT-Team</b>');
expect(rendered.html).not.toContain('Desktop-App');
// feste Bausteine
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('/reset-password/');
expect(rendered.html).toContain('Zu Tessera');
expect(rendered.html).toContain('Benutzername');
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
});
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom }));
await service.send(localUser);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.subject).toBe('Willkommen bei Tessera');
});
it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => {
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
await service.send({ ...ldapUser, displayName: '<img src=x onerror=alert(1)> Böse' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<img src=x');
expect(rendered.html).toContain('&lt;img src=x onerror=alert(1)&gt; Böse');
});
});
describe('WelcomeMailService.preview', () => {
const texts: WelcomeMailTexts = {
subject: 'Betreff {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Bei {{firma}}',
closing: '',
};
it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => {
const { service, mail, templates } = make();
const rendered = await service.preview('t1', texts, 'local');
expect(templates.getTenantName).toHaveBeenCalledWith('t1');
expect(rendered.subject).toBe('Betreff Max Mustermann');
expect(rendered.html).toContain('Hallo Max');
expect(rendered.html).toContain('Bei Firma t1');
expect(rendered.html).toContain('/reset-password/beispiel');
expect(rendered.html).not.toContain('cid:');
expect(prisma.__log).toHaveLength(0);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
const { service } = make();
const rendered = await service.preview('t1', texts, 'directory');
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
});
});
describe('WelcomeMailService.sendTest', () => {
const texts: WelcomeMailTexts = {
subject: 'Test {{name}}',
heading: 'Hallo {{vorname}}',
intro: 'Text',
closing: 'Gruß',
};
it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => {
const { service, mail } = make();
const result = await service.sendTest('t1', 'u-self', texts);
expect(result.to).toBe('ada@example.invalid');
const find = prisma.__log.find((c) => c.method === 'findFirst');
expect(find?.tenantId).toBe('t1');
expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' });
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
expect(prisma.__log.some((c) => c.method === 'update')).toBe(false);
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('ada@example.invalid');
expect(rendered.subject).toBe('Test Ada Admin');
expect(rendered.html).toContain('Testmail');
// lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password
expect(rendered.html).toContain('Passwort festlegen');
expect(rendered.html).toContain('Beispiel-Link');
expect(rendered.html).not.toContain('reset-password');
expect(rendered.text).not.toContain('reset-password');
});
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), ldapDn: 'CN=Ada' };
const { service, mail } = make();
await service.sendTest('t1', 'u-self', texts);
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).toContain('gewohnten Windows-Passwort');
expect(rendered.html).not.toContain('Passwort festlegen');
});
it('ohne eigene Adresse → BadRequestException, kein Versand', async () => {
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), email: null };
const { service, mail } = make();
await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
});
it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => {
await expect(
make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(ConflictException);
await expect(
make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts),
).rejects.toBeInstanceOf(BadGatewayException);
});
});
+128 -10
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import {
BadGatewayException,
BadRequestException,
@@ -7,12 +8,17 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { User } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import type { WelcomeMailTexts } from '@tessera/shared';
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
import { renderWelcomeMail, type WelcomeMailAccount } from '../mail/welcome-mail.template';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type RenderedWelcomeMail,
renderWelcomeMail,
type WelcomeMailAccount,
} from '../mail/welcome-mail.template';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
/**
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
@@ -42,6 +48,15 @@ import { PrismaService } from '../prisma/prisma.service';
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
*
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
* Passwort-Link an eine Testadresse geht.
*
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
@@ -52,13 +67,7 @@ import { PrismaService } from '../prisma/prisma.service';
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
export type WelcomeMailTarget = Pick<
User,
| 'id'
| 'tenantId'
| 'username'
| 'displayName'
| 'email'
| 'ldapDn'
| 'isActive'
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
>;
export interface WelcomeMailResult {
@@ -68,6 +77,13 @@ export interface WelcomeMailResult {
const FALLBACK_APP_URL = 'http://localhost:3000';
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
export const PREVIEW_SAMPLE = {
name: 'Max Mustermann',
username: 'max.mustermann',
email: 'max.mustermann@example.com',
} as const;
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
export function normalizeOrigin(value: string | undefined | null): string | null {
if (!value) return null;
@@ -97,6 +113,7 @@ export class WelcomeMailService {
private readonly prisma: PrismaService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly templateService: WelcomeMailTemplateService,
) {}
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
@@ -158,12 +175,19 @@ export class WelcomeMailService {
};
}
const [texts, tenantName] = await Promise.all([
this.templateService.getCustomTexts(target.tenantId),
this.templateService.getTenantName(target.tenantId),
]);
const mail = renderWelcomeMail({
name: target.displayName?.trim() || target.username,
username: target.username,
email: to,
tenantName,
appUrl,
account,
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
});
try {
@@ -188,4 +212,98 @@ export class WelcomeMailService {
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
return { to, welcomeMailSentAt };
}
/**
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
*/
async preview(
tenantId: string,
texts: WelcomeMailTexts,
accountKind: 'directory' | 'local',
requestOrigin?: string | null,
): Promise<RenderedWelcomeMail> {
const appUrl = this.resolveAppUrl(requestOrigin);
const header = loadWelcomeHeaderPng();
const account: WelcomeMailAccount =
accountKind === 'directory'
? { kind: 'directory' }
: {
kind: 'local',
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
};
return renderWelcomeMail({
name: PREVIEW_SAMPLE.name,
username: PREVIEW_SAMPLE.username,
email: PREVIEW_SAMPLE.email,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl,
account,
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
texts,
});
}
/**
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
*/
async sendTest(
tenantId: string,
userId: string,
texts: WelcomeMailTexts,
requestOrigin?: string | null,
): Promise<{ to: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const self = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
select: { username: true, displayName: true, email: true, ldapDn: true },
});
const to = self?.email?.trim();
if (!self || !to) {
throw new BadRequestException(
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
);
}
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
throw new ConflictException(
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
);
}
const mail = renderWelcomeMail({
name: self.displayName?.trim() || self.username,
username: self.username,
email: to,
tenantName: await this.templateService.getTenantName(tenantId),
appUrl: this.resolveAppUrl(requestOrigin),
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
texts,
notice:
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
});
try {
await this.mailService.sendWelcomeMail(tenantId, to, mail);
} catch (error) {
this.logger.error(
`Welcome mail test to user ${userId} failed`,
error instanceof Error ? error.stack : String(error),
);
throw new BadGatewayException(
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
);
}
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
return { to };
}
}