Blog

Contact forms in EmDash: build, email and spam-proof

The Contact Forms plugin end to end on a live site: install, fields, the page block, the inbox and email log, delivery through the site's own provider, passive anti-spam, and where AI earns its keep.

A vintage brass mailbox feeding a letter past a rubber stamp marked DELIVERED onto a conveyor of envelopes

Every site I have ever run eventually needed a contact form, and on WordPress it was always the same three-plugin stack: the form plugin, the SMTP plugin to make its mail actually arrive, and a captcha plugin to keep the bots out. On EmDash it is one plugin. It stores every message on the site, sends mail through whatever provider the site already uses, and the anti-spam is passive until you decide you want more.

This site runs it at /contact/ - what you see there is what ships. This is the full path: install, the first form, where the messages land, how email works, the spam traps, and the spots where an AI assistant genuinely saves time.

What's in the box

Form builder
An admin page under Plugins, Contact forms: fields, ordering, per-form settings
Public submit route
A same-origin form POST that answers with a 303 redirect - no fetch, no JavaScript needed
Submissions inbox
Every stored entry with read state, resend and CSV export
Email log
Each delivery attempt with recipient, timing, status and the error when one fails
Delivery
ctx.email.send() - the site's own provider, not a new account to set up
Anti-spam
Honeypot, minimum fill time and a per-IP hourly limit, with optional Turnstile

Install and turn it on

Three steps to a working form

  1. Install the plugin

    Admin, Plugins, Registry - search for Contact Forms and install. On a code-managed site, add the package and register it under sandboxed: instead. Either way it runs inside the plugin sandbox, walled off from your content and settings.

  2. Approve what it asks for

    The manifest requests email:send and, only if you switch on Turnstile, network access to Cloudflare's siteverify endpoint. That is the whole list. Read it before approving - it is short on purpose.

  3. Set the defaults

    Under Plugin settings, enter the fallback notification address and, if you want it, the Turnstile keys. Individual forms can override the notify address; the defaults only catch the forms that do not bother.

The Forms admin page listing forms with their slugs, fields and submission counts
The Forms page the plugin adds to the admin: stats up top, one row per form, the setup guide one click away.

Build the first form

From empty page to working form

  1. New form

    Name it and pick a slug - the slug is how the page block finds the form, so keep it short: contact, support, press.

  2. Add the fields

    Eight types: text, email, textarea, select, checkbox, radio, number and hidden. Each gets a label, a key, an optional placeholder and a required flag. Drag to reorder; a required checkbox demands a real tick, not a guess at a default.

  3. Per-form settings

    Notification email, subject line and the success message visitors see after posting. The subject accepts field values, so 'New enquiry from {name}' works.

  4. Save and look at it

    The public form endpoint renders exactly what visitors will get - the same markup the demo on this site serves.

Put it on a page

On this site a Contact form block takes the slug and renders the whole thing server-side - the markup is in the HTML before CSS or JS loads, and it still works as a plain form post with JavaScript off. Other themes get the same result from the generic component that ships in the package's site/ directory, ready to copy in.

Where the messages land

A submission that passes the traps is written to storage first and emailed second. That order matters: if the mail provider is down or misconfigured, the message still sits in your admin inbox instead of dying inside a timeout. Failed sends are marked on the row so you can spot and resend them.

The email log showing a sent notification with delivery time and status
The email log records every attempt - notifications, visitor confirmations and manual resends - with the delivery time in milliseconds and the error when one fails.
The contact forms dashboard widget with unread count and recent submissions
The dashboard widget is the morning check: unread count, recent entries, one click into the inbox.

Email, the part that usually breaks

The plugin never speaks SMTP itself. It calls the site's email hook, so the provider is whatever you already configured: Cloudflare Email Sending through a Worker binding, or a hosted provider through the emdash-smtp plugin. The full setup for both paths is in this video from my EmDash course:

The companion video: the send_email binding on Workers, and the SMTP route for everything else.

The written version lives at Send Email from an EmDash Site. One detail worth knowing: every submission carries an emailStatus - sent, failed or skipped - and skipped is what you get until a provider exists. The inbox fills either way, so a missing provider costs you notifications, not messages.

Anti-spam without the checkbox farm

Three passive traps run before anything else: a honeypot field humans never see, a minimum fill-time check that catches instant bot posts, and a per-IP hourly limit. A bot that trips one gets a fake success back and nothing is stored, so it never learns to retry differently. If the volume still climbs, optional Cloudflare Turnstile adds a managed check that stays invisible for most visitors.

The Turnstile write-up walks the full pipeline with curl tests you can repeat against your own form.

Where AI actually helps

Worth automating

  • Field sets: describe the form - booking enquiry, bug report, press contact - and let it draft the fields, types and validation, then trim to what you need
  • Email copy: notification subjects and the confirmation auto-reply are easy first drafts for a model
  • Spam tuning: paste a batch of blocked submissions and ask what pattern belongs in the honeypot or the rate window
  • Migrating old forms: it reads a Contact Form 7 or WPForms export and proposes the matching field list

Do not automate

  • Recipient addresses: a hallucinated inbox means messages land somewhere nobody reads
  • Auto-reply tone, unreviewed: that is your site's voice writing to strangers
  • Privacy wording: the line about what you do with submissions has to be true, not plausible-sounding

Common questions

FAQ

Does it work with no email provider configured?

Yes. Submissions store normally and each row is marked emailStatus skipped. Add a provider later and nothing is lost - the inbox was filling the whole time.

How many forms can one site have?

As many as you need. Each carries its own slug, fields, notify address and success message; the page block picks one by slug.

Can visitors attach files?

Not in this version. The honest reason: attachments mean file scanning and storage decisions that deserve a deliberate design, not a bolt-on.

Is Turnstile required?

No. The passive traps alone stop the bulk of junk on my sites. Turnstile is there for when they stop being enough.

What does a rejected visitor see?

Their values come back with the failing fields marked. The draft is held once in KV and deleted when read, so a rejection never loses the message they typed.

Keep going

The links from this guide