From e10da76259b990525d71b0abe0149d3397c8c00d Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 30 Sep 2026 16:49:17 +0200 Subject: [PATCH] feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 3 +- apps/api/assets/mail/welcome-header.png | Bin 10755 -> 8937 bytes apps/api/assets/mail/welcome-header.svg | 19 +- .../migration.sql | 52 ++ apps/api/prisma/schema.prisma | 17 + apps/api/scripts/render-mail-header.mjs | 6 +- .../src/mail/welcome-mail.template.spec.ts | 139 +++++ apps/api/src/mail/welcome-mail.template.ts | 199 ++++++-- .../src/user/dto/welcome-mail-template.dto.ts | 83 +++ apps/api/src/user/user.module.ts | 12 +- .../welcome-mail-template.controller.spec.ts | 151 ++++++ .../user/welcome-mail-template.controller.ts | 105 ++++ .../welcome-mail-template.service.spec.ts | 125 +++++ .../src/user/welcome-mail-template.service.ts | 110 ++++ .../api/src/user/welcome-mail.service.spec.ts | 220 +++++++- apps/api/src/user/welcome-mail.service.ts | 138 ++++- .../app/(portal)/admin/welcome-mail/page.tsx | 27 + .../src/components/admin/admin-sidebar.tsx | 12 + .../welcome-mail-template-editor.test.tsx | 227 +++++++++ .../admin/welcome-mail-template-editor.tsx | 481 ++++++++++++++++++ apps/web/src/lib/welcome-mail-template-api.ts | 99 ++++ apps/web/src/messages/de.json | 99 +++- apps/web/src/messages/en.json | 99 +++- apps/web/src/messages/umlaut-dictionary.ts | 6 + docs/anleitung-administration.md | 28 +- ...andantentrennung-zugriffsklassifikation.md | 19 +- packages/shared/src/index.ts | 77 +++ 27 files changed, 2471 insertions(+), 82 deletions(-) create mode 100644 apps/api/prisma/migrations/20260930150000_welcome_mail_template/migration.sql create mode 100644 apps/api/src/mail/welcome-mail.template.spec.ts create mode 100644 apps/api/src/user/dto/welcome-mail-template.dto.ts create mode 100644 apps/api/src/user/welcome-mail-template.controller.spec.ts create mode 100644 apps/api/src/user/welcome-mail-template.controller.ts create mode 100644 apps/api/src/user/welcome-mail-template.service.spec.ts create mode 100644 apps/api/src/user/welcome-mail-template.service.ts create mode 100644 apps/web/src/app/(portal)/admin/welcome-mail/page.tsx create mode 100644 apps/web/src/components/admin/welcome-mail-template-editor.test.tsx create mode 100644 apps/web/src/components/admin/welcome-mail-template-editor.tsx create mode 100644 apps/web/src/lib/welcome-mail-template-api.ts 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 653892e6b9f4469bb43aefb3b286bdef6dfeba1b..a4c7967c66eeb6e60d5626c64279605fb2ba9b1b 100644 GIT binary patch literal 8937 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 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)}