0

Creating a Cloudflare Worker Contact Form

Posted (Updated ) in Uncategorized

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.