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:
114
src/server/lib/email-validator.ts
Normal file
114
src/server/lib/email-validator.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
@@ -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,
|
||||
|
162
src/server/lib/rate-limiter.ts
Normal file
162
src/server/lib/rate-limiter.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user