Free Contact Form with Cloudflare Pages & MailChannels

11 min read • April 5, 2024 (Edited: Jun 1, 2024)

Update (01.06.2024):
Unfortunately, MailChannels recently announced that their API is shutting down on the 30th of June 2024 (thank you, @Ben for mentioning it). I will update the article soon with an alternative API. Stay tuned!

Are you tired of expensive subscriptions for your website’s contact form? Do you host your website on Cloudflare Pages?

Look no further! In this article, I’ll show you how to create a completely free, customizable contact form with Cloudflare Workers and MailChannels API integration.

It’s the same way I built my own contact form here.

Advantages of a custom contact form

  • It’s completely free and lightweight.
  • Unlimited monthly submissions.
  • You can customize the form’s backend logic with JS/TS to reject submissions based on some conditions or integrate with APIs, third-party tools, NPM packages, etc.
  • You can easily add Cloudflare Turnstile (CAPTCHA) to prevent spam.
  • Fun project to build. 😁

1. DNS records

Let’s start with adding DNS records to your website to verify that you have the right to send emails from your domain. It’s a security measure to prevent others from sending emails from your domain without your permission.

This will ensure that all emails sent using MailChannels API will be sent from your website’s form.

We have to add four records.

a. Domain Lockdown™

(based on/more info: Domain Lockdown™ – Help Center (mailchannels.com))

This record will verify that you have the right to send emails from your domain. To do this, add this DNS record:

TypeNameContent
TXT_mailchannelsv=mc1 cfid=<your Cloudflare Pages website’s URL, example: something.pages.dev> cfid=<your linked custom domain (if you have one), example: rivenintech.com>
Not sure how to do it?
Check out this short video:

b. SPF

(based on/more info: Set up SPF Records – Help Center (mailchannels.com))

Create a new TXT record (just like you did with Domain Lockdown):

TypeNameContent
TXT@v=spf1 include:relay.mailchannels.net -all
Note
If you already have an existing SPF record, just add “include:relay.mailchannels.net”. Make sure to add it BEFORE the “-all” as “-all” always matches and typically goes at the end of the SPF record.

c. DKIM

(based on/more info: Adding a DKIM Signature – Help Center (mailchannels.com))

DKIM is an email authentication method that allows you to sign email messages from your domain with a digital signature to prevent others from spoofing your domain.

First, we have to create a DKIM private and public key. You can find DKIM key generators online, but it’s advised to generate them locally using OpenSSL.

Operating System Differences
If you’re on Windows OpenSSL isn’t pre-installed so, you’ll have to install Git from here (OpenSSL comes with it, and it’s the easiest way to get it). Then, open Git Bash.

On Linux and macOS, just use your terminal, as OpenSSL is already pre-installed.
Video Tutorial
Adding DKIM is a bit more complicated, so I would recommend following the video and copying the commands from article (below this video).

1. Creating a DKIM private and public key (run these two commands)

  • Private key as PEM file and base64-encoded text file:

    Terminal window
    openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A > priv_key.txt
  • Public key as DNS record:

    Terminal window
    echo -n "v=DKIM1;p=" > pub_key_record.txt && openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> pub_key_record.txt
  • After you’re done, you should have three files:

    • priv_key.pem (private key in PEM format)
    • priv_key.txt (private key in base64-encoded text format)
    • pub_key_record.txt (public key in DNS record format)

2. Adding required DNS records (DKIM public key & DMARC)

  1. Create two DNS records:

    TypeNameContent
    TXTmailchannels._domainkeycontent from pub_key_record.txt
    TXT_dmarcv=DMARC1; p=reject; adkim=s; aspf=s; pct=100; fo=1;
    Remember
    Add _dmarc record only if you don’t have one yet. You might have one if you have an email with your domain (e.g. [email protected]).

    And if you would like to have a custom email like that with your domain for FREE, check out my another step-by-step tutorial. 😀
  2. Create environment variables for your Cloudflare Pages project:

    Variable nameValue
    DKIM_PRIVATE_KEYThe content from priv_key.txt
    DKIM_SELECTORmailchannels
    DOMAIN<your domain, example: rivenintech.com>

2. Create an HTML contact form

The next step is to create a contact form in HTML. Let’s go with a simple one, you can always customize it later (just make sure input and textarea have the name attributes).

index.html
<form id="contact-form">
  <input type="text" name="name" placeholder="Name" required />
  <input type="email" name="email" placeholder="Email" required />
  <textarea name="message" placeholder="Message" required></textarea>
<input type="submit" value="Send" />
</form>

