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,