Files
tessera-ctl/apps/api/src/mail/welcome-mail.template.ts
T
schalli 52f538c432 fix(mail): Willkommensmail-Kopf ohne Bildzwang – Logo als HTML, Welle als Streifen
Rueckmeldung des Nutzers: in Outlook nur ein grosser schwarzer Kasten, kein
Logo (das eingebettete Kopfbild wurde nicht angezeigt; Logo und Schriftzug
steckten nur darin). Jetzt Bildmarke aus Tabellenzellen und Schriftzug als
Text in einer niedrigen dunklen Leiste, darunter die Duenen-Welle als
600x56-Streifen, der ins Weiss auslaeuft; fehlt das Bild, bleibt nur Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:29:16 +02:00

287 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`.
*
* 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.
*/
/** 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 }
| { kind: 'local-no-link' };
export interface WelcomeMailInput {
/** Anzeigename, sonst Benutzername. */
name: string;
username: string;
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
appUrl: string;
account: WelcomeMailAccount;
/** `cid:...` im Versand, relativer Pfad in der Vorschau, `null` = Textkopf. */
headerImageSrc: string | null;
}
export interface RenderedWelcomeMail {
subject: string;
text: string;
html: string;
}
export const WELCOME_MAIL_SUBJECT = 'Willkommen bei Tessera';
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.';
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
function safeUrl(value: string): string {
return /^https?:\/\//i.test(value) ? value : '';
}
function loginHintText(account: WelcomeMailAccount): string {
switch (account.kind) {
case 'directory':
return 'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.';
case 'local':
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.';
}
}
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 `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
</td></tr></table>`;
}
/** 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 `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;">&nbsp;</td>`;
}
/** Luecke zwischen Kacheln. */
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;">&nbsp;</td>';
/**
* 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<string | null>) =>
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;">&nbsp;</td></tr>`;
const olive = '#9c9440';
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
</table>
</td></tr></table>`;
}
/**
* 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:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
* Abstand, kein dunkler Block.
*/
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;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
</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;">
</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};">&nbsp;</td></tr>`;
return bar + wave;
}
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const hint = loginHintText(input.account);
// ── Text-Alternative ───────────────────────────────────────────────────
const textLines = [
`Guten Tag ${input.name},`,
'',
INTRO,
'',
'Ihre Zugangsdaten',
`Adresse: ${base}`,
`Benutzername: ${input.username}`,
'',
hint,
];
if (input.account.kind === 'local') {
textLines.push(
'',
'Passwort festlegen:',
input.account.setPasswordUrl,
validityText(input.account.validHours),
);
}
textLines.push(
'',
'Zu Tessera:',
loginUrl,
'',
DESKTOP_HINT,
'',
'Viel Erfolg mit Tessera!',
'',
'--',
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>`;
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>`;
let accountBlock = p(escapeHtml(hint), 'margin:24px 0 16px;');
if (input.account.kind === 'local') {
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>`;
}
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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<style>
a { color: ${C.link}; }
@media only screen and (max-width: 620px) {
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
<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)))}
<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')}
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
${label('Benutzername')}
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
</td></tr></table>
${accountBlock}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
${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>
</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>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>`;
return { subject: WELCOME_MAIL_SUBJECT, text, html };
}