From a2dea4b210cfcb017915f17694762a0f1c3164dd Mon Sep 17 00:00:00 2001 From: Nathan Cummins Date: Fri, 16 Oct 2026 12:58:29 +1030 Subject: [PATCH] Harden contact throttle, logging, TLS, and transporter reuse --- src/lib/email.ts | 39 +++++++++++++++++++++++----------- src/pages/api/email/contact.ts | 24 ++++++++++++++++----- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/lib/email.ts b/src/lib/email.ts index fa260ac..8c3941e 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,4 +1,4 @@ -import { createTransport } from "nodemailer"; +import { createTransport, type Transporter } from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; import type { Options } from "nodemailer/lib/mailer"; @@ -24,6 +24,27 @@ export function constructEmailHtml(props: Email) { return `
${escapeHtml(props.message)}
`; } +// Cached for the process lifetime: config comes from the environment and +// does not change between requests. +let cachedTransporter: Transporter | null = null; + +function getTransporter(port: number, secure: boolean): Transporter { + if (!cachedTransporter) { + cachedTransporter = createTransport({ + host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST, + port, + secure, + // Refuse plaintext fallback on submission ports (e.g. 587, 2525). + requireTLS: !secure, + auth: { + user: process.env.EMAIL_USER || import.meta.env.EMAIL_USER, + pass: process.env.EMAIL_PASS || import.meta.env.EMAIL_PASS + } + } as SMTPTransport.Options); + } + return cachedTransporter; +} + export default async function sendContactEmail(props: Email) { const port = parseInt( process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT || "", @@ -35,17 +56,11 @@ export default async function sendContactEmail(props: Email) { throw new Error("Email transport is not configured."); } - const transporter = createTransport({ - host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST, - port, - secure: - process.env.EMAIL_SECURE === "true" || - import.meta.env.EMAIL_SECURE === "true", - auth: { - user: process.env.EMAIL_USER || import.meta.env.EMAIL_USER, - pass: process.env.EMAIL_PASS || import.meta.env.EMAIL_PASS - } - } as SMTPTransport.Options); + const secure = + process.env.EMAIL_SECURE === "true" || + import.meta.env.EMAIL_SECURE === "true"; + + const transporter = getTransporter(port, secure); if (!props.html) { props.html = constructEmailHtml(props); diff --git a/src/pages/api/email/contact.ts b/src/pages/api/email/contact.ts index 291b7a4..8aa6be9 100644 --- a/src/pages/api/email/contact.ts +++ b/src/pages/api/email/contact.ts @@ -20,10 +20,19 @@ function isRateLimited(ip: string): boolean { (t) => now - t < RATE_WINDOW_MS ); if (history.length >= RATE_MAX) { + recentSends.set(ip, history); return true; } history.push(now); recentSends.set(ip, history); + // Prune expired entries so the map cannot grow without bound. + if (recentSends.size > 500) { + for (const [key, times] of recentSends) { + if (!times.some((t) => now - t < RATE_WINDOW_MS)) { + recentSends.delete(key); + } + } + } return false; } @@ -36,10 +45,8 @@ function json(message: string, status: number, statusText: string): Response { } export const POST: APIRoute = async ({ request, clientAddress }) => { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - clientAddress || - "unknown"; + // Prefer the socket address: x-forwarded-for is client-controlled. + const ip = clientAddress || "unknown"; if (isRateLimited(ip)) { return json( "Too many messages sent. Please try again later.", @@ -48,7 +55,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { ); } - const data = await request.formData(); + let data: FormData; + try { + data = await request.formData(); + } catch (error) { + console.error("Contact form parse failed:", error); + return json("Invalid request.", 400, "Bad Request"); + } const email = (data.get("email") as string | null)?.trim() ?? ""; const subject = (data.get("subject") as string | null)?.trim() ?? ""; const message = (data.get("message") as string | null)?.trim() ?? ""; @@ -86,6 +99,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { html }); } catch (error) { + console.error("Contact email failed to send:", error); return json( `Message failed to send with an internal server error. Please contact me directly via a method below.`, 500,