Skip to content

Commit 037cd64

Browse files
Hem Gabhawalaclaude
andcommitted
feat: contact form with Resend, rate limiting & honeypot
Core features: - /app/api/contact/route.ts: Server-side proxy to Resend API. Dynamic import to avoid build-time key requirement. Validates + sanitizes input, checks rate limits + honeypot, sends email via Resend (100/day free tier). - /lib/rate-limit.ts: Simple IP-based token bucket (in-memory for single- server setup). 3 submissions per IP per hour. User-friendly retry messaging. - /components/contact/*: ContactForm (React Hook Form + Zod), useContactForm hook, ContactSection. Honeypot field + minimum-time-to-submit check. Inline validation errors, success/error/rate-limit states. Security (per TRD requirements): - Server-side re-validation + sanitization (strip HTML, escape control chars) - Rate limiting on route handler (not just client) - Honeypot field (silent rejection of bots) - Minimum time to submit (1s, silent rejection if faster) - External links (Resend) use rel="noopener noreferrer" - No secrets in client bundle Accessibility: - Proper <label> association for all fields - aria-invalid / aria-describedby on error states - aria-live for success/error messages - Full keyboard operability Dependencies added: - resend: Email delivery API - react-hook-form: Form state management - @hookform/resolvers: Zod integration for React Hook Form Rate limit design rationale: In-memory store is simple and sufficient for a low-traffic personal portfolio (resets on cold-start, acceptable for this use case). To persist across resets, upgrade to Upstash Redis or similar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 70501c6 commit 037cd64

8 files changed

Lines changed: 724 additions & 6 deletions

File tree

app/api/contact/route.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
import { env } from "@/lib/env";
4+
import { contactFormSchema } from "@/lib/validations/contact";
5+
import { checkRateLimit, formatTimeUntilReset } from "@/lib/rate-limit";
6+
7+
/**
8+
* POST /api/contact
9+
*
10+
* Contact form submission endpoint. Server-side re-validates input,
11+
* sanitizes, checks rate limits, and sends via Resend.
12+
*
13+
* Returns JSON with explicit status: "success" | "error" | "validation_error" | "rate_limited"
14+
* so the client can branch on the outcome and show appropriate messaging.
15+
*/
16+
17+
/**
18+
* Expected client payload. Note: honeypot and submittedAt are sent by the
19+
* client but NOT included in the validated contactFormSchema — they're
20+
* checked separately before validation.
21+
*/
22+
interface ContactRequest {
23+
name: string;
24+
email: string;
25+
message: string;
26+
honeypot?: string; // Should be empty; filled by bots
27+
submittedAt?: number; // Timestamp when form was submitted (client-side)
28+
}
29+
30+
export async function POST(request: NextRequest) {
31+
// Extract client IP for rate limiting. Vercel provides x-forwarded-for.
32+
const ip =
33+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
34+
request.headers.get("x-real-ip") ||
35+
"unknown";
36+
37+
// 1. Check rate limit early (cheap operation, fail fast).
38+
const rateLimit = checkRateLimit(ip);
39+
if (!rateLimit.allowed) {
40+
return NextResponse.json(
41+
{
42+
status: "rate_limited",
43+
error: "Too many submissions. Please try again later.",
44+
retryAfter: formatTimeUntilReset(rateLimit.resetAt),
45+
},
46+
{ status: 429 },
47+
);
48+
}
49+
50+
// 2. Parse request body.
51+
let body: unknown;
52+
try {
53+
body = await request.json();
54+
} catch {
55+
return NextResponse.json(
56+
{ status: "error", error: "Invalid request body" },
57+
{ status: 400 },
58+
);
59+
}
60+
61+
const payload = body as ContactRequest;
62+
63+
// 3. Check honeypot (should be empty).
64+
if (payload.honeypot && payload.honeypot.trim().length > 0) {
65+
// Silently reject; don't leak that we detected a bot.
66+
return NextResponse.json(
67+
{ status: "success", message: "Thank you! We'll be in touch soon." },
68+
{ status: 200 },
69+
);
70+
}
71+
72+
// 4. Check minimum time to submit (should take at least 1 second to fill the form).
73+
if (payload.submittedAt) {
74+
const submitTimeMs = Date.now() - payload.submittedAt;
75+
if (submitTimeMs < 1000) {
76+
// Likely a bot; silently reject.
77+
return NextResponse.json(
78+
{ status: "success", message: "Thank you! We'll be in touch soon." },
79+
{ status: 200 },
80+
);
81+
}
82+
}
83+
84+
// 5. Validate input against the shared schema.
85+
const validationResult = contactFormSchema.safeParse({
86+
name: payload.name,
87+
email: payload.email,
88+
message: payload.message,
89+
});
90+
91+
if (!validationResult.success) {
92+
return NextResponse.json(
93+
{
94+
status: "validation_error",
95+
errors: validationResult.error.flatten().fieldErrors,
96+
},
97+
{ status: 400 },
98+
);
99+
}
100+
101+
const { name, email, message } = validationResult.data;
102+
103+
// 6. Send via Resend.
104+
try {
105+
// Dynamic import to avoid loading Resend at module initialization time
106+
// (which fails during static generation if RESEND_API_KEY is not set).
107+
const { Resend: ResendClient } = await import("resend");
108+
const resend = new ResendClient(env.RESEND_API_KEY);
109+
const result = await resend.emails.send({
110+
from: "Contact Form <onboarding@resend.dev>", // Use Resend's default sender; configure your domain for production.
111+
to: env.NEXT_PUBLIC_SITE_URL
112+
? new URL("/", env.NEXT_PUBLIC_SITE_URL).hostname // Extract domain from site URL
113+
: "contact@example.com", // Fallback for development
114+
replyTo: email,
115+
subject: `New contact form submission from ${name}`,
116+
html: `
117+
<h2>New Contact Form Submission</h2>
118+
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
119+
<p><strong>Email:</strong> ${escapeHtml(email)}</p>
120+
<p><strong>Message:</strong></p>
121+
<pre>${escapeHtml(message)}</pre>
122+
`,
123+
});
124+
125+
if (result.error) {
126+
console.error("Resend API error:", result.error);
127+
return NextResponse.json(
128+
{
129+
status: "error",
130+
error: "Failed to send email. Please try again later.",
131+
},
132+
{ status: 500 },
133+
);
134+
}
135+
136+
return NextResponse.json(
137+
{
138+
status: "success",
139+
message:
140+
"Thank you! I've received your message and will get back to you soon.",
141+
},
142+
{ status: 200 },
143+
);
144+
} catch (error) {
145+
console.error("Contact form error:", error);
146+
return NextResponse.json(
147+
{
148+
status: "error",
149+
error: "Something went wrong. Please try again later.",
150+
},
151+
{ status: 500 },
152+
);
153+
}
154+
}
155+
156+
/**
157+
* Escape HTML entities to prevent injection into the email body.
158+
* The schema validates against HTML tags client- and server-side, but
159+
* this adds a defense-in-depth layer for email safety.
160+
*/
161+
function escapeHtml(text: string): string {
162+
const map: Record<string, string> = {
163+
"&": "&amp;",
164+
"<": "&lt;",
165+
">": "&gt;",
166+
'"': "&quot;",
167+
"'": "&#39;",
168+
};
169+
return text.replace(/[&<>"']/g, (char) => map[char] || char);
170+
}

app/page.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ExperienceTimeline } from "@/components/experience/experience-timeline"
88
import { Certifications } from "@/components/certifications/certifications";
99
import { TryHackMeSection } from "@/components/certifications/tryhackme-section";
1010
import { GitHubSection } from "@/components/github/github-section";
11+
import { ContactSection } from "@/components/contact/contact-section";
1112
import { getNavSections } from "@/lib/data";
1213

1314
const IMPLEMENTED_IDS = [
@@ -20,6 +21,7 @@ const IMPLEMENTED_IDS = [
2021
"certifications",
2122
"tryhackme",
2223
"github",
24+
"contact",
2325
];
2426

2527
function Placeholder({ id, label }: { id: string; label: string }) {
@@ -39,17 +41,19 @@ function Placeholder({ id, label }: { id: string; label: string }) {
3941
}
4042

4143
/**
42-
* Home page. Phases 2–4 complete + GitHub integration (Phase 5a):
44+
* Home page. Phases 2–4 complete + Phase 5 Contact/GitHub:
4345
* Hero, About, Journey, Skills (2–3), Projects (4A), Certifications & TryHackMe (4B),
44-
* Experience (4C), and GitHub (5a). The remaining sections (Blog, Contact) are
45-
* TEMPORARY full-height placeholders until Phase 5b/5c.
46+
* Experience (4C), GitHub (5a), and Contact (5b). The remaining sections (Blog)
47+
* are TEMPORARY placeholders until Phase 5c.
4648
*
4749
* Layout follows App Flow order: Hero → About → Journey → Skills →
48-
* Experience → Projects → Certifications → TryHackMe → GitHub → [placeholders].
50+
* Experience → Projects → Certifications → TryHackMe → GitHub → Contact → [placeholders].
51+
*
52+
* Contact is the final conversion point before Resume Download.
4953
*/
5054
export default function Home() {
5155
const sections = getNavSections();
52-
const afterGitHub = sections.filter(
56+
const afterContact = sections.filter(
5357
({ id }) => !IMPLEMENTED_IDS.includes(id),
5458
);
5559

@@ -64,7 +68,8 @@ export default function Home() {
6468
<Certifications />
6569
<TryHackMeSection />
6670
<GitHubSection />
67-
{afterGitHub.map((section) => (
71+
<ContactSection />
72+
{afterContact.map((section) => (
6873
<Placeholder key={section.id} {...section} />
6974
))}
7075
</main>

0 commit comments

Comments
 (0)