All writing

~/writing/mail-relay-cloudflare-email-service

Systems debugging
8 min read

Relaying a self-hosted mail server through Cloudflare

A personal mail server can receive on its own, but sending needs a relay with a reputation. Mine was rewriting my links and adding tracking pixels. Cloudflare now sells a plain SMTP relay on the five-dollar Workers plan, and the cutover is one token, six postconf lines, and a bounce domain Cloudflare maintains for you.

I run my own mail server. Receiving is the easy half: an MX record, a box on a static address, Postfix and Dovecot, a spam filter, done. Sending is the half that never stops being a problem, and the reason is not technical. Every large receiver scores mail by the reputation of the IP that hands it over, and reputation is earned in millions of messages per month. A personal server sends a few hundred a year. Its IP will never have a reputation, so its mail goes to spam, and the accepted fix is to hand outbound to a relay that does have one.

For years mine was Mailjet. It worked, and it did something I never asked for: every link in every message I sent got rewritten through their click-tracking redirector, and an invisible open-tracking pixel got appended to the body. That is a default on their SMTP relay, not a feature you turn on. A friend clicking a link I sent them was being logged by a third party, and the URL they saw in the status bar was not the one I typed. On a relay I paid for, to send my own mail, with my own domain in the From header.

I went looking for a relay that passes bytes untouched, and found Cloudflare selling exactly that.

Why not just send direct

Oracle Cloud, where the box lives, is usually assumed to block outbound port 25. It does not, at least not for this instance:

for mx in gmail-smtp-in.l.google.com aspmx.l.google.com; do
  timeout 10 bash -c "echo > /dev/tcp/$mx/25" && echo "$mx:25 reachable"
done
# gmail-smtp-in.l.google.com:25 reachable
# aspmx.l.google.com:25 reachable

The escape hatch exists but does not help. Direct delivery from an IP with no history and a cloud provider’s ASN is the exact profile Gmail and Outlook throttle or junk on sight, and there is no volume I could send that would change that.

Cloudflare Email Service

Cloudflare has had inbound Email Routing for years. Sending is new: Email Service, in beta, included with the Workers Paid plan. The part that matters for a mail server is that it speaks real authenticated SMTP, not just a Workers binding:

  • Endpoint smtp.mx.cloudflare.net:465, implicit TLS.
  • Username is the literal string api_token. Password is a Cloudflare API token with the Email Sending permission.
  • 3,000 messages a month included on the five-dollar plan, then $0.35 per thousand. I send a few hundred messages a year.
  • No link rewriting, no tracking pixels. The message that goes in is the message that comes out.
  • One rule: the MAIL FROM domain must be onboarded for Email Sending on the account that owns the token, or the relay answers 550 5.7.1 Sender denied.

Phone or laptop submits to Postfix on 587. Postfix relays to Cloudflare on 465 with the token. Cloudflare signs it twice, once with its own key and once with a key under your domain, and hands it to the recipient’s MX from an IP with a real reputation. Bounces come back to a subdomain Cloudflare owns the MX for.

1. Onboard the sending domain

Dashboard step, documented in Cloudflare’s Send emails guide: Compute, Email Service, Email Sending, Onboard Domain, pick a zone on your account. Cloudflare writes the DNS records itself, and the domain configuration reference covers verifying them and removing a domain later.

Nothing lands on the apex:

records created for example.net
MX   cf-bounce.example.net             route1.mx.cloudflare.net
MX   cf-bounce.example.net             route2.mx.cloudflare.net
MX   cf-bounce.example.net             route3.mx.cloudflare.net
TXT  cf-bounce.example.net             "v=spf1 include:_spf.mx.cloudflare.net ~all"
TXT  cf-bounce._domainkey.example.net  "v=DKIM1; h=sha256; k=rsa; p=MIIBIjAN..."

