584 lines
15 KiB
TypeScript
584 lines
15 KiB
TypeScript
// 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, "&")
|
||
.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 = `
|
||
<!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)} <${escapeHtml(data.email)}>
|
||
</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",
|
||
},
|
||
},
|
||
);
|
||
}
|
||
} |