Now, let’s add JavaScript code that will handle the form submission by sending it with a fetch request to our Cloudflare Worker.

index.html
<form id="contact-form">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<input type="submit" value="Send" />
</form>
<script>
// Get the form element with the id "contact-form"
const form = document.getElementById("contact-form");
// Add an event listener to the form that triggers the submitForm function when the form is submitted
form.addEventListener("submit", submitForm);
// Function to handle form submission
function submitForm(event) {
event.preventDefault();
// Create a FormData object from the form
const formData = new FormData(event.target);
// Send the form to the Cloudflare Worker that will listen to requests on the "/submit-contact-form" path
fetch("/submit-contact-form", {
method: "POST",
body: formData,
})
.then((response) => {
// Parse the response text and include the response status
return response.text().then((text) => ({
text,
ok: response.ok,
}));
})
.then(({ text, ok }) => {
// If the request was successful, disable the form
if (ok) {
// You can modify/add something else here
form.removeEventListener("submit", submitForm);
form.reset();
document.querySelectorAll("input, textarea").forEach((input) => (input.disabled = true));
} else {
// If the request was not successful, throw an error
throw new Error();
}
})
// Catch any errors that occur during the fetch request
.catch(() => {
console.log("Error")
// You can add something that should happen when the form failed
});
}
</script>

3. Cloudflare Worker - logic/backend of our form

Now that we have our HTML contact form, we need to create a Cloudflare Worker to handle the form submission and send the form data to our inbox.

It’s really simple to create a backend “function” (that will be executed on a Cloudflare Worker) for a site hosted with Cloudflare Pages.

  1. Create /functions directory at the root of your Cloudflare Pages website.

  2. Now create a new JS file inside it - submit-contact-form.js. Cloudflare will automatically place your function in the root of your website, with a path based on your filename.
    Example: submit-contact-form.js = /submit-contact-form.

  3. Now just paste this code into the file you created, read the comments, and change email addresses:

    functions/submit-contact-form.js
    // Listen for requests to the path.
    export async function onRequest(context) {
    // Check if the request is a POST request.
    if (context.request.method !== "POST") {
    return new Response("Invalid request method.", { status: 405 });
    }
    // Get the form data.
    const formData = await context.request.formData();
    const body = Object.fromEntries(formData.entries());
    // Send form data to my inbox.
    const sent = await sendFormToMe(body, context.env.DKIM_PRIVATE_KEY);
    if (!sent) {
    return new Response("Oops! Something went wrong. Please try submitting the form again.", { status: 500 });
    }
    return new Response("Form submitted successfully!", { status: 200 });
    }
    // Send email to my inbox (email with form data)
    async function sendFormToMe(body, ENV) {
    const send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
    method: "POST",
    headers: {
    "content-type": "application/json",
    },
    body: JSON.stringify({
    personalizations: [
    {
    to: [{ email: "[email protected]" }], // CHANGE THIS to your email address (emails will be sent TO this address).
    dkim_domain: ENV.DOMAIN,
    dkim_selector: ENV.DKIM_SELECTOR,
    dkim_private_key: ENV.DKIM_PRIVATE_KEY,
    },
    ],
    from: {
    name: "Contact Form",
    email: "[email protected]", // CHANGE THIS to your email address (emails will be sent FROM this address).
    },
    subject: "New contact form submission",
    content: [
    {
    type: "text/plain",
    value: `Name: ${body.name}\nEmail: ${body.email}\nMessage: ${body.message}`,
    },
    ],
    }),
    });
    // Get the response from the email API
    const send_response = await fetch(send_request);
    // Check if the email was sent successfully.
    if (!send_response.ok) {
    return false;
    }
    return true;
    }

That’s it! Now once we submit the form we should receive the submitted data in our email inbox.

4. Adding a captcha to form to avoid spam (CF Turnstile)