The relay sets the envelope sender to bounces@cf-bounce.example.net, so receivers check SPF against that subdomain and bounces route to Cloudflare’s MX. The DKIM selector is cf-bounce under your domain, so the signature is d=example.net, aligned with your From header. Your MX, apex SPF and DMARC stay as they were (onboarding adds a DMARC record only if you have none).

2. Mint the token

The token needs exactly one permission: Email Sending Write, account scope. You can do it in the dashboard, but if you are scripting an account this is the shape:

find the permission group, then mint
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
  "https://api.cloudflare.com/client/v4/user/tokens/permission_groups" \
  | python3 -c '
import json,sys
for g in json.load(sys.stdin)["result"]:
    if "Email Sending" in g["name"]: print(g["id"], "|", g["name"])'
# 5df633d6b41c42bcaf5b4a62b9d14b64 | Email Sending Write
 
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/user/tokens" -d '{
  "name": "postfix-email-sending",
  "policies": [{
    "effect": "allow",
    "resources": {"com.cloudflare.api.account.'"$ACCOUNT"'": "*"},
    "permission_groups": [{"id": "5df633d6b41c42bcaf5b4a62b9d14b64"}]
  }]
}'

Set an expiry you will actually rotate on, or leave it off.

3. Postfix

Snapshot first. postconf -n is the whole effective configuration and it is your rollback. The three settings the cutover replaces:

postconf -n > /root/postconf-n.pre-cfrelay
postconf relayhost smtp_sasl_password_maps smtp_tls_wrappermode
# relayhost = in-v3.mailjet.com:588
# smtp_sasl_password_maps = static:<mailjet user>:<mailjet password>
# smtp_tls_wrappermode = no

The credential goes in its own map file. A separate file means the old relay’s credentials stay in place until you decide to delete them:

/etc/postfix/sasl_passwd_cloudflare
[smtp.mx.cloudflare.net]:465 api_token:<the token>
umask 077
postmap /etc/postfix/sasl_passwd_cloudflare

Before touching the live relay, prove the credential works from the box itself. Ten lines of Python, reading the password from the map you just wrote:

auth-probe.py
import smtplib
pw = open("/etc/postfix/sasl_passwd_cloudflare").read().split("api_token:")[1].strip()
s = smtplib.SMTP_SSL("smtp.mx.cloudflare.net", 465, timeout=20)
s.login("api_token", pw)
print("SMTP AUTH against Cloudflare: OK")
s.quit()

Six settings, one command, one reload:

the cutover
postconf -e \
  "relayhost = [smtp.mx.cloudflare.net]:465" \
  "smtp_tls_wrappermode = yes" \
  "smtp_tls_security_level = encrypt" \
  "smtp_sasl_auth_enable = yes" \
  "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd_cloudflare" \
  "smtp_sasl_security_options = noanonymous"
systemctl reload postfix

Two of these are where first-time setups go wrong:

  • relayhost in square brackets means “connect to this host, do not look up its MX.” Without the brackets Postfix resolves MX for smtp.mx.cloudflare.net and you get whatever that returns.
  • smtp_tls_wrappermode = yes is the one people miss. Port 465 is implicit TLS: the connection is encrypted from the first byte, no STARTTLS negotiation. Postfix defaults to STARTTLS behaviour, which on 465 produces a hang followed by a timeout, not a clear error. Mailjet on 588 was STARTTLS, hence no above; this flag is what changes.
  • smtp_tls_security_level = encrypt refuses to fall back to plaintext if TLS fails. On a relay that carries a credential in every session, that is the only sane setting.
  • smtp_sasl_security_options = noanonymous prevents Postfix from picking an anonymous mechanism if the server ever offers one. Cloudflare advertises PLAIN and LOGIN.

Your first message through the new path should be visible in the log within seconds:

