unify email design

This commit is contained in:
Rajpreet0 2026-08-14 13:37:37 +02:00
parent 249ea113e2
commit b340d7ee03
11 changed files with 753 additions and 361 deletions

View file

@ -0,0 +1,52 @@
# Supabase Auth E-Mail-Templates
Diese HTML-Dateien sind **nicht** Teil des Laufzeitpfads der App. Supabase Auth versendet seine
eigenen E-Mails (Registrierung, Passwort-Reset, E-Mail-Änderung) über einen Mechanismus, der
komplett getrennt von `src/lib/mail/` (Resend) läuft und ausschließlich über das
**Supabase Dashboard** konfiguriert wird – es gibt in diesem Repo kein `supabase/`-Verzeichnis
und keine lokale Supabase-CLI-Konfiguration, über die diese Templates automatisch deployed werden
könnten.
Die Dateien hier sind manuell ins Dashboard einzutragen, im selben Kumasy-Design (Navy `#1E293B` /
Gold `#F4C00C`) wie die Resend-Templates unter `src/lib/mail/templates/`.
## Welche Vorlage gehört wohin
| Datei | Supabase-Template | Wird ausgelöst durch |
| --------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `confirm-signup.html` | **Confirm signup** | `supabase.auth.signUp()` in [register/route.ts](../../../app/api/auth/register/route.ts) (User) und [register/admin/route.ts](../../../app/api/auth/register/admin/route.ts) (Manager) |
| `reset-password.html` | **Reset password** | `supabase.auth.resetPasswordForEmail()` in [forgot-password-view.tsx](../../../modules/auth/ui/views/forgot-password-view.tsx) |
| `change-email.html` | **Change email address** | `supabase.auth.updateUser({ email })` in [personal-settings-actions.ts](../../../modules/settings/actions/personal-settings-actions.ts) |
Hinweis: Mitarbeiter-Accounts ([register/employee/route.ts](../../../app/api/auth/register/employee/route.ts))
nutzen bewusst `admin.createUser({ email_confirm: true })` und lösen **keine** dieser Mails aus – die
Identität ist dort bereits über den Resend-Onboarding-Link bestätigt.
## Einrichtung im Dashboard
Für **jedes** Supabase-Projekt separat wiederholen (Production + Staging `pyxjpsalchmnlyqfnzfw`):
1. Supabase Dashboard → **Authentication → Email Templates**.
2. Für jede der 3 Vorlagen: entsprechende Datei öffnen, Inhalt komplett kopieren, im Dashboard-Editor
der passenden Vorlage einfügen und speichern.
3. Betreff-Zeile im Dashboard separat pflegen (nicht Teil der HTML-Datei), z.B.:
- Confirm signup: „Bestätige deine E-Mail-Adresse bei Kumasy"
- Reset password: „Passwort zurücksetzen – Kumasy"
- Change email address: „Bestätige deine neue E-Mail-Adresse"
## Platzhalter
Supabase nutzt Go-Template-Syntax (`{{ .Variable }}`), kein JS/TS – die Escaping-Helfer aus
`../templates/utils.ts` sind hier nicht anwendbar, da Supabase die Variablen serverseitig selbst
rendert. Verwendete Platzhalter:
- `{{ .ConfirmationURL }}` – vorgefertigter Bestätigungs-/Reset-Link (in allen 3 Templates)
- `{{ .NewEmail }}` – nur in `change-email.html`, zeigt die neue Adresse zur Kontrolle an
## Design-Konsistenz
Da Supabase kein gemeinsames Layout-Modul wie `layout.ts`/`components.ts` unterstützt (jede Vorlage
wird als eigenständiger String im Dashboard gespeichert), ist die visuelle Konsistenz zu den
Resend-Mails hier manuell hergestellt (gleiche Farben/Radien/Spacing hart kodiert in jeder Datei).
Bei künftigen Design-Änderungen an `src/lib/mail/templates/tokens.ts` müssen diese 3 Dateien separat
nachgezogen werden.

