feat: ICS-Kalendereinträge, Rate-Limiting und erweiterte E-Mail-Validierung

- ICS-Dateianhänge in Bestätigungsmails mit Europe/Berlin Zeitzone
- Rate-Limiting: IP-basiert (5/10min) und E-Mail-basiert (3/1h)
- Mehrschichtige E-Mail-Validierung mit Rapid Email Validator API
  - Disposable Email Detection (blockiert Wegwerf-Adressen)
  - MX Record Verification
  - Domain Verification
  - Typo-Erkennung mit Vorschlägen
- Zod-Schema-Validierung für Name, E-Mail und Telefonnummer
- Dokumentation für Rate-Limiting und E-Mail-Validierung
- README mit neuen Features aktualisiert
- Backlog aktualisiert
This commit is contained in:
2025-10-01 11:43:51 +02:00
parent 2dcfb8e2ee
commit 8ee2a2b3b6
8 changed files with 783 additions and 236 deletions

View File

@@ -178,17 +178,31 @@ docker run -d \
## Features
### Buchungssystem
- 📅 **Terminbuchung**: Kunden können online Termine buchen
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
-**Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
-**Termin-Stornierung**: Kunden können Termine über sichere Links stornieren
-**Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN)
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
### E-Mail & Benachrichtigungen
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
- 📅 **ICS-Kalendereinträge**: Termin-Bestätigungen mit ICS-Datei zum Importieren in Kalender-Apps
-**Kalender-Erinnerungen**: 24h-Erinnerung im ICS-Kalendereintrag
- 📎 **AGB-Anhänge**: Automatischer PDF-Anhang der Allgemeinen Geschäftsbedingungen
### Sicherheit
- 🛡️ **Rate-Limiting**: IP- und E-Mail-basierter Schutz gegen Spam (3 Anfragen/E-Mail pro Stunde, 5 Anfragen/IP pro 10 Min)
- ✉️ **E-Mail-Validierung**: Mehrschichtige Validierung inkl. Disposable-Email-Detection und MX-Record-Prüfung
- 🚫 **Wegwerf-Email-Schutz**: Blockierung von temporären E-Mail-Adressen
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen
### Rechtliches
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
- ⚖️ **GDPR-konform**: Datenschutzfreundliche Implementierung
## Admin-Zugang
Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden:

View File

@@ -1,15 +1,14 @@
## Backlog Terminplanung & Infrastruktur
### Kalender & Workflow
- ICS-Anhang/Link in EMails (Kalendereintrag)
- ~~ICS-Anhang/Link in EMails (Kalendereintrag)~~
- Erinnerungsmails (24h/3h vor Termin)
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
### Sicherheit & Qualität
- RateLimiting (IP/EMail) für Formularspam
- CAPTCHA im Buchungsformular
- ~~RateLimiting (IP/EMail) für Formularspam~~
- EMailVerifizierung (DoubleOptIn) optional
- AuditLog (wer/was/wann)
- DSGVO: Einwilligungstexte, Löschkonzept

93
docs/rate-limiting.md Normal file
View File

@@ -0,0 +1,93 @@
# Rate Limiting & E-Mail-Validierung
Das System verwendet ein mehrstufiges Sicherheitssystem, um Spam und Missbrauch des Buchungsformulars zu verhindern.
## E-Mail-Validierung
Neben der Zod-Schema-Validierung wird jede E-Mail-Adresse durch die [Rapid Email Validator API](https://rapid-email-verifier.fly.dev/) geprüft:
### Geprüfte Kriterien
-**Syntax-Validierung:** Korrekte E-Mail-Format-Prüfung
-**Disposable Email Detection:** Wegwerf-E-Mail-Adressen werden blockiert
-**MX Record Verification:** Prüft, ob die Domain E-Mails empfangen kann
-**Domain Verification:** Validiert die Existenz der Domain
-**Typo-Erkennung:** Schlägt Korrekturen bei häufigen Tippfehlern vor
### Datenschutz
Die verwendete API speichert **keine Daten** und ist GDPR/CCPA/PIPEDA-konform. Alle E-Mail-Adressen werden nur im Speicher verarbeitet und sofort verworfen.
### Fallback-Verhalten
Falls die Validierungs-API nicht erreichbar ist, fällt das System auf die Zod-Validierung zurück, um die Funktionalität zu gewährleisten.
---
## Rate Limiting
Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformulars zu verhindern.
## Aktuelle Konfiguration
### Buchungen (E-Mail-basiert)
- **Limit:** 3 Buchungsanfragen pro E-Mail-Adresse
- **Zeitfenster:** 1 Stunde
- **Verhalten:** Nach 3 Anfragen muss der Nutzer 1 Stunde warten
### Buchungen (IP-basiert)
- **Limit:** 5 Buchungsanfragen pro IP-Adresse
- **Zeitfenster:** 10 Minuten
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
## Wie es funktioniert
Das Rate-Limiting prüft **beide** Kriterien:
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
## IP-Erkennung
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
- `x-forwarded-for`
- `x-real-ip`
- `cf-connecting-ip` (Cloudflare)
## Implementierung
- **Speicherung:** In-Memory (Map)
- **Cleanup:** Automatisches Aufräumen alter Einträge alle 10 Minuten
- **Skalierung:** Für Produktionsumgebungen mit mehreren Server-Instanzen sollte Redis o.ä. verwendet werden
## Anpassung
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
```typescript
// E-Mail-Limit anpassen
const emailConfig: RateLimitConfig = {
maxRequests: 3, // Anzahl der Anfragen
windowMs: 60 * 60 * 1000, // Zeitfenster in Millisekunden
};
// IP-Limit anpassen
const ipConfig: RateLimitConfig = {
maxRequests: 5, // Anzahl der Anfragen
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
};
```
## Fehlermeldungen
Bei Überschreitung des Limits erhält der Nutzer folgende Meldung:
```
Zu viele Buchungsanfragen. Bitte versuche es in X Minuten erneut.
```
## Produktions-Empfehlungen
Für Produktionsumgebungen empfehlen sich:
- ✅ Redis als verteilter Speicher für Rate-Limit-Daten
- ✅ Überwachung der Rate-Limit-Trigger (Logging/Monitoring)
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten

View File

@@ -0,0 +1,114 @@
// Email validation using Rapid Email Validator API
// API: https://rapid-email-verifier.fly.dev/
// Privacy-focused, no data storage, completely free
type EmailValidationResult = {
valid: boolean;
email: string;
domain?: string;
disposable?: boolean;
role?: boolean;
typo?: boolean;
suggestion?: string;
mx?: boolean;
error?: string;
};
/**
* Validate email address using Rapid Email Validator API
* Returns true if email is valid, false otherwise
*/
export async function validateEmail(email: string): Promise<{
valid: boolean;
reason?: string;
suggestion?: string;
}> {
try {
// Call Rapid Email Validator API
const response = await fetch(`https://rapid-email-verifier.fly.dev/verify/${encodeURIComponent(email)}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
console.warn(`Email validation API error: ${response.status}`);
// If API is down, allow the email (fallback to Zod validation only)
return { valid: true };
}
const data: EmailValidationResult = await response.json();
// Check if email is disposable/temporary
if (data.disposable) {
return {
valid: false,
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
};
}
// Check if MX records exist (deliverable)
if (data.mx === false) {
return {
valid: false,
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
};
}
// Check if email is generally valid
if (!data.valid) {
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
return {
valid: false,
reason: `Ungültige E-Mail-Adresse.${suggestion}`,
suggestion: data.suggestion,
};
}
// Email is valid
return { valid: true };
} catch (error) {
console.error('Email validation error:', error);
// If validation fails, allow the email (fallback to Zod validation only)
// This ensures the booking system continues to work even if the API is down
return { valid: true };
}
}
/**
* Batch validate multiple emails
* @param emails Array of email addresses to validate
* @returns Array of validation results
*/
export async function validateEmailBatch(emails: string[]): Promise<Map<string, {
valid: boolean;
reason?: string;
suggestion?: string;
}>> {
const results = new Map<string, { valid: boolean; reason?: string; suggestion?: string }>();
// Validate up to 100 emails at once (API limit)
const batchSize = 100;
for (let i = 0; i < emails.length; i += batchSize) {
const batch = emails.slice(i, i + batchSize);
// Call each validation in parallel for better performance
const validations = await Promise.all(
batch.map(async (email) => {
const result = await validateEmail(email);
return { email, result };
})
);
// Store results
validations.forEach(({ email, result }) => {
results.set(email, result);
});
}
return results;
}

View File

@@ -20,6 +20,90 @@ import { dirname, resolve } from "node:path";
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
function formatDateForICS(date: string, time: string): string {
// date is in YYYY-MM-DD format, time is in HH:MM format
const [year, month, day] = date.split('-');
const [hours, minutes] = time.split(':');
return `${year}${month}${day}T${hours}${minutes}00`;
}
// Helper function to create ICS (iCalendar) file content
function createICSFile(params: {
date: string; // YYYY-MM-DD
time: string; // HH:MM
durationMinutes: number;
customerName: string;
treatmentName: string;
}): string {
const { date, time, durationMinutes, customerName, treatmentName } = params;
// Calculate start and end times in Europe/Berlin timezone
const dtStart = formatDateForICS(date, time);
// Calculate end time
const [hours, minutes] = time.split(':').map(Number);
const startDate = new Date(`${date}T${time}:00`);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const endHours = String(endDate.getHours()).padStart(2, '0');
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
// Create unique ID for this event
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
// Current timestamp for DTSTAMP
const now = new Date();
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
// ICS content
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${dtstamp}`,
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
`SUMMARY:${treatmentName} - Stargirlnails Kiel`,
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
'LOCATION:Stargirlnails Kiel',
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
'STATUS:CONFIRMED',
'SEQUENCE:0',
'BEGIN:VALARM',
'TRIGGER:-PT24H',
'ACTION:DISPLAY',
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
'END:VALARM',
'END:VEVENT',
'BEGIN:VTIMEZONE',
'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT',
'TZOFFSETFROM:+0100',
'TZOFFSETTO:+0200',
'TZNAME:CEST',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
'END:DAYLIGHT',
'BEGIN:STANDARD',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0100',
'TZNAME:CET',
'DTSTART:19701025T030000',
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
'END:STANDARD',
'END:VTIMEZONE',
'END:VCALENDAR'
].join('\r\n');
return icsContent;
}
// Cache for AGB PDF to avoid reading it multiple times
let cachedAGBPDF: string | null = null;
@@ -89,6 +173,42 @@ export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ succe
return sendEmail(params);
}
export async function sendEmailWithAGBAndCalendar(
params: SendEmailParams,
calendarParams: {
date: string;
time: string;
durationMinutes: number;
customerName: string;
treatmentName: string;
}
): Promise<{ success: boolean }> {
const agbBase64 = await getAGBPDFBase64();
// Create ICS file content
const icsContent = createICSFile(calendarParams);
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
// Attach both AGB and ICS file
params.attachments = [...(params.attachments || [])];
if (agbBase64) {
params.attachments.push({
filename: "AGB_Stargirlnails_Kiel.pdf",
content: agbBase64,
type: "application/pdf"
});
}
params.attachments.push({
filename: "Termin_Stargirlnails.ics",
content: icsBase64,
type: "text/calendar"
});
return sendEmail(params);
}
export async function sendEmailWithInspirationPhoto(
params: SendEmailParams,
photoData: string,

View File

@@ -0,0 +1,162 @@
// Simple in-memory rate limiter for IP and email-based requests
// For production with multiple instances, consider using Redis
type RateLimitEntry = {
count: number;
resetAt: number; // Unix timestamp in ms
};
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup old entries every 10 minutes to prevent memory leaks
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
}
}, 10 * 60 * 1000);
export type RateLimitConfig = {
maxRequests: number;
windowMs: number; // Time window in milliseconds
};
export type RateLimitResult = {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfterSeconds?: number;
};
/**
* Check if a request is allowed based on rate limiting
* @param key - Unique identifier (IP, email, or combination)
* @param config - Rate limit configuration
* @returns RateLimitResult with allow status and metadata
*/
export function checkRateLimit(
key: string,
config: RateLimitConfig
): RateLimitResult {
const now = Date.now();
const entry = rateLimitStore.get(key);
// No existing entry or window expired - allow and create new entry
if (!entry || entry.resetAt < now) {
rateLimitStore.set(key, {
count: 1,
resetAt: now + config.windowMs,
});
return {
allowed: true,
remaining: config.maxRequests - 1,
resetAt: now + config.windowMs,
};
}
// Existing entry within window
if (entry.count >= config.maxRequests) {
// Rate limit exceeded
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
retryAfterSeconds,
};
}
// Increment count and allow
entry.count++;
rateLimitStore.set(key, entry);
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
/**
* Check rate limit for booking creation
* Applies multiple checks: per IP and per email
*/
export function checkBookingRateLimit(params: {
ip?: string;
email: string;
}): RateLimitResult {
const { ip, email } = params;
// Config: max 3 bookings per email per hour
const emailConfig: RateLimitConfig = {
maxRequests: 3,
windowMs: 60 * 60 * 1000, // 1 hour
};
// Config: max 5 bookings per IP per 10 minutes
const ipConfig: RateLimitConfig = {
maxRequests: 5,
windowMs: 10 * 60 * 1000, // 10 minutes
};
// Check email rate limit
const emailKey = `booking:email:${email.toLowerCase()}`;
const emailResult = checkRateLimit(emailKey, emailConfig);
if (!emailResult.allowed) {
return {
...emailResult,
allowed: false,
};
}
// Check IP rate limit (if IP is available)
if (ip) {
const ipKey = `booking:ip:${ip}`;
const ipResult = checkRateLimit(ipKey, ipConfig);
if (!ipResult.allowed) {
return {
...ipResult,
allowed: false,
};
}
}
// Both checks passed
return {
allowed: true,
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
resetAt: emailResult.resetAt,
};
}
/**
* Get client IP from various headers (for proxy/load balancer support)
*/
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
// Check common proxy headers
const forwardedFor = headers['x-forwarded-for'];
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim();
}
const realIP = headers['x-real-ip'];
if (realIP) {
return realIP;
}
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
if (cfConnectingIP) {
return cfConnectingIP;
}
// No IP found
return undefined;
}

View File

@@ -9,8 +9,8 @@ config();
const UserSchema = z.object({
id: z.string(),
username: z.string(),
email: z.string(),
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
email: z.string().email("Ungültige E-Mail-Adresse"),
passwordHash: z.string(),
role: z.enum(["customer", "owner"]),
createdAt: z.string(),

View File

@@ -3,11 +3,13 @@ import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
import { router } from "@/server/rpc";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter";
import { validateEmail } from "@/server/lib/email-validator";
// Create a server-side client to call other RPC endpoints
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
@@ -29,9 +31,9 @@ function generateUrl(path: string = ''): string {
const BookingSchema = z.object({
id: z.string(),
treatmentId: z.string(),
customerName: z.string(),
customerEmail: z.string(),
customerPhone: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
appointmentDate: z.string(), // ISO date string
appointmentTime: z.string(), // HH:MM format
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
@@ -70,13 +72,44 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
.handler(async ({ input }) => {
.handler(async ({ input, context }) => {
// console.log("Booking create called with input:", {
// ...input,
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
// });
try {
// Rate limiting check
const headers = context.request?.headers || {};
const headersObj: Record<string, string | undefined> = {};
if (headers) {
// Convert Headers object to plain object
headers.forEach((value: string, key: string) => {
headersObj[key.toLowerCase()] = value;
});
}
const clientIP = getClientIP(headersObj);
const rateLimitResult = checkBookingRateLimit({
ip: clientIP,
email: input.customerEmail,
});
if (!rateLimitResult.allowed) {
const retryMinutes = rateLimitResult.retryAfterSeconds
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
: 10;
throw new Error(
`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`
);
}
// Deep email validation using Rapid Email Validator API
const emailValidation = await validateEmail(input.customerEmail);
if (!emailValidation.valid) {
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
if (input.appointmentDate < today) {
@@ -273,12 +306,24 @@ const updateStatus = os
cancellationUrl
});
await sendEmailWithAGB({
// Get treatment information for ICS file
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
date: booking.appointmentDate,
time: booking.appointmentTime,
durationMinutes: treatmentDuration,
customerName: booking.customerName,
treatmentName: treatmentName
});
} else if (input.status === "cancelled") {
const formattedDate = formatDateGerman(booking.appointmentDate);