226 lines
8.6 KiB
TypeScript
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("&", "&")
|
|
.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: `
|
|
<!-- 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} →
|
|
</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 },
|
|
);
|
|
}
|
|
}
|