Cloudflare Workers are an incredibly cost-effective way of adding compute functionality to your serverless site. This post will demonstrate how to create a worker and use it to validate and check your form submission for bots with reCAPTCHA before finally sending the data off to MailGun for emailing.
Step 1: The Form HTML
First the easy part. The HTML markup provided below doesn’t necessarily matter – it’s a FORM element with name, email, subject and message fields as well as an invisible reCAPTCHA v3 field. I added a bit of validation and reCAPTCHA setup but it’s pretty standard.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | <!DOCTYPE html> <!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]--> <!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]--> <!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]--> <!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]--> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>My Contact Form</title> <meta name="description" content="An example of a contact form submitting to a Cloudflare Worker"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"> </head> <body> <div class="container"> <h1>Contact Us</h1> <form id="contact" method="POST" action="https://my.workers.dev"> <div class="form-group"> <label>Name <input name="name" type="text" class="form-control required" placeholder="John Doe"> </label> </div> <div class="form-group"> <label>Email address <input name="email" type="email" class="form-control required email" placeholder="Enter email"> </label> </div> <div class="form-group"> <label>Subject <input name="subject" type="text" class="form-control required" placeholder=""> </label> </div> <div class="form-group"> <label>Message <textarea name="message" class="form-control"></textarea> </label> </div> <input type="hidden" name="g-recaptcha-response" /> <button type="submit" class="btn btn-primary" disabled="disabled">Submit</button> </form> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.1/jquery.validate.min.js"></script> <script src="https://www.google.com/recaptcha/api.js?render=_reCAPTCHA_site_key_"></script> <script> // Add recaptcha token and enable the submit button grecaptcha.ready(reloadRecaptcha); function reloadRecaptcha() { grecaptcha.execute('_reCAPTCHA_site_key_', {action: 'contact'}).then(function(token) { let $form = jQuery('#contact'); $form.find('input[name=g-recaptcha-response]').val(token); $form.find(':submit').removeAttr('disabled'); }); } </script> <script> // Validate the form jQuery("#contact").validate({ submitHandler: function(form) { // Generate a {field: val, field2: val2, ...} object let formData = jQuery('#contact') .serializeArray() .map( function (x) { this[x.name] = x.value; return this; }.bind({}) )[0]; jQuery.ajax({ cache: false, url: jQuery(form).attr('action'), method: jQuery(form).attr('method'), data: JSON.stringify(formData), dataType: 'json', beforeSend: function(jqXHR, settings) { jQuery(form).find(':submit').attr('disabled', 'disabled'); }, success: function(data, textStatus, jqXHR) { alert(data.message); form.reset(); }, error: function(jqXHR, textStatus, errorThrown) { if (jqXHR.responseJSON) { alert(jqXHR.responseJSON.message); } else { alert("An unknown error occurred."); } }, complete: function() { reloadRecaptcha(); } }); } }); </script> </body> </html> |
For those following along at home you’ll want to update your submit URL on line 17 and reCAPTCHA site key on lines 46 and 53. Save this as contact.html or any name of your choice on your web server.
One interesting thing to note here is the way we’re sending our POST request with jQuery – specifically the data option. More on that later though.
Step 2: Set up your Domain
By default your Worker will publish to a workers.dev subdomain. It’s a little nicer to have a subdomain on my own site though so let’s set that up real quick.
If you’re running Cloudflare for DNS, setup is actually super easy. Just create an A record subdomain (I called mine contact) with the IP 192.0.2.1. This IP is relatively meaningless, as if you’re running Cloudflare, the worker will trigger on this subdomain before the DNS resolves. I picked this particular IP because of a post on Cloudflare forums. This is what it had to say:
192.0.2.1 is in the 192.0.2.0/24 block. 192.0.2.0/24 is assigned as “TEST-NET-1” for use in documentation and example code.
Judge – Cloudflare Community
Step 3: Wrangler Installation & Project Setup
Workers are managed through the wrangler CLI tool. As per the docs, installation and setup are done through npm:
1 2 3 4 5 | # Install wrangler npm install -g @cloudflare/wrangler # Set up an empty project wrangler generate my-contact-form cd my-contact-form |
There’s only one other bit of setup which is to add our account_id as well as production environment settings for zone_id and route to the wrangler.toml file. Follow the quick start instructions here to learn how to retrieve those values.
Your file should end up looking something like the following:
1 2 3 4 5 6 7 8 9 10 | name = "my-contact-form" type = "javascript" account_id = "your_account_id"workers_dev = true [env.production] # The ID of your domain you're deploying to zone_id = "your_zone_id"# The route pattern your Workers application will be served at route = "contact.yoursite.com/*" |
Since we’re using reCAPTCHA and MailGun in this project you’ll want to define the API keys somewhere. They should never be stored directly in the code so instead we’ll be defining environment variables for them:
1 2 3 4 5 | wrangler secret put MAILGUN_API_KEY --env production # follow the instructions provided wrangler secret put RECAPTCHA_SECRET_KEY --env production # follow the instructions provided |
Environment variables are stored encrypted on the Cloudflare servers rather than in your project. This allows you to commit project to revision control without any sensitive information being leaked.
With all the boring stuff out of the way, it’s finally time to get to the good part!
Step 4: The Worker Code
Open index.js and add the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | /* * Example contact form submission to MailGun with Cloudflare Workers. * Taken mostly from https://maxkostinevich.com/blog/serverless-contact-form/ * ReCAPTCHA example https://gist.github.com/bcnzer/5911b9375df24489ad327fecf6e4878c */ const config = { from: "no-reply <no-reply@yoursite.com>", to: "your@email.com", mailgun_domain: "yoursite.com",} const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "*" } addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)) }) /** * Respond with hello worker text * * @param {Request} request */ async function handleRequest(request) { // CORS pre-flight request if (request.method === 'OPTIONS') { return await handleCORS(request) } // The contact form POST request else if (request.method === 'POST') { return await handlePOST(request) } return JSONResponse("Expected POST", 500) } /** * Respond to the pre-flight CORS request * * @param {Request} request */ async function handleCORS(request) { if ( request.headers.get("Origin") !== null && request.headers.get("Access-Control-Request-Method") !== null && request.headers.get("Access-Control-Request-Headers") !== null ) { // Handle CORS pre-flight request. return new Response(null, { headers: corsHeaders }) } else { // Handle standard OPTIONS request. return new Response(null, { headers: { "Allow": "GET, HEAD, POST, OPTIONS" } }) } } /** * Validate the request, send the mailout, return the response * * @param {Request} request */ async function handlePOST(request) { console.log("headers", new Map(request.headers)) // Grab the form contents const form = await request.json() // Validate const validationError = await validateForm(form, request.headers.get("CF-Connecting-IP")) if (validationError) { return validationError } // Send to Mailgun return sendMessage(form) } /** * Validates the submitted form * @param {JSON} form * @param {string} ip * @returns {Response} on error else false */ async function validateForm(form, ip) { const required_fields = ['name', 'email', 'subject', 'message'] const email_field = 'email' // Check for required fields for (let i=0; i<required_fields.length; i++) { let field = required_fields[i] if (!form[field]) { return JSONResponse(`${field} is required`, 400) } } // Check for valid email field const email_regex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ if (!email_regex.test(form[email_field])) { return JSONResponse("Please enter a valid email address", 400) } let recaptchaError = await sendRecaptcha(form['g-recaptcha-response'], ip) if (recaptchaError) { return recaptchaError } return false } /** * Validates reCAPTCHA submission * @param {string} token * @param {string} ip * @returns {Response} on error else false */ async function sendRecaptcha(token, ip) { try { let response = await fetch(`https://www.google.com/recaptcha/api/siteverify?secret=${config.recaptcha_secret}&response=${token}&remoteip=${ip}`, { method: "POST", }) let json = await response.json() if (json.success) { return false } console.log("Recaptcha error", json) return JSONResponse("reCAPTCHA failed", 400) } catch (err) { console.log("Fetch error", err) return JSONResponse("Oops! Something went wrong.", 400) } } /** * Send the message to MailGun * * @param {JSON} form */ async function sendMessage(form) { const template = ` <html> <head> <title>New message from ${form.name}</title> </head> <body> New message has been sent via website.<br><br> <b>Name:</b> ${form.name} <br> <b>Email:</b> ${form.email} <br> <br> <b>Message:</b><br> ${form.message.replace(/(?:\r\n|\r|\n)/g, "<br>")} </body> </html> ` const data = { from: config.from, to: config.to, subject: `New message from ${form.name}`, html: template, "h:Reply-To": form.email // reply to user } try { await fetch(`https://api.mailgun.net/v3/${config.mailgun_domain}/messages`, { method: "POST", headers: { "Authorization": "Basic " + btoa("api:" + config.mailgun_key), "Content-Type": "application/x-www-form-urlencoded", "Content-Length": data.length }, body: urlfy(data) }) return JSONResponse("Message has been sent") } catch (err) { console.log("Fetch error", err) return JSONResponse("Oops! Something went wrong.", 400) } } /** * Helper function to return JSON response * * @param {string} message Response message * @param {integer} status HTTP response status */ const JSONResponse = (message, status = 200) => { let headers = { headers: { "Content-Type": "application/json;charset=UTF-8", ...corsHeaders }, status: status } return new Response( JSON.stringify({message: message}), headers ) } /** * Utility function to convert object to url string * * @param {Object} obj */ const urlfy = obj => Object.keys(obj) .map(k => encodeURIComponent(k) + "=" + encodeURIComponent(obj[k])) .join("&") |
This is all decently enough documented so I won’t go through it in depth. The main things here to note are that everything starts with
1 2 3 | addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)) }) |
and the basic flow is
- Check for OPTIONS or POST headers
- if OPTIONS, send CORS pre-flight response
- if POST
- do required/email field validation
- if OK, submit to reCAPTCHA
- if OK, submit to MailGun
Step 5: Testing and Publishing Cloudflare Workers
Wrangler actually has super cool testing functionality. In your project run
1 | wrangler preview --watch |
A browser window will open with terminal at the bottom and a few tabs at the top. If you’ve followed with me until now you should see
1 | {"message":"Expected POST"} |
Remember our form only accepts POST requests, so hit the Testing tab, change GET to POST in the dropdown provided and in the Body field add:
1 2 3 4 5 6 7 | { "name":"your name", "subject":"your subject", "email":"your@email.com", "message":"your message", "g-recaptcha-response":"some_string" } |
Hit Run Test and you should see
1 2 3 4 5 6 7 8 | status: 400 Bad Request access-control-allow-headers: * access-control-allow-methods: GET, HEAD, POST, OPTIONS access-control-allow-origin: * content-length: 30 content-type: application/json;charset=UTF-8 {"message":"reCAPTCHA failed"} |
Try removing subject from the body object and run the test. The error will update to {“message”:”subject is required”}. Great.
Since we don’t have a valid reCAPTCHA value, for now just comment out return recaptchaError on line 116, save and Run Test again. If you receive an email we’re good to publish!
1 | wrangler publish --env production |
Believe it or not that’s all there is to it. You should now be able to go to the contact form we created all the way back in Step 1, submit it and receive your email. Congratulations, you have your very first Cloudflare Worker contact form!
Issues and Pitfalls. What Went Wrong?
Throughout this process I had many issues – some solveable and some not. I’ll attempt to go through them in this section. If you have any information to add please leave a comment below and I’ll update my post accordingly.
Sometimes the preview client doesn’t update properly
This one had me stumped for a little bit. I’d update my wrangler.toml file and hit save, the watcher would indicate the changes have been applied, however back in the browser the changes weren’t reflected until I killed the watcher and re-ran it. Not a huge deal as I didn’t update this file too often.
[vars] works in preview but not in production
When I added a [vars] section to my wrangler.toml and ran the preview watcher, any variables declared in this section would work fine in index.html however in the published worker they were all undefined. I think this may be because I was previewing without an environment defined but publishing with environment production. I don’t know if there’s a way to specify environment-specific [vars]. As a result I ended up declaring these variables directly in index.html at the top instead which IMO is quite awful.
The cross-origin POST request is extremely finicky
During development I was plagued with CORS errors. Pre-flight was passing fine, as were GET requests however POST was always returning an error. Turns out this was because of the very specific way data must be passed in jQuery.ajax(). data MUST be in the form JSON.stringify({field1: some_val, field2: some_val, …})
Any error in your Worker script results in a CORS error
Not only was the way I was passing info to the Worker causing these errors, but if your Worker itself breaks during operation, you’ll get the exact same CORS error when submitting to it. This happened a lot to me while attempting to use vars that were working in preview but undefined in production.