static-contact-form
v0.4.1
Published
Contact-form backend for static sites on Cloudflare Workers: validation, Turnstile, email, optional KV copy.
Maintainers
Readme
static-contact-form
A small contact-form backend for static sites on Cloudflare Workers. It validates the fields, checks Cloudflare Turnstile, can store a copy in KV, and emails the inquiry to the site owner. It does not depend on any framework.
Install
pnpm add static-contact-form
# or straight from git
pnpm add github:blazardsky/static-contact-form#v0.4.1The compiled JavaScript and type declarations in dist/ are committed, so installing from git needs no build step. Vite, Astro, Wrangler and Node all import it as plain ESM.
Releasing
- Change
src/, then bumpversioninpackage.json. pnpm buildand commitdist/(git installs use it).git tag vX.Y.Z && git push origin main --tagsnpm publish(prepublishOnlybuilds, typechecks and tests first).
Server: handleInquiry
import {handleInquiry, turboSmtp} from 'static-contact-form'
const result = await handleInquiry(formDataOrObject, {
send: turboSmtp({key: env.TURBO_KEY, secret: env.TURBO_SECRET}),
store: env.INQUIRIES, // optional KV namespace
turnstileSecret: env.TURNSTILE_SECRET,
to: env.INQUIRY_TO, // owner inbox
fields: { // optional extra fields, see below
phone: {label: 'Phone', maxLength: 40},
weddingDate: {label: 'Wedding date', pattern: /^\d{4}-\d{2}-\d{2}$/},
audience: {label: 'Audience'},
},
from: 'Studio <[email protected]>', // optional, defaults to `to`; must be a sender the email provider accepts
confirmation: { // optional email to the visitor
subject: 'Thanks, {name}',
text: 'Hi {name}, we received your message and will reply soon.',
},
})The function does these steps in order:
Honeypot. A non-empty
websitefield returns{ok: false, reason: 'spam'}.Validation.
name(100 characters max),email(254 max, must look like an address) andmessage(5000 max) are required. Fields declared infieldsare validated too (see below), and any other field is ignored. A failure returns{ok: false, reason: 'invalid', errors: Record<string, FieldError>}, whereFieldErroris'required' | 'invalid' | 'too-long'.Turnstile. The
cf-turnstile-responsetoken is verified withsiteverify. A missing or rejected token returns{ok: false, reason: 'spam'}. If siteverify itself fails (network error or a non-2xx response), the result is{ok: false, reason: 'verify-failed'}and nothing is sent, so the site can ask the visitor to try again.Store. When
storeis set, the copy is saved as JSON withput(key, value, {expirationTtl}). The copy is kept for 30 days by default; setstoreTtlin seconds to change it. The copy is stored before sending, so it survives a failed send.Send. The inquiry is emailed to
toas plain text, withreply-toset to the visitor. If sending fails, the result is{ok: false, reason: 'send-failed', stored}.Confirmation (optional). The visitor receives the fixed subject and text from config. Only
{name}is filled in, in both the subject and the text, and the visitor's message is never included. If the confirmation fails, the result is stillok.Name check.
{name}is filled in only when the name looks like a person's name: it starts with a letter, contains only Unicode letters, spaces, apostrophes ('’) and hyphens, and is at most 50 characters. That rules out digits,.,@and/, so URLs and addresses cannot get through. Any other name is replaced byconfirmation.fallbackName, which defaults to''. When the replacement is empty, the space before{name}is removed too, soCiao {name},becomesCiao,. Write templates that still read well that way. The owner email always shows the name exactly as submitted.Remaining risk: because of the name check, a visitor can no longer place a link or an address in the confirmation email. The fixed confirmation text can still be sent to an address the visitor does not own. Turnstile limits how many can be sent. To remove the risk entirely, leave out
confirmation.
The code that calls handleInquiry turns the result into a response, for example 200, 400 for invalid, 403 for spam, 503 for verify-failed and 502 for send-failed.
Extra fields: fields
fields declares the fields the form may send besides name, email and message. Each declared field is validated, saved in the KV copy under its key, and listed in the owner email as label ?? key. Undeclared fields are dropped. name, email, message, website and cf-turnstile-response are reserved: if declared in fields, they are ignored there. Each field accepts these options:
| Option | Meaning | Error code |
|---|---|---|
| required?: boolean | the field must be non-empty | required |
| maxLength?: number | defaults to 500 | too-long |
| pattern?: RegExp | an empty optional field skips the check; anchor the pattern with ^…$, and do not use the g or y flag, because RegExp.test is stateful with them | invalid |
| label?: string | heading in the owner email | — |
A wedding studio example:
fields: {
phone: {label: 'Phone', maxLength: 40},
weddingDate: {label: 'Wedding date', pattern: /^\d{4}-\d{2}-\d{2}$/}, // <input type="date"> sends YYYY-MM-DD
audience: {label: 'Audience', maxLength: 40}, // hidden input set by the site
}Secrets and bindings (names are up to the site, passed in config)
| Value | Where |
|---|---|
| TURBO_KEY, TURBO_SECRET | turboSMTP consumer key and secret (wrangler secret put) |
| TURNSTILE_SECRET | Turnstile widget secret key |
| INQUIRY_TO | owner inbox |
| INQUIRIES | optional KV namespace binding |
Astro endpoint example
An on-demand endpoint runs in the Worker, so it works even when every page is prerendered. It answers JSON when the request asks for it (enhanceForm does), and otherwise 303-redirects, so the form also works without JavaScript.
// src/pages/api/inquiry.ts
import type {APIRoute} from 'astro'
import {env} from 'cloudflare:workers'
import {handleInquiry, turboSmtp} from 'static-contact-form'
export const prerender = false
const status = {invalid: 400, spam: 403, 'verify-failed': 503, 'send-failed': 502}
export const POST: APIRoute = async ({request, redirect}) => {
const result = await handleInquiry(await request.formData(), {
send: turboSmtp({key: env.TURBO_KEY, secret: env.TURBO_SECRET, region: 'eu'}),
store: env.INQUIRIES,
turnstileSecret: env.TURNSTILE_SECRET,
to: env.INQUIRY_TO,
})
if (request.headers.get('accept')?.includes('application/json')) {
return Response.json(result, {status: result.ok ? 200 : status[result.reason]})
}
return redirect(result.ok ? '/thanks/' : '/contact/?error=1', 303)
}<form method="post" action="/api/inquiry">
<input name="name" required> <input name="email" type="email" required>
<textarea name="message" required></textarea>
<!-- honeypot: hidden from people and assistive tech -->
<input name="website" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button>Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Client: enhanceForm
import {enhanceForm} from 'static-contact-form/client'
enhanceForm(form, {success: statusEl, error: errorEl})This is progressive enhancement for the site's existing markup. When the form is submitted, enhanceForm:
- Posts
new FormData(form)toform.actionwithfetchandAccept: application/json, so the server can answer JSON instead of redirecting. The form data includes thecf-turnstile-responseinput that the Turnstile widget adds inside the form. - Disables the submit buttons and sets
aria-busyon the form while the request runs. Further submits are ignored until it finishes. - On a 2xx response, resets the form, unhides
success, gives ittabindex="-1"and focuses it. The buttons stay disabled so the same inquiry cannot be sent twice. - On any other response, or a network failure, unhides
error, focuses it and re-enables the buttons. - Calls
turnstile.reset()after each attempt, because a token can be used only once. - Calls
umami.track('inquiry-sent')orumami.track('inquiry-error'), but only whenwindow.umamiexists.
Use the site's own elements for the messages, such as <p role="status" hidden> for success and <p role="alert" hidden> for errors, so the site keeps control of their design and copy. Without JavaScript the form posts normally.
<form method="post" action="/api/inquiry" id="inquiry">…</form>
<p role="status" id="inquiry-ok" hidden>Thanks, we'll be in touch.</p>
<p role="alert" id="inquiry-ko" hidden>Sorry, that didn't go through. Please try again or email us.</p>
<script>
import {enhanceForm} from 'static-contact-form/client'
enhanceForm(document.querySelector('#inquiry')!, {
success: document.querySelector('#inquiry-ok')!,
error: document.querySelector('#inquiry-ko')!,
})
</script>Email adapters
An adapter is a plain function:
type Send = (message: {from: string; to: string; replyTo: string; subject: string; text: string}) => Promise<void>It should resolve when the message is accepted and throw when it is not. Any provider works, for example:
const send: Send = async (m) => {
const res = await fetch('https://api.example-mail.com/send', {method: 'POST', body: JSON.stringify(m), headers: {authorization: `Bearer ${env.MAIL_KEY}`}})
if (!res.ok) throw new Error(`mail ${res.status}`)
}turboSmtp({key, secret, region?})
This adapter calls the turboSMTP HTTP API. Its behaviour was checked against turboSMTP's official OpenAPI spec (turboSMTP/developers-hub, api-reference/Domains/Mail.yaml) and their official JS SDK (turboSMTP/turboSMTP-js) in October 2026:
- It sends
POST https://api.turbo-smtp.com/api/v2/mail/send. European accounts usehttps://api.eu.turbo-smtp.com/api/v2/mail/send, selected withregion: 'eu'. - It authenticates with the
consumerKeyandconsumerSecretheaders. The endpoint does not accept anAuthorizationheader. - The JSON body contains
from,to,subjectandcontent(plain text). Reply-to is sent ascustom_headers: {"reply-to": "..."}, which is how both the spec example and the official SDK do it. - A 200 response means the message was accepted. A 400 response lists
errors, such asnocredit, and a 401 response means the credentials are wrong. Any response other than 2xx throws, and the error includes the status and the response body.
License
MIT
