From 5ca4819bcb14aa6eccd0223f0096cb1b8c3862d7 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 8 Mar 2026 06:27:09 +0100 Subject: [PATCH] Add SMTP admin settings and contact form delivery --- app/[locale]/(site)/contact/actions.ts | 109 ++++++++++++ app/[locale]/(site)/contact/page.tsx | 65 ++++---- app/[locale]/(site)/success/page.tsx | 44 +++-- app/root/layout.tsx | 6 +- app/root/site-settings/page.tsx | 1 + app/root/smtp/actions.ts | 113 +++++++++++++ app/root/smtp/contact-protection/actions.ts | 110 ++++++++++++ app/root/smtp/contact-protection/page.tsx | 85 ++++++++++ app/root/smtp/page.tsx | 91 ++++++++++ components/root/contact-protection-form.tsx | 133 +++++++++++++++ components/root/root-dashboard-shell.tsx | 14 +- components/root/smtp-settings-form.tsx | 176 ++++++++++++++++++++ components/site/contact-form.tsx | 83 +++++++++ components/site/contact-turnstile.tsx | 107 ++++++++++++ lib/app-config.ts | 108 ++++++++++++ lib/contact-guard.ts | 112 +++++++++++++ lib/contact-protection.ts | 132 +++++++++++++++ lib/mail-settings.ts | 133 +++++++++++++++ lib/mail.ts | 172 +++++++++++++++++++ lib/root-navigation.ts | 27 ++- messages/de.json | 3 + messages/en.json | 3 + package-lock.json | 22 ++- package.json | 2 + tests/contact-protection.test.ts | 65 ++++++++ tests/mail-settings.test.ts | 88 ++++++++++ tests/mail.test.ts | 130 +++++++++++++++ tests/metadata.test.ts | 8 +- 28 files changed, 2087 insertions(+), 55 deletions(-) create mode 100644 app/[locale]/(site)/contact/actions.ts create mode 100644 app/root/smtp/actions.ts create mode 100644 app/root/smtp/contact-protection/actions.ts create mode 100644 app/root/smtp/contact-protection/page.tsx create mode 100644 app/root/smtp/page.tsx create mode 100644 components/root/contact-protection-form.tsx create mode 100644 components/root/smtp-settings-form.tsx create mode 100644 components/site/contact-form.tsx create mode 100644 components/site/contact-turnstile.tsx create mode 100644 lib/contact-guard.ts create mode 100644 lib/contact-protection.ts create mode 100644 lib/mail-settings.ts create mode 100644 lib/mail.ts create mode 100644 tests/contact-protection.test.ts create mode 100644 tests/mail-settings.test.ts create mode 100644 tests/mail.test.ts diff --git a/app/[locale]/(site)/contact/actions.ts b/app/[locale]/(site)/contact/actions.ts new file mode 100644 index 0000000..d1ce63a --- /dev/null +++ b/app/[locale]/(site)/contact/actions.ts @@ -0,0 +1,109 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { isRedirectError } from "next/dist/client/components/redirect"; +import { z } from "zod"; + +import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard"; +import { getContactProtectionSettings } from "@/lib/app-config"; +import { getLocalizedPath, resolveLocale } from "@/lib/locale"; +import { sendContactMessage } from "@/lib/mail"; + +const contactFormSchema = z.object({ + locale: z.string().trim().min(1), + name: z.string().trim().min(2).max(120), + email: z.string().trim().email(), + phone: z.string().trim().max(40).optional(), + company: z.string().trim().max(120).optional(), + message: z.string().trim().min(10).max(5000), + turnstileToken: z.string().trim().optional(), +}); + +const contactErrorMessages = { + ar: { + invalid: "يرجى تعبئة كل الحقول بشكل صحيح.", + failed: "تعذر إرسال الرسالة حالياً.", + blocked: "تم إرسال عدد كبير من الطلبات. حاول لاحقاً.", + verification: "يرجى إكمال التحقق قبل الإرسال.", + }, + en: { + invalid: "Please fill all fields correctly.", + failed: "Message could not be sent right now.", + blocked: "Too many requests. Please try again later.", + verification: "Please complete the verification before sending.", + }, + de: { + invalid: "Bitte alle Felder korrekt ausfuellen.", + failed: "Nachricht konnte gerade nicht gesendet werden.", + blocked: "Zu viele Anfragen. Bitte spaeter erneut versuchen.", + verification: "Bitte die Verifizierung vor dem Senden abschliessen.", + }, +} as const; + +function withError(pathname: string, message: string) { + const params = new URLSearchParams(); + params.set("error", message); + + return `${pathname}?${params.toString()}`; +} + +function getStringValue(formData: FormData, key: string) { + const value = formData.get(key); + return typeof value === "string" ? value : ""; +} + +export async function submitContactFormAction(formData: FormData) { + const locale = resolveLocale(String(formData.get("locale") ?? "")); + const contactPath = getLocalizedPath(locale, "/contact"); + + try { + const protectionSettings = await getContactProtectionSettings(); + const values = contactFormSchema.parse({ + locale, + name: getStringValue(formData, "name"), + email: getStringValue(formData, "email"), + phone: getStringValue(formData, "phone"), + company: getStringValue(formData, "company"), + message: getStringValue(formData, "message"), + turnstileToken: getStringValue(formData, "turnstileToken"), + }); + + await verifyTurnstileToken(protectionSettings, values.turnstileToken ?? ""); + await enforceContactRateLimit(protectionSettings); + + await sendContactMessage({ + locale, + name: values.name, + email: values.email, + phone: values.phone, + company: values.company, + message: values.message, + }); + + redirect(getLocalizedPath(locale, "/success")); + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + + if (error instanceof z.ZodError) { + redirect(withError(contactPath, contactErrorMessages[locale].invalid)); + } + + if (error instanceof Error && error.message === "Too many contact requests. Please try again later.") { + redirect(withError(contactPath, contactErrorMessages[locale].blocked)); + } + + if ( + error instanceof Error && + (error.message === "Turnstile verification is required." || + error.message === "Turnstile verification failed." || + error.message === "Turnstile verification request failed.") + ) { + redirect(withError(contactPath, contactErrorMessages[locale].verification)); + } + + console.error("Contact form delivery failed.", error); + redirect(withError(contactPath, contactErrorMessages[locale].failed)); + } +} diff --git a/app/[locale]/(site)/contact/page.tsx b/app/[locale]/(site)/contact/page.tsx index 85c40e8..05e57f8 100644 --- a/app/[locale]/(site)/contact/page.tsx +++ b/app/[locale]/(site)/contact/page.tsx @@ -1,24 +1,27 @@ import type { Metadata } from "next"; import { Mail, MapPin, Phone } from "lucide-react"; -import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; +import { ContactForm } from "@/components/site/contact-form"; import { buildLocalizedMetadata } from "@/lib/metadata"; +import { getPublicContactProtectionSettings } from "@/lib/app-config"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { AppCard } from "@/components/ui/app-card"; -import { Button } from "@/components/ui/button"; import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; + +import { submitContactFormAction } from "./actions"; type ContactPageProps = { params: { locale: string; }; + searchParams?: { + error?: string; + }; }; export async function generateMetadata({ @@ -35,9 +38,15 @@ export async function generateMetadata({ }); } -export default async function ContactPage({ params: { locale } }: ContactPageProps) { +export default async function ContactPage({ + params: { locale }, + searchParams, +}: ContactPageProps) { const localeKey = resolveLocale(locale); - const t = await getTranslations({ locale: localeKey, namespace: "contactPage" }); + const [t, protection] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "contactPage" }), + getPublicContactProtectionSettings(), + ]); return ( <> @@ -72,29 +81,27 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro -
- - - - -