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

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.

Updated
Applies to
  • Odoo 18
  • Frappe 16
  • cloudflare-email-relay 2.0.0
  • WordPress plugin 0.1.4
Tags
  • email
  • cloudflare
  • architecture
  • deliverability
Reading time
11 min

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 operator

The 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 own mail.mail queue, failure handling and From rewriting 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-email plugin, see Cloudflare Email for WordPress): the plugin hooks wp_mail() and sends through the same API, with credentials set as constants in wp-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:

  1. Streams the raw bytes into an R2 object at inbox/<tenant>/<id>.eml, with Delivered-To and Return-Path prepended from the SMTP envelope, the way a delivering mail server would write them.

  2. 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_error roughly 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 responseRelay doesTypical cause
2xxMarks the row delivered and purges it after the retention periodNormal case
408, 429, any 5xx, a timeout or a network errorKeeps it pending and retriesERP restarting, overloaded or unreachable
Any other 4xx, or a 3xxMarks it rejected and parks it for an operatorWrong 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-Path on 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 rewrites Message-ID, a client that drops References can break threading.

  • Inbound size. Frappe refuses a webhook body larger than the site's max_file_size config value (25 MiB when it is not set) with a 413, which the relay parks as rejected.

  • Messages without a Message-ID cannot 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: