// functions/contact/index.ts declare global { interface KVNamespace { get(key: string): Promise; put( key: string, value: string, options?: { expirationTtl?: number }, ): Promise; } } interface Env { RESEND_API_KEY: string; EMAILS_API: string; EMAILS_TO: string; EMAILS_TO_CC: string; EMAILS_FROM: string; TURNSTILE_SECRET_KEY?: string; RATE_LIMIT_KV?: KVNamespace; } function escapeHtml(input: unknown): string { return String(input ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function safeEmailHref(email: string): string { return `mailto:${encodeURIComponent(email)}`; } type Validated = { fullName: string; email: string; phone?: string; subject: "job-application" | "suggestion" | "something-else"; subjectOther?: string; message: string; consent: boolean; }; function validatePayload( body: any, ): { ok: true; value: Validated } | { ok: false; error: string } { if (!body || typeof body !== "object") { return { ok: false, error: "Invalid payload" }; } const fullName = String(body.fullName ?? "").trim(); if (fullName.length < 4 || fullName.length > 100) { return { ok: false, error: "Invalid name length" }; } const email = String(body.email ?? "").trim(); const emailRe = /^(?:[^\s@]+)@(?:[^\s@]+)\.[^\s@]{2,}$/i; if (!emailRe.test(email) || email.length > 254) { return { ok: false, error: "Invalid email" }; } const phone = String(body.phone ?? "").trim(); if (phone) { const phoneRe = /^\+?[1-9]\d{0,3}[\s\-().]?\d{1,4}[\s\-().]?\d{1,4}[\s\-().]?\d{1,9}$/; if (!phoneRe.test(phone) || phone.length > 30) { return { ok: false, error: "Invalid phone" }; } } const subject = String(body.subject ?? ""); const allowedSubjects = ["job-application", "suggestion", "something-else"]; if (!allowedSubjects.includes(subject)) { return { ok: false, error: "Invalid subject" }; } const subjectOther = String(body.subjectOther ?? "").trim(); if (subject === "something-else" && subjectOther.length === 0) { return { ok: false, error: "Subject other required" }; } if (subjectOther.length > 120) { return { ok: false, error: "subjectOther too long" }; } const message = String(body.message ?? "").trim(); if (message.length < 25 || message.length > 600) { return { ok: false, error: "Invalid message length" }; } const consent = Boolean(body.consent); if (!consent) { return { ok: false, error: "Consent required" }; } return { ok: true, value: { fullName, email, phone, subject: subject as Validated["subject"], subjectOther, message, consent, }, }; } function isAllowedOrigin(request: Request): boolean { const origin = request.headers.get("Origin") || ""; const allowed = ["https://mucomutfak.com", "https://www.mucomutfak.com"]; if (!origin) return true; return allowed.includes(origin); } async function verifyTurnstile( secret: string | undefined, token: string | undefined, remoteIp?: string, ) { if (!secret) return { ok: false, error: "Turnstile not configured" }; if (!token) return { ok: false, error: "Missing Turnstile token" }; try { const form = new URLSearchParams(); form.set("secret", secret); form.set("response", token); if (remoteIp) { form.set("remoteip", remoteIp); } const res = await fetch( "https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body: form, }, ); const json = await res.json(); if (json.success) { return { ok: true }; } return { ok: false, error: "Turnstile failed", details: json["error-codes"] || json, }; } catch { return { ok: false, error: "Turnstile verification error" }; } } async function rateLimitCheck( ip: string | null, kv: KVNamespace | undefined, ): Promise<{ ok: true } | { ok: false; retryAfter?: number }> { if (!kv || !ip) return { ok: true }; const key = `cf_rl:${ip}:${new Date().toISOString().slice(0, 16)}`; try { const existing = await kv.get(key); const count = existing ? parseInt(existing, 10) : 0; const limitPerMinute = 6; if (count >= limitPerMinute) { return { ok: false, retryAfter: 60 }; } await kv.put(key, String(count + 1), { expirationTtl: 65, }); return { ok: true }; } catch (e) { console.error("KV rate limit error", e); return { ok: true }; } } export async function onRequestPost({ request, env, }: { request: Request; env: Env; }) { try { console.log("[contact] function invoked", { origin: request.headers.get("Origin"), hasSecret: !!env.TURNSTILE_SECRET_KEY, hasKV: !!env.RATE_LIMIT_KV, }); if (!isAllowedOrigin(request)) { return new Response( JSON.stringify({ success: false, error: "Forbidden", }), { status: 403, headers: { "Content-Type": "application/json", }, }, ); } const body = await request.json().catch(() => null); if (body?.checkIfYouAreNotARobot) { console.warn("Robot triggered from request"); return new Response( JSON.stringify({ success: true, }), { headers: { "Content-Type": "application/json", }, }, ); } const validated = validatePayload(body); if (!validated.ok) { return new Response( JSON.stringify({ success: false, error: validated.error, }), { status: 400, headers: { "Content-Type": "application/json", }, }, ); } const data = validated.value; const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || null; const rateLimit = await rateLimitCheck(ip, env.RATE_LIMIT_KV); if (!rateLimit.ok) { return new Response( JSON.stringify({ success: false, error: "Rate limit exceeded", }), { status: 429, headers: { "Content-Type": "application/json", }, }, ); } const turnstileToken = String(body?.turnstileToken ?? ""); if (env.TURNSTILE_SECRET_KEY) { console.log("[contact] using TURNSTILE_SECRET_KEY"); const turnstile = await verifyTurnstile( env.TURNSTILE_SECRET_KEY, turnstileToken, ip || undefined, ); if (!turnstile.ok) { console.warn("Turnstile failed", turnstile); return new Response( JSON.stringify({ success: false, error: "Bot verification failed", }), { status: 400, headers: { "Content-Type": "application/json", }, }, ); } } const subjectLine = data.subject === "something-else" ? data.subjectOther || "Contact Form" : data.subject; const receivedAt = new Date().toISOString(); const sourceOrigin = request.headers.get("Origin") || request.headers.get("Referer") || "N/A"; const clientIp = ip || "N/A"; const subjectOtherSafe = data.subject === "something-else" ? data.subjectOther || "" : ""; const consentText = data.consent ? "Yes" : "No"; const phoneText = data.phone || "-"; const quotedMessage = String(data.message) .split("\n") .map((line) => `> ${line}`) .join("\n"); const mailText = ` Yeni Mesajınız Var! ── İletişim Detayları ───────────────────── Ad Soyad: ${data.fullName} E-posta: ${data.email} Telefon: ${phoneText} Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""} İletişim İzni: ${consentText} ── Mesaj ───────────────────────────── ${data.message} ── Meta ───────────────────────────────── Origin: ${sourceOrigin} IP: ${clientIp} Tarih: ${receivedAt} ── Yanıtla ─ > Ad Soyad: ${data.fullName} > E-posta: ${data.email} > Telefon: ${phoneText} > Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""} > ${quotedMessage} `; const mailHtml = `
`; const cc = env.EMAILS_TO_CC ? env.EMAILS_TO_CC.split(",") .map((email) => email.trim()) .filter(Boolean) : []; const mailResponse = await fetch(env.EMAILS_API, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.RESEND_API_KEY}`, }, body: JSON.stringify({ from: env.EMAILS_FROM, to: [env.EMAILS_TO], cc, subject: `Yeni Mesajınız Var: ${subjectLine}${ subjectOtherSafe ? " - " + subjectOtherSafe : "" }`, text: mailText, html: mailHtml, reply_to: data.email, }), }); if (!mailResponse.ok) { const detail = await mailResponse.text().catch(() => "no details"); console.error("Resend error:", mailResponse.status, detail); return new Response( JSON.stringify({ success: false, error: "Unable to send message", }), { status: 500, headers: { "Content-Type": "application/json", }, }, ); } const responseJson = await mailResponse.json().catch(() => ({})); return new Response( JSON.stringify({ success: true, id: responseJson.id || null, }), { headers: { "Content-Type": "application/json", }, }, ); } catch (err) { console.error("Unhandled error in contact function", err); return new Response( JSON.stringify({ success: false, error: "Server error", }), { status: 500, headers: { "Content-Type": "application/json", }, }, ); } }