Contact Form
/contact/ is the only route in Urbic that renders per request, and the enquiry form is the reason. It is a real, connected form: schema, spam gates, escaping and the delivery call are all live code, not a stub — the wrapper in src/actions/contact.ts is about thirty lines because everything it leans on is in src/js/contact.ts.
Why it renders on demand
The form is an Astro action with accept: "form", so it parses native FormData and the page works with JavaScript off — the server re-renders it with the errors or the sent state. That re-render needs Astro.getActionResult, which exists only on a page rendering per request, so contact.astro is the one page in src/pages/ without export const prerender = true.
An action rather than an API route also keeps the POST on a content type security.checkOrigin actually covers. A JSON fetch is not.
Making it deliver
Two environment variables:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
CONTACT_TO_EMAIL=[email protected]
CONTACT_FROM_EMAIL is a third, defaulting to Resend’s shared sandbox sender so a first test send works before you have verified a domain.
All three are optional, and that is deliberate: the site still builds and the page still renders without them. The handler checks for them at request time rather than at build time, so a fresh clone can be built and looked at before anyone signs up for anything. Without them the visitor gets a readable “This form is not configured yet” message naming the two variables.
What it validates
The schema lives in src/js/contact.ts and is Zod through astro/zod — Astro’s own bundled copy, so it adds no dependency. It imports nothing from astro:*, which is what lets its check run under plain node --experimental-strip-types the way pnpm test runs everything.
Three fields are required: a name, an email, and a message of at least ten characters. Five are optional — phone, location, project type, budget and timeline — which is the mock’s own reading of its own copy, “fill this in with as much or as little as you have.”
The three enumerated fields take their options from contactData.json.ts rather than restating them, so the <select> a visitor sees and the values the server accepts cannot drift. That same array is also the CMS’s locked prop on EnquiryField, declared in src/admin.config.ts and round-tripped untouched — because the option list is the allow-list, and letting an author edit one half of that would be a security change disguised as a copy change.
The header-injection guard
name ends up in a mail header, so carriage returns and line feeds are rejected, not stripped.
That is the whole difference between a contact form and an open relay. A newline smuggled into a header value means the provider concatenates it and everything after the break becomes an attacker’s own header — a Bcc:, a forged Reply-To:. Rejecting at the boundary means nothing downstream has to remember to escape it, and sanitizing instead would silently rewrite what the visitor typed.
email needs no such guard — the email validator already rejects a break. location also reaches a header, because it is appended to the subject, but it is not rejected: a pasted two-line address is ordinary input for a “City or postcode” box, and refusing it would cost a real enquiry to protect a line where the break carries no meaning. It is flattened at the point the subject is built instead. Every other field is interpolated into the body, where a newline is just a newline.
That asymmetry is the kind of decision worth preserving if you extend the form: ask where the value lands before deciding whether to reject or flatten.
The spam gates
Two, both checked before any provider request is made, because they are the cheapest checks available.
A honeypot field, and a time gate — under three seconds from render to submit, assume a bot.
The honeypot is deliberately permissive in the schema itself. Rejecting it there would surface it as a per-field error on an input the visitor cannot see, so a browser that autofilled it would fail forever with nothing on screen to fix. spamReason rejects it with a message a person can actually read.
There is also a provider timeout of ten seconds — the code decides what a hung provider is rather than waiting on one.
Swapping the provider
Delivery is one function, sendContactEmail, taking an API key, a to and a from. Point it at a different provider and nothing else changes: the schema, the gates, the escaping and the action wrapper are all provider-agnostic.
Removing the form
Drop contactServer from src/actions/index.ts and restore export const prerender = true on src/pages/contact.astro. The markup keeps rendering; it simply posts nowhere.
With that done, the site has no on-demand route of its own — though output: "server" still has to stay if the CMS is mounted, because the admin’s session guard is middleware. See Deployment.
The contact page itself
Sections/Contact/ holds six sections. Beyond the form there is a studio block reading the four contact values from siteData.json.ts — the same four the header panel and the footer identity row read — and a nextSteps list from contactData.json.ts describing what happens after you send: a reply, a visit, a fee. Those three stop where work begins, which is where servicesProcess’s five phases pick up. The two lists meet at the site visit and then diverge deliberately.