Skip to content

Commit 14ce831

Browse files
feat: production contact form with Resend email API
API route (src/app/api/contact/route.ts): - POST /api/contact — validates name/email/description, rejects empties - Honeypot field check (website): silently returns 200 for bots - Email regex validation on the email field - Uses Resend SDK to send structured HTML + plain-text email - Recipient: process.env.NEXT_PUBLIC_CONTACT_EMAIL - replyTo: submitter email so replies go directly back to them - Dark-theme branded HTML template (header/body/footer layout) - Field-level errors returned as JSON {fieldErrors} for inline display - GET /api/contact returns 405 Method Not Allowed - Vercel-compatible (ƒ Dynamic server-rendered route) Frontend (src/app/contact/page.tsx): - FormValues: added honeypot 'website' field (hidden input, tabIndex=-1) - handleSubmit converted to async: POST fetch to /api/contact - loading state: button shows Loader2 spinner + 'Sending…', disabled - submitError state: red AlertCircle banner above submit button - Field-level API errors surfaced directly into existing inline error UI - reset (Send another message): clears website + submitError too - submit button: disabled:opacity-60 + disabled:cursor-not-allowed Env: - .env.local: RESEND_API_KEY placeholder added - .env.example: RESEND_API_KEY documented with resend.com link - ci.yml deploy: --env RESEND_API_KEY='${{ secrets.RESEND_API_KEY }}' (server-only runtime var, not --build-env) Setup required: 1. Create free account at resend.com → generate API key 2. Add RESEND_API_KEY to GitHub repo secrets 3. Add RESEND_API_KEY to Vercel project env vars (dashboard) 4. In local dev: set real key in .env.local
1 parent b87ccce commit 14ce831

6 files changed

Lines changed: 344 additions & 13 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
# This file is committed (no real secrets here — use .env.local for overrides).
55
# ─────────────────────────────────────────────────────────────────────────────
66

7+
# ── Email (server-only — no NEXT_PUBLIC prefix, never exposed to browser) ────
8+
# Generate at https://resend.com → API Keys
9+
RESEND_API_KEY=re_your_key_here
10+
711
# ── Brand identity ────────────────────────────────────────────────────────────
812
NEXT_PUBLIC_SITE_NAME=RelientOps
913
NEXT_PUBLIC_SITE_TAGLINE=Freelance DevOps & Cloud Engineering

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,4 @@ jobs:
9191
--build-env NEXT_PUBLIC_FREE_REVIEW_HREF="/free-review"
9292
--build-env NEXT_PUBLIC_FREE_REVIEW_DURATION="30 min"
9393
--build-env NEXT_PUBLIC_CALENDLY_URL="https://calendly.com/sagardeepak2002/30min"
94+
--env RESEND_API_KEY="${{ secrets.RESEND_API_KEY }}"

package-lock.json

