Jump to
↵select↑↓navigateescclose

Cloudflare Email Delivery

On this page

Send and receive Frappe / ERPNext email through Cloudflare, with no SMTP or IMAP provider in the loop. Outbound mail goes to the Cloudflare Email Sending REST API straight from Frappe's override_email_send hook; inbound mail arrives from the Cloudflare email relay (the @avunu/cloudflare-email-relay Worker, deployed per fleet) over a signed HTTPS webhook and is fed to Frappe's own InboundMail — the same path IMAP polling uses, so Append To, auto-replies, attachments, notifications and reply threading all behave as they do for a mailbox.

What it does

Outbound. An Email Domain with Send via Cloudflare holds an account id and an API token instead of SMTP settings. Every Email Queue entry sent from an account on that domain becomes one REST call per recipient: the From, the recipient, Reply-To, text and HTML bodies, attachments (inline ones by Content-ID), and the headers Cloudflare allows (In-Reply-To, References, X-*, Precedence, …). Frappe keeps its own Message-Id — the value Email Queue and Communication store — and adds it to References, because Cloudflare assigns a Message-ID of its own. Rate limits and transient errors are retried briefly; an invalid token fails the rest of the batch fast; recipients Cloudflare bounces or suppresses synchronously fail the entry. Accounts on other domains keep using SMTP or Frappe Mail as before.

Inbound. An Email Account on a Cloudflare domain with Enable Incoming has no IMAP server; it gets a Webhook URL and a Webhook Secret the moment it is saved. The relay posts every message routed to the account's domain to that URL, signed; the app verifies the signature, checks the recipient domain is the account's own, and hands the raw message to InboundMail with the account's Append To. Replies to Cloudflare-sent mail thread correctly even though Cloudflare replaced the Message-ID: the app matches References against the ids it issued or stored.

Requirements

  • Frappe 16.

  • A Cloudflare account on the Workers Paid plan with the domain onboarded for Email Sending and Email Routing enabled on the zone.

  • An API token with Email Sending: Edit for outbound.

  • For inbound: the site reachable over https, with host_name set in its site config (the webhook URL is built from it), and a tenant entry in the relay fleet.

Setup

Sending

  1. Email Domain: tick Send via Cloudflare, fill Cloudflare Account ID and Cloudflare API Token. Saving verifies the token.

  2. Email Account: link the domain, set the address, tick Enable Outgoing. No password, no SMTP.

Receiving

  1. On the same Email Account tick Enable Incoming, choose Append To (ToDo, Issue, Lead, …) and the usual options (auto-reply, attachment limit, notifications). Save.

  2. The Cloudflare Email Receiving section now shows the Webhook URL (Copy Webhook URL) and, for System Managers, Copy Webhook Secret. Hand both to the relay fleet's onboarding as the tenant's inboundUrl and secret, together with the domain(s) whose mail should reach this site.

  3. In the zone's Email Routing, route the addresses (or the catch-all) to the relay Worker.

Regenerate Webhook Secret rotates the secret only; the URL stays, so only the tenant's relay secret needs updating afterwards (deliveries in between park as rejected on the relay and can be bulk-retried).

The webhook contract

POST /api/method/cloudflare_email_delivery.api.inbound?key=<key>, body message/rfc822, headers:

