feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar), GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile, Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig). Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste: Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert apps/api/assets. Lokal per MailHog nachgewiesen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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);
|
||||
* - der Kopf im Stil des Dashboard-Hintergrunds "Duenen" ist ein PNG
|
||||
* (`headerImageSrc`, im Versand `cid:`), weil SVG und CSS-Hintergruende in
|
||||
* Outlook nicht erscheinen; fehlt das Bild, steht dort ein dunkler
|
||||
* Textkopf;
|
||||
* - 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, '&')
|
||||
.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 : '';
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function headerRow(src: string | null): string {
|
||||
if (src) {
|
||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;line-height:0;font-size:0;">
|
||||
<img src="${escapeHtml(src)}" width="600" height="150" alt="Tessera" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;border-radius:12px 12px 0 0;color:#ffffff;font-family:${FONT};font-size:28px;font-weight:700;">
|
||||
</td></tr>`;
|
||||
}
|
||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:40px 40px 36px;font-family:${FONT};font-size:30px;line-height:36px;font-weight:700;color:#ffffff;border-bottom:3px solid ${C.yellow};">Tessera</td></tr>`;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user