Skip to content

Send through Cloudflare Email Service, a provider with no API key - #7

Merged
sergebulaev merged 2 commits into
CCCrafts:mainfrom
dmno-dev:feat/cloudflare-email-service
Sep 15, 2026
Merged

sergebulaev merged 2 commits into
CCCrafts:mainfrom
dmno-dev:feat/cloudflare-email-service

Conversation

@theoephraim

@theoephraim theoephraim commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

I don't want to use an external email service. I want to use cloudflare's new beta email sending capability.


Why

On a Cloudflare-hosted deployment, the send_email binding is capability-scoped by wrangler.toml — so unlike Resend and Brevo there is no secret to set, rotate, leak or forget. This provider removes a credential rather than adding one, which felt like the point of having the EmailSender port at all (ADR-0003).

It also closes a small gap: Punctual runs entirely on Cloudflare, but until now the one thing it could not do without a third party was send mail.

Cloudflare Email Service went to public beta on 2026-04-16. Sends to arbitrary recipients need a Workers Paid plan and include 3,000 emails/month.

Two platform facts that shape the adapter

Arbitrary recipients require an onboarded sending domain. Before that, the binding delivers only to verified destination addresses in the account — to the operator, never to a guest. A deployment in that state looks perfectly healthy: no key is missing, so the console-sender banner correctly stays silent, while every guest confirmation is rejected. That's exactly the failure the banner exists to catch, so the adapter recognises the condition and raises an error naming the onboarding step, instead of letting an opaque provider body reach the log.

Attachment content is a base64 string — already how EmailMessage.attachments carries the .ics. It crosses untouched, with no decode/re-encode that could corrupt a calendar invite. Field names differ from both existing providers (type, not contentType/content_type; disposition required), which is pinned by a test for the same reason the Brevo {name, content} case is.

Precedence

The binding wins over both API keys. A [[send_email]] block is an edit to wrangler.toml — the most explicit configuration act available, and the only one of the three that cannot arrive by accident from a secret inherited off another deployment. Swapping back is "remove the binding", not "set a key and hope the precedence falls your way".

Happy to invert this if you'd rather keys stayed on top.

Opt-in, not default

It ships commented out in wrangler.toml, alongside LEGAL_OPERATOR and DEMO_BOOKING_PATH. Defaulting it on would silently move every existing deployment off the provider they configured — and worse, any deployment that hadn't onboarded a sending domain would start failing guest sends while /health still read healthy, because a provider is configured and the warning only fires for the console sender. That tradeoff is written into the config comment, the docs page and docs/self-hosting.md, since it's the one sharp edge here.

Also

DashboardChrome.emailDelivery now refers to EmailDelivery instead of restating 'resend' | 'brevo' | 'console'. The duplicate didn't include the new provider — a compile error in ten call sites, and a good argument for naming the type once.

Tests

8 new. 7 in test/core (pure, alongside the existing Resend/Brevo sender tests), 1 in test/workers:

  • sends through the binding with no API key anywhere
  • bare address when no display name is configured
  • {content, filename, type, disposition} attachment mapping
  • omits attachments/replyTo entirely when absent, rather than sending undefined
  • sanitises header-injection characters in guest-controlled to/toName/replyTo
  • translates the un-onboarded-domain failure instead of echoing it
  • still throws on an unrelated failure, so the queue retries
  • /health reports cloudflare and does not warn when the binding is present alongside an inherited RESEND_API_KEY

npm run typecheck clean, npm test 882 passing. Miniflare's email simulator exercises the real binding in the Workers project — the invite.ics shows up as a written attachment during the run, so the booking path is covered end to end rather than only against a mock.

Deployed and sending on my own instance since yesterday.

A third `EmailSender`. On a Cloudflare-hosted deployment the `send_email`
binding is capability-scoped by wrangler.toml, so unlike Resend and Brevo
there is no secret to set, rotate, leak or forget — this provider removes a
credential rather than adding one. ADR-0003's port exists so that is a
choice, and on this platform it is the cheapest correct one.

Two platform facts shape the adapter:

Arbitrary recipients need an onboarded sending domain. Before that, the
binding delivers only to verified destination addresses in the account — to
the operator, never to a guest. A deployment in that state looks healthy: no
key is missing, so the console-sender banner correctly stays silent while
every guest confirmation is rejected. That is precisely the failure the
banner exists to catch, so the adapter translates it into an error naming the
onboarding step rather than letting a bare provider body reach the log.

Attachment content is a base64 string, which is already how
`EmailMessage.attachments` carries the .ics — so it crosses untouched, with
no decode/re-encode that could corrupt a calendar invite.

The binding takes precedence over both API keys. A `[[send_email]]` block is
an edit to wrangler.toml, the most explicit configuration act available and
the only one of the three that cannot arrive by accident from a secret
inherited off another deployment.

It ships commented out in wrangler.toml. Uncommenting changes where every
email goes, and defaulting it on would silently move existing deployments off
the provider they configured.

`DashboardChrome.emailDelivery` now refers to `EmailDelivery` rather than
restating its members — the duplicate did not include the new provider, which
was a compile error in ten places and is the reason to name the type once.
The two failures this avoids are both invisible until a send happens, so
the ordering is the documentation: onboard the domain BEFORE uncommenting
the binding, and put FROM_EMAIL on the domain you onboarded rather than
anywhere on the zone. Names the sign-in link as the check, since it is the
one email sent on the request path and so fails where someone is looking.

Also notes that onboarding leaves the apex MX alone, because "will this
break the mail I already receive here" is the first question anyone with a
live domain asks.
@sergebulaev
sergebulaev merged commit a0892a2 into CCCrafts:main Sep 15, 2026
@sergebulaev

Copy link
Copy Markdown
Contributor

Merged — thank you, both for the adapter and for writing down the onboarding trap. One change on main after the merge: precedence is inverted, so RESEND_API_KEY / BREVO_API_KEY win over the binding when set, and the binding is what a deployment with no key uses. Reasoning: a key is set on purpose for one deployment, and nobody who already sends through Resend should be moved off it by uncommenting a template block. Docs and the smoke test follow.

@theoephraim

Copy link
Copy Markdown
Contributor Author

you could also consider letting the user explicitly setting which provider they want to use, then it can yell at them about if the key is set.

@sergebulaev

Copy link
Copy Markdown
Contributor

Done — 8f07f79 and 8d72322. EMAIL_PROVIDER = "cloudflare" | "resend" | "brevo" | "console" in [vars]. Named, that sender is used whatever else is set; named without its key or binding, mail is logged and /health says exactly what is missing (email_provider_unavailable), the boot log and the dashboard banner too. Unset keeps the inference. Thanks for the nudge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants