feat: testing contact form features

This commit is contained in:
2026-02-18 21:34:16 -06:00
parent 7d9c0014ed
commit 87f0443b31
30 changed files with 2625 additions and 50 deletions

41
contact-api/src/config.ts Normal file
View File

@@ -0,0 +1,41 @@
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const boolFromEnv = z
.enum(["true", "false"])
.transform((value) => value === "true");
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8787),
CONTACT_ALLOWED_ORIGIN: z.string().url(),
TURNSTILE_SECRET_KEY: z.string().min(1),
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.coerce.number().int().positive().default(587),
SMTP_SECURE: boolFromEnv.default("false"),
SMTP_REQUIRE_TLS: boolFromEnv.default("true"),
SMTP_USER: z.string().min(1),
SMTP_PASS: z.string().min(1),
MAIL_FROM_NAME: z.string().min(1).default("Portfolio Contact"),
MAIL_FROM_ADDRESS: z.string().email(),
MAIL_TO_ADDRESS: z.string().email(),
MAIL_SUBJECT_PREFIX: z.string().min(1).default("[Portfolio Contact]"),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(600000),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5),
HONEYPOT_FIELD: z.string().min(1).default("website"),
MIN_SUBMIT_TIME_MS: z.coerce.number().int().nonnegative().default(3000),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid contact-api environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;

138
contact-api/src/index.ts Normal file
View File

@@ -0,0 +1,138 @@
import cors from "cors";
import express, { type NextFunction, type Request, type Response } from "express";
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import { pinoHttp } from "pino-http";
import { config } from "./config.js";
import { sendContactEmail, verifyMailerConnection } from "./mailer.js";
import { verifyTurnstileToken } from "./turnstile.js";
import { contactRequestSchema } from "./validation.js";
type ApiErrorResponse = {
ok: false;
error: string;
};
const app = express();
app.set("trust proxy", 1);
app.use(
pinoHttp({
level: config.NODE_ENV === "production" ? "info" : "debug",
redact: {
paths: ["req.headers.authorization"],
remove: true,
},
}),
);
app.use(helmet());
app.use(
cors({
origin(origin, callback) {
if (!origin || origin === config.CONTACT_ALLOWED_ORIGIN) {
callback(null, true);
return;
}
callback(new Error("Origin not allowed"));
},
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
}),
);
app.use(express.json({ limit: "16kb" }));
app.use((err: Error, _req: Request, res: Response<ApiErrorResponse>, next: NextFunction) => {
if (err.message === "Origin not allowed") {
res.status(403).json({ ok: false, error: "Origin not allowed." });
return;
}
next(err);
});
const contactLimiter = rateLimit({
windowMs: config.RATE_LIMIT_WINDOW_MS,
max: config.RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false,
message: {
ok: false,
error: "Too many requests. Please try again later.",
},
});
app.get("/health", (_req, res) => {
res.status(200).json({ ok: true });
});
app.post("/contact", contactLimiter, async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const honeypotRaw = body[config.HONEYPOT_FIELD];
if (typeof honeypotRaw === "string" && honeypotRaw.trim().length > 0) {
req.log.warn({ event: "contact_honeypot_triggered" }, "Contact form blocked by honeypot field");
res.status(400).json({ ok: false, error: "Invalid submission." });
return;
}
const startedAt = Number(body.startedAt);
if (!Number.isFinite(startedAt) || Date.now() - startedAt < config.MIN_SUBMIT_TIME_MS) {
req.log.warn({ event: "contact_submit_too_fast" }, "Contact form rejected by min submit time");
res.status(400).json({ ok: false, error: "Please wait a moment and try again." });
return;
}
const parsed = contactRequestSchema.safeParse(body);
if (!parsed.success) {
req.log.warn({ event: "contact_validation_failed", issues: parsed.error.issues }, "Invalid contact payload");
res.status(400).json({ ok: false, error: "Please review your form entries and try again." });
return;
}
try {
const turnstileResult = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
if (!turnstileResult.ok) {
req.log.warn(
{ event: "turnstile_failed", reason: turnstileResult.reason },
"Turnstile verification failed",
);
res.status(403).json({ ok: false, error: "Human verification failed. Please try again." });
return;
}
} catch (error) {
req.log.error({ err: error }, "Turnstile verification request failed");
res.status(502).json({ ok: false, error: "Unable to verify your request right now." });
return;
}
try {
await sendContactEmail(parsed.data, req.ip ?? "");
req.log.info({ event: "contact_sent" }, "Contact email sent");
res.status(200).json({ ok: true, message: "Message sent." });
} catch (error) {
req.log.error({ err: error }, "Failed to send contact email");
res.status(502).json({ ok: false, error: "Unable to send message. Please try again soon." });
}
});
app.use((err: Error, req: Request, res: Response<ApiErrorResponse>, _next: NextFunction) => {
void _next;
req.log.error({ err }, "Unhandled contact-api error");
res.status(500).json({ ok: false, error: "Internal server error." });
});
const startServer = async (): Promise<void> => {
await verifyMailerConnection();
app.listen(config.PORT, () => {
console.log(`contact-api listening on port ${config.PORT}`);
});
};
startServer().catch((error) => {
console.error("Failed to start contact-api", error);
process.exit(1);
});

