Files
ksan.dev/app/api/contact/route.ts
T
2026-08-01 16:07:57 +02:00

226 lines
8.6 KiB
TypeScript

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<string, number[]>();
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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: `
<!-- Dark wrapper: without an explicit dark background the theme's
light text colors are unreadable on white inbox backgrounds -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#0F1117" style="background-color: #0F1117; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; width: 100%; font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<!-- Header -->
<tr>
<td style="padding: 0 8px 20px 8px;">
<span style="display: inline-block; border: 1px solid rgba(167, 139, 250, 0.4); background-color: #221A38; color: #C4B5FD; border-radius: 999px; padding: 6px 14px; font-size: 12px; letter-spacing: 1px; text-transform: uppercase;">
ksan.dev contact form
</span>
<h1 style="margin: 16px 0 0 0; color: #F5F7FF; font-size: 22px; font-weight: 600; letter-spacing: -0.5px;">
New message from your portfolio
</h1>
</td>
</tr>
<!-- Sender card -->
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-radius: 14px;">
<tr>
<td style="padding: 20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Name</td>
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px; font-weight: 600;">${safe.name}</td>
</tr>
<tr>
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Email</td>
<td style="padding: 6px 0; font-size: 14px;">
<a href="mailto:${safe.email}" style="color: #A78BFA; text-decoration: none;">${safe.email}</a>
</td>
</tr>
<tr>
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Subject</td>
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px;">${safe.subject}</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- Message card -->
<tr>
<td style="padding-top: 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-left: 3px solid #8B5CF6; border-radius: 14px;">
<tr>
<td style="padding: 20px 24px;">
<p style="margin: 0 0 10px 0; color: #A1A8C3; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase;">
Message
</p>
<p style="margin: 0; color: #F5F7FF; font-size: 15px; line-height: 1.7; white-space: pre-wrap;">${safe.message}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Reply button -->
<tr>
<td align="center" style="padding: 24px 0 8px 0;">
<a href="mailto:${safe.email}?subject=Re:%20${encodeURIComponent(subject)}" style="display: inline-block; background-color: #8B5CF6; color: #FFFFFF; font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 12px; padding: 12px 28px;">
Reply to ${safe.name} &rarr;
</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style="padding-top: 16px;">
<p style="margin: 0; color: #6B7290; font-size: 12px;">
Sent from the contact form at ksan.dev
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to send email:", error);
return NextResponse.json(
{ error: "Failed to send message" },
{ status: 500 },
);
}
}