Harden contact throttle, logging, TLS, and transporter reuse

This commit is contained in:
encie22 committed 2026-10-16 12:58:29 +10:30
1 parent 2001963035
commit a2dea4b210
2 files changed
+46 -17

No files matched your search

+27 -12
View File
@@ -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 `<div>${escapeHtml(props.message)}</div>`;
}
// 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);
+19 -5
View File
@@ -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,