Harden contact throttle, logging, TLS, and transporter reuse
This commit is contained in:
2 files changed
+46
-17
No files matched your search
+27
-12
@@ -1,4 +1,4 @@
|
|||||||
import { createTransport } from "nodemailer";
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
|
||||||
import type { Options } from "nodemailer/lib/mailer";
|
import type { Options } from "nodemailer/lib/mailer";
|
||||||
@@ -24,6 +24,27 @@ export function constructEmailHtml(props: Email) {
|
|||||||
return `<div>${escapeHtml(props.message)}</div>`;
|
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) {
|
export default async function sendContactEmail(props: Email) {
|
||||||
const port = parseInt(
|
const port = parseInt(
|
||||||
process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT || "",
|
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.");
|
throw new Error("Email transport is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = createTransport({
|
const secure =
|
||||||
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
|
process.env.EMAIL_SECURE === "true" ||
|
||||||
port,
|
import.meta.env.EMAIL_SECURE === "true";
|
||||||
secure:
|
|
||||||
process.env.EMAIL_SECURE === "true" ||
|
const transporter = getTransporter(port, secure);
|
||||||
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);
|
|
||||||
|
|
||||||
if (!props.html) {
|
if (!props.html) {
|
||||||
props.html = constructEmailHtml(props);
|
props.html = constructEmailHtml(props);
|
||||||
|
|||||||
@@ -20,10 +20,19 @@ function isRateLimited(ip: string): boolean {
|
|||||||
(t) => now - t < RATE_WINDOW_MS
|
(t) => now - t < RATE_WINDOW_MS
|
||||||
);
|
);
|
||||||
if (history.length >= RATE_MAX) {
|
if (history.length >= RATE_MAX) {
|
||||||
|
recentSends.set(ip, history);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
history.push(now);
|
history.push(now);
|
||||||
recentSends.set(ip, history);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +45,8 @@ function json(message: string, status: number, statusText: string): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||||
const ip =
|
// Prefer the socket address: x-forwarded-for is client-controlled.
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
const ip = clientAddress || "unknown";
|
||||||
clientAddress ||
|
|
||||||
"unknown";
|
|
||||||
if (isRateLimited(ip)) {
|
if (isRateLimited(ip)) {
|
||||||
return json(
|
return json(
|
||||||
"Too many messages sent. Please try again later.",
|
"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 email = (data.get("email") as string | null)?.trim() ?? "";
|
||||||
const subject = (data.get("subject") as string | null)?.trim() ?? "";
|
const subject = (data.get("subject") as string | null)?.trim() ?? "";
|
||||||
const message = (data.get("message") 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
|
html
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Contact email failed to send:", error);
|
||||||
return json(
|
return json(
|
||||||
`Message failed to send with an internal server error. Please contact me directly via a method below.`,
|
`Message failed to send with an internal server error. Please contact me directly via a method below.`,
|
||||||
500,
|
500,
|
||||||
|
|||||||
Reference in new issue
Block a user