Contact form
@takuhon/contact is a lightweight, chat-style contact form: a single stateless
POST delivered as email — no database, no message history, no auto-reply. The
two Cloudflare-specific pieces (a Turnstile
challenge and Cloudflare’s send_email) are injected through portable seams, so
the form’s core stays host-agnostic.
On the standard Cloudflare deployment (@takuhon/cloudflare, what create-takuhon
scaffolds) the form is turnkey — you turn it on with configuration, not code.
The server-rendered profile page embeds the widget and the Worker serves its
assets and the submission endpoint for you.
Turnkey setup (Cloudflare)
Section titled “Turnkey setup (Cloudflare)”1. Enable it in takuhon.json
Section titled “1. Enable it in takuhon.json”Add settings.contact with your public Turnstile site key. The secret stays
out of the document — only public values live here:
"settings": { "contact": { "enabled": true, "turnstileSiteKey": "0xYOUR_PUBLIC_SITE_KEY", "subjectPrefix": "[example.com contact]" }}subjectPrefix is optional (it prefixes the delivered email’s subject). You can
edit this in the admin form’s Contact form section instead of by hand.
2. Wire Cloudflare delivery
Section titled “2. Wire Cloudflare delivery”Set the recipient and From in wrangler.toml, and provision the secret. These
are the only environment values the form needs:
[vars]TAKUHON_CONTACT_TO = "you@example.com" # recipient — see the note belowTAKUHON_CONTACT_FROM = "noreply@example.com" # From label on the delivered email
[[send_email]]name = "TAKUHON_CONTACT_EMAIL"# destination_address = "you@example.com" # optional: lock to one inboxwrangler secret put TAKUHON_TURNSTILE_SECRET # the Turnstile secret keyThat’s it. Redeploy, and the contact widget appears on your profile page; messages
arrive in your inbox. The create-takuhon scaffold ships this block commented out
in wrangler.toml, so enabling the form is mostly uncommenting it and filling in
your addresses.
Turnstile keys
Section titled “Turnstile keys”Create a Turnstile widget in the
Cloudflare dashboard to obtain two keys: the site key (public — goes in
settings.contact.turnstileSiteKey) and the secret key (goes in the
TAKUHON_TURNSTILE_SECRET Wrangler secret).
How it works
Section titled “How it works”- The widget is served by the Worker at
/contact-widget.jsand/contact-widget.css(bundled in — no asset binding required) and embedded on the server-rendered page via a deferred<script>withdata-*attributes, so no inline script is needed and the page’s Content-Security-Policy stays strict (only the Turnstile origin is allowed, and only on the page that uses it). - The endpoint
POST /api/contactmounts only when thesend_emailbinding is bound andsettings.contact.enabledis true. It runs the method /Originguard, input validation, Turnstile verification, and delivery, same-origin only. - It never throws. A missing secret degrades to a rejected challenge (
422), a missing recipient / From to a failed delivery (502), and a honeypot hit returns a forged200— never a crash. Disablingsettings.contact(or removing the binding) turns both the widget and the endpoint off.
Custom renderers & other hosts
Section titled “Custom renderers & other hosts”The turnkey path above covers the standard Cloudflare adapter. If you run a
custom renderer (you generate the profile HTML yourself instead of using
@takuhon/api’s server renderer), or a host without a turnkey adapter yet
(Vercel / WordPress), you wire the two halves manually.
Backend endpoint
Section titled “Backend endpoint”npm install @takuhon/contactMount POST /api/contact in your Worker, before your other routes:
import { handleContact } from '@takuhon/contact';import { createSendEmailTransport, createTurnstileVerifier } from '@takuhon/cloudflare';
// inside fetch(request, env):const url = new URL(request.url);if (request.method === 'POST' && url.pathname === '/api/contact') { return handleContact(request, { verifier: createTurnstileVerifier(env.TURNSTILE_SECRET ?? ''), transport: createSendEmailTransport(env.CONTACT_EMAIL, { to: env.CONTACT_TO, from: { email: env.MAIL_FROM, name: 'example.com' }, subjectPrefix: '[example.com contact]', }), config: { allowedOrigins: [url.origin] }, });}(On a non-Cloudflare host, implement the ChallengeVerifier and EmailTransport
seams for that platform; only those two pieces are host-specific.)
The widget
Section titled “The widget”The package ships a self-contained dist/contact-widget.js and
dist/contact-widget.css, also exported as inlined strings from
@takuhon/contact/assets (contactWidgetJs / contactWidgetCss) for bundling.
Serve them from your origin, then inject the widget into your page. The
CSP-friendly way is data-* attributes on the script — no inline script:
<link rel="stylesheet" href="/contact-widget.css" /><script src="/contact-widget.js" data-site-key="0x..." defer></script>It also reads a window.TAKUHON_CONTACT global, set before the script loads, if
you prefer that:
<script>window.TAKUHON_CONTACT = { siteKey: "0x..." };</script>Either way the widget auto-mounts on DOM ready when a site key is present:
Option (global / data-*) | Default | Meaning |
|---|---|---|
siteKey / data-site-key | — (required) | Turnstile public site key |
endpoint / data-endpoint | /api/contact | URL the form POSTs to |
lang / data-lang | <html lang> | widget UI language |
locale / data-locale | from lang | explicit locale override |
pageUrl / data-page-url | location.href | included in the email metadata |
Inject these only when a site key is configured, so an unconfigured deploy mounts nothing and stays safe.
Why it’s built this way
Section titled “Why it’s built this way”- Stateless. One
POST, delivered as email. No stored messages, no history, no third-party inbox — keeping the privacy surface minimal. - Portable. The
ChallengeVerifierandEmailTransportseams keep the core free of Cloudflare specifics; another host implements just those two seams.