A built-in contact form for EmDash CMS. One form, one shortcode, submissions stored and managed in the admin UI. No SMTP setup, no per-form configuration — install it and it works.
Single-form by design. This plugin ships one fixed contact form (Name / Email / Message). Configurable bits — success message, privacy note, spam protection, retention — live in the plugin Settings. If you need multiple forms with arbitrary fields, this isn't the right plugin.
- One-line shortcode — drop
<div data-form-slug="contact"></div>on any page - Editor-friendly — also available as a Portable Text block in the slash menu
- Auto-injected loader — no manual
<script>tag needed if your layout uses<EmDashHead /> - Submissions admin — list, search, status filter (All / New / Read), soft-delete
- Spam protection — honeypot, minimum-submit-time, per-IP and global rate limits, same-origin check
- Privacy-aware — soft delete, configurable retention with daily auto-purge
- Zero external runtime dependencies — vanilla JS hydration, no NPM extras at runtime
Pin to a release tag (recommended for production):
npm install github:wpmudev/emdash-contact-form#v0.2.0Or track the latest commit on main:
npm install github:wpmudev/emdash-contact-formFor local development against an in-progress copy of the plugin (see the Developing the plugin section for the full workflow):
npm install file:../path/to/emdash-contact-formThe package name is
@incsub/emdash-contact-form— that's the import path you'll use in your code. It will land innode_modules/@incsub/emdash-contact-form/regardless of which install method you use.
Then in astro.config.mjs:
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
import { contactFormPlugin } from "@incsub/emdash-contact-form";
export default defineConfig({
output: "server",
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
plugins: [contactFormPlugin()],
}),
],
});Restart the dev server. The plugin is ready.
Local-path installs only (
npm install file:ornpm link) need a small Vite hint to dedupe the symlinked dependencies. Skip this if you installed from GitHub or npm:vite: { resolve: { dedupe: ["emdash", "astro"] }, optimizeDeps: { exclude: ["@incsub/emdash-contact-form"] }, },
Two equivalent ways:
A. In the EmDash editor. Open any page, type /, pick Contact Form under the "Forms" category, save. The form renders on the published page.
B. In Astro markup. Drop this anywhere in any .astro page or Markdown file:
<div data-form-slug="contact"></div>The loader script is auto-injected (inlined as a <script> tag) into pages that include <EmDashHead /> — which all default EmDash layouts do. When you change a setting in the admin, the live form updates automatically.
Important: your page layout must include
<EmDashHead />. That's where the loader script gets injected. Without it, the form won't render — there is no separateloader.jsURL you can fall back to.
contactFormPlugin({
requireHoneypot: true, // Default: true
maxMessageLength: 5000, // Default: 5000
retentionDays: 0, // Default: 0 (no auto-deletion)
})These are seeded as KV defaults on first install and can be edited later from Admin → Plugins → Form Settings:
| Setting | Description |
|---|---|
successMessage |
Shown in place of the form after a successful submission |
privacyNote |
Shown below the form |
requireHoneypot |
Reject submissions where the hidden honeypot field is filled |
maxMessageLength |
Cap on text / textarea field length |
retentionDays |
Auto-purge submissions older than N days at 02:00 daily (0 disables) |
The plugin adds two entries to the sidebar under Plugins:
- Form Submissions — list of all received messages. Status filter chips at the top let you switch between All / New / Read. Search matches name, email, message body, and page slug. Click View on any row to open the full submission — opening auto-marks it as read. The detail page also has a Delete button (soft-delete).
- Form Settings — auto-rendered editor for the success message, privacy note, honeypot toggle, max message length, and retention days.
The plugin's root path (/_emdash/admin/plugins/contact-form) shows an overview page with submission counts and the embed shortcode for quick copy-paste.
All under /_emdash/api/plugins/contact-form/.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
submit |
Public | Visitor submission |
GET |
form-config |
Public | Form config for the loader |
GET |
submissions |
Admin | Paginated list |
GET |
submission?id=… |
Admin | Single submission |
POST |
submission?id=… |
Admin | Update status {status: "read" | ...} |
DELETE |
submission?id=… |
Admin | Soft-delete |
Admin routes are protected by EmDash's session middleware automatically.
All routes return plain objects. EmDash wraps them in a {"data": ...} envelope before sending to the client. Success and error shapes are uniform:
Common error codes: invalid_json, invalid_content_type, payload_too_large, forbidden_origin, rate_limited, validation_error, not_found, method_not_allowed, id_required, invalid_status, server_error.
The loader script is inlined into every public page via the
page:fragmentshook — there is noloader.jsURL. EmDash's plugin route wrapper JSON-serializes all responses, so non-JSON content (loader script, CSV download) can't be served from a plugin route.
| Concern | Mitigation |
|---|---|
| Untrusted input | Stored raw, never executed; submission body capped at 64 KB; per-field length capped by maxMessageLength |
| Cross-origin spam | Submit endpoint rejects requests whose Origin header doesn't match the host (legitimate same-origin clients always pass) |
| CSV formula injection | Cells beginning with = + - @ are prefixed with ' so Excel / Sheets treat them as text |
| Bots | Hidden _hp honeypot rejects filled-honeypot submissions silently; submissions in under 2 s are treated as bot activity |
| Rate limiting | 5 / IP / 15 min, plus a global 60 / 15 min fallback for direct-IP deployments without x-forwarded-for. In-process — for multi-instance hosting, also enforce edge-level rate limits (Nginx, Cloudflare) |
| IP capture | Read from x-forwarded-for / x-real-ip. Document this in your privacy policy |
| Soft delete | status: "deleted" is hidden from the UI; not erased until the retention purge |
Plugin uninstall with deleteData: true |
All submissions hard-deleted; KV settings removed by EmDash |
The plugin uses only relative paths (/_emdash/api/plugins/contact-form/...), so it works on any custom domain without configuration. The loader fetches form config from the same origin the page was served from. Page slug is captured client-side via window.location.pathname.
If your site is mounted at a sub-path (e.g. example.com/blog/), EmDash's API still lives at /_emdash/... from the root — confirm your reverse proxy isn't stripping /_emdash/ before requests reach EmDash.
If you serve behind Cloudflare or another reverse proxy, forward x-forwarded-for so per-IP rate limits work; otherwise the plugin falls back to its global counter.
- Single fixed form. Fields are Name / Email / Message. No per-form customization, no multiple forms.
- No file uploads.
- No CAPTCHA. Wire one in
routes/submit.tsbeforevalidateSubmissionPayload()if needed. - In-memory rate limiter — works per-process. Use edge-level limits for multi-instance.
- No GDPR self-service. Visitors can't request their own data; admins handle deletion.
Want to fix a bug or add a feature? Workflow:
git clone https://github.com/wpmudev/emdash-contact-form.git
cd emdash-contact-form
npm installMake your changes in src/. Then build before committing or testing in a consumer site:
npm run build # regenerates dist/
npm run typecheck # optional: confirms TS is cleanThe compiled dist/ is committed to git so GitHub installs work without needing the consumer's host to run a build step. If you change src/ without rebuilding, your changes won't reach consumers until you do npm run build and commit dist/.
To test changes locally in a consumer site before pushing:
# In your consumer site
npm install file:../path/to/emdash-contact-form
# After every plugin change:
cd ../path/to/emdash-contact-form && npm run build && cd -
rm -rf node_modules/.vite .astro
npm run devMIT-licensed. To ship your own version:
- Update
nameinpackage.jsonto your scope (e.g.@yourorg/emdash-contact-form). - Update
entrypointandcomponentsEntryinsrc/index.tsto match the new package name. - Update
repository.url,bugs.url,homepageinpackage.json. npm run buildto refreshdist/.- Commit, tag a release, and push to GitHub — or
npm publishfor npm registry.
MIT — see LICENSE.