Send and receive Odoo email through Cloudflare
Install the mail_cloudflare module to send Odoo mail through the Cloudflare Email Sending API and receive it by signed webhook, with no SMTP or IMAP server.
On this page
This document covers the mail_cloudflare Odoo module: how to install it, configure an outgoing mail server for Cloudflare Email Sending, create an incoming mail server that receives mail through a signed webhook, and test and troubleshoot both directions. Use it when you want Odoo to send and receive mail without running an SMTP relay or polling an IMAP mailbox. For the design behind it, read Email Without SMTP for Business Systems first.
How it works
The module plugs into two stock Odoo models and leaves the rest of the mail stack alone.
Outbound. An outgoing mail server (ir.mail_server) gets a new authentication type, Cloudflare Email Sending. Instead of a host and a password it holds a Cloudflare account ID and an API token. When Odoo sends, the message it already built is turned into one REST call per recipient. The mail queue, failure handling and the From rewrite are still stock Odoo.
Inbound. An incoming mail server (fetchmail.server) gets a new type, Cloudflare Email Worker. It has no host or login. It carries a webhook key (part of the URL) and a signing secret, both generated when you save the record. A Cloudflare Worker, the Cloudflare email relay, receives mail from Cloudflare Email Routing and POSTs the raw message to Odoo. Odoo verifies the signature and hands the message to the same message_process() path that IMAP polling uses, so aliases, reply threading and the server's fallback model behave as they always have.
Nothing is polled. The standard fetchmail cron stays off unless you also have an IMAP or POP server configured.
Before you start
You need:
Odoo 18.0 with the
mailmodule. The module's manifest version is18.0.1.2.0and it depends only onmail.A Cloudflare account with your domain on it.
Workers Paid plan on the account, which Email Sending requires.
A public
https://address for Odoo, set as theweb.base.urlsystem parameter.Shell access to put the module in your Odoo addons path.
Prepare Cloudflare
Email Sending
In the Cloudflare dashboard, go to Compute > Email Service > Email Sending and choose Onboard Domain for every domain you send from. Cloudflare adds the bounce MX, SPF, DKIM and DMARC records for you.
Create an API token with the Email Sending: Edit permission.
Note your Cloudflare account ID. In the dashboard, open any domain's Overview page and find it under API, or press
Ctrl+K(Cmd+Kon macOS), search for "Copy account ID" and select the result.
Email Routing
Enable Email Routing on the zone for free. Then route the addresses Odoo should receive, or the catch-all, to the deployed relay Worker with the Send to a Worker action. Deploying cloudflare-email-relay covers deploying the Worker and adding Odoo as a tenant. The relay repository holds the code.
Install the module
Copy the mail_cloudflare directory from the avunu-odoo-addons repository into a directory on your Odoo addons_path. Then, in Odoo with developer mode on, open Apps, choose Update Apps List, search for "Cloudflare Email Transport" and install it.
Warning
Uninstalling the module resets Cloudflare-typed outgoing servers to the default type with no host. Archive them first.
Configure the outgoing mail server
With developer mode on, go to Settings > Technical > Email > Outgoing Mail Servers and create a new record.
| Field | Value |
|---|---|
| Authenticate with | Cloudflare Email Sending |
| Cloudflare Account ID | <CLOUDFLARE_ACCOUNT_ID> |
| Cloudflare API Token | <CLOUDFLARE_API_TOKEN> |
| FROM Filtering | example.com, the domain you onboarded |
| Convert attachments to links for emails over | 5 MB, set automatically |
Choosing the Cloudflare authentication blanks the SMTP host and port and hides the Connection tab, since none of it applies.
The module enforces three things when you save: an account ID, an API token and a FROM Filtering value are all required. The filter matters because Cloudflare only sends from onboarded domains. With the filter set, Odoo rewrites a From address outside your domain to the notifications address instead of sending something Cloudflare will refuse.
Click Test Connection. For Cloudflare servers this calls the token verification endpoint and succeeds when the token is active. It does not send a message. Detect Max Limit sets the size to 5 MB, which is Cloudflare's fixed limit rather than a measured value.
Also check Settings > General > Discuss > Alias Domain. It should be the domain you routed in Cloudflare, so catchall@, bounce@ and notifications@ exist on it.
What Odoo sends
Each message becomes one JSON request to the Cloudflare send API, carrying these pieces:
From,ToandCcfrom the headers, andBccfrom the envelope.Text and HTML bodies, and
Reply-To.Attachments, with inline ones tied to their
Content-ID.Only the headers Cloudflare allows: threading headers,
List-*,Precedence,Auto-Submitted, anything starting withX-, and a few others. Unknown headers are dropped rather than sent, because one disallowed header fails the whole request.
Cloudflare replaces the Message-ID of every message. To keep reply threading working, the module appends Odoo's own message ID to References. Mail clients copy References into replies, and Odoo matches on it.
Retries are short. Network errors and 5xx responses are retried with a 1 second and then 2 second back-off, up to 3 attempts. A 429 honors Retry-After, capped at 10 seconds. A 401 or 403 is treated as fatal and the rest of the batch fails immediately. Other 4xx responses are not retried.
Configure the incoming mail server
Go to Settings > Technical > Email > Incoming Mail Servers and create a new record.
Set the type to Cloudflare Email Worker. Optionally set Create a New Record to a fallback model, which receives mail that no alias matches.
Save the record. The webhook key and secret are generated on save, and the form shows a Webhook URL, Webhook Key and Webhook Secret with copy buttons.
Click Test & Confirm.
Give the Webhook URL and Webhook Secret to your relay Worker as this tenant's
inboundUrlandsecret.
The URL is built from web.base.url. Confirmation fails unless that parameter is an https:// address. Plain http:// is accepted only for localhost, 127.0.0.1 or ::1, which exists for local wrangler dev work. Set the parameter to your public HTTPS address and freeze it with web.base.url.freeze, so Odoo does not rewrite it from the browser's address.
Rotate the secret
Use Regenerate Secret on the server form to revoke a leaked or retired secret. It changes only the secret. The key, and so the URL, stay the same, so you only update the secret in the relay. Requests signed with the old secret are rejected as soon as you regenerate.
A duplicated server gets its own key and secret rather than copying the original's.
The webhook contract
The relay sends POST /mail_cloudflare/inbound/<KEY> with a message/rfc822 body containing the raw message. These headers accompany it.
| Header | Meaning |
|---|---|
X-Email-Relay-Id | The relay's queue ID, logged and echoed back. |
X-Email-Relay-Tenant | The tenant the relay routed by, logged. |
X-Email-Relay-Timestamp | Unix seconds. Rejected when more than 300 seconds from Odoo's clock. |
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 | The SMTP envelope sender. Written into the message as Return-Path when absent. |
X-Email-Relay-Envelope-To | The routed address. Written into the message as Delivered-To when absent. |
The envelope headers matter. Odoo recognizes aliases and Bcc'd recipients from Delivered-To, and bounces from Return-Path.
Odoo answers in JSON, and the status code tells the relay what to do next.
| Response | Meaning | Relay action |
|---|---|---|
200 | Processed. remote_ref is the record ID, or null for a duplicate, bounce or loop that was ignored on purpose. | Delivered |
401 | Bad or missing signature, or stale timestamp. | Parked as rejected |
404 | Unknown key, or the server is not confirmed or is archived. | Parked as rejected |
422 | No route: no alias matched and there is no fallback model. | Parked as rejected |
500 | Anything else. | Retried with back-off |
The route needs no session. The signature is the credential, and a failed check never says which part failed. Duplicates are ignored by Message-Id, so a retried push is harmless. Messages without a Message-Id cannot be deduplicated.
Note
On a host that serves several databases, set dbfilter or db_name so Odoo can pick the database from the request. A ?db= redirect would drop the POST body.
Test it
Outbound
Click Test Connection on the outgoing server and confirm it succeeds.
Send a real message from Odoo, for example a chatter message to a partner whose address is on your side, and check it arrives.
If it does not, open the failed outgoing mail record. A mail that Cloudflare refused ends in the Exception state with the reason in its failure text, prefixed with
CloudflareEmailError.
Inbound
Send mail to an address you routed to the relay Worker and watch for a new record or a chatter reply. If you want to test Odoo in isolation, sign a request yourself. This Python sketch follows the contract above, and is not a shipped tool.
import hashlib
import hmac
import time
import urllib.error
import urllib.request
url = "https://odoo.example.com/mail_cloudflare/inbound/<WEBHOOK_KEY>"
secret = "<WEBHOOK_SECRET>"
body = b"From: jane@example.com\r\nTo: support@example.com\r\nSubject: Webhook test\r\nMessage-Id: <webhook-test-1@example.com>\r\n\r\nHello.\r\n"
timestamp = str(int(time.time()))
digest = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
request = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "message/rfc822",
"X-Email-Relay-Id": "manual-test",
"X-Email-Relay-Timestamp": timestamp,
"X-Email-Relay-Signature": f"v1={digest}",
"X-Email-Relay-Envelope-From": "jane@example.com",
"X-Email-Relay-Envelope-To": "support@example.com",
})
try:
print(urllib.request.urlopen(request).read().decode())
except urllib.error.HTTPError as error:
print(error.code, error.read().decode())A routed message answers with a 200 and an ok flag. If no alias matches support@example.com and the server has no fallback model, you get a 422, which is the correct result. Sending the same Message-Id twice returns 200 with a null remote_ref the second time.
The module's own test suite never contacts Cloudflare. It scripts the API responses and signs real HTTP requests against a running test server. To run it from an odoo-nix dev shell, turn the dev mail catcher off first. It is server-wide and would swallow every send the tests make:
ODOO_MAILCATCH_ENABLED=0 python odoo/odoo-bin -c odoo.conf -d mc_test -i mail_cloudflare --test-enable --test-tags /mail_cloudflare --stop-after-initTroubleshooting
These are the Odoo-side checks. For relay-side symptoms and the ops API, see Troubleshooting Cloudflare Email.
Test Connection fails
The message includes Cloudflare's response. A 401 or 403 means the token is wrong or lacks permission. An inactive token is reported as such. "Could not reach Cloudflare" is a network problem between Odoo and api.cloudflare.com.
Mail goes to Exception
Read the failure reason on the mail. The common ones:
No recipient could be reached. Cloudflare synchronously bounced or suppressed every recipient. If only some were refused, Odoo logs a warning and reports success.
More than 50 recipients. To, Cc and Bcc combined are capped at 50.
Message is too large. The encoded message must fit in 5 MiB, and Odoo checks before it makes a request. Attachments attached to a record are turned into links above the size limit. Attachments added in the composer are always embedded.
Header limits. Cloudflare allows 20 allow-listed custom headers, 16 KB of headers in total and 2,048 bytes per value. The module counts every header it sends against the 20,
X-headers included. Odoo trims the oldestReferencesentries first.Refused
Fromaddress. A mail forced onto the Cloudflare server whoseFromis not on the onboarded domain is sent unchanged and refused. Odoo only rewritesFromwhen it picks the server through FROM Filtering.No body. Cloudflare needs at least one non-empty text or HTML body.
Replies do not thread
Threading depends on the replying mail client keeping References, which mainstream clients do. If one strips it, the reply creates a new record instead.
Bounce messages never reach Odoo
Asynchronous bounces go to a Cloudflare-controlled address, not Odoo's bounce@ alias. Only the synchronous failures described above are visible to Odoo.
Webhook errors
Check the Odoo log first. Every rejection is logged with the relay ID from X-Email-Relay-Id.
| Symptom | Likely cause |
|---|---|
| 401 | The relay holds an old secret (after Regenerate Secret), the body was altered in transit, or the clocks differ by more than 300 seconds. |
| 404 | The key in the URL is wrong, the server is archived, or you did not click Test & Confirm. |
| 422 | No alias matches the routed address and the server has no fallback model. Set one or create the alias. |
| 500 | An unexpected error in Odoo. The relay retries with back-off. Read the traceback in the Odoo log. |
| Confirmation refuses to proceed | web.base.url is not https://. Fix it and confirm again. |
| Nothing arrives and no log lines | The request never reached Odoo. Check Email Routing, the relay's tenant secret and your reverse proxy. |
Limitations
Cloudflare rewrites
Message-ID, so Odoo's own ID survives only insideReferences.Asynchronous bounces never reach
bounce@.The 5 MiB message limit is Cloudflare's.
A stock SMTP server pointed at
smtp.mx.cloudflare.neton port 465 (SSL/TLS, userapi_token, password the token) is a sending-only alternative that needs no module. It gives no per-recipient result, and it has not been verified whetherMessage-IDsurvives it.
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 Frappe and ERPNext: the same suite for Frappe 16.
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.