mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #142 from Since-3/refactor/change-mail-design
unify email design
This commit is contained in:
commit
a4db10664a
12 changed files with 994 additions and 362 deletions
52
src/lib/mail/supabase-templates/README.md
Normal file
52
src/lib/mail/supabase-templates/README.md
Normal 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.
|
||||
177
src/lib/mail/supabase-templates/change-email.html
Normal file
177
src/lib/mail/supabase-templates/change-email.html
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<!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">
|
||||
<p
|
||||
style="
|
||||
margin: 0 0 12px;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
"
|
||||
>
|
||||
Kumasy
|
||||
</p>
|
||||
<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>
|
||||
146
src/lib/mail/supabase-templates/confirm-signup.html
Normal file
146
src/lib/mail/supabase-templates/confirm-signup.html
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<!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">
|
||||
<p
|
||||
style="
|
||||
margin: 0 0 12px;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
"
|
||||
>
|
||||
Kumasy
|
||||
</p>
|
||||
<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>
|
||||
150
src/lib/mail/supabase-templates/reset-password.html
Normal file
150
src/lib/mail/supabase-templates/reset-password.html
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!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">
|
||||
<p
|
||||
style="
|
||||
margin: 0 0 12px;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
"
|
||||
>
|
||||
Kumasy
|
||||
</p>
|
||||
<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>
|
||||
|
|
@ -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,45 +67,16 @@ 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;">
|
||||
<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
|
||||
const bannerHtml = 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>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</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;">
|
||||
const bodyHtml = `
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Hallo,
|
||||
</p>
|
||||
|
|
@ -111,92 +86,48 @@ export function generateBookingConfirmationEmail({
|
|||
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>
|
||||
${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 }] : []),
|
||||
],
|
||||
})}
|
||||
|
||||
<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>
|
||||
${renderDivider(renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice }))}
|
||||
|
||||
<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;">
|
||||
<p style="margin: 30px 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>
|
||||
${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: #667eea; font-size: 14px; word-break: break-all; text-align: center;">
|
||||
<p style="margin: 8px 0 0; color: #1E293B; 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;">
|
||||
${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>
|
||||
</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>
|
||||
`)}
|
||||
`;
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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,47 +116,14 @@ 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);">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
const bodyHtml = `
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Hallo,
|
||||
</p>
|
||||
|
|
@ -171,35 +142,21 @@ export function generateBookingRefundEmail({
|
|||
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;">
|
||||
${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>
|
||||
</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>
|
||||
`)}
|
||||
`;
|
||||
|
||||
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 };
|
||||
|
|
|
|||
102
src/lib/mail/templates/components.ts
Normal file
102
src/lib/mail/templates/components.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Wiederverwendbare HTML-Fragmente für E-Mail-Templates.
|
||||
*
|
||||
* Jede Funktion gibt einen HTML-String-Fragment zurück (kein vollständiges Dokument).
|
||||
*/
|
||||
|
||||
import { escapeHtml, sanitizeColor, 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);
|
||||
const safeAccentColor = sanitizeColor(branding.accentColor, KUMASY_BRANDING.accentColor);
|
||||
const safeAccentTextColor = sanitizeColor(
|
||||
branding.accentTextColor,
|
||||
KUMASY_BRANDING.accentTextColor
|
||||
);
|
||||
|
||||
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: ${safeAccentColor}; color: ${safeAccentTextColor}; 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>`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
const bodyHtml = `
|
||||
<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>${companyName}</strong> registriert. Um Ihr Profil zu vervollständigen und Zugang zum System zu erhalten, klicken Sie bitte auf den Button unten.
|
||||
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 30px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Ihre registrierte E-Mail-Adresse: <strong>${email}</strong>
|
||||
Ihre registrierte E-Mail-Adresse: <strong>${safeEmail}</strong>
|
||||
</p>
|
||||
|
||||
<!-- 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>
|
||||
${renderButton({ label: "Profil vervollständigen", href: onboardingUrl, branding })}
|
||||
|
||||
<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: #667eea; font-size: 14px; word-break: break-all;">
|
||||
${onboardingUrl}
|
||||
<p style="margin: 0 0 30px; color: #1E293B; font-size: 14px; word-break: break-all;">
|
||||
${safeOnboardingUrl}
|
||||
</p>
|
||||
|
||||
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
|
||||
${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>
|
||||
</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>
|
||||
`)}
|
||||
`;
|
||||
|
||||
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 };
|
||||
|
|
|
|||
93
src/lib/mail/templates/layout.ts
Normal file
93
src/lib/mail/templates/layout.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* 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, sanitizeColor, sanitizeUrl } 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 safePrimaryColor = sanitizeColor(branding.primaryColor, KUMASY_BRANDING.primaryColor);
|
||||
|
||||
const headerLogoHtml = branding.logoUrl
|
||||
? `<img src="${sanitizeUrl(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: ${safePrimaryColor}; 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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -4,12 +4,18 @@
|
|||
* Dieses Template wird an den Manager gesendet, wenn ein Mitarbeiter das Onboarding abgeschlossen hat.
|
||||
*/
|
||||
|
||||
import { escapeHtml, singleLine } from "./utils";
|
||||
import { 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,46 @@ 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>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
const bodyHtml = `
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Hallo ${managerName},
|
||||
Hallo ${safeManagerName},
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
${renderInfoBox({
|
||||
rows: [
|
||||
{
|
||||
label: "Mitarbeiter",
|
||||
valueHtml: `<strong style="font-size: 18px; font-weight: 600;">${safeEmployeeFirstName} ${safeEmployeeLastName}</strong><br /><span style="color: #1E293B;">${safeEmployeeEmail}</span>`,
|
||||
},
|
||||
],
|
||||
})}
|
||||
|
||||
<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>
|
||||
`;
|
||||
|
||||
const html = renderEmailLayout({
|
||||
title: "Onboarding abgeschlossen",
|
||||
headerHeading: "Onboarding abgeschlossen ✓",
|
||||
bodyHtml,
|
||||
branding,
|
||||
});
|
||||
|
||||
const text = `
|
||||
Onboarding abgeschlossen – ${employeeFirstName} ${employeeLastName}
|
||||
|
||||
|
|
@ -113,6 +81,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 };
|
||||
|
|
|
|||
67
src/lib/mail/templates/tokens.ts
Normal file
67
src/lib/mail/templates/tokens.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* 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",
|
||||
// TODO: auf eine stabile, statische Logo-URL setzen, sobald eine aktive
|
||||
// Production-Domain feststeht (kumasy.de ist aktuell eine geparkte
|
||||
// Hostinger-Domain, liefert kein Bild aus). Bis dahin Text-Wordmark-Fallback.
|
||||
// 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 nicht-öffentlichen
|
||||
// Preview-Deployments unzuverlässig umgehen.
|
||||
logoUrl: null,
|
||||
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;
|
||||
|
|
@ -40,3 +40,23 @@ export function sanitizeUrl(value: string): string {
|
|||
export function singleLine(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert einen Farbwert für die Verwendung in einem style-Attribut.
|
||||
* Erlaubt nur #hex (3/4/6/8-stellig), rgb()/rgba() und CSS-Keyword-Farben
|
||||
* (Buchstaben only) – verhindert, dass ein nicht vertrauenswürdiger
|
||||
* Branding-Wert (z.B. künftig manager-gesteuert) das style-Attribut
|
||||
* verlässt und beliebiges Markup/CSS injiziert.
|
||||
* Fällt bei ungültigem Wert auf den übergebenen Fallback zurück.
|
||||
*/
|
||||
export function sanitizeColor(value: string, fallback: string): string {
|
||||
const isHex = /^#[0-9a-fA-F]{3,8}$/.test(value);
|
||||
const isRgb = /^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(value);
|
||||
const isKeyword = /^[a-zA-Z]+$/.test(value);
|
||||
|
||||
if (isHex || isRgb || isKeyword) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue