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

@@ -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,