feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
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:
@@ -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};"> </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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user