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;');
});
});