Cloudflare Email Routing Through Mailgun Instead of Forwarding

After noticing some deliverability issues with several Cloudflare Email Routing forwarding rules that were working previously, I migrated to an Email Worker that sends these through the Mailgun API for more reliability and logging.

Deliverability Issues

I recently noticed that I was expecting to receive a couple emails to an address that was using Cloudflare Email Routing to forward these to my personal email address, but that weren't coming through. Unable to see any logs or determine other ways of troubleshooting, I wondered if there was a more reliable way to ensure these made it through.

I discovered that in addition to sending emails to a verified email address, I'm also able to send these to an Email Worker to implement some custom logic. After some testing and troubleshooting, I was able to grab the contents of the original email and still send to the intended recipient but using the Mailgun API instead. This seems to be more reliable since forwarding can introduce authentication failures with SPF/DMARC/ARC, but also includes support for logging issues through your Mailgun account.

The Setup

It seems that a Worker created through the account-level Compute (Workers) menu doesn't actually register as an Email Worker, so make sure you enable and create this from your zone's Email > Email Routing > Email Workers interface.

I connected mine to a Git repo to track version history, add a `wrangler.jsonc` file to disable the workers.dev and preview URLs and enable observability, etc.

{
    "$schema": "node_modules/wrangler/config-schema.json",
    "name": "mailgun",
    "main": "src/index.ts",
    "compatibility_date": "2025-10-11",
    "workers_dev": false,
    "preview_urls": false,
    "observability": {
        "enabled": true
    }
}

The Worker itself is very similar the one I setup to send email from a Cloudflare Pages function, but did need some modification to get working in the Email Worker.

Since this is grabbing the contents of the original email and creating a new message instead of just forwarding, it won't show the original sender or recipient. So we grab those from the forwarded message and add a note to the beginning of the new message so we can keep track of what's what.

Make sure to replace:

  1. `RECIPIENT@DOMAIN.COM` with your intended recipient email address
  2. `MG.SENDINGDOMAIN.COM` with your Mailgun sending domain, and
  3. `APIUSER@MG.SENDINGDOMAIN.COM` with a valid API username
import axios from 'axios';
import PostalMime from 'postal-mime';

export default {
    async email(message: ForwardableEmailMessage, env: unknown, ctx: ExecutionContext) {
        const email = await PostalMime.parse(message.raw);

        // Determine recipient
        let to = 'RECIPIENT@DOMAIN.COM';

        // Include original sender and recipient
        let originalNote = 'Message forwarded from: ' + (email.from?.address || 'unknown') + '; intended for: ' + (email.to?.map(addr => addr.address).join(', ') || 'unknown');

        let config = {
            method: 'post',
            url: 'https://api.mailgun.net/v3/MG.SENDINGDOMAIN.COM/messages',
            headers: {
                'Authorization': 'Basic ' + btoa('api:' + (env as any).MAILGUN_API_KEY),
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            data: new URLSearchParams({
                from: 'APIUSER@MG.SENDINGDOMAIN.COM',
                to: to,
                subject: email.subject || 'No Subject',
                text: originalNote + '\n\n' + (email.text || ''),
                html: originalNote + '<br /><br />' + (email.html || '')
            })
        };

        try {
            const response = await axios.request(config);
            return new Response('Email sent successfully', { status: 200 });
        } catch (error) {
            console.error('Mailgun API error:', error);
            return new Response('Failed to send email: ' + String(error), { status: 500 });
        }
    },
};

A couple notes

  1. This setup doesn't account for email attachments, so if the original message has any those are essentially dropped.
  2. I would like to have a single Email Worker that's used both for Email Routing as well as other Workers and Pages projects, so instead of each one managing its own email logic they all use the same one to send everything. I haven't started to dig into this yet, but I imagine this would need some updates to account for both scenarios. I will update this post when that happens.

You May Also Like

Send Email Through Mailgun From Cloudflare Pages Function
Mailgun Cloudflare Pages

Send Email Through Mailgun From Cloudflare Pages Function

After a lot of troubleshooting various packages and attempting to follow multiple pieces of official documentation that would not work, I ended up at a simple way to send email through the Mailgun HTTP API from a Cloudflare Pages function.

Read Article
Blocking Access to Deployment Previews and app pages dev Domains in Cloudflare Pages
Cloudflare Security Pages

Blocking Access to Deployment Previews and app.pages.dev Domains in Cloudflare Pages

After creating my first Cloudflare Pages application, I wanted to ensure that access to the app.pages.dev URL and all deployment preview URLs was restricted. Unfortunately, these options don't currently exist on the Pages applications themselves so a few workarounds are necessary.

Read Article
Configuring a Cloudflare R2 Bucket and Worker For Public Access
Cloudflare R2 Workers

Configuring a Cloudflare R2 Bucket and Worker for Public Access

I recently published a plugin to integrate Cloudflare R2 with Craft CMS. After having a chance to utilize this on a few more projects after its release, I wanted to put together a guide to streamline this process for future use cases. Hopefully this will help out any others out there looking to setup R2 on their Cloudflare accounts, as most of this won't be specific to Craft CMS.

Read Article
Securing a Site With a Cloudflare Client Certificate and mTLS
Cloudflare Security

Securing a Site With a Cloudflare Client Certificate and mTLS

When a website required limited access, I needed a way to lock it down to specific physical devices. I couldn't rely on IP addresses which might change regularly, and while a strong password requirement might be sufficient I wanted something a little more secure. Not to mention that it shouldn't be crawled by any search engines either. The solution was a Cloudflare client certificate and mTLS firewall rule.

Read Article