/** * welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail. * * Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte * (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff, * Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt * `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen * Anmeldehinweis" `WelcomeMailService`. * * Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift, * Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto) * 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 = `
`. 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; * - keine externen Ressourcen, keine Web-Fonts (Systemschriften); * - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter * die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im * Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht * erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss; * - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"), * Outlook ignoriert Innenabstaende und Rundungen am Link selbst. * * Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden * nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale * Konten bekommen einen Link zum Festlegen (Token wie beim * "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das * Windows-Passwort. */ 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', card: '#ffffff', ink: '#1a1d21', body: '#3d4450', muted: '#6b7280', line: '#e3e5e9', well: '#f7f7f5', yellow: '#ffed00', link: '#1d5fc2', header: '#1a1c20', } as const; const FONT = "'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif"; /** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */ export type WelcomeMailAccount = | { kind: 'directory' } | { kind: 'local'; setPasswordUrl: string; validHours: number } /** * Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService` * legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis * fest und ist nicht Teil der Vorlage. */ | { 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, `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 { subject: string; text: string; html: string; } export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject; const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.'; 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 .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */ function safeUrl(value: string): string { return /^https?:\/\//i.test(value) ? value : ''; } /** * Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht * ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist * er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext. */ function loginHintText(account: WelcomeMailAccount, texts: Partial): string { const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => { const value = texts[field]; return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field]; }; switch (account.kind) { case 'directory': return pick('loginHintDirectory'); case 'local': case 'local-example': return pick('loginHintLocal'); case 'local-no-link': return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.'; } } function validityText(hours: number): string { const span = hours % 24 === 0 && hours >= 24 ? hours === 24 ? '1 Tag' : `${hours / 24} Tage` : hours === 1 ? '1 Stunde' : `${hours} Stunden`; return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`; } /** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */ function button(href: string, label: string, bg: string, fg: string): string { return `
${escapeHtml(label)}
`; } /** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */ function tile(color: string | null): string { const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="'; return ` `; } /** Luecke zwischen Kacheln. */ const GAP = ' '; /** * Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen — * oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in * der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App. * Braucht kein Bild und erscheint deshalb in jedem Mailprogramm. */ function logoMark(): string { const row = (cells: Array) => `${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}`; const spacer = ` `; const olive = '#9c9440'; return `
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
`; } /** * Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein * riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und * Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das * eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt: * - 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:`, 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 = `
${logoMark()} Tessera
`; const wave = src ? ` ` : ` `; return bar + wave; } /** * Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift, * Einleitung, Anmeldehinweis je Kontoart, 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, Knopf "Passwort festlegen" mit * Gueltigkeitshinweis, 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 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 hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts))); const closingParagraphs = paragraphsOf(fill(texts.closing)); const notice = input.notice ? singleLine(input.notice) : ''; // ── Text-Alternative ─────────────────────────────────────────────────── 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}`); for (const para of hintParagraphs) textLines.push('', para); if (input.account.kind === 'local') { textLines.push( '', 'Passwort festlegen:', 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); for (const para of closingParagraphs) textLines.push('', para); textLines.push('', '--', FOOTER); const text = textLines.join('\n'); // ── HTML ─────────────────────────────────────────────────────────────── 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}
`; let accountBlock = hintParagraphs .map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : '')) .join('\n'); if (input.account.kind === 'local') { 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 = ` ${escapeHtml(subject)}
Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.
${noticeRow}${headerRow(input.headerImageSrc)}
${headingHtml}${introHtml}
${label('Adresse')} ${label('Benutzername')}
${username}
${accountBlock}
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
${closingHtml}
${escapeHtml(FOOTER)}
`; return { subject, text, html }; }