From 5551955aaa7610c934a2f1c1ac66d9076d8286ac Mon Sep 17 00:00:00 2001 From: Alejandro Veras Date: Mon, 12 Oct 2026 12:09:05 -0400 Subject: [PATCH] Harden the contact form and email visitors a confirmation Adds a fill-time trap, link-spam filter and optional Cloudflare Turnstile on top of the honeypot; bot hits get a silent success. Once CONTACT_FROM points at a Resend-verified domain, the visitor receives a branded copy of their message after the response. Co-Authored-By: Claude Opus 5.5 --- src/app/contact/actions.ts | 157 ++++++++++++++++++++---- src/components/content/contact-form.tsx | 36 +++++- 2 files changed, 164 insertions(+), 29 deletions(-) diff --git a/src/app/contact/actions.ts b/src/app/contact/actions.ts index 02c11ca..f88bc44 100644 --- a/src/app/contact/actions.ts +++ b/src/app/contact/actions.ts @@ -1,20 +1,117 @@ "use server"; -export type ContactState = { status: "idle" | "sent" | "error"; message?: string }; +import { headers } from "next/headers"; +import { after } from "next/server"; + +export type ContactState = { status: "idle" | "sent" | "error"; message?: string; copySent?: boolean }; // Where enquiries land. Override with CONTACT_TO in the environment if it ever changes. const TO = process.env.CONTACT_TO || "rey@aigrowth360.io"; -// Resend's shared sender works without verifying a domain, but only delivers to the address that -// owns the Resend account. Set CONTACT_FROM once aigrowth360.io (or another domain) is verified. +// Resend's shared sender works without a verified domain, but only delivers to the address that +// owns the Resend account. Set CONTACT_FROM to an address on a domain verified in Resend +// (e.g. "AI Growth Technologies ") to also send visitors a confirmation. const FROM = process.env.CONTACT_FROM || "AI Growth Website "; +const CAN_EMAIL_VISITORS = Boolean(process.env.CONTACT_FROM); +const SITE = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : "https://www.aigrowthtechnologies.com"; + +const MIN_FILL_MS = 3000; +const MAX_LINKS = 3; const clean = (v: FormDataEntryValue | null, max: number) => String(v ?? "").trim().slice(0, max); const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +// Bots get a normal-looking success so they don't learn what tripped them. +const silentlyDrop = (reason: string): ContactState => { + console.warn("Contact form: dropped submission —", reason); + return { status: "sent" }; +}; + +async function passesTurnstile(token: string): Promise { + const secret = process.env.TURNSTILE_SECRET_KEY; + if (!secret) return true; // Turnstile not configured yet + if (!token) return false; + const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim(); + const body = new URLSearchParams({ secret, response: token }); + if (ip) body.set("remoteip", ip); + try { + const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body }); + const data = (await res.json()) as { success?: boolean }; + return data.success === true; + } catch (err) { + console.error("Contact form: Turnstile check failed", err); + return false; + } +} + +async function sendEmail(apiKey: string, payload: Record) { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ from: FROM, ...payload }), + }); + if (!res.ok) throw new Error(`Resend ${res.status}: ${await res.text()}`); +} + +function confirmationEmail(name: string, rows: [string, string][], message: string) { + const first = name.split(/\s+/)[0]; + const text = [ + `Hi ${first},`, + "", + "Thanks for reaching out to AI Growth Technologies. Your message is with our team, and a strategist will review what you shared and get back to you shortly.", + "", + "Here's a copy of what you sent:", + "", + ...rows.map(([k, v]) => `${k}: ${v}`), + "", + message, + "", + "— The AI Growth Technologies team", + SITE, + ].join("\n"); + + const html = ` +
+
+
+ AI Growth Technologies +
+
+

Message received

+

Thanks, ${escape(first)}. We’ve got it.

+

+ Your message is with our team. A strategist will review what you shared and get back to you shortly. +

+

What you sent

+ + ${rows + .map( + ([k, v]) => + ``, + ) + .join("")} +
${k}${escape(v)}
+

${escape(message)}

