Files
mucomutfak-webapp/functions/contact/index.ts
T

584 lines
15 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.
// functions/contact/index.ts
declare global {
interface KVNamespace {
get(key: string): Promise<string | null>;
put(
key: string,
value: string,
options?: { expirationTtl?: number },
): Promise<void>;
}
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 = `
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style>
.wrapper { width:100%; background:#f5f7fb; margin:0; padding:24px 12px; }
.container { width:100%; max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; border:1px solid #e5e7eb; overflow:hidden; }
.header { padding:20px 24px; background:#0f172a; color:#ffffff; }
.title { margin:0; font-size:20px; line-height:1.3; }
.section { padding:20px 24px; }
.kv { width:100%; border-collapse:collapse; }
.kv th { text-align:left; padding:8px 0; font-size:13px; color:#64748b; width:160px; vertical-align:top; }
.kv td { padding:8px 0; font-size:14px; color:#0f172a; }
.divider { height:1px; background:#e5e7eb; margin:12px 0; }
.message { white-space:pre-wrap; padding:12px; border-radius:8px; background:#f8fafc; border:1px solid #e2e8f0; color:#0f172a; font-size:14px; }
.footer { padding:16px 24px; font-size:12px; color:#94a3b8; background:#fafafa; }
.pill { display:inline-block; padding:4px 10px; border-radius:9999px; border:1px solid #e2e8f0; font-size:12px; color:#0f172a; background:#f8fafc; }
.btn { display:inline-block; font-size:14px; text-decoration:none; padding:10px 14px; border-radius:8px; border:1px solid #0f172a; }
@media (max-width: 480px) {
.section { padding:16px; }
.header { padding:16px; }
.kv th { width:120px; }
}
</style>
</head>
<body style="margin:0; padding:0; background:#f5f7fb;">
<div class="wrapper">
<table role="presentation" class="container" cellpadding="0" cellspacing="0">
<tr>
<td class="header">
<h2 class="title">Yeni Mesajınız Var!</h2>
<div style="margin-top:8px;">
<span class="pill">${escapeHtml(subjectLine)}</span>
</div>
</td>
</tr>
<tr>
<td class="section">
<table class="kv" role="presentation">
<tr>
<th>Ad Soyad</th>
<td>${escapeHtml(data.fullName)}</td>
</tr>
<tr>
<th>E-posta</th>
<td>
<a href="${safeEmailHref(data.email)}" style="color:#0f172a;">
${escapeHtml(data.email)}
</a>
</td>
</tr>
<tr>
<th>Telefon</th>
<td>${escapeHtml(phoneText)}</td>
</tr>
<tr>
<th>Konu</th>
<td>
${escapeHtml(subjectLine)}
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
</td>
</tr>
<tr>
<th>İletişim İzni</th>
<td>${escapeHtml(consentText)}</td>
</tr>
</table>
<div style="margin-bottom:6px;">Mesaj</div>
<div class="message">
${escapeHtml(data.message)}
</div>
<div class="divider"></div>
<div style="font-weight:600; margin-bottom:6px;">
Reply-ready quote
</div>
<blockquote style="margin:0; padding-left:12px; border-left:3px solid #e2e8f0; color:#334155;">
<div>
<strong>Kimden:</strong>
${escapeHtml(data.fullName)} &lt;${escapeHtml(data.email)}&gt;
</div>
<div>
<strong>Telefon:</strong>
${escapeHtml(phoneText)}
</div>
<div>
<strong>Konu:</strong>
${escapeHtml(subjectLine)}
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
</div>
<div style="margin-top:8px; white-space:pre-wrap;">
${escapeHtml(data.message)}
</div>
</blockquote>
<div style="margin-top:16px;">
<a class="btn" href="${safeEmailHref(data.email)}">
Yanıtla ${escapeHtml(data.fullName)}
</a>
</div>
</td>
</tr>
<tr>
<td class="section" style="padding-top:0;">
<table class="kv" role="presentation">
<tr>
<th>Origin</th>
<td>${escapeHtml(sourceOrigin)}</td>
</tr>
<tr>
<th>IP</th>
<td>${escapeHtml(clientIp)}</td>
</tr>
<tr>
<th>Tarih</th>
<td>${escapeHtml(receivedAt)}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="footer">
Bu e-posta iletişim formundan otomatik olarak gönderilmiştir.
</td>
</tr>
</table>
</div>
</body>
</html>`;
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",
},
},
);
}
}