Email without SMTP for business systems
How Avunu sends and receives ERP and website email through Cloudflare's API and a store-first relay, with no SMTP or IMAP credentials and no lost inbound mail.
On this page
This document explains how Avunu's Cloudflare email suite moves mail in and out of Odoo, Frappe and ERPNext, and WordPress without a single SMTP or IMAP mailbox in the loop. Read it first if you want to understand the design before you deploy it, or when you need to know why a message was delayed rather than lost.
The problem with mailbox-style email
Most business systems still talk to email the way a desktop mail client does: log in to a mailbox over SMTP to send, log in over IMAP on a timer to receive.
That model has four recurring problems:
Shared credentials. The ERP holds a mailbox username and password, often one shared by several people or systems. Rotating it means touching every place it was pasted.
Polling. Inbound mail waits for the next fetch cycle, and every system you run needs its own mailbox and its own fetch job.
Fragile delivery. If the ERP is down, a fetch fails or a mailbox misbehaves, you find out later and usually by accident.
Deliverability. Sending through a general-purpose mailbox provider means SPF, DKIM and DMARC are somebody else's configuration, and the sender identity does not always match your domain.
The suite described here replaces both directions with APIs and signed web requests.
The design at a glance
Outbound and inbound take different paths, and only the inbound path needs the relay.
Outbound: each system calls the Cloudflare Email Sending REST API directly, using an API token. No relay is involved.
Inbound: Cloudflare Email Routing hands each message to a multi-tenant Worker, the cloudflare-email-relay. The Worker stores the message in R2 first, then pushes it to the right ERP as an HMAC-signed HTTPS request. A per-tenant Durable Object retries on a schedule until the ERP accepts it or the attempts run out.
OUTBOUND
Odoo / Frappe / WordPress
|
| HTTPS POST /accounts/<ACCOUNT_ID>/email/sending/send
| (Bearer API token, one call per message or recipient)
v
Cloudflare Email Sending ---> recipient's mail server
INBOUND
sender's mail server
|
v
Cloudflare Email Routing (address or catch-all -> "Send to a Worker")
|
v
Relay Worker: email()
1. look up the recipient's domain in the tenant table
2. write the raw message to R2 (inbox/<tenant>/<id>.eml)
3. add a row to that tenant's queue
|
v
Durable Object "InboxQueue" (one per tenant, SQLite + alarm)
|
| HTTPS POST, message/rfc822 body,
| X-Email-Relay-Signature: v1=HMAC-SHA256(secret, "<timestamp>." + body)
v
Odoo or Frappe site (the tenant)
|
+-- 2xx -> delivered, purged after the retention period
+-- 408/429/5xx -> pending, retried on a schedule
+-- other 4xx/3xx -> rejected, parked for an operatorThe ordering in the inbound path is the whole point: the message is on disk in R2 before anything tries to deliver it.
Outbound: the Email Sending API
Every integration sends with a POST to /accounts/<ACCOUNT_ID>/email/sending/send on api.cloudflare.com, authenticated with an API token that has the Email Sending permission.
What each platform does with it:
Odoo 18 (
mail_cloudflare, see Cloudflare Email for Odoo): an outgoing mail server with the authentication type "Cloudflare Email Sending" holds the account ID and token instead of a host and password. Odoo's ownmail.mailqueue, failure handling andFromrewriting are unchanged. Only the connection step is replaced.Frappe 16 and ERPNext (
cloudflare_email_delivery, see Cloudflare Email for Frappe and ERPNext): an Email Domain with "Send via Cloudflare" ticked holds the account ID and token. Each Email Queue entry sent from an account on that domain becomes one API call per recipient. Accounts on other domains keep using SMTP or Frappe Mail.WordPress (
cloudflare-emailplugin, see Cloudflare Email for WordPress): the plugin hookswp_mail()and sends through the same API, with credentials set as constants inwp-config.php. It also keeps a searchable log of every send. It is send-only, so it has no inbound half.
Because Cloudflare owns the sending infrastructure, the domain's SPF, DKIM and DMARC records are managed where you onboard the domain, not inside each application. For the Odoo module, that means Cloudflare adds the bounce MX, SPF, DKIM and DMARC records when you onboard a domain.
Limits to plan around
The shared API client in the Odoo and Frappe integrations enforces Cloudflare's limits before it makes the request:
5 MiB for the whole message.
50 recipients per message.
20 custom headers (Cloudflare counts only allow-listed ones, the clients count every header they send), 2 KB per header value. Headers outside Cloudflare's allow-list are dropped.
Transient errors and rate limits are retried briefly (up to three attempts), and an invalid token fails the rest of a batch quickly. Recipients that Cloudflare rejects or suppresses synchronously come back to the application as a failure with a reason.
Note
Cloudflare assigns its own Message-ID to outbound mail. Odoo and Frappe keep their original ID and add it to References, so replies still thread, because mainstream mail clients propagate References. Avunu's integrations rely on that behavior.
Inbound: Email Routing, then a store-first relay
Inbound mail needs somewhere to go that does not depend on the ERP being up at that exact second. That is the job of the relay, which Deploying cloudflare-email-relay walks through setting up.
Routing by recipient domain
The relay is multi-tenant. A tenant is one ERP instance: an Odoo database or a Frappe site. Each tenant owns one or more recipient domains, exact (example.com) or wildcard (*.example.com, which never matches the bare domain).
When Email Routing hands the Worker a message, the Worker routes on the domain of the envelope recipient. The tenant table is validated when the Worker is built, so overlapping claims fail the deploy instead of misrouting mail at runtime.
If the domain belongs to no tenant, or to a disabled one, the Worker rejects the message at SMTP time with a fixed reason. It never guesses a tenant and never drops mail silently. A shared relay must not deliver one client's mail to another, and a bounce tells the sender what a silent drop would hide.
Store first
For an accepted message the Worker:
Streams the raw bytes into an R2 object at
inbox/<tenant>/<id>.eml, withDelivered-ToandReturn-Pathprepended from the SMTP envelope, the way a delivering mail server would write them.Only then adds a row to that tenant's queue.
If storing or queuing fails, the Worker raises the error. Cloudflare answers the sending server with a temporary failure and the sender tries again later. After the handler returns normally, the message exists on disk and will be retried until the ERP takes it or an operator decides otherwise.
One queue per tenant
Each tenant has its own Durable Object (InboxQueue) backed by SQLite. An alarm in that object delivers due messages oldest first, ten per pass, and a failed attempt moves the next attempt out along a backoff schedule.
Because the queue is per tenant, tenants stay independent:
A tenant whose ERP is down backs up only its own queue.
A tenant with a missing or malformed secret has its rows held as pending, with no attempts charged, and the relay logs a
tenant_config_errorroughly once a minute until it is fixed. Other tenants carry on. Fixing the secret needs no redeploy.
The signed push
Each attempt is an HTTPS POST of the stored message, content type message/rfc822, to the tenant's inbound URL. Redirects are never followed.
The request carries X-Email-Relay-* headers: the queue ID, tenant slug, a fresh Unix timestamp, the attempt number and the SMTP envelope. The signature header is X-Email-Relay-Signature, with the value v1= followed by the hex HMAC-SHA256 of <timestamp>. plus the body, keyed with the tenant's secret.
The receiving side checks that the signature matches and that the timestamp is within 300 seconds. It then deduplicates on the message's own Message-ID, so a retried or replayed push is harmless. The relay's own queue ID is also sent on every attempt for ERPs that want to deduplicate on it.
The inbound URLs have a per-server key in them, so treat the URL as a secret as well:
Odoo:
/mail_cloudflare/inbound/<key>Frappe:
/api/method/cloudflare_email_delivery.api.inbound?key=<key>
Both integrations feed the message into the platform's normal inbound path, the same one IMAP polling uses: message_process() in Odoo, InboundMail in Frappe. Aliases, reply threading, attachments and auto-replies behave as they always did. The standard fetch job is not needed for this mail.
Delayed, never lost
How the relay reacts to the ERP's answer decides what happens to the message:
| ERP response | Relay does | Typical cause |
|---|---|---|
| 2xx | Marks the row delivered and purges it after the retention period | Normal case |
| 408, 429, any 5xx, a timeout or a network error | Keeps it pending and retries | ERP restarting, overloaded or unreachable |
| Any other 4xx, or a 3xx | Marks it rejected and parks it for an operator | Wrong secret, unknown key, no matching route, wrong URL |
The default retry schedule is 1 minute, 5 minutes, 15 minutes, 1 hour, then every 6 hours, repeating the last value. With the default limit of 32 attempts, a message survives about a week before it is marked dead. Delivered messages stay in R2 for 30 days by default (retentionDays), and each tenant can tune the schedule, attempt limit, timeout and retention.
Rejected and dead messages are not discarded. An operator can list them, read the stored .eml and requeue them one at a time or in bulk through the relay's ops API, which is protected by a bearer token. That is how you recover after an outage or after rotating a secret.
Warning
The relay protects a message once it has been stored. It cannot help with mail that Cloudflare Email Routing never delivered to the Worker, such as a recipient domain with no route configured or an address that does not match your routing rules. Check the zone's Email Routing settings when mail never shows up in the relay at all.
What you gain
No mailbox credentials in the ERP. Outbound uses an API token. Inbound uses a per-tenant signing secret plus an unguessable URL.
No polling. Mail arrives as a push the moment the ERP can accept it.
A queue you can inspect. Every inbound message has a row with its status, attempt count, last HTTP status and the ERP's reference to the record it created.
Tenant isolation. One client's outage, bad secret or deploy cannot block another client's mail.
Deliverability managed in one place. Sender authentication records live with the domain in Cloudflare.
Limitations
These come from the integrations' own documentation:
Asynchronous bounces do not reach Odoo or Frappe.
Return-Pathon outbound mail is Cloudflare's, and bounces go to Cloudflare's own bounce subdomain. Only recipients Cloudflare rejects at send time are reported back to the application.Reply matching depends on
References. Because Cloudflare rewritesMessage-ID, a client that dropsReferencescan break threading.Inbound size. Frappe refuses a webhook body larger than the site's
max_file_sizeconfig value (25 MiB when it is not set) with a 413, which the relay parks as rejected.Messages without a
Message-IDcannot be deduplicated on retry by Odoo.Email Routing and the Worker must share a Cloudflare account. A zone's Email Routing can only hand mail to a Worker in the same account. A client who owns their own Cloudflare account therefore needs a dedicated Worker rather than a shared relay.
Plans. Cloudflare Email Sending, which the Odoo, Frappe and WordPress integrations call for outbound mail, is available on the Workers Paid plan. The relay uses SQLite-backed Durable Objects and R2, which have their own plan limits, so check them against your inbound volume.
Where to go next
If something is already misbehaving, go straight to Troubleshooting Cloudflare Email. Otherwise pick the guide that matches what you are doing:
Deploying cloudflare-email-relay: stand up the inbound Worker, its R2 bucket, tenants and secrets.
Cloudflare Email for Frappe and ERPNext: configure the Email Domain, Email Account and webhook on a Frappe 16 site.
Cloudflare Email for Odoo: configure the outgoing and incoming mail servers in Odoo 18.
Cloudflare Email for WordPress: route
wp_mail()through Cloudflare and read the send log.Troubleshooting Cloudflare Email: diagnose stuck, rejected and missing mail.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.