import { NextRequest, NextResponse } from "next/server"; import nodemailer from "nodemailer"; // Validate environment variables at startup const GMAIL_USER = process.env.GMAIL_USER; const GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD; const CONTACT_TO_EMAIL = process.env.CONTACT_TO_EMAIL || GMAIL_USER; // Field length limits — keeps abuse and mail-size problems in check const MAX_LENGTHS = { name: 100, email: 200, subject: 200, message: 5000 }; // Simple in-memory rate limit: max 5 messages per IP per 10 minutes. // Resets on server restart, which is fine for a portfolio contact form. const RATE_LIMIT = 5; const RATE_WINDOW_MS = 10 * 60 * 1000; const submissions = new Map(); function isRateLimited(ip: string): boolean { const now = Date.now(); const recent = (submissions.get(ip) || []).filter( (t) => now - t < RATE_WINDOW_MS, ); if (recent.length >= RATE_LIMIT) return true; recent.push(now); submissions.set(ip, recent); // Prune old entries so the map doesn't grow unbounded if (submissions.size > 1000) { for (const [key, times] of submissions) { if (times.every((t) => now - t >= RATE_WINDOW_MS)) submissions.delete(key); } } return false; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } export async function POST(request: NextRequest) { try { const body = await request.json(); const { name, email, subject, message, company } = body; // Honeypot: real users never fill this hidden field. Pretend success // so bots don't learn they were filtered. if (company) { return NextResponse.json({ success: true }); } const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; if (isRateLimited(ip)) { return NextResponse.json( { error: "Too many messages — please try again later" }, { status: 429 }, ); } // Validate required fields if (!name || !email || !subject || !message) { return NextResponse.json( { error: "All fields are required" }, { status: 400 }, ); } for (const [field, max] of Object.entries(MAX_LENGTHS)) { const value = body[field]; if (typeof value !== "string" || value.length > max) { return NextResponse.json( { error: `Field "${field}" is too long (max ${max} characters)` }, { status: 400 }, ); } } // Basic email validation if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return NextResponse.json( { error: "Invalid email address" }, { status: 400 }, ); } // Check environment if (!GMAIL_USER || !GMAIL_APP_PASSWORD) { console.error( "Missing GMAIL_USER or GMAIL_APP_PASSWORD environment variables", ); return NextResponse.json( { error: "Server configuration error" }, { status: 500 }, ); } // Create transporter const transporter = nodemailer.createTransport({ service: "gmail", auth: { user: GMAIL_USER, pass: GMAIL_APP_PASSWORD, }, }); // Escape user input before interpolating into the HTML body const safe = { name: escapeHtml(name), email: escapeHtml(email), subject: escapeHtml(subject), message: escapeHtml(message), }; // Send email await transporter.sendMail({ from: `"Portfolio Contact Form" <${GMAIL_USER}>`, to: CONTACT_TO_EMAIL, replyTo: email, subject: `[Ksan.dev contact] ${subject}`, text: `Name: ${name}\nEmail: ${email}\nSubject: ${subject}\n\nMessage:\n${message}`, html: `
ksan.dev contact form

New message from your portfolio

Name ${safe.name}
Email ${safe.email}
Subject ${safe.subject}

Message

${safe.message}

Reply to ${safe.name} →

Sent from the contact form at ksan.dev

`, }); return NextResponse.json({ success: true }); } catch (error) { console.error("Failed to send email:", error); return NextResponse.json( { error: "Failed to send message" }, { status: 500 }, ); } }