Skip to content

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.

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.

Set the recipient and From in wrangler.toml, and provision the secret. These are the only environment values the form needs:

wrangler.toml
[vars]
TAKUHON_CONTACT_TO = "you@example.com" # recipient — see the note below
TAKUHON_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 inbox
Terminal window
wrangler secret put TAKUHON_TURNSTILE_SECRET # the Turnstile secret key

That’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.

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).

  • The widget is served by the Worker at /contact-widget.js and /contact-widget.css (bundled in — no asset binding required) and embedded on the server-rendered page via a deferred <script> with data-* 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/contact mounts only when the send_email binding is bound and settings.contact.enabled is true. It runs the method / Origin guard, 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 forged 200 — never a crash. Disabling settings.contact (or removing the binding) turns both the widget and the endpoint off.

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.

Terminal window
npm install @takuhon/contact

Mount 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 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-*)DefaultMeaning
siteKey / data-site-key— (required)Turnstile public site key
endpoint / data-endpoint/api/contactURL the form POSTs to
lang / data-lang<html lang>widget UI language
locale / data-localefrom langexplicit locale override
pageUrl / data-page-urllocation.hrefincluded in the email metadata

Inject these only when a site key is configured, so an unconfigured deploy mounts nothing and stays safe.

  • Stateless. One POST, delivered as email. No stored messages, no history, no third-party inbox — keeping the privacy surface minimal.
  • Portable. The ChallengeVerifier and EmailTransport seams keep the core free of Cloudflare specifics; another host implements just those two seams.