65
contact-api/src/mailer.ts Normal file
View File

@@ -0,0 +1,65 @@
import nodemailer from "nodemailer";
import { config } from "./config.js";
import type { ContactRequest } from "./validation.js";
const transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_SECURE,
requireTLS: config.SMTP_REQUIRE_TLS,
auth: {
user: config.SMTP_USER,
pass: config.SMTP_PASS,
},
tls: {
rejectUnauthorized: true,
},
});
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const newlineToBreaks = (value: string): string =>
escapeHtml(value).replace(/\n/g, "<br>");
export async function verifyMailerConnection(): Promise<void> {
await transporter.verify();
}
export async function sendContactEmail(payload: ContactRequest, sourceIp: string): Promise<void> {
const receivedAt = new Date().toISOString();
const subject = `${config.MAIL_SUBJECT_PREFIX} ${payload.name}`;
const textBody = [
`Name: ${payload.name}`,
`Email: ${payload.email}`,
`Source IP: ${sourceIp || "unknown"}`,
`Received At: ${receivedAt}`,
"",
"Message:",
payload.message,
].join("\n");
const htmlBody = `
<h2>Portfolio Contact Submission</h2>
<p><strong>Name:</strong> ${escapeHtml(payload.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
<p><strong>Source IP:</strong> ${escapeHtml(sourceIp || "unknown")}</p>
<p><strong>Received At:</strong> ${escapeHtml(receivedAt)}</p>
<p><strong>Message:</strong><br>${newlineToBreaks(payload.message)}</p>
`;
await transporter.sendMail({
from: `"${config.MAIL_FROM_NAME}" <${config.MAIL_FROM_ADDRESS}>`,
to: config.MAIL_TO_ADDRESS,
replyTo: payload.email,
subject,
text: textBody,
html: htmlBody,
});
}

View File

@@ -0,0 +1,48 @@
import { config } from "./config.js";
type TurnstileVerifyResponse = {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
};
export async function verifyTurnstileToken(
token: string,
remoteIp?: string,
): Promise<{ ok: boolean; reason?: string }> {
const body = new URLSearchParams({
secret: config.TURNSTILE_SECRET_KEY,
response: token,
});
if (remoteIp) {
body.append("remoteip", remoteIp);
}
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body,
});
if (!response.ok) {
return { ok: false, reason: `turnstile_http_${response.status}` };
}
const result = (await response.json()) as TurnstileVerifyResponse;
if (!result.success) {
const codes = result["error-codes"]?.join(",") ?? "verification_failed";
return { ok: false, reason: codes };
}
if (result.hostname !== config.TURNSTILE_EXPECTED_HOSTNAME) {
return { ok: false, reason: "hostname_mismatch" };
}
if (result.action !== config.TURNSTILE_EXPECTED_ACTION) {
return { ok: false, reason: "action_mismatch" };
}
return { ok: true };
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const contactRequestSchema = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email().max(320),
message: z.string().trim().min(20).max(2000),
website: z.string().optional().default(""),
startedAt: z.coerce.number().int().positive(),
turnstileToken: z.string().trim().min(1),
});
export type ContactRequest = z.infer<typeof contactRequestSchema>;