View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail-Änderung bestätigen</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1E293B; padding: 40px; text-align: center;">
<img src="https://kumasy.de/logo-dark.png" alt="Kumasy" width="160" style="display: block; margin: 0 auto 16px; max-width: 160px; height: auto;" />
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
E-Mail-Änderung bestätigen
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
für dein Kumasy-Konto wurde eine Änderung der E-Mail-Adresse angefordert. Bitte bestätige die neue Adresse, damit die Änderung wirksam wird.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8f9fa; border: none; border-radius: 10px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Neue E-Mail-Adresse</p>
<p style="margin: 0; color: #333333; font-size: 15px; font-weight: 600;">{{ .NewEmail }}</p>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="{{ .ConfirmationURL }}"
style="display: inline-block; background-color: #F4C00C; color: #1E293B; text-decoration: none; padding: 16px 40px; border-radius: 999px; font-size: 16px; font-weight: 600;">
E-Mail-Änderung bestätigen
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopiere diesen Link in deinen Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
{{ .ConfirmationURL }}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren – deine aktuelle E-Mail-Adresse bleibt dann unverändert.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
Kumasy
</p>
<p style="margin: 0 0 10px; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworte nicht auf diese E-Mail.
</p>
<p style="margin: 0; color: #999999; font-size: 11px;">
Powered by Kumasy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bestätige deine E-Mail-Adresse</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1E293B; padding: 40px; text-align: center;">
<img src="https://kumasy.de/logo-dark.png" alt="Kumasy" width="160" style="display: block; margin: 0 auto 16px; max-width: 160px; height: auto;" />
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Willkommen bei Kumasy!
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
vielen Dank für deine Registrierung bei Kumasy. Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="{{ .ConfirmationURL }}"
style="display: inline-block; background-color: #F4C00C; color: #1E293B; text-decoration: none; padding: 16px 40px; border-radius: 999px; font-size: 16px; font-weight: 600;">
E-Mail-Adresse bestätigen
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopiere diesen Link in deinen Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
{{ .ConfirmationURL }}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Falls du dich nicht bei Kumasy registriert hast, kannst du diese E-Mail ignorieren.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
Kumasy
</p>
<p style="margin: 0 0 10px; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworte nicht auf diese E-Mail.
</p>
<p style="margin: 0; color: #999999; font-size: 11px;">
Powered by Kumasy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1E293B; padding: 40px; text-align: center;">
<img src="https://kumasy.de/logo-dark.png" alt="Kumasy" width="160" style="display: block; margin: 0 auto 16px; max-width: 160px; height: auto;" />
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Passwort zurücksetzen
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
wir haben eine Anfrage erhalten, das Passwort für dein Kumasy-Konto zurückzusetzen. Klicke auf den Button unten, um ein neues Passwort zu vergeben.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="{{ .ConfirmationURL }}"
style="display: inline-block; background-color: #F4C00C; color: #1E293B; text-decoration: none; padding: 16px 40px; border-radius: 999px; font-size: 16px; font-weight: 600;">
Neues Passwort vergeben
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopiere diesen Link in deinen Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
{{ .ConfirmationURL }}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0 0 10px; color: #999999; font-size: 13px; line-height: 1.6;">
<strong>Wichtig:</strong> Dieser Link ist aus Sicherheitsgründen zeitlich begrenzt gültig.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail ignorieren – dein Passwort bleibt unverändert.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
Kumasy
</p>
<p style="margin: 0 0 10px; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworte nicht auf diese E-Mail.
</p>
<p style="margin: 0; color: #999999; font-size: 11px;">
Powered by Kumasy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -6,6 +6,9 @@
*/
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
import { renderButton, renderDivider, renderInfoBox, renderPriceRow } from "./components";
import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
interface BookingConfirmationEmailParams {
courseName: string;
@ -18,6 +21,7 @@ interface BookingConfirmationEmailParams {
myCoursesUrl: string;
coverImageUrl?: string | null;
companyName?: string;
branding?: EmailBranding;
}
function formatDate(date: Date): string {
@ -48,6 +52,7 @@ export function generateBookingConfirmationEmail({
myCoursesUrl,
coverImageUrl,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: BookingConfirmationEmailParams): { subject: string; html: string; text: string } {
// Defense gegen Markup-Injection: alle dynamischen Werte vor dem HTML-Einsetzen escapen.
const safeCourseName = escapeHtml(courseName);
@ -55,7 +60,6 @@ export function generateBookingConfirmationEmail({
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
const safeTimeFrom = escapeHtml(timeFrom);
const safeTimeTo = escapeHtml(timeTo);
const safeCompanyName = escapeHtml(companyName);
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
@ -63,140 +67,67 @@ export function generateBookingConfirmationEmail({
const formattedDate = formatDate(courseDate);
const formattedPrice = formatPrice(amountPaidCents);
const html = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buchungsbestätigung</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
const bannerHtml = safeCoverImageUrl
? `
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
${
safeCoverImageUrl
? `
<!-- Cover Image Banner -->
<tr>
<td style="padding: 0; line-height: 0;">
<img src="${safeCoverImageUrl}" alt="${safeCourseName}" width="600" style="display: block; width: 100%; max-width: 600px; height: auto; object-fit: cover;" />
</td>
</tr>
`
: ""
}
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Buchung bestätigt ✓
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
Hier sind die Details Ihres Kurses:
</p>
<!-- Course Info Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Kurs</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 18px; font-weight: 600;">${safeCourseName}</p>
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Anbieter</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeBusinessName}</p>
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Termin</p>
<p style="margin: 0 0 4px; color: #333333; font-size: 15px;">${formattedDate}</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeTimeFrom} – ${safeTimeTo} Uhr</p>
${
safeBusinessAddress
? `
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Ort</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeBusinessAddress}</p>
`
: ""
}
<div style="border-top: 1px solid #e0e0e0; padding-top: 16px; margin-top: 8px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #666666; font-size: 14px;">Bezahlt</td>
<td align="right" style="color: #333333; font-size: 18px; font-weight: 600;">${formattedPrice}</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Wir wünschen Ihnen viel Spaß im Kurs!
</p>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="${safeMyCoursesUrl}"
style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(102, 126, 234, 0.4);">
Zu meinen Kursen
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 8px 0 0; color: #667eea; font-size: 14px; word-break: break-all; text-align: center;">
${safeMyCoursesUrl}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter <strong>${safeBusinessName}</strong>.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
${safeCompanyName}
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
</td>
</tr>
</table>
<td style="padding: 0; line-height: 0;">
<img src="${safeCoverImageUrl}" alt="${safeCourseName}" width="600" style="display: block; width: 100%; max-width: 600px; height: auto; object-fit: cover;" />
</td>
</tr>
</table>
</body>
</html>
</tr>`
: "";
const bodyHtml = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
Hier sind die Details Ihres Kurses:
</p>
${renderInfoBox({
rows: [
{
label: "Kurs",
valueHtml: `<span style="font-size: 18px; font-weight: 600;">${safeCourseName}</span>`,
},
{ label: "Anbieter", valueHtml: safeBusinessName },
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
],
})}
${renderDivider(renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice }))}
<p style="margin: 30px 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Wir wünschen Ihnen viel Spaß im Kurs!
</p>
${renderButton({ label: "Zu meinen Kursen", href: myCoursesUrl, branding })}
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
${safeMyCoursesUrl}
</p>
${renderDivider(`
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter <strong>${safeBusinessName}</strong>.
</p>
`)}
`;
const html = renderEmailLayout({
title: "Buchungsbestätigung",
headerHeading: "Buchung bestätigt ✓",
bannerHtml,
bodyHtml,
branding,
});
const text = `
Buchung bestätigt – ${courseName}
@ -217,6 +148,8 @@ Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter ${busin
${companyName}
Diese E-Mail wurde automatisch generiert.
Powered by Kumasy
`;
return { subject, html, text };

View file

@ -10,6 +10,9 @@
import { escapeHtml, singleLine } from "./utils";
import { formatPrice, formatPercent } from "@/src/lib/currency";
import { renderDivider, renderInfoBox } from "./components";
import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
export type BookingRefundReason = "overbooked" | "cancelled_by_user" | "cancelled_by_manager";
@ -21,6 +24,7 @@ interface BookingRefundEmailParams {
reason: BookingRefundReason;
originalAmountCents?: number; // nur relevant/angezeigt wenn refundPercent < 100
companyName?: string;
branding?: EmailBranding;
}
const REASON_COPY: Record<
@ -64,11 +68,11 @@ export function generateBookingRefundEmail({
reason,
originalAmountCents,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: BookingRefundEmailParams): { subject: string; html: string; text: string } {
// Defense gegen Markup-Injection in der Mail.
const safeCourseName = escapeHtml(courseName);
const safeBusinessName = escapeHtml(businessName);
const safeCompanyName = escapeHtml(companyName);
const hasRefund = refundPercent > 0 && amountRefundedCents > 0;
const isPartial = hasRefund && refundPercent < 100 && originalAmountCents !== undefined;
@ -112,94 +116,47 @@ export function generateBookingRefundEmail({
: "";
const refundBoxHtml = hasRefund
? `<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fff5f5; border: 1px solid #fed7d7; border-radius: 6px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #666666; font-size: 14px;">Erstatteter Betrag</td>
<td align="right" style="color: #c53030; font-size: 18px; font-weight: 600;">${formattedPrice}</td>
</tr>
</table>
</td>
</tr>
</table>`
? renderInfoBox({
variant: "danger",
rows: [{ label: "Erstatteter Betrag", valueHtml: formattedPrice }],
})
: "";
const refundBoxText = hasRefund ? `Erstatteter Betrag: ${formattedPrice}\n\n` : "";
const html = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${hasRefund ? "Rückerstattung" : "Stornierung"}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
const bodyHtml = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #f6a86b 0%, #ee5a52 100%); padding: 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
${hasRefund ? "Rückerstattung wurde veranlasst" : "Stornierung bestätigt"}
</h1>
</td>
</tr>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
${introHtml}
</p>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
${refundStatusHtml}
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
${introHtml}
</p>
${policyLineHtml}
${refundStatusHtml}
${refundBoxHtml}
${policyLineHtml}
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Sie können gerne einen anderen freien Kurs bei <strong>${safeBusinessName}</strong> buchen.
</p>
${refundBoxHtml}
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Sie können gerne einen anderen freien Kurs bei <strong>${safeBusinessName}</strong> buchen.
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zur Rückerstattung antworten Sie nicht auf diese E-Mail, sondern
wenden Sie sich an Ihre Bank oder Ihren Karten-Anbieter.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
${safeCompanyName}
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
${renderDivider(`
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zur Rückerstattung antworten Sie nicht auf diese E-Mail, sondern
wenden Sie sich an Ihre Bank oder Ihren Karten-Anbieter.
</p>
`)}
`;
const html = renderEmailLayout({
title: hasRefund ? "Rückerstattung" : "Stornierung",
headerHeading: hasRefund ? "Rückerstattung wurde veranlasst" : "Stornierung bestätigt",
bodyHtml,
branding,
});
const text = `
${hasRefund ? "Rückerstattung" : "Stornierung bestätigt"} – ${courseName}
@ -215,6 +172,8 @@ Bei Fragen zur Rückerstattung wenden Sie sich an Ihre Bank oder Ihren Karten-An
${companyName}
Diese E-Mail wurde automatisch generiert.
Powered by Kumasy
`;
return { subject, html, text };

View file

@ -0,0 +1,97 @@
/**
* Wiederverwendbare HTML-Fragmente für E-Mail-Templates.
*
* Jede Funktion gibt einen HTML-String-Fragment zurück (kein vollständiges Dokument).
*/
import { escapeHtml, sanitizeUrl } from "./utils";
import { EMAIL_TOKENS, KUMASY_BRANDING, type EmailBranding } from "./tokens";
interface InfoBoxRow {
label: string;
/** Bereits fertiges HTML (z.B. inkl. <strong>). Caller ist für Escaping verantwortlich. */
valueHtml: string;
}
export function renderInfoBox({
rows,
variant = "default",
}: {
rows: InfoBoxRow[];
variant?: "default" | "danger";
}): string {
const background =
variant === "danger"
? EMAIL_TOKENS.colors.dangerBackground
: EMAIL_TOKENS.colors.infoBoxBackground;
const border = variant === "danger" ? `1px solid ${EMAIL_TOKENS.colors.dangerBorder}` : "none";
const rowsHtml = rows
.map(
(row, index) => `
<p style="margin: 0 0 4px; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">${escapeHtml(row.label)}</p>
<p style="margin: 0 0 ${index === rows.length - 1 ? 0 : 16}px; color: ${variant === "danger" ? EMAIL_TOKENS.colors.dangerText : EMAIL_TOKENS.colors.textPrimary}; font-size: 15px; font-weight: ${variant === "danger" ? 600 : 400};">${row.valueHtml}</p>`
)
.join("");
return `
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: ${background}; border: ${border}; border-radius: ${EMAIL_TOKENS.radius.infoBox}; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
${rowsHtml}
</td>
</tr>
</table>`;
}
export function renderButton({
label,
href,
branding = KUMASY_BRANDING,
}: {
label: string;
href: string;
branding?: EmailBranding;
}): string {
const safeLabel = escapeHtml(label);
const safeHref = sanitizeUrl(href);
return `
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="${safeHref}"
style="display: inline-block; background-color: ${branding.accentColor}; color: ${branding.accentTextColor}; text-decoration: none; padding: 16px 40px; border-radius: ${EMAIL_TOKENS.radius.button}; font-size: 16px; font-weight: 600;">
${safeLabel}
</a>
</td>
</tr>
</table>`;
}
export function renderDivider(innerHtml: string): string {
return `
<div style="border-top: 1px solid ${EMAIL_TOKENS.colors.border}; padding-top: 20px; margin-top: 30px;">
${innerHtml}
</div>`;
}
export function renderPriceRow({
label,
amountFormatted,
emphasis = false,
}: {
label: string;
amountFormatted: string;
emphasis?: boolean;
}): string {
const valueColor = emphasis ? EMAIL_TOKENS.colors.dangerText : EMAIL_TOKENS.colors.textPrimary;
return `
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: ${EMAIL_TOKENS.colors.textSecondary}; font-size: 14px;">${escapeHtml(label)}</td>
<td align="right" style="color: ${valueColor}; font-size: 18px; font-weight: 600;">${escapeHtml(amountFormatted)}</td>
</tr>
</table>`;
}

View file

@ -4,10 +4,16 @@
* Dieses Template wird an neue Mitarbeiter gesendet, um ihnen den Onboarding-Link zu schicken.
*/
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
import { renderButton, renderDivider } from "./components";
import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
interface OnboardingEmailParams {
email: string;
onboardingUrl: string;
companyName?: string;
branding?: EmailBranding;
}
/**
@ -17,99 +23,54 @@ export function generateOnboardingEmail({
email,
onboardingUrl,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: OnboardingEmailParams): { subject: string; html: string; text: string } {
const subject = `Willkommen bei ${companyName} - Vervollständigen Sie Ihr Profil`;
const safeEmail = escapeHtml(email);
const safeOnboardingUrl = sanitizeUrl(onboardingUrl);
const safeCompanyName = escapeHtml(companyName);
const html = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<!-- Main Container -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
const subject = singleLine(`Willkommen bei ${companyName} - Vervollständigen Sie Ihr Profil`);
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Willkommen bei ${companyName}!
</h1>
</td>
</tr>
const bodyHtml = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Sie wurden als Mitarbeiter bei <strong>${safeCompanyName}</strong> registriert. Um Ihr Profil zu vervollständigen und Zugang zum System zu erhalten, klicken Sie bitte auf den Button unten.
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Sie wurden als Mitarbeiter bei <strong>${companyName}</strong> registriert. Um Ihr Profil zu vervollständigen und Zugang zum System zu erhalten, klicken Sie bitte auf den Button unten.
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihre registrierte E-Mail-Adresse: <strong>${safeEmail}</strong>
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihre registrierte E-Mail-Adresse: <strong>${email}</strong>
</p>
${renderButton({ label: "Profil vervollständigen", href: onboardingUrl, branding })}
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<a href="${onboardingUrl}"
style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(102, 126, 234, 0.4);">
Profil vervollständigen
</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 30px 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 0 0 30px; color: #1E293B; font-size: 14px; word-break: break-all;">
${safeOnboardingUrl}
</p>
<p style="margin: 0 0 30px; color: #667eea; font-size: 14px; word-break: break-all;">
${onboardingUrl}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0 0 10px; color: #999999; font-size: 13px; line-height: 1.6;">
<strong>Wichtig:</strong> Dieser Link ist 7 Tage gültig.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Falls Sie diese E-Mail nicht erwartet haben, ignorieren Sie sie bitte.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
${companyName}
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
${renderDivider(`
<p style="margin: 0 0 10px; color: #999999; font-size: 13px; line-height: 1.6;">
<strong>Wichtig:</strong> Dieser Link ist 7 Tage gültig.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Falls Sie diese E-Mail nicht erwartet haben, ignorieren Sie sie bitte.
</p>
`)}
`;
const html = renderEmailLayout({
title: "Willkommen",
headerHeading: `Willkommen bei ${safeCompanyName}!`,
bodyHtml,
branding,
});
const text = `
Willkommen bei ${companyName}!
@ -117,7 +78,7 @@ Hallo,
Sie wurden als Mitarbeiter bei ${companyName} registriert. Um Ihr Profil zu vervollständigen und Zugang zum System zu erhalten, öffnen Sie bitte den folgenden Link:
${onboardingUrl}
${safeOnboardingUrl}
Ihre registrierte E-Mail-Adresse: ${email}
@ -127,6 +88,8 @@ Falls Sie diese E-Mail nicht erwartet haben, ignorieren Sie sie bitte.
${companyName}
Diese E-Mail wurde automatisch generiert.
Powered by Kumasy
`;
return { subject, html, text };

View file

@ -0,0 +1,92 @@
/**
* Gemeinsame HTML-Shell für alle E-Mail-Templates.
*
* Tabellen-basiertes Layout mit ausschließlich inline-Styles (Outlook-Kompatibilität,
* kein <style>-Block, kein Flexbox/Grid, keine CSS-Gradients).
*/
import { escapeHtml } from "./utils";
import { EMAIL_TOKENS, KUMASY_BRANDING, type EmailBranding } from "./tokens";
export interface RenderEmailLayoutParams {
title: string;
/** H1-Text im Header-Band. Vom Aufrufer bereits escaped/vertrauenswürdig. */
headerHeading: string;
/** Optionale volle Reihe ÜBER dem Header-Band (z.B. Cover-Bild). */
bannerHtml?: string;
/** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */
bodyHtml: string;
branding?: EmailBranding;
}
export function renderEmailLayout({
title,
headerHeading,
bannerHtml,
bodyHtml,
branding = KUMASY_BRANDING,
}: RenderEmailLayoutParams): string {
const safeTitle = escapeHtml(title);
const safeCompanyName = escapeHtml(branding.companyName);
const safeLogoAlt = escapeHtml(branding.logoAlt);
const headerLogoHtml = branding.logoUrl
? `<img src="${branding.logoUrl}" alt="${safeLogoAlt}" width="160" style="display: block; margin: 0 auto 16px; max-width: 160px; height: auto;" />`
: `<p style="margin: 0 0 12px; color: #ffffff; font-size: 15px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase;">${safeCompanyName}</p>`;
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle}</title>
</head>
<body style="margin: 0; padding: 0; font-family: ${EMAIL_TOKENS.fontFamily}; background-color: ${EMAIL_TOKENS.colors.bodyBackground};">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: ${EMAIL_TOKENS.colors.bodyBackground}; padding: ${EMAIL_TOKENS.spacing.pagePadding}px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: ${EMAIL_TOKENS.colors.cardBackground}; border-radius: ${EMAIL_TOKENS.radius.card}; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
${bannerHtml ?? ""}
<!-- Header -->
<tr>
<td style="background-color: ${branding.primaryColor}; padding: 40px; text-align: center;">
${headerLogoHtml}
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
${headerHeading}
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: ${EMAIL_TOKENS.spacing.cardPadding}px;">
${bodyHtml}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: ${EMAIL_TOKENS.colors.footerBackground}; padding: ${EMAIL_TOKENS.spacing.footerPadding}px; text-align: center; border-top: 1px solid ${EMAIL_TOKENS.colors.border};">
<p style="margin: 0 0 10px; color: ${EMAIL_TOKENS.colors.textSecondary}; font-size: 14px;">
${safeCompanyName}
</p>
<p style="margin: 0 0 10px; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
<p style="margin: 0; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 11px;">
Powered by Kumasy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View file

@ -4,12 +4,18 @@
* Dieses Template wird an den Manager gesendet, wenn ein Mitarbeiter das Onboarding abgeschlossen hat.
*/
import { escapeHtml, singleLine } from "./utils";
import { renderDivider, renderInfoBox } from "./components";
import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
interface OnboardingCompleteEmailParams {
managerName: string;
employeeFirstName: string;
employeeLastName: string;
employeeEmail: string;
companyName?: string;
branding?: EmailBranding;
}
/**
@ -21,84 +27,52 @@ export function generateOnboardingCompleteEmail({
employeeLastName,
employeeEmail,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: OnboardingCompleteEmailParams): { subject: string; html: string; text: string } {
const subject = `Onboarding abgeschlossen – ${employeeFirstName} ${employeeLastName}`;
const safeManagerName = escapeHtml(managerName);
const safeEmployeeFirstName = escapeHtml(employeeFirstName);
const safeEmployeeLastName = escapeHtml(employeeLastName);
const safeEmployeeEmail = escapeHtml(employeeEmail);
const safeCompanyName = escapeHtml(companyName);
const html = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Onboarding abgeschlossen</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<!-- Main Container -->
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
const subject = singleLine(`Onboarding abgeschlossen – ${employeeFirstName} ${employeeLastName}`);
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Onboarding abgeschlossen ✓
</h1>
</td>
</tr>
const bodyHtml = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo ${safeManagerName},
</p>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo ${managerName},
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihr Mitarbeiter hat das Onboarding erfolgreich abgeschlossen und ist nun im System aktiv.
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihr Mitarbeiter hat das Onboarding erfolgreich abgeschlossen und ist nun im System aktiv.
</p>
${renderInfoBox({
rows: [
{
label: "Mitarbeiter",
valueHtml: `<strong style="font-size: 18px; font-weight: 600;">${safeEmployeeFirstName} ${safeEmployeeLastName}</strong><br /><span style="color: #1E293B;">${safeEmployeeEmail}</span>`,
},
],
})}
<!-- Employee Info Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<p style="margin: 0 0 8px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Mitarbeiter</p>
<p style="margin: 0 0 4px; color: #333333; font-size: 18px; font-weight: 600;">${employeeFirstName} ${employeeLastName}</p>
<p style="margin: 0; color: #667eea; font-size: 14px;">${employeeEmail}</p>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
Der Mitarbeiter kann sich ab sofort mit seinen Zugangsdaten anmelden.
</p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6;">
Der Mitarbeiter kann sich ab sofort mit seinen Zugangsdaten anmelden.
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0; color: #666666; font-size: 14px;">
${companyName}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
${renderDivider(`
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
</p>
`)}
`;
const html = renderEmailLayout({
title: "Onboarding abgeschlossen",
headerHeading: "Onboarding abgeschlossen ✓",
bodyHtml,
branding,
});
const text = `
Onboarding abgeschlossen – ${employeeFirstName} ${employeeLastName}
@ -113,6 +87,8 @@ Der Mitarbeiter kann sich ab sofort mit seinen Zugangsdaten anmelden.
${companyName}
Diese E-Mail wurde automatisch generiert.
Powered by Kumasy
`;
return { subject, html, text };

View file

@ -0,0 +1,65 @@
/**
* Design-Tokens für E-Mail-Templates.
*
* Single Source of Truth für Farben, Radien, Font-Stack und Spacing.
* `EmailBranding` ist bewusst parametrisiert, damit künftiges White-Labeling
* (Manager senden Mails mit eigenem Logo/Farben an ihre Kunden/Mitarbeiter)
* später rein additiv ist, statt die Templates erneut anzufassen.
*/
export interface EmailBranding {
companyName: string;
/** null => Text-Wordmark-Fallback im Header statt <img> */
logoUrl: string | null;
logoAlt: string;
/** Header-Hintergrund */
primaryColor: string;
/** CTA-Button-Hintergrund */
accentColor: string;
/** Textfarbe auf accentColor (Kontrast) */
accentTextColor: string;
}
export const KUMASY_BRANDING: EmailBranding = {
companyName: "Kumasy",
// Statischer public/-Pfad auf der Production-Domain — bewusst NICHT über den
// Next.js /_next/image-Optimizer oder eine Vercel-Preview-Branch-URL, da
// E-Mail-Client-Bildproxys (z.B. Gmail) mit dynamisch generierten
// Optimizer-URLs und mit nicht-öffentlichen Preview-Deployments unzuverlässig
// umgehen.
logoUrl: "https://kumasy.de/logo-dark.png",
logoAlt: "Kumasy",
primaryColor: "#1E293B",
accentColor: "#F4C00C",
accentTextColor: "#1E293B",
};
export const EMAIL_TOKENS = {
colors: {
bodyBackground: "#f5f5f5",
cardBackground: "#ffffff",
textPrimary: "#333333",
textSecondary: "#666666",
textMuted: "#999999",
border: "#e0e0e0",
infoBoxBackground: "#f8f9fa",
footerBackground: "#f8f9fa",
// Semantische Statusfarbe (Rückerstattung), bewusst nicht brand-parametrisiert
dangerBackground: "#fff5f5",
dangerBorder: "#fed7d7",
dangerText: "#c53030",
},
radius: {
card: "14px",
infoBox: "10px",
button: "999px",
},
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
spacing: {
pagePadding: 40,
cardPadding: 40,
sectionGap: 30,
footerPadding: 30,
},
} as const;