const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const RATE_LIMIT_MS = 10_000; // 10 seconds between requests per IP+contact const IP_RATE_LIMIT_MS = 60_000; // 1 minute window for global IP limit const IP_MAX_REQUESTS = 5; // max 5 requests per minute per IP const rateLimitMap = new Map(); const ipRequestLog = new Map(); Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const { address, contact, fiber_available, max_speed } = await req.json(); if (!address || !contact) { return new Response( JSON.stringify({ error: "Missing address or contact" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // Validate inputs if (typeof address !== "string" || address.length > 500) { return new Response( JSON.stringify({ error: "Invalid address" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } if (typeof contact !== "string" || contact.length > 254) { return new Response( JSON.stringify({ error: "Invalid contact" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // Server-side contact format validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const phoneRegex = /^\+?[1-9]\d{1,14}$/; const isEmailContact = contact.includes("@"); if (isEmailContact && !emailRegex.test(contact)) { return new Response( JSON.stringify({ error: "Invalid email format" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } if (!isEmailContact && !phoneRegex.test(contact)) { return new Response( JSON.stringify({ error: "Invalid phone format" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // Server-side rate limiting const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; // Global per-IP rate limit: max 5 requests per minute const now = Date.now(); const ipLog = ipRequestLog.get(clientIp) || []; const recentIpRequests = ipLog.filter(ts => now - ts < IP_RATE_LIMIT_MS); if (recentIpRequests.length >= IP_MAX_REQUESTS) { return new Response( JSON.stringify({ error: "Too many requests. Please wait before trying again." }), { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } // Per IP+contact rate limit: 10s cooldown const rateLimitKey = `${clientIp}:${contact}`; const lastRequest = rateLimitMap.get(rateLimitKey); if (lastRequest && now - lastRequest < RATE_LIMIT_MS) { return new Response( JSON.stringify({ error: "Too many requests. Please wait before trying again." }), { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } rateLimitMap.set(rateLimitKey, now); recentIpRequests.push(now); ipRequestLog.set(clientIp, recentIpRequests); // Clean old entries periodically if (rateLimitMap.size > 1000) { for (const [key, ts] of rateLimitMap) { if (now - ts > RATE_LIMIT_MS) rateLimitMap.delete(key); } } if (ipRequestLog.size > 500) { for (const [key, timestamps] of ipRequestLog) { const valid = timestamps.filter(ts => now - ts < IP_RATE_LIMIT_MS); if (valid.length === 0) ipRequestLog.delete(key); else ipRequestLog.set(key, valid); } } const safeAddress = escapeHtml(address); const safeContact = escapeHtml(contact); const isEmail = contact.includes("@"); const contactType = isEmail ? "Courriel" : "Mobile"; const availability = fiber_available ? "✅ Disponible" : "❌ Non disponible"; const speedText = max_speed && max_speed > 0 ? max_speed >= 1000 ? `${(max_speed / 1000).toFixed(1)} Gbit/s` : `${max_speed} Mbit/s` : "N/A"; const htmlBody = `

Nouvelle demande de disponibilité fibre

Adresse${safeAddress}
Disponibilité${availability}
Vitesse max${speedText}
${contactType}${safeContact}
`; const MAILJET_API_KEY = Deno.env.get("MAILJET_API_KEY")!; const MAILJET_SECRET_KEY = Deno.env.get("MAILJET_SECRET_KEY")!; const response = await fetch("https://api.mailjet.com/v3.1/send", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Basic " + btoa(`${MAILJET_API_KEY}:${MAILJET_SECRET_KEY}`), }, body: JSON.stringify({ Messages: [ { From: { Email: "louis.targo@gmail.com", Name: "Targo - Vérification" }, To: [{ Email: "support@targo.ca", Name: "Support Targo" }], Subject: `Nouvelle demande – ${fiber_available ? "Fibre disponible" : "Fibre non disponible"} – ${safeContact}`, HTMLPart: htmlBody, }, ], }), }); const result = await response.text(); if (!response.ok) { console.error("Mailjet error:", result); return new Response( JSON.stringify({ error: "Email send failed" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } return new Response( JSON.stringify({ success: true }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } catch (err) { console.error("send-lead-email error:", err); return new Response( JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } });