Harden contact throttle, logging, TLS, and transporter reuse
This commit is contained in:
2 files changed
+45
-16
No files matched your search
+26
-11
@@ -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:
|
||||
const 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);
|
||||
import.meta.env.EMAIL_SECURE === "true";
|
||||
|
||||
const transporter = getTransporter(port, secure);
|
||||
|
||||
if (!props.html) {
|
||||
props.html = constructEmailHtml(props);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user