/var/log/mail.log
postfix/smtp[4043402]: Trusted TLS connection established to smtp.mx.cloudflare.net[162.159.205.27]:465: TLSv1.2 with cipher ECDHE-RSA-CHACHA20-POLY1305
postfix/smtp[4043402]: 9AD89BECD0: to=<check-auth@verifier.port25.com>, relay=smtp.mx.cloudflare.net[162.159.205.27]:465, delay=2.3, status=sent

“Trusted” in that first line means Postfix verified the certificate chain, not just encrypted the session. If you see “Untrusted” or “Anonymous”, your CA bundle or smtp_tls_CAfile needs attention before you rely on encrypt.

4. The sender rule

A server also sends mail on its own behalf, not just what gets submitted through 587. Cron output, fail2ban notices, unattended-upgrades reports and Postfix’s own bounce notifications leave with an envelope sender like root@mail.example.net, the machine’s hostname, and the relay rejects that with 550 5.7.1 Sender denied unless that domain is onboarded too.

Check what your box uses for its own mail:

postconf myorigin myhostname

Onboard the hostname’s domain if it is a zone you control, or rewrite system senders onto one that is with sender_canonical_maps, so root@mail.example.net leaves as root@example.net. Do this before cron, fail2ban or unattended-upgrades sends its next report.

5. Verify with the strictest grader available

The port25 auth verifier is the usual check; its reflector did not respond when I tried it. The better verification is a real message to a Gmail address, sent from a phone through the normal 587 submission path, then Show Original. This is what came back, with addresses swapped:

Authentication-Results from Gmail
Return-Path: <bounces@cf-bounce.example.net>
Received-SPF: pass (google.com: domain of bounces@cf-bounce.example.net
    designates 104.30.10.49 as permitted sender)
Authentication-Results: mx.google.com;
    dkim=pass header.i=@cloudflare-email.net header.s=cf2024-1;
    dkim=pass header.i=@example.net header.s=cf-bounce;
    spf=pass smtp.mailfrom=cf-bounce.example.net;
    dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=example.net

SPF passes on the cf-bounce subdomain, where the return-path points and where Cloudflare put the include. DKIM passes twice: Cloudflare’s own cloudflare-email.net signature, and d=example.net with selector cf-bounce. The second one is aligned with the From domain, and that is what carries DMARC under p=reject.

Then I decoded the base64 HTML part and compared the link in my signature with what my phone sent. Byte-identical. No redirector, no pixel.

6. Retire the old relay

With the new path proven by a receiver I do not control, the old provider’s DNS can go:

inventory before deleting anything
curl -s -H "Authorization: Bearer $DNS_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?per_page=100" \
  | python3 -c '
import json,sys
for r in json.load(sys.stdin)["result"]:
    if "mailjet" in (r["name"]+r["content"]).lower():
        print(r["id"], r["type"], r["name"], "|", r["content"][:50])'

Mine turned up the DKIM key at mailjet._domainkey, a domain-validation TXT with a hash for a name, a stale key under an old test. subdomain, and the include:spf.mailjet.com in the apex SPF. All deleted, except the SPF record, which got rewritten:

apex SPF, after
example.net  TXT  "v=spf1 -all"

Nothing sends with the apex as MAIL FROM any more: every legitimate outbound message leaves with bounces@cf-bounce.example.net, and receivers evaluate SPF against that subdomain, which has its own record. A hard-fail apex SPF rejects anyone forging the bare domain in an envelope, independent of DMARC.

Keep the postconf -n snapshot and the old SASL map around for a week. Rollback is one postconf -e away.

What it costs and what it fixes

Monthly bill
$5
Workers Paid plan
Included messages
3,000/mo
then $0.35 per 1,000
DMARC under p=reject
pass
aligned DKIM on your own domain
Links rewritten
all of them 0

Cloudflare signs with a key under your domain, so a p=reject DMARC policy keeps passing, and the apex SPF can say -all truthfully. Postfix now delivers exactly the bytes I hand it, with Cloudflare’s reputation behind them.