Contact form update

This commit is contained in:
Greg Kilmer
2026-07-07 21:58:37 -04:00
parent 738f978120
commit e7c5e9d66e
4 changed files with 176 additions and 44 deletions
+27 -14
View File
@@ -15,6 +15,17 @@
*/
import type { APIRoute } from 'astro';
import { z } from 'zod/v4';
import {
contactTo,
contactWebhookUrl,
env,
smtpFrom,
smtpHost,
smtpPass,
smtpPort,
smtpUser,
turnstileSecretKey,
} from '@lib/env';
// --- Validation schema ------------------------------------------------------
const schema = z.object({
@@ -31,11 +42,13 @@ const schema = z.object({
// --- In-memory rate limiter (per worker) ------------------------------------
// Good enough for a personal site behind a reverse proxy. For multi-instance,
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
const rateWindowMin = Number(process.env.CONTACT_RATE_WINDOW_MIN ?? 15);
const rateLimit = Number(process.env.CONTACT_RATE_LIMIT ?? 5);
// Tunables are read per-request via the env helper so they resolve in both dev
// (import.meta.env) and Docker (process.env).
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
const rateWindowMin = Number(env('CONTACT_RATE_WINDOW_MIN') ?? 15);
const rateLimit = Number(env('CONTACT_RATE_LIMIT') ?? 5);
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now > entry.reset) {
@@ -66,7 +79,7 @@ function clientIp(request: Request): string {
// the token from the client is POSTed to Cloudflare's siteverify endpoint
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
const secret = turnstileSecretKey();
if (!secret) return true; // Turnstile not configured -> skip.
if (!token) return false;
@@ -146,19 +159,19 @@ interface Message {
}
async function deliver(msg: Message): Promise<boolean> {
const smtpHost = process.env.SMTP_HOST;
const webhook = process.env.CONTACT_WEBHOOK_URL;
const host = smtpHost();
const webhook = contactWebhookUrl();
if (smtpHost) {
if (host) {
// Dynamic import keeps nodemailer out of cold-start when unused.
const nodemailer = (await import('nodemailer')).default;
const port = Number(smtpPort() ?? 587);
const user = smtpUser();
const transport = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT ?? 587),
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
host,
port,
secure: port === 465,
auth: user ? { user, pass: smtpPass() } : undefined,
});
const hostname = (() => {
try {
@@ -168,8 +181,8 @@ async function deliver(msg: Message): Promise<boolean> {
}
})();
await transport.sendMail({
from: process.env.SMTP_FROM ?? `Greg's Site <no-reply@${hostname}>`,
to: process.env.CONTACT_TO ?? 'greg@gregkilmer.com',
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
to: contactTo() ?? 'greg@gregkilmer.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New message from ${msg.name} via gregkilmer.com`,
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,