Harden email setup
This commit is contained in:
4 files changed
+587
-2500
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 {
|
interface ImportMetaEnv {
|
||||||
readonly EMAIL_USER: string;
|
readonly EMAIL_USER: string;
|
||||||
readonly EMAIL_PASS: string;
|
readonly EMAIL_PASS: string;
|
||||||
readonly EMAIL_PORT: number;
|
readonly EMAIL_PORT: string;
|
||||||
readonly EMAIL_SECURE: "true" | "false";
|
readonly EMAIL_SECURE: "true" | "false";
|
||||||
readonly EMAIL_HOST: string;
|
readonly EMAIL_HOST: string;
|
||||||
readonly EMAIL_FROM: string;
|
readonly EMAIL_FROM: string;
|
||||||
|
|||||||
+24
-5
@@ -11,14 +11,33 @@ interface Email {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function constructEmailHtml(props: Email) {
|
export function escapeHtml(input: string): string {
|
||||||
return `<div>${props.message}</div>`;
|
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({
|
const transporter = createTransport({
|
||||||
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
|
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
|
||||||
port: process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT,
|
port,
|
||||||
secure:
|
secure:
|
||||||
process.env.EMAIL_SECURE === "true" ||
|
process.env.EMAIL_SECURE === "true" ||
|
||||||
import.meta.env.EMAIL_SECURE === "true",
|
import.meta.env.EMAIL_SECURE === "true",
|
||||||
@@ -33,7 +52,7 @@ export default async function (props: Email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message: Options = {
|
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",
|
to: "nathan@nathancummins.com.au",
|
||||||
replyTo: props.email,
|
replyTo: props.email,
|
||||||
subject: props.subject,
|
subject: props.subject,
|
||||||
|
|||||||
@@ -1,52 +1,101 @@
|
|||||||
import sendEmail from "@lib/email";
|
import sendEmail, { escapeHtml } from "@lib/email";
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
export const prerender = false;
|
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 data = await request.formData();
|
||||||
const email = data.get("email") as string | null;
|
const email = (data.get("email") as string | null)?.trim() ?? "";
|
||||||
const subject = data.get("subject") as string | null;
|
const subject = (data.get("subject") as string | null)?.trim() ?? "";
|
||||||
const message = data.get("message") as string | null;
|
const message = (data.get("message") as string | null)?.trim() ?? "";
|
||||||
const name = data.get("name") as string | null;
|
const name = (data.get("name") as string | null)?.trim() ?? "";
|
||||||
|
|
||||||
if (!email || !subject || !message || !name) {
|
if (!email || !subject || !message || !name) {
|
||||||
return new Response(
|
return json("Required field missing.", 400, "Bad Request");
|
||||||
JSON.stringify({ message: "Required field missing." }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
statusText: "Missing required fields."
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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>
|
const html = `<h1>Message Received via Contact Form</h1><br>
|
||||||
<b>Name</b>: ${name}<br>
|
<b>Name</b>: ${escapeHtml(safeName)}<br>
|
||||||
<b>Email</b>: ${email}<br>
|
<b>Email</b>: ${escapeHtml(email)}<br>
|
||||||
<b>Message</b>: ${message}`;
|
<b>Message</b>: ${escapeHtml(message).replace(/\n/g, "<br>")}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail({ email, subject, message, name, html });
|
await sendEmail({
|
||||||
|
email,
|
||||||
|
subject: safeSubject,
|
||||||
|
message,
|
||||||
|
name: safeName,
|
||||||
|
html
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
return json(
|
||||||
JSON.stringify({
|
`Message failed to send with an internal server error. Please contact me directly via a method below.`,
|
||||||
message: `Message failed to send with an internal server error. Please contact me directly via a method below.`
|
500,
|
||||||
}),
|
"Internal Server Error"
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
statusText: `Message failed to send with an internal server error. Please contact me directly via a method below.`
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return json(
|
||||||
JSON.stringify({
|
"Thanks for getting in touch! I will try to get back to you as soon as I can.",
|
||||||
message:
|
200,
|
||||||
"Thanks for getting in touch! I will try to get back to you as soon as I can."
|
"OK"
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK"
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user