Skip to content
AstroCraft Docs
On this theme

Contact Form

/contact/ is the only server-rendered page in 8-BitQuest. It sets export const prerender = false, binds a native <form method="POST"> to an Astro action, and re-renders itself with the result — so it works with JavaScript disabled, and it is the reason the @astrojs/node adapter is mounted (see Deployment). Everything about it is built to be safe by construction: one schema is the only definition of a valid submission, and the server never trusts the client.

The pieces

The form is split across a few small, single-purpose modules, and the split is what makes each part testable and framework-free:

  • src/actions/index.ts — the action handler. It orchestrates: run the spam gates, check the mail keys, build and send the email, translate the result.
  • src/js/contact.ts — the trust boundary. It holds the Zod contactSchema, the spam gates, the HTML escaping, and the email builder. It is pure: no import.meta.env, no fetch, no Astro imports, so it type-strips for pnpm test and the handler starts from data that is already validated and escaped.
  • src/js/resend.ts — the send boundary. A framework-free fetch to the Resend API that never throws and never logs, returning a classified result.

Validation

contactSchema is the single source of truth for a valid submission, and the action re-runs it on the server rather than trusting whatever the browser posted:

export const contactSchema = z.object({
  name:    z.string().trim().min(1).max(100).refine(noLineBreaks, …),
  email:   z.string().trim().pipe(z.email().max(254)),
  subject: z.string().trim().min(1).max(150).refine(noLineBreaks, …),
  message: z.string().trim().min(10).max(5000),
  _gotcha: z.string().optional(),      // honeypot
  _ts:     z.coerce.number().int().positive(),  // render timestamp
});

Because accept: "form" binds the action to a native form, the browser posts real FormData, Astro validates it against the schema first, and the handler only runs on valid data. A validation failure comes back per-field (via isInputError), so each input shows its own message; a non-validation failure surfaces in a single role="alert" on the page.

The error messages are written in the theme’s retro voice — “Enter your name, player.”, “Give your transmission a subject.”, “Tell me a little more than that.” — but they map to real rules: name and subject are 1–100 and 1–150 characters, the email must parse and be at most 254, and the message is 10–5000 characters.

How it defends itself

Three layers, cheapest first:

  • Header-injection guard. name and subject are the two fields that reach a mail header (the subject line), so the schema rejects any value containing a carriage return or line feed. It rejects rather than strips, so the visitor sees why.
  • Honeypot. A hidden _gotcha field that a human never fills. If it arrives with a value, the submission is rejected with a readable form-level message.
  • Time gate. A hidden _ts render timestamp. If the form is submitted faster than a human could plausibly read it (MIN_FILL_MS, 3 seconds), it is rejected. Both gates are judged server-clock to server-clock, never the visitor’s device.

The gates have a documented ceiling: _ts is a forgeable hidden field and the time gate has no upper bound, so this stops drive-by bots, not a targeted attacker. If drive-by volume ever becomes a real problem, the noted upgrade is Cloudflare Turnstile — left out here deliberately, because the Turnstile widget requires JavaScript and would cost the no-JS path this form otherwise keeps.

Building and sending the mail

buildEmail composes the subject ([SiteName] <subject> — <name>) and an HTML body, escaping every interpolated field so a message can never inject markup into the email, and setting replyTo to the visitor’s address so a reply goes straight to them.

sendContactEmail then POSTs that to the Resend API with a plain fetch — no SDK, no dependency — under a 10-second timeout. It never throws; it returns a discriminated result: ok, a network failure (a thrown fetch or the timeout firing), or a provider failure (Resend reached but rejecting, its status and body kept). The action translates that result: on failure it logs the provider’s real answer server-side — where it can safely name the account — and throws a generic ActionError so the visitor only ever sees “Your message could not be sent. Please email me directly.”

Configuration

Two environment variables are required, both read at request time so a missing key never breaks the build:

  • RESEND_API_KEY — a Resend API key.
  • CONTACT_TO_EMAIL — where submissions are delivered.
  • CONTACT_FROM_EMAIL — optional, defaulting to Resend’s shared [email protected]. That sender only delivers to the address that owns the Resend account, which is fine for a first smoke test; to send anywhere else, verify a domain in Resend and set a From address on it.

With RESEND_API_KEY or CONTACT_TO_EMAIL missing, a submission returns “This form is not configured yet…” — a real, visible message, not a silent drop, and the build stays green because the check is at request time.

Adding a field

Because the schema is the contract, a new field is three coordinated edits:

  1. Add it to contactSchema in src/js/contact.ts, with its validation rules (and a noLineBreaks refine if it will reach a mail header).
  2. Add the input to the form markup in Sections/Contact/Form.astro, named to match the schema key.
  3. Include it in buildEmail, escaped, so it appears in the delivered mail.

The schema edit is what makes the field trusted — add the input without it and the value is simply ignored by the validated handler. The two tested modules (contact.test.ts, resend.test.ts) are the fastest way to confirm your change: pnpm test runs them with no framework and no network.

NEXT STEPImages & Assets