cloudflare-email-sending
id: cloudflare-email-sendingkind: platform-limitmeasured_on: 2026-08-04stale_when: > Email Sending leaves public beta, the legacy EmailMessage API is withdrawn, the structured send() header limits change, Cloudflare publishes the daily sending quota, or the cf-bounce subdomain's records become unlockable — which would make a Node able to receive its own bounces for the first time and reopen the mechanism corrected belowvalues: send.paid.included_per_month: 3000 send.paid.cost_per_thousand_cents: 35 send.max_custom_headers: 20 send.max_header_value_bytes: 2048 send.max_headers_total_bytes: 16384 send.max_references_entries_for_reply: 100 send.references_emitted_max: 20 send.hold_window_default_seconds: 15 send.delivers_to_same_account_routing: 1 send.preserves_authored_message_id: 0 send.delivers_externally: 1 send.daily_limit_is_published: 0 send.bounce_dsn_reaches_node: 0 send.counts_per_recipient: 1Read from Cloudflare’s documentation and checked against the live account on 4 August 2026. Email Sending entered public beta on 16 April 2026 and is Workers Paid only; Email Routing is on both plans.
The entitlement is domain onboarding, not the plan
Section titled “The entitlement is domain onboarding, not the plan”This is the part that is easy to get wrong, because ADR 25 already made Workers Paid mandatory and it would be natural to assume that settles it. It does not:
Before you onboard a sending domain, you can send emails only to verified destination addresses in your account. After you onboard a sending domain, you can send to any recipient immediately.
So there are two gates, and a Node can be on Workers Paid and still unable to reply to a customer. Onboarding means SPF and DKIM DNS records on the sending domain.
Checked on the development account: whymelabs.com is onboarded and enabled (since 20 June 2026,
DKIM selector cf-bounce, return path cf-bounce.whymelabs.com), as is infinirewards.com. Neither
was changed.
Sends to verified destination addresses are free on any plan, do not count toward the monthly quota or the daily limit, and work even when only Email Routing is configured. That makes them the only zero-cost way to prove the send path works, which matters given the finding below.
Onboarding is per subdomain, and is not inherited (measured 5 August 2026)
Section titled “Onboarding is per subdomain, and is not inherited (measured 5 August 2026)”Assumed wrong, then tested against the live API. whymelabs.com is onboarded and enabled, and a send
from inbox@mailda-test.whymelabs.com was refused:
email sending not authorized for subdomain 'mailda-test.whymelabs.com'Onboarding applies to the exact name, not to the zone. wrangler email sending list shows a
zone and a name column, and the name is the unit. A subdomain of a fully onboarded apex is a
separate thing that must be enabled separately.
This is not a footnote. §10 makes a delegated subdomain (mail.example.com) the default install
path, so the default path requires onboarding a subdomain for sending, and #21 already found that
subdomain routing onboarding is dashboard-only with no API. Sending appears better served,
since wrangler email sending enable <domain> accepts “a zone or subdomain”, so the two halves of the same
subdomain may have different automation stories, which mailda deploy has to handle rather than
assume.
The string also has to be classified correctly, and was not at first: it went to outcome_unknown
because nothing matched it. The safe default behaved as designed (an unclassifiable failure gets the
state that forbids automatic retry), but it is the wrong answer, since the message provably never left
and this is the most fixable failure in the set. It is now refused, with a message naming
per-subdomain onboarding, because Cloudflare’s own wording names a subdomain and no remedy.
Enabling a subdomain: what it actually does (5 August 2026)
Section titled “Enabling a subdomain: what it actually does (5 August 2026)”wrangler email sending enable mailda-test.whymelabs.com succeeded, and Cloudflare published the DNS
itself because the zone is on Cloudflare DNS. No manual record entry, unlike the dashboard flow’s
wording. The records land on new names only, so they cannot collide with an apex that is already
onboarded:
| Name | Type |
|---|---|
cf-bounce.<subdomain> | 3 × MX (route{1,2,3}.mx.cloudflare.net) |
cf-bounce.<subdomain> | TXT v=spf1 include:_spf.mx.cloudflare.net ~all |
cf-bounce._domainkey.<subdomain> | TXT DKIM |
_dmarc.<subdomain> | TXT v=DMARC1; p=reject; |
The DMARC record is listed for a subdomain and was not listed for the apex, which matters because
p=reject on a subdomain is a policy an operator should know they now have.
Verified after enabling: the apex’s own cf-bounce records and its MX were unchanged, so onboarding a
subdomain is additive rather than a modification of the parent. Reversible with
wrangler email sending disable <subdomain>.
Same-account delivery works, correcting #21 (measured 5 August 2026)
Section titled “Same-account delivery works, correcting #21 (measured 5 August 2026)”#21 recorded that “Cloudflare Email Sending does not deliver to same-account Email Routing”, from two
sends that reported success and never arrived. That finding is wrong, or at least wrongly
generalised. Four sends from inbox@mailda-test.whymelabs.com to inbox2@mailda-test.whymelabs.com,
same zone and same account, all arrived and were parsed:
| Path | MIME form | Arrived |
|---|---|---|
| Workers binding | raw (EmailMessage) | yes |
| Workers binding | structured (send()) | yes |
wrangler email sending send (REST) | structured | yes |
wrangler email sending send-raw (REST) | raw | yes |
The most likely explanation for the original observation is the one this session stumbled into
independently: sending from a subdomain that is not onboarded is accepted and silently dropped.
Before 5 August, mailda-test.whymelabs.com had no SPF, DKIM or DMARC records, and this session
separately confirmed Cloudflare accepts mail it cannot deliver (see below). Once the subdomain was
onboarded, same-account delivery worked on every path.
Two wrong conclusions were reached along the way and are recorded because the method matters more than the result. First, “the message did not arrive after 2.5 minutes” was treated as evidence of non-delivery; it is not. Mail is asynchronous and one of these messages arrived later than that. Second, a single negative observation was generalised into “the Workers binding cannot deliver same-account while REST can”, which the retest contradicted. Absence of arrival is only evidence after a bounce or a timeout, and neither had happened.
Consequence for §5A: the synthetic inbound test at step 6 can be same-account, which is materially simpler than requiring an external sender. What it must not do is treat “accepted” as “delivered”, which is the real lesson, and a different one.
External delivery, confirmed
Section titled “External delivery, confirmed”Two probes to an external Gmail address were received (confirmed by the recipient, 5 August 2026). That is the control the same-account correction above needed: without it, “all four paths delivered same-account” could have been a same-account-only quirk. Both directions work, from an onboarded subdomain.
Cloudflare rewrites the Message-ID, even on the raw path
Section titled “Cloudflare rewrites the Message-ID, even on the raw path”Measured, and it matters more than it looks:
authored by Mailda : snd_01KZ8FDVNGKDGFHHCJ3B8P3XSQ@mailda-test.whymelabs.comreturned by CF : <yKx5fgOqPsRcMxkZvII4348Xnd2w5n3oJZ0s@mailda-test.whymelabs.com>actually delivered : yKx5fgOqPsRcMxkZvII4348Xnd2w5n3oJZ0s@mailda-test.whymelabs.comThe Message-ID a Node authors is replaced, on both the structured and the raw-MIME paths, and the
id the recipient sees is the one send() returns. send.preserves_authored_message_id: 0.
This is a concrete instance of ADR 33’s claim that neither API can record what the recipient received,
and it has a consequence ADR 33 did not draw: a reply cannot be threaded onto the Message-ID Mailda
authored. A recipient’s client will set In-Reply-To to Cloudflare’s id, so threading must key on
send_manifests.transport_message_id, verified above to be exactly the delivered id, and the
authored id is useful only as an internal trace back to its manifest.
Recorded as a correction to the design rather than a footnote: it changes which column the inbound threading path has to match against.
handed_over means less than it sounds, and this is the proof
Section titled “handed_over means less than it sounds, and this is the proof”Immediately after enabling, a send to nobody@example.invalid, a TLD reserved by RFC 2606 that
provably cannot exist, was accepted, with a messageId returned:
state: handed_overtransport messageId: <5BLzoF3tTBQdixkRz9uMRDBpBVh3wKGgOvU9@mailda-test.whymelabs.com>Cloudflare does not validate the recipient domain at submission time. It accepted mail for a domain that cannot resolve, counted it against the daily quota, and returned an id.
This is the strongest available evidence for ADR 39’s refusal to ever display Sent: the most
optimistic thing the transport can tell a Node is compatible with the message being undeliverable by
construction. handed_over is not a euphemism, it is the literal truth and the ceiling of what is
knowable at that moment.
Corrected 7 August 2026. This paragraph was wrong, and it was wrong in the direction that costs most.
Section titled “Corrected 7 August 2026. This paragraph was wrong, and it was wrong in the direction that costs most.”It read: “A rejection for this message can only arrive later, asynchronously, as inbound mail to
cf-bounce.<subdomain>, which means outbound state is partly inbound mail, and nothing consumes that yet.”A Node cannot receive its own bounces at all. From Domains, read 7 August 2026: the
cf-bounceMX records exist to “Route bounce emails back to Cloudflare for processing”, and “Only Email Routing records on the root domain (MX, SPF, and DKIM) support unlocking. Email Sending records on thecf-bouncesubdomain stay managed by Email Service for the lifetime of the domain configuration.” The MX points at Cloudflare, and the record cannot be taken over. There is no DSN arriving anywhere a Worker can read it.The cost of leaving this wrong was not a wasted afternoon. It named a mechanism, parse inbound DSNs, that reads as the obvious implementation of Layer 2’s
bouncedstate, and a receipt is this repository’s trusted corpus, so the next person to build bounce handling would have written an RFC 3464 parser that could never fire once. It survived three days and one whole layer of planning because nobody tried to consume the thing it described.The real channel is Queues event subscriptions, and it is strictly better than a DSN parser: see
email-sending-events.md. Cloudflare emits one event per recipient, which means per-recipient outcome is observable without splitting submission per recipient, so the manifest id stays the effect key,submitted_keystays one evidence pair, and the Bcc header/envelope asymmetry stays intact.
This paragraph originally reasoned about bounce handling from a mechanism that does not exist; the corrected mechanism is above.
One send to three recipients counts as three
Section titled “One send to three recipients counts as three”Measured 8 August 2026 on the live account. A throwaway Worker sent one structured
env.EMAIL.send({ to: [three addresses] }), and the zone’s emailSendingAdaptiveGroups count went from
0 to 3.
So the unit Cloudflare bills and counts is the recipient, not the send. Which settles the question
that was blocking #36: submitting once per recipient
costs nothing extra, because a multi-recipient submission is already three messages by Cloudflare’s own
accounting. The objection that per-recipient submission would triple a customer’s usage was wrong. The
usage is already tripled, and only Mailda’s send_counters disagreed.
That means send_counters.handed_over, which this Node shows a user as their observed daily limit, has
been counting the wrong unit all along: one row per manifest, against an allowance consumed per
recipient. On single-recipient sends, every send this Node has made until now, the two agree, which is
why nothing noticed.
Incidentally confirmed: 2 of the 3 recipients came back deliveryFailed, both on
mailda-test.whymelabs.com. That is not a sending fault. The Email Routing rules for those addresses
were deleted during a cleanup on 7 August, so mail to them has nowhere to land. A useful reminder that
deliveryFailed in this data can mean the recipient’s own routing is gone, not that the address is bad.
Two APIs, and they record different things
Section titled “Two APIs, and they record different things”env.EMAIL.send({from, to, subject, html, text}) | new EmailMessage(from, to, rawMime) | |
|---|---|---|
| Status | Preferred for new code | “Legacy… supported for backward compatibility” |
| Who builds the MIME | Cloudflare | Mailda |
| What can be recorded | What we asked for | The bytes we submitted |
| Custom headers | ≤ 20 non-X-, ≤ 2,048 bytes each, ≤ 16 KB total; From must use the API field | Whatever the MIME contains |
Neither API can record what the recipient received: Cloudflare adds Received and DKIM-Signature
in transit either way. The honest claim a manifest can make is therefore “these are the bytes Mailda
authored and submitted”, never “this is what arrived”, and §5C requires the product to say the former.
message.reply() is not the reply API
Section titled “message.reply() is not the reply API”It looks like exactly what Mailda needs (threaded, same SMTP session, preserves the Message-ID
chain), and it is unusable for the product’s actual case, because it only exists inside the
email() handler, during inbound delivery. A human composing a reply an hour later cannot reach it.
Its constraints are recorded anyway, because they describe what Cloudflare considers a legitimate
reply and the outbound path will meet the same scrutiny: valid DMARC on the incoming message, one
reply per event, recipient must match the incoming sender, sending domain must match the receiving
domain, and rejection above 100 References entries.
The daily limit is deliberately invisible
Section titled “The daily limit is deliberately invisible”New accounts start with a conservative daily quota and scale up over time based on your sending behavior, deliverability rates, and account standing.
No number is published, anywhere. It is per-account, varies with reputation, and changes without
notice. send.daily_limit_is_published: 0 records that as a fact rather than leaving a gap that reads
like an oversight.
This is precisely what AGENTS.md means by a limit developers can hit is a limit they must see, and
it cannot be resolved by reading. So Mailda measures it: sends are counted per rolling day in D1,
and the count at which a rate rejection first occurs is recorded. The operator’s own limit becomes an
observed number with a date, which is the only form of it that can exist.
A suppression list also exists; sends it blocks are rejected at the API boundary and do not count toward quota. A suppressed recipient means the message will never arrive, which is a distinct state from a bounce and from an unknown outcome.
Two derived numbers
Section titled “Two derived numbers”send.references_emitted_max = 20. Cloudflare rejects a reply whose incoming message carries more
than 100 References entries, and ADR 27 stores only two threading anchors, so a reply’s chain is
reconstructed at composition time rather than carried. Reconstruction must therefore be bounded,
not faithful: 20 entries keeps a Node an order of magnitude clear of the ceiling while preserving more
history than any client displays. Long threads lose their middle, which is what every other client
does too, and the root and the immediate parent, the two entries that decide threading, are always
kept.
send.hold_window_default_seconds = 15. This one has no measurement behind it and that is
deliberate. It is the undo-send window from ADR 39: a preference about human regret, not a limit or a
budget, and no measurement could settle it, which is exactly why it is configurable per mailbox,
including zero. Recorded here so a reader does not conclude the receipt rule was skipped. Fifteen
seconds is long enough to notice the most common regret (the wrong recipient) and short enough that
operational mail does not feel broken.
Configuration is portable, if one field is avoided
Section titled “Configuration is portable, if one field is avoided”send_email: [{ "name": "EMAIL" }] carries no account-specific value, so unlike Secrets Store
(ADR 28) it does not collide with ADR 24’s byte-identical fork.
That holds only for the unrestricted form. destination_address and allowed_destination_addresses
take customer addresses, and allowed_sender_addresses takes customer domains, and putting any of them
in committed configuration reintroduces exactly the problem ADR 28 had to solve. Restriction, where
Mailda wants it, belongs in the adapter and in D1, not in wrangler.jsonc.
remote: true on the binding lets local development call the real API, which is the only way to
exercise this path outside a deploy.
Correction, 19 August 2026: the two priced figures are Paid figures, and now say so (#68)
Section titled “Correction, 19 August 2026: the two priced figures are Paid figures, and now say so (#68)”No value moved. send.included_per_month: 3000 is now send.paid.included_per_month and
send.cost_per_thousand_cents: 35 is now send.paid.cost_per_thousand_cents. Nothing was remeasured; the
names were wrong and the numbers were not. #68 flagged both as inheriting the caveat it found on
workflow.subrequest_budget_per_instance, and they do, but not in the same way, so the treatment differs
and the difference is the point of this section.
They are Paid figures. cloudflare-plan-costs.md read the pricing table on 3 August 2026: “Outbound
emails (Email Sending) — Workers Free: Not available. Workers Paid: 3,000 included per month, then $0.35
per 1,000.” A quota and a unit price with no plan in their names invite a reader, or an agent pricing a
Node, to grant a Free account 3,000 metered sends a month. That is the overclaiming name AGENTS.md §4
forbids, and the fix is the same rename the D1 keys got.
There is no send.free.* sibling, deliberately, and this is where it differs from the subrequest ceiling.
That ceiling is plan-conditional: one thing, two numbers, and a receipt that records only one of them is
incomplete. This is plan-gated: on Workers Free the metered product does not exist, so there is no second
number to record. Writing send.free.included_per_month: 0 would be worse than omitting it, because zero
reads as an exhausted quota (wait for the month to turn) when the truth is an absent product, whose
remedy is a plan upgrade. Those are different states with different fixes, and §5C forbids collapsing them.
The free state is already recorded, as measured values, in the receipt that measured it.
free-plan-node-capability.md probed a live Workers Free account: freeplan.send_to_verified_destination: 1,
freeplan.send_to_arbitrary_recipient: 0, freeplan.send_error_names_the_plan: 0. A Free Node’s send
capability is a capability question, answered by those three, not a quantity question answered by a fourth
figure here.
Nothing else in this receipt’s set is plan-scoped, and that is evidenced rather than assumed. Sends to
verified destinations “are always free: they do not count toward your monthly quota or your daily sending
limits, on any plan” (quoted in cloudflare-email-service-limits.md), so the quota and the unit price
are the only two figures here that the plan touches. The header, References and threading limits are
published once with no plan column; send.counts_per_recipient: 1 is a unit of accounting measured on the
live account, not a quantity; send.daily_limit_is_published: 0 and send.bounce_dsn_reaches_node: 0 are
facts about the platform that hold on both; and send.hold_window_default_seconds: 15 is Mailda’s own
preference about human regret, as the section above already says at length.
Plan-naming the quota does not make the plan the only gate. “The entitlement is domain onboarding, not the plan” above still stands: a Node can be fully paid up, hold all 3,000 of these, and still be unable to reply to a customer until its sending domain is onboarded. Two gates, and the rename touches only one of them.
The 3,000 is recorded twice and the copies are now pinned. plan.paid.emails_included_per_month: 3000 in
cloudflare-plan-costs.md is the same figure from the same pricing table, read a day earlier.
test/node/budget-plan-scope.test.ts asserts the two are equal, so a remeasurement that moves one and not the
other fails instead of leaving two receipts quietly disagreeing, which is exactly how the withdrawn
1,000-subrequest cap survived six months in doctor-check-cost.md. send.paid.cost_per_thousand_cents has no
twin to pin.