HeaderMeaning
X-Email-Relay-Idthe relay's queue id (echoed back; deduplicates retries of a message without a Message-ID)
X-Email-Relay-Tenantthe tenant slug the relay routed by (logged)
X-Email-Relay-TimestampUnix seconds; rejected outside ± 300 s
X-Email-Relay-Signaturev1= + hex HMAC-SHA256(secret, "<timestamp>." + body)
X-Email-Relay-Envelope-From / -Tothe SMTP envelope; written into the message as Return-Path / Delivered-To when absent
ResponseMeaningRelay reaction
200 {"message": {"ok": true, "remote_ref": "<Communication>" or null}}processed (null: our own mail looped back, deliberately ignored)delivered
401unknown key, incoming disabled, bad or missing signature, stale timestampparked as rejected
413larger than 26 MiB (or the site's max_file_size, when lower)parked as rejected
422recipient domain not this account's, invalid envelope, or the reference document refused the messageparked as rejected
500anything else — logged to Error Log and stored as an Unhandled Emailretried with back-off
503the relay user is missing: the site needs bench migrateretried with back-off

The endpoint is a guest method; the HMAC is the credential, checked in constant time. A 401 says nothing about which check failed. Once the signature verifies, the message is processed as cloudflare-email-relay@relay.invalid, a disabled Website User whose only role, Cloudflare Email Relay, adds one permission rule: read and write the Communications it owns. bench migrate creates the user and resets it to that shape; nothing runs as Administrator.

Limitations

  • Asynchronous bounces never reach Frappe: Return-Path is Cloudflare's and bounces go to its own cf-bounce subdomain. Only the recipients Cloudflare rejects synchronously are surfaced.

  • Cloudflare rewrites Message-ID; reply matching relies on the replying client propagating References, which every mainstream client does.

  • Limits: 5 MiB per message, 50 recipients, 20 custom headers of 2 KB each; headers outside Cloudflare's allow-list (Disposition-Notification-To, …) are dropped.

  • Bcc'd recipients receive their copy but are not listed anywhere in the delivered message (Frappe sends each recipient their own copy).

Tests

frappe-test --ci   # in the frappe-nix dev shell: what CI's ci / test runs

# on any bench
bench --site test_site install-app cloudflare_email_delivery
bench --site test_site run-tests --app cloudflare_email_delivery

tests/test_relay.py and tests/test_cloudflare_api.py are plain unittest (no site needed: python -m unittest cloudflare_email_delivery.tests.test_relay). The site-backed suites cover the webhook (in-process and through the real WSGI app), threading and the send hook.

frappe-nix's mail guard deliberately strips the very Email Account and Email Domain extensions and the override_email_send hook these tests exercise, so [tool.frappe-nix.tests] devguard-disable = ["mail"] in pyproject.toml turns it off for frappe-test's test processes only. The dev shell and devenv up keep it, so mail sent while working on the app by hand still lands in Mailpit, and the egress guard stays on everywhere: nothing, SMTP and the Cloudflare API included, leaves a dev bench. To run the site-backed tests with plain bench run-tests on a frappe-nix bench, turn the guard off for that one command: FRAPPE_DEVGUARD_DISABLE=mail bench --site … run-tests ….

How it works

HookPurpose
extend_doctype_class → Email Domainverify the API token instead of SMTP/IMAP connections
extend_doctype_class → Email Accountblank the server fields, generate the webhook key + secret, build the URL, no-op the IMAP pull
override_email_send → email_domain.sendthe Email Sending API call, with Frappe Mail / SMTP fallback for other accounts
doctype_js → email_account.jsthe copy / regenerate buttons
api.inboundthe webhook
after_install / after_migratecreate the relay user, role and permission rule, or reset them to least privilege

cloudflare_api.py (the API client) is shared with the Odoo module mail_cloudflare in Avunu/avunu-odoo-addons; relay.py implements the contract documented in the relay's README.

Part of the Cloudflare Email suite

Four open-source projects work together to give business systems email without SMTP credentials or IMAP polling:

ProjectRole
cloudflare-email-relayMulti-tenant inbound Worker: stores each message in R2, then delivers it to the right system with signed, retried requests
cloudflare_email_deliveryFrappe and ERPNext adapter
mail_cloudflareOdoo adapter
wordpress-cloudflare-emailWordPress plugin: outbound mail and a delivery log

Need it set up for your business? Avunu can help.

License

MIT. cloudflare_email_delivery/cloudflare_api.py derives from the AGPL-3.0 Odoo module of the same author, relicensed here by its copyright holder, Avunu LLC.