"use server"; import { Resend } from "resend"; import { autoReplyEmail, staffNotificationEmail } from "@/lib/emails"; import { CONTACT_CHANNELS } from "@/lib/site"; export type ContactState = { status: "idle" | "success" | "error"; message: string; fieldErrors?: Partial>; }; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export async function sendContact( _prev: ContactState, formData: FormData, ): Promise { const name = String(formData.get("name") ?? "").trim(); const email = String(formData.get("email") ?? "").trim(); const subject = String(formData.get("subject") ?? "").trim(); const message = String(formData.get("message") ?? "").trim(); const honeypot = String(formData.get("website") ?? ""); // Which desk the message is for. Never trusted as-is: it only counts when it // matches a declared channel, so a crafted request cannot inject a label // into the staff email subject. const rawTopic = String(formData.get("topic") ?? "").trim(); const topic = CONTACT_CHANNELS.some((channel) => channel.label === rawTopic) ? rawTopic : null; if (honeypot) { return { status: "success", message: "Thanks for reaching out." }; } const fieldErrors: ContactState["fieldErrors"] = {}; if (!name) fieldErrors.name = "Required."; if (!email || !EMAIL_RE.test(email)) fieldErrors.email = "Valid email required."; if (!subject) fieldErrors.subject = "Required."; if (!message || message.length < 10) { fieldErrors.message = "Tell us a bit more (10+ chars)."; } if (Object.keys(fieldErrors).length > 0) { return { status: "error", message: "Please fix the highlighted fields.", fieldErrors, }; } const apiKey = process.env.RESEND_API_KEY; const to = process.env.CONTACT_TO_EMAIL; const from = process.env.CONTACT_FROM_EMAIL; if (!apiKey || !to || !from) { console.error( "[contact] Missing email env vars (RESEND_API_KEY / CONTACT_TO_EMAIL / CONTACT_FROM_EMAIL).", ); return { status: "error", message: "Email isn't configured yet. Please try again later.", }; } // The studio inbox is a single address, so the desk is carried in the // subject line — that is what makes it filterable. const contact = { name, email, subject: topic ? `[${topic}] ${subject}` : subject, message, }; try { const resend = new Resend(apiKey); // Critical: notify the studio staff. If this fails, surface an error. const staff = staffNotificationEmail(contact); const { error } = await resend.emails.send({ from, to, replyTo: email, subject: staff.subject, html: staff.html, text: staff.text, }); if (error) { console.error("[contact] Resend error (staff):", error); return { status: "error", message: "Something went wrong sending your message. Please try again.", }; } // Best-effort: send an acknowledgement to the visitor. A failure here // shouldn't fail the whole submission — the staff was already notified. try { const reply = autoReplyEmail(contact); const { error: replyError } = await resend.emails.send({ from, to: email, subject: reply.subject, html: reply.html, text: reply.text, }); if (replyError) { console.error("[contact] Resend error (auto-reply):", replyError); } } catch (replyErr) { console.error("[contact] Auto-reply failed:", replyErr); } } catch (err) { console.error("[contact] Unexpected error:", err); return { status: "error", message: "Something went wrong sending your message. Please try again.", }; } return { status: "success", message: "Message sent. We'll get back to you soon.", }; }