Lines changed: 73 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"react-dom": "19.2.3",
2222
"remark": "^15.0.1",
2323
"remark-html": "^16.0.1",
24+
"resend": "^6.9.2",
2425
"tailwind-merge": "^3.5.0"
2526
},
2627
"devDependencies": {

src/app/api/contact/route.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { Resend } from "resend";
3+
4+
// ── Resend client (initialised lazily so missing key gives a clear error) ─────
5+
function getResend(): Resend {
6+
const key = process.env.RESEND_API_KEY;
7+
if (!key) {
8+
throw new Error("RESEND_API_KEY environment variable is not set.");
9+
}
10+
return new Resend(key);
11+
}
12+
13+
// ── Types ─────────────────────────────────────────────────────────────────────
14+
interface ContactPayload {
15+
name: string;
16+
email: string;
17+
company?: string;
18+
description: string;
19+
/** Honeypot — must remain empty; bots autofill it */
20+
website?: string;
21+
}
22+
23+
// ── Helpers ───────────────────────────────────────────────────────────────────
24+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
25+
26+
function json(data: object, status = 200) {
27+
return NextResponse.json(data, { status });
28+
}
29+
30+
// ── HTML email template ───────────────────────────────────────────────────────
31+
function buildHtml(p: Required<Omit<ContactPayload, "website">>): string {
32+
const escape = (s: string) =>
33+
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
34+
35+
const row = (label: string, value: string) => `
36+
<tr>
37+
<td style="padding:10px 16px;font-weight:600;color:#94a3b8;white-space:nowrap;vertical-align:top;font-size:13px;width:130px">${label}</td>
38+
<td style="padding:10px 16px;color:#f1f5f9;font-size:14px;line-height:1.6">${escape(value)}</td>
39+
</tr>`;
40+
41+
return `<!DOCTYPE html>
42+
<html lang="en">
43+
<head>
44+
<meta charset="UTF-8" />
45+
<meta name="viewport" content="width=device-width,initial-scale=1" />
46+
<title>New contact enquiry</title>
47+
</head>
48+
<body style="margin:0;padding:0;background:#0a0a0f;font-family:ui-sans-serif,system-ui,sans-serif">
49+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
50+
<tr>
51+
<td align="center" style="padding:40px 16px">
52+
<table role="presentation" width="600" style="max-width:600px;width:100%">
53+
54+
<!-- Header -->
55+
<tr>
56+
<td style="background:#111827;border-radius:12px 12px 0 0;padding:28px 32px;border-bottom:1px solid #1e293b">
57+
<p style="margin:0;font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#818cf8">RelientOps</p>
58+
<h1 style="margin:8px 0 0;font-size:20px;font-weight:700;color:#f1f5f9">New contact enquiry</h1>
59+
</td>
60+
</tr>
61+
62+
<!-- Body -->
63+
<tr>
64+
<td style="background:#111827;padding:8px 0">
65+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse">
66+
${row("Name", p.name)}
67+
<tr><td colspan="2" style="padding:0 16px"><hr style="border:none;border-top:1px solid #1e293b;margin:0" /></td></tr>
68+
${row("Email", p.email)}
69+
<tr><td colspan="2" style="padding:0 16px"><hr style="border:none;border-top:1px solid #1e293b;margin:0" /></td></tr>
70+
${row("Company", p.company || "—")}
71+
<tr><td colspan="2" style="padding:0 16px"><hr style="border:none;border-top:1px solid #1e293b;margin:0" /></td></tr>
72+
<tr>
73+
<td style="padding:10px 16px;font-weight:600;color:#94a3b8;vertical-align:top;font-size:13px;width:130px">Project</td>
74+
<td style="padding:10px 16px;color:#f1f5f9;font-size:14px;line-height:1.7;white-space:pre-wrap">${escape(p.description)}</td>
75+
</tr>
76+
</table>
77+
</td>
78+
</tr>
79+
80+
<!-- Footer -->
81+
<tr>
82+
<td style="background:#0f172a;border-radius:0 0 12px 12px;padding:20px 32px;border-top:1px solid #1e293b">
83+
<p style="margin:0;font-size:12px;color:#475569">
84+
Sent from the <a href="https://relientops.io/contact" style="color:#818cf8;text-decoration:none">relientops.io contact form</a>
85+
&nbsp;·&nbsp; ${new Date().toUTCString()}
86+
</p>
87+
</td>
88+
</tr>
89+
90+
</table>
91+
</td>
92+
</tr>
93+
</table>
94+
</body>
95+
</html>`;
96+
}
97+
98+
// ── Route handler ─────────────────────────────────────────────────────────────
99+
export async function POST(req: NextRequest) {
100+
// ── 1. Parse body ──────────────────────────────────────────────────────────
101+
let body: ContactPayload;
102+
try {
103+
body = await req.json();
104+
} catch {
105+
return json({ success: false, error: "Invalid request body." }, 400);
106+
}
107+
108+
const { name, email, company = "", description, website = "" } = body;
109+
110+
// ── 2. Honeypot — silently discard bot submissions ─────────────────────────
111+
if (website.trim() !== "") {
112+
// Return 200 to not reveal bot detection
113+
return json({ success: true });
114+
}
115+
116+
// ── 3. Validate required fields ────────────────────────────────────────────
117+
const fieldErrors: Record<string, string> = {};
118+
119+
if (!name?.trim()) {
120+
fieldErrors.name = "Name is required.";
121+
}
122+
if (!email?.trim()) {
123+
fieldErrors.email = "Email is required.";
124+
} else if (!EMAIL_RE.test(email.trim())) {
125+
fieldErrors.email = "Enter a valid email address.";
126+
}
127+
if (!description?.trim()) {
128+
fieldErrors.description = "Project description is required.";
129+
} else if (description.trim().length < 20) {
130+
fieldErrors.description = "Please add more detail (at least 20 characters).";
131+
}
132+
133+
if (Object.keys(fieldErrors).length > 0) {
134+
return json({ success: false, fieldErrors }, 422);
135+
}
136+
137+
// ── 4. Resolve recipient ───────────────────────────────────────────────────
138+
const to = process.env.NEXT_PUBLIC_CONTACT_EMAIL;
139+
if (!to) {
140+
console.error("[contact] NEXT_PUBLIC_CONTACT_EMAIL is not configured.");
141+
return json({ success: false, error: "Server misconfiguration — please email directly." }, 500);
142+
}
143+
144+
// ── 5. Send via Resend ─────────────────────────────────────────────────────
145+
try {
146+
const resend = getResend();
147+
148+
const payload = {
149+
name: name.trim(),
150+
email: email.trim().toLowerCase(),
151+
company: company.trim(),
152+
description: description.trim(),
153+
};
154+
155+
const { error } = await resend.emails.send({
156+
from: "RelientOps Contact <onboarding@resend.dev>",
157+
to: [to],
158+
replyTo: payload.email,
159+
subject: `[RelientOps] New enquiry from ${payload.name}${payload.company ? ` · ${payload.company}` : ""}`,
160+
html: buildHtml(payload),
161+
text: [
162+
`Name: ${payload.name}`,
163+
`Email: ${payload.email}`,
164+
`Company: ${payload.company || "—"}`,
165+
"",
166+
`Project description:`,
167+
payload.description,
168+
].join("\n"),
169+
});
170+
171+
if (error) {
172+
console.error("[contact] Resend error:", error);
173+
return json({ success: false, error: "Failed to send email. Please try again." }, 502);
174+
}
175+
176+
return json({ success: true });
177+
} catch (err) {
178+
console.error("[contact] Unexpected error:", err);
179+
return json(
180+
{ success: false, error: "An unexpected error occurred. Please try again later." },
181+
500
182+
);
183+
}
184+
}
185+
186+
// Reject non-POST methods
187+
export async function GET() {
188+
return json({ error: "Method not allowed." }, 405);
189+
}

0 commit comments

Comments
 (0)