+
+
+ AI Growth Technologies · Dubai · Houston · Miami · New York
+ ${SITE.replace(/^https?:\/\//, "")} +
+
+
`; + + return { subject: "We received your message — AI Growth Technologies", text, html }; +} + export async function sendContact(_prev: ContactState, formData: FormData): Promise { - // Honeypot: real visitors never see or fill this field. - if (clean(formData.get("company_url"), 200)) return { status: "sent" }; + // 1. Honeypot: real visitors never see or fill this field. + if (clean(formData.get("company_url"), 200)) return silentlyDrop("honeypot"); + + // 2. Time trap: people need more than a few seconds to fill the form in. + const started = Number(formData.get("started")); + if (!started || Date.now() - started < MIN_FILL_MS) return silentlyDrop("submitted too fast"); const name = clean(formData.get("name"), 120); const email = clean(formData.get("email"), 200); @@ -31,6 +128,14 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom return { status: "error", message: "Please add your name, a valid email, and a short message." }; } + // 3. Link spam. + if ((message.match(/https?:\/\/|www\./gi) ?? []).length > MAX_LINKS) return silentlyDrop("too many links"); + + // 4. Cloudflare Turnstile, once its keys are configured. + if (!(await passesTurnstile(clean(formData.get("cf-turnstile-response"), 2048)))) { + return { status: "error", message: "We couldn’t verify you’re human. Please try again." }; + } + const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { console.error("Contact form: RESEND_API_KEY is not set"); @@ -44,32 +149,32 @@ export async function sendContact(_prev: ContactState, formData: FormData): Prom ["Website", website || "—"], ["Interested in", interests.join(", ") || "—"], ]; - const text = `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`; - const html = `${rows - .map(([k, v]) => ``) - .join("")}
${k}${escape(v)}

${escape(message)}

`; try { - const res = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ - from: FROM, - to: [TO], - reply_to: email, - subject: `New enquiry — ${company || name}`, - text, - html, - }), + await sendEmail(apiKey, { + to: [TO], + reply_to: email, + subject: `New enquiry — ${company || name}`, + text: `${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\n${message}`, + html: `${rows + .map(([k, v]) => ``) + .join("")}
${k}${escape(v)}

${escape(message)}

`, }); - if (!res.ok) { - console.error("Contact form: Resend responded", res.status, await res.text()); - return { status: "error", message: "We couldn’t send your message." }; - } } catch (err) { - console.error("Contact form: request failed", err); + console.error("Contact form: could not email the team", err); return { status: "error", message: "We couldn’t send your message." }; } - return { status: "sent" }; + // The visitor's copy goes out after the response, and never fails the submission. + if (CAN_EMAIL_VISITORS) { + after(async () => { + try { + await sendEmail(apiKey, { to: [email], reply_to: TO, ...confirmationEmail(name, rows, message) }); + } catch (err) { + console.error("Contact form: could not send the visitor confirmation", err); + } + }); + } + + return { status: "sent", copySent: CAN_EMAIL_VISITORS }; } diff --git a/src/components/content/contact-form.tsx b/src/components/content/contact-form.tsx index ec9678e..30d3211 100644 --- a/src/components/content/contact-form.tsx +++ b/src/components/content/contact-form.tsx @@ -1,21 +1,44 @@ "use client"; -import { useActionState } from "react"; +import Script from "next/script"; +import { useActionState, useEffect, useRef } from "react"; import { sendContact, type ContactState } from "@/app/contact/actions"; import { CONTACT_EMAIL, serviceNav } from "@/lib/routes"; const interests = [...serviceNav.map((s) => s.name), "Not sure yet"]; const initial: ContactState = { status: "idle" }; +// Cloudflare Turnstile renders only once a site key is configured. +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + +declare global { + interface Window { + turnstile?: { reset: (el?: Element | string) => void }; + } +} export default function ContactForm() { const [state, action, pending] = useActionState(sendContact, initial); + const startedRef = useRef(null); + + // When the visitor started filling the form; the server rejects instant (bot) submissions. + useEffect(() => { + if (startedRef.current && !startedRef.current.value) startedRef.current.value = String(Date.now()); + }, []); + + // A Turnstile token is single-use, so get a fresh one after a failed attempt. + useEffect(() => { + if (state.status === "error") window.turnstile?.reset(); + }, [state]); if (state.status === "sent") { return (
Received

Thanks — your message is with the team.

-

We’ll review what you shared and get back to you shortly.

+

+ We’ll review what you shared and get back to you shortly. + {state.copySent && " A confirmation with a copy of your message is on its way to your inbox."} +

); } @@ -44,6 +67,7 @@ export default function ContactForm() { {/* Honeypot for bots — hidden from people and assistive tech. */} +
What are you looking at?
@@ -59,13 +83,19 @@ export default function ContactForm() { What’s happening in the business?