site-web-targo/supabase/functions/send-lead-email/index.ts
louispaulb 88dc3714a1 Initial deploy: gigafibre.ca website with self-hosted address search
React/Vite/shadcn-ui site for Gigafibre ISP.
Address qualification via PostgreSQL (5.2M AQ addresses, pg_trgm fuzzy search).
No Supabase dependency for address search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:37:50 -04:00

173 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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<string, number>();
const ipRequestLog = new Map<string, number[]>();
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 = `
<h2>Nouvelle demande de disponibilité fibre</h2>
<table style="border-collapse:collapse;width:100%;max-width:500px;">
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Adresse</td><td style="padding:8px;border:1px solid #ddd;">${safeAddress}</td></tr>
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Disponibilité</td><td style="padding:8px;border:1px solid #ddd;">${availability}</td></tr>
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Vitesse max</td><td style="padding:8px;border:1px solid #ddd;">${speedText}</td></tr>
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">${contactType}</td><td style="padding:8px;border:1px solid #ddd;">${safeContact}</td></tr>
</table>
`;
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" } }
);
}
});