diff --git a/CHANGELOG.md b/CHANGELOG.md index 2207be7..17c3478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Neu +- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung und Abschluss lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten, Anmeldehinweis und Knöpfe fügt Tessera immer selbst ein. - Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging. - Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“. @@ -16,7 +17,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Behoben - Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard. -- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen. +- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr. - Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden. ## 1.8.0 – 2026-09-30 diff --git a/apps/api/assets/mail/welcome-header.png b/apps/api/assets/mail/welcome-header.png index 653892e..a4c7967 100644 Binary files a/apps/api/assets/mail/welcome-header.png and b/apps/api/assets/mail/welcome-header.png differ diff --git a/apps/api/assets/mail/welcome-header.svg b/apps/api/assets/mail/welcome-header.svg index c741cc7..ab817c2 100644 --- a/apps/api/assets/mail/welcome-header.svg +++ b/apps/api/assets/mail/welcome-header.svg @@ -1,7 +1,7 @@ - + - + - - - - + + + + diff --git a/apps/api/prisma/migrations/20260930150000_welcome_mail_template/migration.sql b/apps/api/prisma/migrations/20260930150000_welcome_mail_template/migration.sql new file mode 100644 index 0000000..39895ad --- /dev/null +++ b/apps/api/prisma/migrations/20260930150000_welcome_mail_template/migration.sql @@ -0,0 +1,52 @@ +-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail). +-- +-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff, +-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen +-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant +-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann +-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail +-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in +-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters +-- als reinen Anzeigetext (keine Relation). +-- +-- Von Hand geschrieben (Vorbild 20260929120000_custom_module). +-- +-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die +-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE +-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die +-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines +-- einzelnen Benutzers. +-- +-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer +-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers +-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die +-- Vorlagen ueber alle Mandanten liest. +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber +-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +CREATE TABLE "WelcomeMailTemplate" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "heading" TEXT NOT NULL, + "intro" TEXT NOT NULL, + "closing" TEXT NOT NULL, + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId"); + +ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate" + USING ("tenantId" = current_tenant_id()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c196f5b..50eefbc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -767,3 +767,20 @@ model Reminder { @@index([tenantId, userId, dueAt]) @@index([dueAt]) } + +// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail): +// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus +// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die vier Texte — +// Kopf, Zugangsdaten, Anmeldehinweis und Knoepfe bleiben fest im Code. +model WelcomeMailTemplate { + id String @id @default(uuid()) + tenantId String @unique + subject String + heading String + intro String + closing String + // Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige) + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/apps/api/scripts/render-mail-header.mjs b/apps/api/scripts/render-mail-header.mjs index dc74e89..6276f31 100644 --- a/apps/api/scripts/render-mail-header.mjs +++ b/apps/api/scripts/render-mail-header.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail - * (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende - * Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden + * (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende + * Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden * Quelle welcome-header.svg. * * Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word- @@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp'); const svg = readFileSync(join(assetDir, 'welcome-header.svg')); const png = await sharp(svg, { density: 72 }) - .resize(1200, 112) + .resize(1200, 80) .png({ compressionLevel: 9, palette: false }) .toBuffer(); writeFileSync(join(assetDir, 'welcome-header.png'), png); diff --git a/apps/api/src/mail/welcome-mail.template.spec.ts b/apps/api/src/mail/welcome-mail.template.spec.ts new file mode 100644 index 0000000..5e56095 --- /dev/null +++ b/apps/api/src/mail/welcome-mail.template.spec.ts @@ -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: '', + texts: { + subject: 'Hallo\r\n{{firma}}', + heading: '

{{name}}

', + intro: 'klick', + closing: '', + }, + }); + expect(mail.subject).toBe('Hallo Muster & Co'); + expect(mail.html).not.toContain(''); + expect(mail.html).not.toContain('

'); + expect(mail.html).not.toContain('href="javascript:'); + expect(mail.html).toContain('<h2><script>x</script></h2>'); + expect(mail.html).toContain('Hallo Muster & Co'); + }); + + it('Leerzeile = neuer Absatz, einfacher Umbruch =
; 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
A2

'); + expect(mail.html).toContain('>B

'); + 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( + /\n`. 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; + +/** 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 = ` @@ -165,30 +240,56 @@ function headerRow(src: string | null): string { `; const wave = src - ? ` - + ? ` + ` : ` `; 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 = '') => `

${content}

`; + /** Reiner Text -> escaped, einfache Umbrueche als
. */ + const para = (value: string) => escapeHtml(value).replace(/\n/g, '
'); + const label = (content: string) => `
${content}
`; @@ -227,8 +323,35 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail const setUrl = safeUrl(input.account.setPasswordUrl); accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}

${escapeHtml(validityText(input.account.validHours))}

`; + } else if (input.account.kind === 'local-example') { + accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')} +

${escapeHtml(EXAMPLE_LINK_NOTE)}

`; } + const noticeRow = notice + ? `
${escapeHtml(notice)}
+` + : ''; + + const headingHtml = heading + ? `

${escapeHtml(heading)}

+` + : ''; + const introHtml = introParagraphs.map((part) => p(para(part))).join('\n'); + + const closingHtml = closingParagraphs.length + ? `
+${closingParagraphs + .map((part, i) => + p( + para(part), + `font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`, + ), + ) + .join('\n')} +
` + : ''; + const html = ` @@ -237,7 +360,7 @@ export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail -${escapeHtml(WELCOME_MAIL_SUBJECT)} +${escapeHtml(subject)}