Store first, notify second: a contact form that survives a broken mail provider
How the contact form handles bots, floods and email outages: a honeypot, two throttles in one atomic upsert, and a message that is saved before anyone is notified.
A contact form looks like the smallest feature on a site. It is also the one that quietly loses messages: the email provider rejects a request, the function times out, and the visitor sees a success message for a message nobody will ever read. The template’s form is built around one rule, store the message first and treat everything after that as optional. This post walks through what happens on a submission and why each step is there.
The submission path#
submitContactMessage() in src/lib/contact.ts runs four steps in order:
-
Honeypot. The form has a
websitefield that people never see. When it arrives filled in, the handler answers with the normal success response and stores nothing. Bots that check for an error message see none, so they have no signal to adapt to. -
Throttles. Two limits apply to every message: five per fifteen minutes per client address and three per hour per email address. Keys are SHA-256 hashes, so the
throttletable never holds a raw address. Exceeding either limit ends the request withTOO_MANY_REQUESTSbefore anything is written. -
Store. The message is inserted into
contact_message. This insert is the authoritative success: once it commits, the visitor’s message exists whatever happens next. -
Notify. If
CONTACT_TO_EMAILis set, the owner notification is sent and its outcome is recorded on the row:sent, orfailedwith the attempt count and the error text. If no recipient is configured the row is markedskipped.
A failed notification never fails the request. The visitor still sees the success message, which is accurate, and the failure is visible to administrators instead, in the inbox and on the dashboard’s list of failed notifications.
Why the throttle is one SQL statement#
Serverless platforms run many copies of the same function at once, so two submissions from the
same sender can hit two instances in the same millisecond. A read-then-write counter would let
both through. consumeThrottle() in src/lib/throttle.ts avoids that with a single upsert:
INSERT INTO throttle (key, count, reset_at) VALUES (?, 1, ?)
ON CONFLICT (key) DO UPDATE SET
count = CASE WHEN reset_at <= ? THEN 1 ELSE count + 1 END,
reset_at = CASE WHEN reset_at <= ? THEN ? ELSE reset_at END
RETURNING count, reset_at;
The database serialises the statement, so every concurrent request gets a distinct count and the window resets atomically when it has expired. There is no in-memory state to lose between invocations, and the same code runs unchanged on Node, Vercel, Netlify and Cloudflare Workers.
What the visitor is told#
The success message says the message was received and that a reply comes by email when one is needed. It does not promise that anyone has been notified, because at that moment the template cannot know. Writing copy that matches what the code guarantees is cheaper than explaining a lost message later.
Recovering from an outage#
When the provider is down for an hour, the inbox at /admin/messages shows every message with a
failed notification, and the detail page has a Resend notification button that runs the
delivery step again for that row. Nothing needs to be re-typed and nothing is duplicated:
resending only updates the delivery columns of the existing message.
Retention#
Messages are personal data, so they do not live forever by default. pnpm db:prune deletes
archived messages older than CONTACT_RETENTION_DAYS (365 by default) and expired throttle
counters. Open messages are kept until someone archives them, unless CONTACT_MAX_AGE_DAYS sets
a hard maximum for every message regardless of status. Both values belong in your privacy
policy; the privacy and data guide has a scheduled-workflow
example for running the job weekly.
Adapting it#
The action in src/actions/index.ts validates with Zod 4 from astro/zod and calls
submitContactMessage() with its dependencies injected, which is what makes the unit tests in
src/lib/contact.test.ts fast: they run against an in-memory database and a fake mail sender.
To add a field, extend the schema and the insert. To add a CAPTCHA for a high-traffic site,
verify the token in the action handler before the honeypot check; the rest of the pipeline
stays the same.