Contact Form & Email
POST /api/contact/ is the only route in TVfolio that runs code at request time, and the only reason an adapter is installed. It exists because a Resend call needs a secret and a secret cannot live in a static page.
The form ships zero JavaScript. That is not a constraint the theme worked around; it is the design.
How a no-JS form reports its result
The form is a plain method="POST". The endpoint answers with a 303 redirect to a fragment on /contact/ — #sent, #invalid or #failed — and the page reveals the matching block with CSS :target.
function back(status: ContactStatus): Response {
return new Response(null, { status: 303, headers: { Location: statusUrl(status) } });
}
303 specifically, because it is the status that turns the browser’s POST into a GET — so a refresh on the landing page cannot re-send the message.
The three statuses are a single exported list that both halves read, so the redirect and the block it reveals cannot drift:
export const CONTACT_STATUS = ["sent", "invalid", "failed"] as const;
export const statusUrl = (status: ContactStatus) => `/contact/#${status}`;
That is the platform doing a job a fetch plus a status div would otherwise need a script for — and it means the form works with scripting off.
The two halves
src/js/contact.ts is the pure half: parse, validate, rate-limit, build the email body. src/pages/api/contact.ts is the I/O half: read the request, call Resend, redirect.
The split exists so the parts that break silently — a bad address slipping through, a field growing unbounded, an injected header line — are the parts contact.test.ts can assert on with hostile input.
The validation is hand-written rather than using Zod, and the reason is practical rather than ideological: four fields need required-ness, two length caps and one shape check. Zod ships inside Astro but is not a direct dependency, so pnpm’s strict layout does not expose it — importing it would work in the bundle and fail in pnpm test, which runs plain Node.
Validation at the trust boundary
export function parseContact(input: Record<string, unknown>): ContactParse {
const name = asString(input.name).trim();
const email = oneLine(asString(input.email));
const subject = oneLine(asString(input.subject));
const message = asString(input.message).trim();
if (!name || name.length > CONTACT_LIMITS.name) return null;
if (email.length > CONTACT_LIMITS.email || !EMAIL_SHAPE.test(email)) return null;
if (subject.length > CONTACT_LIMITS.subject) return null;
if (!message || message.length > CONTACT_LIMITS.message) return null;
return { message: { name, email, subject, message }, trap: /* … */ };
}
oneLine collapses CR/LF on every value that reaches a header — the subject, and the address that becomes reply_to. That is header injection, and it is the single most important line in the file.
The email regex is deliberately loose: something, an @, something, a dot, a TLD. The comment is worth quoting because it is a good general principle:
The only real test of an address is whether mail reaches it, and every regex stricter than this one is famous for rejecting valid addresses. Its job is to catch a typo and a bot, not to prove deliverability.
The caps are generous for a person, finite for a script — 80 / 200 / 140 / 5000. An uncapped textarea is a free channel into your inbox.
It returns null rather than a per-field error map, and that is a considered call: the endpoint answers with a redirect, so nothing survives the round trip to render one. The reader’s per-field feedback comes from native constraint validation in the browser, which is where it can actually be shown.
The honeypot
export const HONEYPOT = "website";
A hidden field that is aria-hidden, off-screen and tabindex="-1", so no human and no screen reader ever reaches it — while an autofilling bot writes into it because it looks like a normal input.
It is named for something a bot wants to fill, not for what it is. A field called honeypot in the DOM is a honeypot a bot can skip.
When it is filled, the endpoint answers exactly as if the message had worked:
if (parsed.trap) return back("sent");
Telling a spammer which of their fields gave them away is free tuning data for the next attempt.
The body-size gate
Two checks run before formData(), which buffers whatever it is handed:
const type = request.headers.get("content-type") ?? "";
if (!FORM_TYPES.some((t) => type.startsWith(t))) return back("invalid");
const declared = Number(request.headers.get("content-length") ?? NaN);
if (!Number.isFinite(declared) || declared > MAX_BODY_BYTES) return back("invalid");
An absent content-length is refused, not waved through, and the comment explains the bug that motivated it: Number(null) is 0, so trusting the header’s mere presence let a chunked body reach formData() with no ceiling at all — the one case the cap exists for. Every browser sends the header on a form POST.
MAX_BODY_BYTES is 64 KB against caps totalling roughly 5.4 KB of text, leaving room for multipart boundaries and field encoding while still refusing a body only a script would send.
The rate limiter
Five messages per client per ten minutes, in a Map the endpoint owns:
export const RATE_LIMIT = { max: 5, windowMs: 10 * 60_000, maxKeys: 1000 } as const;
The honeypot stops bots that autofill; it does nothing against a script that simply omits the hidden field, and every message that gets through spends the deployer’s Resend quota and lands in their inbox. That asymmetry is worth one Map.
The function is pure but for the store it is handed — the caller owns the Map, so a test can pass its own and drive its own clock. The store is bounded, and eviction needs no LRU bookkeeping because a Map iterates in insertion order, so the first key is the oldest.
Its three ceilings, stated honestly
The source marks all three rather than implying a guarantee:
- The store is the function instance’s memory, so it holds only while an instance stays warm, resets on a cold start, and two concurrent instances count separately.
- Where the adapter cannot supply
clientAddress, the endpoint keys onx-forwarded-for, which the caller controls. - Because eviction is oldest-first, a client can flush its own record by spraying
maxKeysforged ones.
All three make this a flood damper, not a guarantee. The upgrade path is named: the host’s own rate limiter (Cloudflare’s ratelimits binding) or a shared KV store — both configuration rather than code, which is why neither is in the theme.
The endpoint’s error handling
clientAddress is never assumed, because it throws on an adapter that cannot supply it:
try { client = clientAddress; }
catch { client = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; }
A shared fallback key still throttles — conservatively, which is the right direction to be wrong in.
A missing key is logged loudly and answered honestly:
if (!key) {
console.error("RESEND_API_KEY is unset — the contact form accepted a message and could not send it. …");
return back("failed");
}
Not an error the visitor caused, and not one to hide from whoever deployed the site. The reader sees the direct mailto the frame prints under the button. Nothing silently swallows a message.
A GET on the endpoint redirects to /contact/ — someone typing the URL gets the form.
Configuring Resend
Three environment variables, all read at request time:
| Variable | Default |
|---|---|
RESEND_API_KEY |
none — unset is a supported state |
CONTACT_TO |
siteData.author.email |
CONTACT_FROM |
TVFOLIO <[email protected]> |
The default from is Resend’s shared sandbox sender, which needs no DNS setup — enough to see the form work the day you paste in a key. It will only deliver to the address that owns the API key. Sending to anyone else, or from your own name, needs a domain verified in Resend.
The endpoint reads process.env through a small helper rather than import.meta.env, and the comment is the general rule: import.meta.env would inline the value into the bundle during astro build, baking in whatever was present on the build machine — usually nothing.
On Cloudflare:
pnpm exec wrangler secret put RESEND_API_KEY
The one deployment trap
run_worker_first: ["/api/*"] in wrangler.jsonc is not optional, and curl will not reproduce its absence. Cloudflare’s asset router sees a request before the Worker and claims every browser navigation that matches no file — which is exactly what a form submit is, since it carries Sec-Fetch-Mode: navigate. Without that line the POST returns 405 from the asset router for every real visitor, while every scripted check passes. Deployment has the measurement.
Removing the form
If you do not want a contact endpoint, deleting it removes the last reason for an adapter:
rm -rf src/pages/api/
pnpm remove @astrojs/cloudflare
Then drop the adapter, session and prerenderEnvironment lines from astro.config.mjs, and edit Sections/Contact/ContactForm.astro, which posts to the route you just removed. The page already prints a direct mailto under the button, so keeping that and dropping the form is the least-work version.