Harden email setup
This commit is contained in:
4 files changed
+588
-2501
No files matched your search
Generated
+480
-2461
File diff suppressed because it is too large.
Load diff
Vendored
+1
-1
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user