Email sending for the Gateway
Step-by-step guide: let the Gateway and its apps send mail (Cloudflare Email Sending or Resend): onboarding the domain, EMAIL_FROM and EMAIL_FROM_NAME, app senders and inbound addresses, the check.
Lets the Gateway send its own mail (invitations, password resets, sign-up
verification) and every app's, through one provider for the whole Gateway:
Cloudflare Email Sending (the default) or Resend. Apps that send: openmail
(replies from its own addresses; required), Open Analytics (reports and
alerts; in an optional feature, so it opens without it) and any app whose
everyapp.config.ts lists capabilities: { email: { access: "write" } }.
Email is optional: without it, an invite shows its link to share instead. At the end the person
has a sending domain set up with the provider, EMAIL_FROM and
EMAIL_FROM_NAME on the Gateway, and an invitation that arrived by mail.
Settings
| NAME | required or optional | what it holds |
|---|---|---|
EMAIL_FROM |
required | the sender address, noreply@<your domain>; or the combined form Acme <noreply@<your domain>>, the display name inside it (quotes around the name are dropped) |
EMAIL_FROM_NAME |
required, unless EMAIL_FROM carries the name |
the display name people see, e.g. Acme; set, it outranks the name inside EMAIL_FROM |
RESEND_API_KEY |
optional (Resend only) | a Resend API key, re_…, with Sending access to every domain the Gateway sends as (step 6) |
EMAIL_PROVIDER |
optional | cloudflare or resend (any case): pins the provider; unset, the Gateway picks one (below) |
EMAIL_REST_API_TOKEN |
optional (a Gateway outside Workers: local dev) | a Cloudflare API token with Email Sending Edit, with CLOUDFLARE_ACCOUNT_ID; never set it on a deployed Gateway: it outranks the EMAIL binding |
Set both, the default. Or EMAIL_FROM alone in the combined form, with
EMAIL_FROM_NAME unset. Either way the Gateway sends only once it has an
address and a name (parseEmailSender in packages/sdk/src/internal/email.ts;
the Gateway's configuredSender() uses it): a bare address with no name
anywhere counts as no sender, so the Gateway says "Email is not configured"
and an app's send gets integration_not_configured (503). secret list
and status read names, not values: an app's email setting counts as set
once EMAIL_FROM and EMAIL_FROM_NAME are both on the Gateway (both
required: true in data.gateway.needed), so the combined form with no
EMAIL_FROM_NAME reads as ! not set there while the Gateway, which reads
the values (isEmailConfigured), opens the app and sends. Verify 2 is the
check that counts.
How the provider is picked: EMAIL_PROVIDER when set (and it must be set up;
it never falls back); else Cloudflare when EMAIL_REST_API_TOKEN is set, else
Resend when RESEND_API_KEY is set, else Cloudflare's EMAIL binding, which
every deployed Gateway has. So a deployed Gateway with RESEND_API_KEY sends
through Resend without EMAIL_PROVIDER. Neither EMAIL_FROM nor
EMAIL_FROM_NAME is a secret: pipe them with echo.
Costs and plans
As of October 2026. Cloudflare Email Sending (in beta): "Sending to arbitrary
recipients requires the Workers Paid plan" ($5 a month, which everyapp setup
already needs); 3,000 emails a month included, then $0.35 per 1,000. Before a
domain is onboarded, mail goes only to verified destination addresses (those
sends are free and uncounted on any plan). New accounts start with "a
conservative daily quota" that grows with sending behaviour (Verify 6 reads
it). 50 recipients and 5 MiB a message. Resend Free: 3,000 a month, 100 a
day, 3 domains, 30 days of logs; Pro is $20 a month for 50,000. Resend's
pages don't say whether Free needs a card. Resend's default rate limit is 10
requests a second per team, all keys together (raised on request at
resend.com/help). The Gateway's own budget for app mail: 100 a day per
recipient address (20 on a hosted Gateway) and 5,000 a day per Gateway.
Only a person can
- Pay for Workers Paid, or create a Resend account, accept its terms and pick a plan.
- Click Onboard Domain in the Cloudflare dashboard (you can do this with
cfinstead, step 2). - Create the Resend API key and copy it off the screen into the hidden prompt.
- Approve Resend's Sign in to Cloudflare (Domain Connect) if they use it.
- Say yes to turning Email Routing on for a domain (its MX and SPF records move to Cloudflare; mail hosted elsewhere stops arriving).
- Open their inbox and confirm the invitation or reset mail arrived.
Before you start
pnpm everyapp secret list <app id> --json(openmailoranalytics, the launch apps that listemail; from anywhere once it is deployed, elsepnpm everyapp secret list --jsonin its directory): this file applies whendata.app.setup.unmethas the entryid: "email"(kind: "capability",holder: "gateway",names: ["EMAIL_FROM", "EMAIL_FROM_NAME"],need.kind: "required",setup: "setup/email.md": the pathstatus(under "<app> needs setup") andsecret list <app id>(under the unmet setting) print asGuide: https://everyapp.com/docs/agent/setup/email.md, andsecret list --jsoncarries per unmet setting; how an agent lands here), anddata.app.setup.commandispnpm everyapp configure <app id>, which asks forEMAIL_FROMandEMAIL_FROM_NAMEamong what the app still needs (visible, checked prompts; step 8 says what to type). An app that listsemailin an optional feature (Open Analytics) has the entry insettingswithset: false, never inunmet: it opens without a sender, and this guide applies when they want its mail (configurenames it under that feature instead of asking:pnpm everyapp secret set EMAIL_FROM EMAIL_FROM_NAME). The olderdata.gateway.needed(each{ name, required, set }) anddata.gateway.missingstill say the same ofEMAIL_FROMandEMAIL_FROM_NAME;RESEND_API_KEY,EMAIL_PROVIDERandEMAIL_REST_API_TOKENare read when set (required: false), never inunmetormissing. The entryset: true: both are on the Gateway already; ask before replacing them.pnpm everyapp status --json:data.installedanddata.claimedtrue,data.gatewayUrlgives<gateway host>,data.accountIdis<account id>(the sending domain's zone must be in this account),data.apps[].setupsays which apps still need setup (anAPP_NEEDS_SETUPfix each, severitywarning), and eachdata.apps[].email.senderssays which apps already send as their own.pnpm everyapp secret list --jsonfrom the checkout root: whetherEMAIL_FROM,EMAIL_FROM_NAME,RESEND_API_KEY,EMAIL_PROVIDERorEMAIL_REST_API_TOKENare indata.gateway.names(the one place the last four show as set). Any set: ask before replacing.EMAIL_REST_API_TOKENset on a deployed Gateway: it outranks the binding; ask whether that is meant.- Every
cf …below is the CLI's pinned cf, the onepnpm everyapp login --jsonsigns in: the setup rules' "Where you are" says how to run it. cf zones list --name <your domain>: one zone,statusactive, in<account id>. None: the person adds the domain to Cloudflare first (the domain page in the public docs).cf email-sending subdomains list -z <your domain>: whether the domain is onboarded already (its entry, whosetagis<subdomain id>below). Empty: step 2.- For an app's senders, in the app's directory:
pnpm everyapp email show --json:data.app.deployedtrue (false: deploy first),data.declares.sendstrue,data.sendersthe current list.
Steps
You: choose with the person: the provider and the sender. Cloudflare when the domain is on Cloudflare DNS in
<account id>and they want nothing else to sign up for (the default; steps 2 to 3). Resend when they already use it or want its dashboard (steps 4 to 7). One provider sends everything; both can be set up, withEMAIL_PROVIDERpicking. The sender: the address to send from (offernoreply@<your domain>, on the domain step 2 or 4 sets up: the apex ormail.<your domain>) and the name people see (their company name); set neither before they chose. Then step 8 for either.Cloudflare Email Sending
You: first, read-only,
cf email-sending subdomains preview -z <your domain> --name <your domain>: the records onboarding needs and any conflict (a foreign MX, a second SPF) for the person to clean up under DNS > Records first. Then onboard the sending domain in the Gateway's account:cf email-sending subdomains create -z <your domain> --name <your domain>(--nameis the domainEMAIL_FROMuses: the apex, ormail.<your domain>; never the full address). It turns zone-level Email Sending on too when the account has the entitlement (Workers Paid; refused for it: gateway.md, step 2). Note the subdomain'stagin the output: that is<subdomain id>below. Alternative, Person: https://dash.cloudflare.com/?to=/:account/email-service/sending (Compute > Email Service > Email Sending; the public docs say Compute & AI) > Onboard Domain > pick the domain > review the records > Done (Add records and onboard in the public docs). Wait until the domain is listed under Email Sending.You: check the DNS records. Dashboard onboarding adds them itself (MX and SPF at
cf-bounce.<your domain>, DKIM atcf-bounce._domainkey.<your domain>, DMARC at_dmarc.<your domain>); whether step 2's API path does too is unverified, so look:cf email-sending subdomains dns status <subdomain id> -z <your domain>diffs the expected records against the zone. Any missing or unlocked:cf email-sending subdomains dns fix <subdomain id> -z <your domain>creates them (idempotent; a 409 is a foreign MX or a duplicate SPF/DKIM/DMARC record at those names, for the person to clean up under DNS > Records). Rundns statusagain until nothing is missing, usually within 5 to 15 minutes. Go to step 8.Resend
Person: https://resend.com/domains > Add Domain > type the domain
EMAIL_FROMwill use (Resend recommends a subdomain,mail.<your domain>)the region closest to recipients > add it. Fastest: Resend's Sign in to Cloudflare button (Domain Connect): they approve once, the records land in the zone, and the domain verifies within minutes; skip step 5's records. Else the Records tab lists a DKIM TXT record at
resend._domainkeyand SPF records atsend(an MX, priority 10, and a TXT); DMARC is recommended, not required. They paste the names and values to you (public DNS values, not secrets):<dkim value>,<mx host>and<spf value>below.You: add the records to the zone,
DNS only, onecf dns records create -z <your domain> --body '…'per record, each with its complete name (the record it prints shows thenamecreated):{"type":"TXT","name":"resend._domainkey.<your domain>","content":"<dkim value>","ttl":1},{"type":"MX","name":"send.<your domain>","content":"<mx host>","priority":10,"ttl":1},{"type":"TXT","name":"send.<your domain>","content":"<spf value>","ttl":1}(undermail.<your domain>:resend._domainkey.mail.<your domain>andsend.mail.<your domain>; a free priority if 10 is taken). Then Person: on the domain's page, Verify DNS Records; the status goes Pending > Verified (often within 15 minutes, up to 72 hours; Failed after that: Restart verification). Until then Resend refuses mail from it.Person: https://resend.com/api-keys > Create API Key > Name
every-app-gateway> Permission Sending access > Domain: leave it unrestricted if any app will send as its own domain (step 9), else the domain from step 4 (both are editable there later; the key's value is not) > create. The key (re_…) is shown once. The setup rules' first way: with it on screen, they open a second terminal in theevery-appfolder, runpnpm everyapp secret set RESEND_API_KEY, paste at the hidden prompt, and tell you. Or the setup rules' third way: exported before they started you, you runpnpm everyapp secret set RESEND_API_KEY --from-env RESEND_API_KEY --json; else they read it in hidden in their own terminal (read -rs RESEND_API_KEY && export RESEND_API_KEY; never a typedexport …=, which the shell history keeps), run that line there, thenunset RESEND_API_KEY.You: pin the provider only when both are set up, or the person wants it explicit:
echo resend | pnpm everyapp secret set EMAIL_PROVIDER --json(the setup rules' second way;resendis not a secret). Look fordone.The sender, either provider
You: the address and name chosen in step 1, one value per pipe (the setup rules' second way; two names in one pipe is
SECRET_SEVERAL_NAMES):echo "noreply@<your domain>" | pnpm everyapp secret set EMAIL_FROM --jsonthenecho "Acme" | pnpm everyapp secret set EMAIL_FROM_NAME --json, with their address and name in place of the examples. Or, when the person wants one setting, the combined form (Settings):echo "Acme <noreply@<your domain>>" | pnpm everyapp secret set EMAIL_FROM --jsonand noEMAIL_FROM_NAME. Each ends withdone; the Gateway reads the new values on its next request, no deploy. Then Verify 1:pnpm everyapp secret list --json, both names indata.gateway.names(EMAIL_FROMalone with the combined form; andRESEND_API_KEYfor Resend). Or, at the person's terminal,pnpm everyapp configure <app id>(openmailoranalytics) asks forEMAIL_FROMand thenEMAIL_FROM_NAMEamong the app's other settings, at visible prompts that say what each takes and check it (the address, then the name, as in the two pipes above; whereemailis required it wants both, so the combined form alone is for the pipe).An app that sends as its own addresses (openmail)
You: after the app's first deploy, set up its domain with the same provider (steps 2 to 3, or 4 to 5; a domain done for
EMAIL_FROMneeds nothing). Resend: the key must cover the new domain too; one restricted to step 4's domain refuses mail from any other, so the person edits its Domain at https://resend.com/api-keys (the value stays; nothing to re-set) or creates a new key (step 6,secret set RESEND_API_KEYagain). Then in the app's directory:pnpm everyapp email set --sender "*@<your domain>" --json.donewithdata.senders.valuethe new list anddata.senders.changedtrue.--senderreplaces the whole list (repeat it for several; one address works too); the Gateway picks it up within 30 seconds. Until it has one the app sends as the Gateway's sender, and the CLI says so: itspnpm everyapp deploy --jsonends withdata.nextnamingpnpm everyapp email set --sender '*@<domain>'("lets <app> send as your domain"), andpnpm everyapp email show --jsoncarries the same command indata.next("send as your own domain") under "<app> sends as the Gateway's sender"; neither on a hosted Gateway, which takes no app senders. An app deployed from a release has no directory here: clone its repository and run the command in that directory (onlyeveryapp.config.tsis read), or pass--manifest <path to its everyapp.config.ts>from here.Inbound mail (an app declaring
email: { inbound: true }): in its directory,pnpm everyapp email set --inbound "*@<your domain>" --json(an apex catch-all) or one address; the zone must be in<account id>.EMAIL_ROUTING_DISABLED: Email Routing is off for that domain; tell the person what turning it on does (above) and rerun with--enable-email-routingonly when they said yes.EMAIL_MX_ELSEWHERE: the domain already gets its mail elsewhere (the message names its MX hosts, e.g. Google Workspace); turning routing on would cut that mail off. Offer a subdomain without mail of its own (--inbound hello@mail.<domain>);--replace-mxonly when the person says that domain's mail should move to Cloudflare. Thenpnpm everyapp status --json:data.fixeshas noEMAIL_*code (all four are about inbound routes; each names what to run, usuallypnpm everyapp deploy --json). The checks and the manual path: docs/runbooks/email-sending-setup.md, "Inbound mail to an app".
Verify
pnpm everyapp secret list openmail --json(or the app that brought you here, from anywhere): theemailentry indata.app.setup.settingsisset: trueand out ofdata.app.setup.unmet,data.app.setup.readytrue once nothing else is missing,EMAIL_FROMandEMAIL_FROM_NAMEout ofdata.gateway.missing(with the combined form and noEMAIL_FROM_NAME, that name stays in it: this reads names). From the checkout root with no app named:EMAIL_FROMandEMAIL_FROM_NAMEindata.gateway.names(EMAIL_FROMalone with the combined form;RESEND_API_KEYtoo for Resend). Thenpnpm everyapp status --json:data.apps[].setup.readytrue for that app and noAPP_NEEDS_SETUPfix naming it. All of this reads names: the combined form with noEMAIL_FROM_NAMEfails here while the Gateway sends; Verify 2 is the check that counts.- Smoke check, by the person (
GET /api/integrations/emailis for apps, not operators): onhttps://<gateway host>/, Manage workspace (bottom of the sidebar) > Users > Invite user > an address they control > Invite. The invitation arrives fromEMAIL_FROM_NAME <EMAIL_FROM>(the combined form:EMAIL_FROMas set). An Invite link dialog instead (the link with a copy button, "Send this link to <address>…") means it didn't; the Users page's banner "Email is not configured." says which case (If it fails). Orhttps://<gateway host>/forgot-passwordwith their address: the reset mail arrives (unlessEMAIL_PASSWORD_SIGN_INisfalse). - The provider alone, Cloudflare, with the person's yes (one counted
email, unless
tois a verified destination address):cf email-sending send --body '{"from":"noreply@<your domain>","to":"<address>","subject":"EveryApp test","text":"It works."}'sends without the Gateway. It arriving means the provider is done and a failed invite is the Gateway's side (its settings; the logs below). A refusal is the REST API's numeric code (10203sending disabled,10105not entitled,10004throttled), not the binding'sE_*; an un-onboarded sender's code isn't documented. Resend: the domain's Verified is the only check short of sending (a Sending-access key can't list domains:restricted_api_key). - The app: in its directory
pnpm everyapp email show --jsonlists each pattern indata.senders(none yet, and the app declares sending on a self-hosted Gateway:data.nextnamesemail set --sender '*@<domain>', step 9);pnpm everyapp status --jsonshows it indata.apps[].email.senders; the app itself seesready: trueandproviderfromintegrationStatus({ env, provider: "email" }). Then send one mail from the app. - Logs, when a send fails: cf 1.0.0-beta.14 has no
workers tail(cf workers --help;cf o11y telemetry live-tailonly prepares a websocket), so use the dashboard, the setup rules' path: Workers & Pages →every-app-gateway→ Observability (Logs on older accounts) → Live, then trigger the send. A failure is one line, "Email delivery failed", withevent: "email.send.failed",kind,provider(which one refused),code,providerCodeandmessage; an app's send logsemail.app.send. Ornpx wrangler tail every-app-gateway --format prettystreams the same (npx fetches wrangler, not in the checkout; it signs in apart from cf:npx wrangler login, which the person approves in the browser). - Cloudflare's quota and usage:
cf email-sending limits get:quota.valuea day (quota.unit),usage.sentso far,usage.over_quota;quotanull means none is assigned yet (a new account).
If it fails
| error or symptom | meaning | what to do |
|---|---|---|
SECRET_VALUE_REQUIRED |
nothing was piped and there is no terminal | one of the setup rules' three ways |
SECRET_VALUE_REQUIRED from pnpm everyapp configure <app id> --json |
nothing was piped (with --from-env, stdin isn't read), no variable of a missing name was found, and there is no terminal; the message names what the app still needs (EMAIL_FROM and EMAIL_FROM_NAME among them while unset) |
NAME=value lines on stdin (EMAIL_FROM="Acme <noreply@<your domain>>", quoted), or the person's terminal (step 8) |
SECRET_SEVERAL_NAMES |
a pipe or --from-env with two names |
one secret set per name |
SECRET_EMPTY |
the --from-env variable isn't set in the shell that ran the command (exported in theirs, not before you started) |
they run it in their terminal, or the hidden prompt (step 6) |
SETTING_VALUE_INVALID with data.name |
EMAIL_FROM, EMAIL_FROM_NAME or EMAIL_PROVIDER got a value the Gateway wouldn't read (EMAIL_FROM takes an address or Name <address>; EMAIL_PROVIDER cloudflare or resend); nothing was written |
pipe the value data.fix describes |
SECRET_NOT_DECLARED with data.didYouMean |
the name isn't a Gateway setting (a typo) | rerun with the name in data.didYouMean and the same value source (--from-env VAR --json, or the same pipe); data.rerun carries only the names, so run as is it stops with SECRET_VALUE_REQUIRED |
SECRET_NOT_SETTABLE |
a name setup owns (CLOUDFLARE_ACCOUNT_ID, written by everyapp setup) or a dev-only one |
nothing to set; use the names in Settings |
CONFIRMATION_REQUIRED from secret unset --json |
it needs --yes |
ask the person, then rerun with it |
the Invite link dialog instead of a sent mail, and the Users page's banner "Email is not configured."; app gets integration_not_configured (503) |
no sender (an EMAIL_FROM with no name in it and no EMAIL_FROM_NAME counts as none), or no provider is set up, or EMAIL_PROVIDER names one that isn't |
set EMAIL_FROM and EMAIL_FROM_NAME, or EMAIL_FROM in the combined form (step 8); set that provider's setting or unset EMAIL_PROVIDER |
| the Invite link dialog with no banner on the Users page | configured, but the provider refused the send | Verify 5's email.send.failed line, then the provider rows below |
the app's host answers 503 app_needs_setup, or a browser lands on https://<gateway host>/?setup=<app id>; status has APP_NEEDS_SETUP naming EMAIL_FROM, or the Gateway's app list says "Needs setup" while secret list says set: true |
the Gateway keeps an app that lists email closed until it can send: a sender with a name and a provider (Settings) |
step 8 (both settings, or the combined form), then the provider's steps; Verify 2 |
log: EMAIL_PROVIDER is "…", which isn't an email provider |
a value other than cloudflare or resend |
echo cloudflare | pnpm everyapp secret set EMAIL_PROVIDER --json, or unset it |
email_sender_not_verified (422); E_SENDER_NOT_VERIFIED, E_SENDER_DOMAIN_NOT_AVAILABLE, E_RECIPIENT_NOT_ALLOWED (before onboarding, only verified destination addresses are allowed); Resend 403 naming the domain; mail reaches only verified addresses |
the sender's domain isn't onboarded or Verified with the provider | steps 2 to 3 (Cloudflare) or 4 to 5 (Resend) for that domain; dns status or Resend's Records tab |
email_provider_limited (429); E_DAILY_LIMIT_EXCEEDED, E_RATE_LIMIT_EXCEEDED, 10004; Resend daily_quota_exceeded, monthly_quota_exceeded, rate_limit_exceeded |
the provider's quota or rate | wait; cf email-sending limits get, Cloudflare's limit increase form; Resend Pro |
E_ACCOUNT_SUSPENDED |
Cloudflare paused the account's sending (reputation) | Cloudflare support; no self-service path is documented |
email_budget_exhausted (429) |
the Gateway's own daily app-mail budget | wait until UTC midnight (Retry-After) |
email_send_failed (502); log Resend refused the API key (the stable marker; providerCode is Resend's name: validation_error "API key is invalid", restricted_api_key, suspended_api_key, invalid_permission) |
the key is wrong, deleted, suspended, or restricted to another domain; any other providerCode: the provider refused it |
a new key (step 6) and secret set RESEND_API_KEY again, or widen its Domain (step 9); else read providerCode in the log |
email_sender_not_allowed (403) |
the app's from matches none of its senders |
step 9 |
EMAIL_NOT_DECLARED |
everyapp.config.ts doesn't list capabilities.email with access: "write" |
add it; pnpm everyapp deploy --json in the app's directory, then rerun |
APP_NOT_FOUND from email set; email show: "isn't deployed yet" |
no first deploy | pnpm everyapp deploy --json in the app's directory, then rerun |
EMAIL_SENDER_INVALID; EMAIL_SENDERS_HOSTED |
a pattern that isn't lowercase *@<domain> or one address; a hosted Gateway takes no app senders |
fix the pattern (its data.rerun lowercases it); apps send as the Gateway's sender |
EMAIL_ROUTING_DISABLED, EMAIL_ZONE_NOT_FOUND, EMAIL_ADDRESS_TAKEN |
inbound: routing off (needs a yes), zone not in the account, address routed elsewhere | the runbook's inbound section |
| mail lands in spam | no DMARC, or a new domain with no reputation | Cloudflare adds _dmarc itself; add Resend's recommended DMARC record; keep volume low at first |
Rerun, rotate, remove
- Rerun: every step repeats safely.
subdomains createre-enables a domain that exists,dns fixis idempotent,configureandsecret setreplace,email set --senderreplaces the list. Start again from Verify and continue at the first step that isn't done. - Rotate Resend: create a new key (step 6),
secret set RESEND_API_KEY, then delete the old key at https://resend.com/api-keys. The Cloudflare binding has nothing to rotate. Change the sender: step 8 again. - Switch provider: set up the other one, then
EMAIL_PROVIDER;pnpm everyapp secret unset EMAIL_PROVIDER --jsongoes back to the automatic choice. - Remove:
pnpm everyapp secret unset RESEND_API_KEY EMAIL_PROVIDER --json(add--yesonly when the person said so; without it--jsonstops withCONFIRMATION_REQUIRED), then delete the key at https://resend.com/api-keys. UnsettingEMAIL_FROMandEMAIL_FROM_NAMEreturns the Gateway to invite and reset links. An app's senders:pnpm everyapp email unset senders --jsonin its directory. A Cloudflare sending domain, only when asked:cf email-sending subdomains delete <subdomain id> -z <your domain>(disables sending and removes its records; it asks, so add--forceonly after the person said yes, as with--yes), or the dashboard.
Sources
- Cloudflare: https://developers.cloudflare.com/email-service/ (beta, Workers Paid, verified destinations free), https://developers.cloudflare.com/email-service/configuration/domains/ (onboarding, the records it adds), https://developers.cloudflare.com/email-service/get-started/send-emails/ (the dashboard path and deep link, the
EMAILbinding), https://developers.cloudflare.com/api/resources/email_sending/subresources/subdomains/ (the subdomain'stag), https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ (theE_*codes), https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ (the numeric codes), https://developers.cloudflare.com/email-service/platform/limits/ (quota, verified destination addresses, 50 recipients, 5 MiB), https://developers.cloudflare.com/email-service/platform/pricing/ (Workers Paid, 3,000 a month, $0.35 per 1,000), https://developers.cloudflare.com/workers/platform/pricing/ ($5 a month), https://developers.cloudflare.com/workers/observability/logs/real-time-logs/ (Logs > Live), https://developers.cloudflare.com/workers/wrangler/commands/workers/ (wrangler tail) - Resend: https://resend.com/pricing, https://resend.com/docs/api-reference/rate-limit (10 a second per team), https://resend.com/docs/api-reference/errors (error names), https://resend.com/docs/add-a-domain, https://resend.com/docs/dashboard/domains/manage-domains (statuses), https://resend.com/docs/knowledge-base/cloudflare (Sign in to Cloudflare, record names, DNS only, Verify DNS Records), https://resend.com/docs/create-an-api-key, https://resend.com/docs/api-reference/api-keys/create-api-key (Sending access, domain restriction, shown once), https://resend.com/docs/dashboard/api-keys/introduction (permission and domain editable in the Dashboard)
- Local, 2026-10-23:
cf email-sending subdomains {create,preview,delete,dns status,dns fix} --help,cf email-sending limits get(its fields),cf dns records create --helpandcf workers --helpof cf 1.0.0-beta.14 (packages/cli/package.jsonpins it,lib/cf/bin.tsruns it,pnpm --filter everyapp exec cf --versionprints it); docs/runbooks/email-sending-setup.md, docs/capabilities.md and the CLI's error paths (commands/secret,commands/email,lib/gateway/registry.ts,commands/status/problems.ts) in the checkout; 2026-10-24:packages/sdk/src/internal/email.ts(parseEmailSender: the combined form, a name required) and the Gateway'sserver/email/sendEmail.ts(configuredSender,isEmailConfigured),commands/deploy/deployApp.tsandcommands/email/index.ts(theemail set --sendernext step),commands/configure/index.ts(configure <app>: the visible prompts forEMAIL_FROMandEMAIL_FROM_NAME,NAME=valuelines on stdin), the runbook's "Sender configuration" as of #389;packages/sdk/src/internal/capabilities.ts(theemailentry:settings: ["EMAIL_FROM"], the restoptionalSettings; later on 2026-10-24EMAIL_FROM_NAMEmoved intosettings, the shape the top of this guide describes) andappSettings.ts,packages/cli/src/lib/app/settings.ts(gatewayNeeds,gatewayMissing) andcommands/secret/index.ts(secret list <app>), the Gateway'sserver/app-setup.ts(readiness fromisEmailConfigured) andserver/app-needs-setup.ts(the closed app), after #383, #395, #396 and #397 merged
Cloudflare Access for the Gateway
Step-by-step guide: turn Cloudflare Access on in front of the Gateway and every app (the Zero Trust team, the one-time PIN, Sign in with Cloudflare), check it, recover from a lockout, turn it off.
The default model
Step-by-step guide: give apps that say ai: { chat: true } a default model with one provider's key (OpenRouter first), pick it with everyapp ai default, check it.