Basic Contact Form with Cloudflare Workers
A contact form just needs to take form data and get it to whoever needs it. For the small sites that I work on, this has been done so easily for years with the PHP mail()
function. Moving to a serverless platform becomes somewhat more involved, but provides opportunities to add features that would be harder to implement with a PHP script.
The Options
For a serverless form handler, Cloudflare Workers aren’t really the first place you would probably look. Not being a full Node environment, they cannot run an SMTP client like nodemailer, so you’re dependent upon a third party to actually deliver any email. Here are some other options that could be considered:
- Use a service like Form Backend
- Host on Netlify and use their form handling
- Use a serverless platform that supports the nodemailer package
I’ve chosen to go the Cloudflare route because I’m already hosting this site on Cloudflare Pages and it seems like as good a place as any to get started with Workers. Also, Cloudflare’s bot detection should eliminate the need for using any type of captcha.
Michael Wanyoike has already covered much of this in a great, in-depth tutorial on SitePoint. I’m taking some ideas from his example and developing my own version that will ultimately use Cloudflare’s KV storage to collect submissions and then send them in a single daily digest email. But first, I just need to get the basic form handling up and running.
Workers with Cloudflare Pages
When using Cloudflare Pages as I am, setting up a worker is a little different from having one that runs on its own. Rather than having a separate project that needs to be published using the Wrangler CLI, a Pages Function can just be placed in a functions
directory in the project root. I’m putting mine in an api
subdirectory, but that’s not a requirement.
There are a few important differences when using a Worker inside Cloudflare Pages:
- Rather than using an Event Listener to respond to incoming requests, a Pages Function exports an
onRequest()
function. Functions with method-specific suffixes, such asonRequestPost()
, can be used to only respond to specific types of requests. - Instead of only getting access to the
request
, acontext
argument is passed into your function. Thecontext
object contains therequst
, as well as anenv
object for your environmental variables,params
, which lets you take in data from the URL, andwaitUntil
,next
anddata
for developing Express-like middleware. - While it may be possible to update environmental variables using the Wrangler CLI, I have not been able to get it to work. I needed to add them from the Settings > Environmental Variables section of the Pages dashboard.
- Local development with Wrangler and Pages is also a bit clunky at the time of this writing. I’ve found it easier to develop Workers as standalone projects and then port them to my Pages projects once they are finished.
The Worker
This single file is all that is needed to accept a JSON payload and use it to send an email. I’m using Mailgun in this case, but this should be easily adaptable to most services that allow you to send email via a REST API.
/* /functions/api/form-submit/index.js */
export async function onRequestPost({ request, env }) {
const json = await request.json();
const body = `
Name: ${json.firstName} ${json.lastName}
Email: ${json.email}
${json.message}
`;
const data = {
to: env.TO_EMAIL_ADDRESS,
from: env.FROM_EMAIL_ADDRESS,
subject: `New Contact from ${json.email}`,
text: body,
};
const submitUrlString = encodeURI(
Object.entries(data)
.map((param) => param.join("="))
.join("&")
);
const init = {
method: "POST",
headers: {
Authorization: "Basic " + btoa("api:" + env.MAILGUN_API_KEY),
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": submitUrlString.length.toString(),
},
body: submitUrlString,
};
const result = await fetch(`${env.MAILGUN_API_BASE_URL}/messages`, init);
if (result.status === 200) {
return new Response(JSON.stringify({ message: "OK" }), {
headers: { "content-type": "application/json" },
status: 200,
});
} else {
return new Response(
JSON.stringify({ message: "Message submission failed!", result }),
{
headers: { "content-type": "application/json" },
status: 400,
}
);
}
}
That Authorization header might look a little odd. Basic Authentication is really intended for use with a username and password. but Mailgun’s authentication scheme uses “api” as the username and the API key as the password. The btoa()
function is built into Javascript and encodes the string as base64, which is required by the Basic Authentication standard.
What’s Next?
Eventually, I need add server-side validation, provide better responses for various failure points and allow for accepting regular HTML form data, so I can accept submissions without the need for client-side Javascript.
However, I’m publishing this in its current form as an example of the bare minimum required to process form submissions using Cloudflare Pages Functions.