Send and receive Frappe and ERPNext email through Cloudflare
Install cloudflare_email_delivery to send Frappe email through the Cloudflare Email Sending API and receive it through a signed webhook, with no SMTP or IMAP.
On this page
This document shows how to run Frappe or ERPNext email entirely through Cloudflare with the cloudflare_email_delivery app. Use it when you want to retire SMTP and IMAP credentials for a domain and let Cloudflare carry both directions. For the design behind it, read Email Without SMTP for Business Systems first.
How it works
The app replaces two things Frappe normally does with mailbox servers.
Outbound. Frappe's
override_email_sendhook hands every Email Queue entry to the app. For an account on a Cloudflare domain, the app makes one call to the Cloudflare Email Sending REST API per recipient. Accounts on other domains keep using SMTP or Frappe Mail as before.Inbound. Cloudflare Email Routing delivers mail to a relay Worker (
cloudflare-email-relay). The Worker posts each message to your site as a signed HTTPS request. The app verifies the signature and gives the raw message to Frappe's ownInboundMail, the same class IMAP polling uses. Append To, auto-replies, attachments, notifications and reply threading behave as they do for a normal mailbox.
The relay Worker is a separate project. This document covers only the Frappe side. To stand up the relay and add your site as a tenant, follow Deploying cloudflare-email-relay.
Requirements
Frappe 16. The app declares
frappe >=16.0.0,<17.0.0and Python 3.12 or newer.A Cloudflare account on the Workers Paid plan, with your domain onboarded for Email Sending and Email Routing enabled on the zone.
A Cloudflare API token with the Email Sending: Edit permission.
For inbound mail: a site reachable over https, with
host_nameset in its site config, and a tenant entry for the site on your relay.
Install the app
From the bench directory (see Bench Operations for day-to-day bench commands), fetch the app and install it on your site:
bench get-app https://github.com/Avunu/cloudflare_email_delivery
bench --site <SITE_NAME> install-app cloudflare_email_deliveryThe app adds custom fields to Email Domain, Email Account and Communication, and overrides the Email Domain and Email Account classes. The fields ship as customizations that sync on migrate, so if they do not appear after install, run bench --site <SITE_NAME> migrate.
Configure outbound
Create the Email Domain
Open Email Domain and create or edit the domain you send from.
Tick Send via Cloudflare. The SMTP and IMAP server fields stop being required.
Fill Cloudflare Account ID and Cloudflare API Token. Both are mandatory once the box is ticked.
Save. The app checks the token against Cloudflare and refuses to save if Cloudflare rejects it or reports it as not active.
Note
The token check calls Cloudflare's token verification endpoint. It confirms the token is active; the Email Sending permission and the domain onboarding are only exercised when a message is actually sent.
Create the Email Account
Open Email Account and create the account.
Set the email address and link the Email Domain from the previous step.
Tick Enable Outgoing. You do not need a password or any SMTP settings; the app blanks the server fields and skips connection tests.
Save.
Send yourself a test message from any document, then open Email Queue and check that the entry reaches Sent. If it fails, the error is recorded on the queue entry.
What gets sent
Each recipient of a queue entry becomes one API call. The request carries the From address, the recipient, Reply-To, the text and HTML bodies, and attachments. Attachments with a Content-ID are sent as inline images so cid: references keep working.
Cloudflare allows only some headers. The app passes through the threading headers (In-Reply-To, References), the documented List-* headers, Precedence, Auto-Submitted and anything starting with X-, and drops the rest. Cloudflare's header reference is the authority on the list.
Cloudflare also replaces the Message-ID on every message. Frappe keeps its own id in Email Queue and Communication, so the app appends that id to References. The inbound side uses it to thread replies, as described below.
Retries and failures
A network error or a 5xx response is retried up to three attempts in total, with a 1 second then 2 second back-off.
A 429 response waits for
Retry-Afterwhen Cloudflare sends it, otherwise 2 seconds then 4, capped at 10 seconds per wait.A 401 or 403 is treated as fatal. The remaining recipients in the same batch fail immediately instead of retrying with a bad token.
Any other 4xx fails at once, because retrying a malformed or oversized request cannot help.
If Cloudflare reports a recipient as permanently bounced or suppressed in its immediate response, that recipient is flagged. When no recipient could be reached the queue entry fails; when some could, the app logs a warning and counts the send as successful.
Configure inbound
Enable incoming on the Email Account
On the same Email Account:
Tick Enable Incoming.
Choose Append To (ToDo, Issue, Lead and so on) and set the usual options: auto-reply, attachment limit, notifications.
Save.
There is no IMAP server for this account, and the scheduler's mail pull does nothing for it. On save, the app generates a webhook key and a webhook secret once and never rotates them on later saves.
Set host_name first
The webhook URL is built from the site's host_name. The app requires https and refuses to save otherwise, except on a loopback address (localhost, 127.0.0.1, ::1) or when the site is in developer mode. Set it before you save the account:
bench --site <SITE_NAME> set-config host_name https://site1.example.comHand the credentials to the relay
After saving, the Cloudflare Email Receiving section shows the Webhook URL. The form has a Cloudflare Webhook group of buttons:
Copy Webhook URL, available to anyone who can open the form.
Copy Webhook Secret, for users with the System Manager role.
Regenerate Webhook Secret, for System Managers.
Give the URL and the secret to whoever manages your relay. They become the tenant's inboundUrl and secret, together with the domain or domains whose mail should reach this site.
Then, in the Cloudflare zone's Email Routing, route the addresses you want (or the catch-all) to the relay Worker.
Warning
The Webhook URL embeds the key, and the secret signs every delivery. Treat both as credentials. Do not paste them into tickets, chat or Git.
Rotate the secret
Regenerate Webhook Secret changes the secret only. The URL and key stay the same, so you only update the tenant's secret on the relay. Until you do, deliveries signed with the old secret are rejected with 401 and park as rejected on the relay, where they can be retried in bulk.
Test inbound
Send a message to an address that routes to the relay and confirm a Communication appears on the Email Account's Append To document type. If nothing arrives, check Error Log and Unhandled Email on the site, then the relay's own logs.
You can also exercise the endpoint directly, which is useful for separating site problems from relay problems. This script signs a small message the way the relay does and posts it. Run it from any machine, with the real secret and the webhook URL from the Email Account:
import hmac, hashlib, time, urllib.request, urllib.error
URL = "<WEBHOOK_URL>"
SECRET = "<WEBHOOK_SECRET>"
body = (
b"From: jane@example.org\r\n"
b"To: support@example.com\r\n"
b"Subject: Webhook test\r\n"
b"Message-ID: <webhook-test-1@example.org>\r\n"
b"\r\n"
b"Hello from a manual test.\r\n"
)
ts = str(int(time.time()))
sig = "v1=" + hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
request = urllib.request.Request(URL, data=body, method="POST", headers={
"Content-Type": "message/rfc822",
"X-Email-Relay-Timestamp": ts,
"X-Email-Relay-Signature": sig,
"X-Email-Relay-Envelope-To": "support@example.com",
"X-Email-Relay-Envelope-From": "jane@example.org",
})
try:
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
except urllib.error.HTTPError as error:
print(error.code, error.read().decode())Replace support@example.com with an address on the Email Account's domain, or the domain check returns 422. A successful call prints status 200 and a body with "ok": true and the new Communication name as remote_ref.
The webhook contract
The endpoint is POST /api/method/cloudflare_email_delivery.api.inbound?key=<KEY> with the raw message as a message/rfc822 body. It is a guest method: the signature is the only credential. The relay sends these headers:
| Header | Meaning |
|---|---|
X-Email-Relay-Id | The relay's queue id. The app uses it to drop duplicate retries. |
X-Email-Relay-Tenant | The tenant slug the relay routed by. It is logged. |
X-Email-Relay-Timestamp | Unix seconds. Requests more than 300 seconds from the server clock are rejected. |
X-Email-Relay-Signature | v1= followed by the hex HMAC-SHA256 of <timestamp>. plus the body, keyed with the secret. |
X-Email-Relay-Envelope-From, X-Email-Relay-Envelope-To | The SMTP envelope. Written into the message as Return-Path and Delivered-To when absent. |
The status code tells the relay what to do next:
| Response | Meaning | Relay behavior |
|---|---|---|
| 200 | Processed. remote_ref is null when your own outgoing mail looped back and was deliberately ignored. | Delivered |
| 401 | Missing or bad signature, or stale timestamp. | Parked as rejected |
| 404 | Unknown key, or incoming disabled on the account. | Parked as rejected |
| 422 | Recipient domain is not the account's, invalid envelope, or the reference document refused the message. | Parked as rejected |
| 500 | Anything else. The error is written to Error Log and the message is stored as an Unhandled Email. | Retried with back-off |
Replay protection is the 300 second window plus the relay id check. Frappe refuses a request body larger than the site's max_file_size config value (25 MiB when it is not set) with a 413, which the relay treats as a rejection.
How replies thread
Frappe threads replies on In-Reply-To only. Because Cloudflare rewrites the Message-ID of outgoing mail, a reply's In-Reply-To points at an id Frappe never issued. The app handles that by checking In-Reply-To first, then References from newest to oldest, for an id it recognises: one containing the site name, or one stored on a Communication or Email Queue entry. The first match becomes the reply target, so the reply lands on the right queue entry, Communication and document.
This relies on the replying mail client copying References, which mainstream clients do.
Limits and known gaps
Message size. 5 MiB per outgoing message once encoded.
Recipients. 50 per message, To, Cc and Bcc combined. Frappe already sends one message per recipient, so this rarely matters.
Custom headers. At most 20 (Cloudflare counts only allow-listed headers toward this, but the app counts every header it sends,
X-headers included), 2,048 bytes per value and 16 KB in total. The app trims the oldestReferencesentries first if it must, and fails the send only when that is not enough.Dropped headers. Headers outside Cloudflare's allow-list, such as
Disposition-Notification-To, are not sent.Asynchronous bounces never reach Frappe. The return path belongs to Cloudflare, so only recipients Cloudflare rejects in its immediate response are reported. Watch bounce activity in Cloudflare.
Bcc. Bcc recipients receive their copy, but the delivered message does not list them.
Troubleshoot
These are the Frappe-side checks. For relay-side symptoms and the ops API, see Troubleshooting Cloudflare Email.
| Symptom | Where to look |
|---|---|
| Domain will not save | The token failed verification. The error message includes Cloudflare's reason. |
| Account will not save, mentions https | Set host_name in the site config to the public https address, then save again. |
| Queue entry failed with a Cloudflare error | Read the error on the Email Queue entry. A 401 or 403 means the token or its permissions. |
| Mail sent but replies create new documents | The replying client dropped References, or the original id is not in Email Queue or Communication. |
| Inbound deliveries parked as rejected on the relay | The status code in the contract table above. 401 after a rotation means the relay still has the old secret. |
| Inbound returns 500 | Open Error Log on the site and the matching Unhandled Email entry. |
Run the tests
The app ships a test suite. From the bench directory:
bench --site <TEST_SITE> install-app cloudflare_email_delivery
bench --site <TEST_SITE> run-tests --app cloudflare_email_deliveryThe signing and API client tests are plain unittest and need no site:
python -m unittest cloudflare_email_delivery.tests.test_relayIf your development bench has a mail guard that strips email overrides, disable it for the test process, because it removes the very hooks these tests exercise.
Related documents
Email Without SMTP for Business Systems: the architecture and its limits.
Deploying cloudflare-email-relay: the inbound Worker, tenants and secrets.
Cloudflare Email for Odoo: the same suite for Odoo 18.
Troubleshooting Cloudflare Email: symptom-first checks across all three platforms.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.