← Journal
Build log / 08 · 6 min read

Building a contact form that files itself

The contact form on this site used to open your email client. That is the laziest possible implementation, and for a long time it was the right call — until we wanted messages to land somewhere structured instead of in an inbox.

A mailto link works until it does not. It assumes the visitor has a configured desktop mail client, which plenty of people do not. On a shared or work machine it can open something they never use. And when it fails, it fails silently: the form appears to do nothing, and you never learn the message existed.

It also throws away the structure you just collected. You asked for a name, an email and a message as separate fields, then flattened all three into a body of text that somebody has to read and re-key if they want to track it.

Now the form posts to a small endpoint that files the message into DodoForm, where it lands as a record with real fields.

On the first submission the destination form did not exist. Rather than create it by hand and copy an identifier around, the endpoint sends a form name and lets the API create it, inferring the fields from the payload. Later submissions reuse it. One less piece of configuration to keep in sync, and one less thing to get wrong when setting the site up somewhere new.

Submissions carry an idempotency key. If the network drops after the request arrives but before the response gets back, a retry is safe — the same key cannot file twice. Without it, the polite thing for a client to do after a timeout is retry, and the polite thing produces duplicates.

The endpoint validates before it forwards: all three fields present, the email shaped like an email, and a length cap on each so a single submission cannot carry a novel.

When something upstream goes wrong, the visitor gets a short, plain message and the detail goes to the server log. This is deliberate. Nobody filling in a contact form benefits from an upstream status code, and echoing internal errors into the page leaks information about your infrastructure to anyone who pokes at it.

The messages are written like a person wrote them. If the email looks wrong, it says so and asks you to check. If the form is not configured yet, it says the form is not hooked up and points at the email address instead. That last case matters more than it sounds: a form that is silently broken is worse than one that admits it and offers another route.

There is a hidden field in the markup, positioned off-screen and marked so assistive technology ignores it. A person never sees it and cannot fill it in. A bot filling in every field it finds will.

When it arrives populated, the endpoint returns success and quietly drops the message. Returning an error would tell the bot it had been spotted and invite another attempt with the field left blank. Returning success ends the interaction.

This is not a complete defence and it is not meant to be. It costs a few lines, needs no third-party service, and adds no puzzle for real visitors to solve. If volume ever justifies a proper challenge, that can be added later.

Four: idle, sending, sent, and failed. The submit button disables itself and changes its label while a request is in flight, so an impatient double click cannot fire twice. Success replaces the form with a short confirmation and an option to send another. Failure keeps everything the visitor typed on screen — losing a message you just wrote because a request failed is unforgivable, and it is the default if you are not careful.

None of this is clever. It is the difference between a form that works and a form that appears to.

Follow on X →