Skip to content
Skip to the article
In Email: 6 articles
Email

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.

Updated
Applies to
  • Odoo 18.0
  • mail_cloudflare 18.0.1.2.0
Tags
  • email
  • cloudflare
  • odoo
  • fetchmail
Reading time
12 min

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 mail module. The module's manifest version is 18.0.1.2.0 and it depends only on mail.

  • 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 the web.base.url system parameter.

  • Shell access to put the module in your Odoo addons path.

Prepare Cloudflare

Email Sending

  1. 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.

  2. Create an API token with the Email Sending: Edit permission.

  3. Note your Cloudflare account ID. In the dashboard, open any domain's Overview page and find it under API, or press Ctrl+K (Cmd+K on 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.

FieldValue
Authenticate withCloudflare Email Sending
Cloudflare Account ID<CLOUDFLARE_ACCOUNT_ID>
Cloudflare API Token<CLOUDFLARE_API_TOKEN>
FROM Filteringexample.com, the domain you onboarded
Convert attachments to links for emails over5 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, To and Cc from the headers, and Bcc from 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 with X-, 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

  1. Go to Settings > Technical > Email > Incoming Mail Servers and create a new record.

  2. Set the type to Cloudflare Email Worker. Optionally set Create a New Record to a fallback model, which receives mail that no alias matches.

  3. 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.

  4. Click Test & Confirm.

  5. Give the Webhook URL and Webhook Secret to your relay Worker as this tenant's inboundUrl and secret.

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.

HeaderMeaning
X-Email-Relay-IdThe relay's queue ID, logged and echoed back.
X-Email-Relay-TenantThe tenant the relay routed by, logged.
X-Email-Relay-TimestampUnix seconds. Rejected when more than 300 seconds from Odoo's clock.
X-Email-Relay-Signaturev1= followed by the hex HMAC-SHA256 of <timestamp>. plus the body, keyed with the secret.
X-Email-Relay-Envelope-FromThe SMTP envelope sender. Written into the message as Return-Path when absent.
X-Email-Relay-Envelope-ToThe 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.

ResponseMeaningRelay action
200Processed. remote_ref is the record ID, or null for a duplicate, bounce or loop that was ignored on purpose.Delivered
401Bad or missing signature, or stale timestamp.Parked as rejected
404Unknown key, or the server is not confirmed or is archived.Parked as rejected
422No route: no alias matched and there is no fallback model.Parked as rejected
500Anything 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

  1. Click Test Connection on the outgoing server and confirm it succeeds.

  2. Send a real message from Odoo, for example a chatter message to a partner whose address is on your side, and check it arrives.

  3. 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-init

Troubleshooting

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 oldest References entries first.

  • Refused From address. A mail forced onto the Cloudflare server whose From is not on the onboarded domain is sent unchanged and refused. Odoo only rewrites From when 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.

SymptomLikely cause
401The relay holds an old secret (after Regenerate Secret), the body was altered in transit, or the clocks differ by more than 300 seconds.
404The key in the URL is wrong, the server is archived, or you did not click Test & Confirm.
422No alias matches the routed address and the server has no fallback model. Set one or create the alias.
500An unexpected error in Odoo. The relay retries with back-off. Read the traceback in the Odoo log.
Confirmation refuses to proceedweb.base.url is not https://. Fix it and confirm again.
Nothing arrives and no log linesThe 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 inside References.

  • Asynchronous bounces never reach bounce@.

  • The 5 MiB message limit is Cloudflare's.

  • A stock SMTP server pointed at smtp.mx.cloudflare.net on port 465 (SSL/TLS, user api_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 whether Message-ID survives it.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.