Spam is a common issue with online forms, but we can use a captcha to prevent automated spam submissions. Cloudflare offers a service called Turnstile that fits our needs. It’s free and fully automatic, so you won’t frustrate your users by making them select squares with traffic lights 🚦.

  1. Go to Cloudflare Dashboard > Turnstile and click Add site. Follow the instructions, then copy the Site Key and Secret Key.

  2. Add this div inside your HTML form with your site key and this script:

    index.html
    <form id="contact-form">
      <input type="text" name="name" placeholder="Name" required />
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required></textarea>
    <!-- You can change between dark/light widget -->
    <div class="cf-turnstile" data-sitekey="<YOUR_SITE_KEY>" data-theme="dark"></div>
    <input type="submit" value="Send" />
    </form>
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
    <script>
    // Get the form element with the id "contact-form"
    const form = document.getElementById("contact-form");
    // Add an event listener to the form that triggers the submitForm function when the form is submitted
    form.addEventListener("submit", submitForm);
    // Function to handle form submission
    function submitForm(event) {
    event.preventDefault();
    // Create a FormData object from the form
    const formData = new FormData(event.target);
    // Send the form to the Cloudflare Worker that will listen to requests on the "/submit-contact-form" path
    fetch("/submit-contact-form", {
    method: "POST",
    body: formData,
    })
    .then((response) => {
    // Parse the response text and include the response status
    return response.text().then((text) => ({
    text,
    ok: response.ok,
    }));
    })
    .then(({ text, ok }) => {
    // If the request was successful, disable the form
    if (ok) {
    // You can modify/add something else here
    form.removeEventListener("submit", submitForm);
    form.reset();
    document.querySelectorAll("input, textarea").forEach((input) => (input.disabled = true));
    } else {
    // If the request was not successful, throw an error
    throw new Error();
    }
    })
    // Catch any errors that occur during the fetch request
    .catch(() => {
    console.log("Error")
    // You can add something that should happen when the form failed
    });
    }
    </script>

    More info in Cloudflare Docs

  3. Now we have to validate the captcha on the backend (server-side validation).

    functions/submit-contact-form.js
    // Listen for requests to the path.
    export async function onRequest(context) {
    // Check if the request is a POST request.
    if (context.request.method !== "POST") {
    return new Response("Invalid request method.", { status: 405 });
    }
    // Get the form data.
    const formData = await context.request.formData();
    const body = Object.fromEntries(formData.entries());
    // Validate Turnstile captcha token.
    const captchaResult = await captchaCheck(context.request.headers, body, context.env.TURNSTILE_SECRET_KEY);
    if (!captchaResult) {
    return new Response("Ivalid captcha. Refresh page and try submitting it again.", { status: 422 });
    }
    // Send form data to my inbox.
    const sent = await sendFormToMe(body, context.env.DKIM_PRIVATE_KEY);
    if (!sent) {
    return new Response("Oops! Something went wrong. Please try submitting the form again.", { status: 500 });
    }
    return new Response("Form submitted successfully!", { status: 200 });
    }
    // Send email to my inbox (email with form data)
    async function sendFormToMe(body, ENV) {
    const send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
    method: "POST",
    headers: {
    "content-type": "application/json",
    },
    body: JSON.stringify({
    personalizations: [
    {
    to: [{ email: "[email protected]" }], // CHANGE THIS to your email address (emails will be sent TO this address).
    dkim_domain: ENV.DOMAIN,
    dkim_selector: ENV.DKIM_SELECTOR,
    dkim_private_key: ENV.DKIM_PRIVATE_KEY,
    },
    ],
    from: {
    name: "Contact Form",
    email: "[email protected]", // CHANGE THIS to your email address (emails will be sent FROM this address).
    },
    subject: "New contact form submission",
    content: [
    {
    type: "text/plain",
    value: `Name: ${body.name}\nEmail: ${body.email}\nMessage: ${body.message}`,
    },
    ],
    }),
    });
    // Get the response from the email API
    const send_response = await fetch(send_request);
    // Check if the email was sent successfully.
    if (!send_response.ok) {
    return false;
    }
    return true;
    }
    async function captchaCheck(headers, body, TURNSTILE_SECRET_KEY) {
    // Turnstile injects a token in "cf-turnstile-response".
    const token = body["cf-turnstile-response"];
    const ip = headers.get("CF-Connecting-IP");
    // Validate the token by calling the "/siteverify" API.
    let formData = new FormData();
    formData.append("secret", TURNSTILE_SECRET_KEY);
    formData.append("response", token);
    formData.append("remoteip", ip);
    const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    body: formData,
    method: "POST",
    });
    const outcome = await result.json();
    return outcome.success;
    }

    More info in Cloudflare Docs

Summary

That’s it! Hopefully, you should have a working, fully customizable, free contact form. Feel free to ask in the comments below if you have any questions.

I didn’t cover sending a confirmation email to the user who submitted the form (see demo) yet! I’ll update this article with a template pack containing different contact form designs, confirmation email templates, and all the necessary code to make it work. Stay tuned!

If you found this article useful, I would appreciate a comment below (no account needed) or follow/share on Twitter. You can also support my coffee addiction on ko-fi.com/rivenintech to boost my energy for more articles!

Thank you for reading! 😊


SHARE: