Harden email setup

This commit is contained in:
encie22 committed 2026-10-15 15:21:17 +10:30
1 parent 3b702a1399
commit b75f17e604
4 files changed
+588 -2501

No files matched your search

+480 -2461
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -16,7 +16,7 @@ interface Window {
interface ImportMetaEnv {
readonly EMAIL_USER: string;
readonly EMAIL_PASS: string;
readonly EMAIL_PORT: number;
readonly EMAIL_PORT: string;
readonly EMAIL_SECURE: "true" | "false";
readonly EMAIL_HOST: string;
readonly EMAIL_FROM: string;
+24 -5
View File
@@ -11,14 +11,33 @@ interface Email {
name: string;
}
export function constructEmailHtml(props: Email) {
return `<div>${props.message}</div>`;
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export default async function (props: Email) {
export function constructEmailHtml(props: Email) {
return `<div>${escapeHtml(props.message)}</div>`;
}
export default async function sendContactEmail(props: Email) {
const port = parseInt(
process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT || "",
10
);
const fromMailbox =
process.env.EMAIL_FROM || import.meta.env.EMAIL_FROM || "";
if (!Number.isFinite(port) || !fromMailbox) {
throw new Error("Email transport is not configured.");
}
const transporter = createTransport({
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
port: process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT,
port,
secure:
process.env.EMAIL_SECURE === "true" ||
import.meta.env.EMAIL_SECURE === "true",
@@ -33,7 +52,7 @@ export default async function (props: Email) {
}
const message: Options = {
from: `${props.name} (Website) <${process.env.EMAIL_FROM || import.meta.env.EMAIL_FROM}>`,
from: `Website Contact <${fromMailbox}>`,
to: "nathan@nathancummins.com.au",
replyTo: props.email,
subject: props.subject,
+83 -34
View File
@@ -1,52 +1,101 @@
import sendEmail from "@lib/email";
import sendEmail, { escapeHtml } from "@lib/email";
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME = 100;
const MAX_EMAIL = 254;
const MAX_SUBJECT = 150;
const MAX_MESSAGE = 5000;
// Minimal in-memory throttle (single instance): 5 sends per IP per 10 min.
const RATE_WINDOW_MS = 10 * 60 * 1000;
const RATE_MAX = 5;
const recentSends = new Map<string, number[]>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const history = (recentSends.get(ip) ?? []).filter(
(t) => now - t < RATE_WINDOW_MS
);
if (history.length >= RATE_MAX) {
return true;
}
history.push(now);
recentSends.set(ip, history);
return false;
}
function json(message: string, status: number, statusText: string): Response {
return new Response(JSON.stringify({ message }), {
status,
statusText,
headers: { "Content-Type": "application/json" }
});
}
export const POST: APIRoute = async ({ request, clientAddress }) => {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
clientAddress ||
"unknown";
if (isRateLimited(ip)) {
return json(
"Too many messages sent. Please try again later.",
429,
"Too Many Requests"
);
}
const data = await request.formData();
const email = data.get("email") as string | null;
const subject = data.get("subject") as string | null;
const message = data.get("message") as string | null;
const name = data.get("name") as string | null;
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() ?? "";
const name = (data.get("name") as string | null)?.trim() ?? "";
if (!email || !subject || !message || !name) {
return new Response(
JSON.stringify({ message: "Required field missing." }),
{
status: 400,
statusText: "Missing required fields."
}
);
return json("Required field missing.", 400, "Bad Request");
}
if (
!EMAIL_RE.test(email) ||
email.length > MAX_EMAIL ||
subject.length > MAX_SUBJECT ||
message.length > MAX_MESSAGE ||
name.length > MAX_NAME
) {
return json("Invalid field value.", 400, "Bad Request");
}
// Strip line breaks so values can never smuggle SMTP headers.
const safeSubject = subject.replace(/[\r\n]+/g, " ");
const safeName = name.replace(/[\r\n]+/g, " ");
const html = `<h1>Message Received via Contact Form</h1><br>
<b>Name</b>: ${name}<br>
<b>Email</b>: ${email}<br>
<b>Message</b>: ${message}`;
<b>Name</b>: ${escapeHtml(safeName)}<br>
<b>Email</b>: ${escapeHtml(email)}<br>
<b>Message</b>: ${escapeHtml(message).replace(/\n/g, "<br>")}`;
try {
await sendEmail({ email, subject, message, name, html });
await sendEmail({
email,
subject: safeSubject,
message,
name: safeName,
html
});
} catch (error) {
return new Response(
JSON.stringify({
message: `Message failed to send with an internal server error. Please contact me directly via a method below.`
}),
{
status: 500,
statusText: `Message failed to send with an internal server error. Please contact me directly via a method below.`
}
return json(
`Message failed to send with an internal server error. Please contact me directly via a method below.`,
500,
"Internal Server Error"
);
}
return new Response(
JSON.stringify({
message:
"Thanks for getting in touch! I will try to get back to you as soon as I can."
}),
{
status: 200,
statusText: "OK"
}
return json(
"Thanks for getting in touch! I will try to get back to you as soon as I can.",
200,
"OK"
);
};