There used to be a mailto: link on this site. One line, one address, sitting in the page source for any scraper to pick up. It’s gone now, replaced by a form, and getting there took more layers than a form usually needs.
Why a form beats a link
A plaintext email address in HTML is free harvest for spam bots — no JavaScript required, no rate limit to hit, just a regex over the page. A form doesn’t remove the target entirely (the message still ends up in an inbox), but it removes the free part: whoever wants to reach that inbox now has to get through a submission pipeline that’s built to notice they’re not human.
The layers, cheapest to most expensive
- Honeypot field. A text input hidden off-screen (
clip: rect(0 0 0 0), notdisplay: none— some bots skip fields hidden that way, so this one stays “visible” to a naive DOM scan), with a deliberately unguessable name and id,autocomplete="off"and the usual password-manager opt-outs. Real visitors never see it, let alone fill it in. Bots that fill every field walk straight into it. - A timing trap. The form records the moment it became interactive; the server rejects submissions that arrive faster than a human could plausibly type a message. Costs nothing to run, catches a surprising amount.
- An Origin check. The request has to claim to come from this site. Doesn’t stop a determined attacker, but it stops the lazy ones replaying the endpoint from a random script.
- Cloudflare Turnstile, gating the expensive step. This is the one that actually costs a bot something: it runs a short check in the browser and returns a token, and the server verifies that token with Cloudflare before it’s allowed to trigger an email send. No valid token, no email — Turnstile sits in front of the send call, not just the UI.
None of these is bulletproof alone. Together, each one raises the cost of the next attempt, which is the actual goal — spam economics is about margin, not about building a wall nobody can climb.
The GDPR side of the same form
Collecting a name, an email address and a message means the GDPR applies, so the site needed a privacy policy. Two things stood out from actually writing one:
- Cookies and GDPR are not the same question. The ePrivacy consent-banner requirement is triggered by non-essential cookies or device storage, not by data processing in general. This site sets none of its own; Cloudflare Turnstile’s cookie, if any, is strictly necessary, and Cloudflare Web Analytics uses no persistent identifier at all. No banner needed, because there’s nothing to consent to.
- Where you put the disclosure matters. Article 13 requires the information “at the time when personal data are obtained” — so the privacy-policy link belongs next to the submit button, not just buried in the footer. It’s in both places now; the footer link alone was, in practice, invisible.
The mistake
While writing that privacy policy, I put two mailto: links straight into it — the exact plaintext address the whole form exists to avoid exposing. Caught it late, not early. Fixed it by linking to the contact form instead, and by adding a standing check to this workflow: grep the built output for the address before anything ships. Undoing the one thing a project is for, in an unrelated page about how the project protects data, is the kind of mistake that only happens once you stop treating a rule as a rule and start treating it as something you already did.