Skip to content

Email and texts

A provider sends codes and notices by email or by text. Email goes through Cloudflare Email Sending: no API key, no third party. Texts go through TextBee, a phone app that sends SMS from its own SIM. Without either, the provider still runs; the doors that need it say so plainly.

What turns on what

You set What works
Nothing OIDC, passkeys, the Omxus key. Email sign-up on the standard door answers 503 email_unavailable; phone sign-up answers 503 sms_unavailable. The email-and-password door (/email/*) is not mounted, so it answers 404.
EMAIL binding Standard-door codes and notices by email.
EMAIL binding + RECOVERY_JWK The email-and-password door, with forgot-password.
TEXTBEE_API_KEY + TEXTBEE_DEVICE_ID Standard-door codes by text.

1. Turn on Email Sending for your domain

In the Cloudflare dashboard: Compute → Email Service → Email Sending → Onboard Domain, pick your zone, then Add records and onboard. Or:

npx wrangler email sending enable example.com
npx wrangler email sending dns get example.com   # the records it added

It adds four records:

Type Name Value
MX cf-bounce.example.com Cloudflare's bounce handlers
TXT cf-bounce.example.com v=spf1 include:_spf.mx.cloudflare.net ~all
TXT cf-bounce._domainkey.example.com the DKIM key Cloudflare makes for the zone
TXT _dmarc.example.com a DMARC policy

Allow 5 to 15 minutes on a Cloudflare zone. A send before then fails with E_SENDER_NOT_VERIFIED. Check, then send yourself one:

dig TXT cf-bounce._domainkey.example.com +short
npx wrangler email sending send --from [email protected] --to [email protected] \
  --subject test --text test

If anything else sends as your domain (a mailing list, a contact form), check the DMARC policy first: a strict one bounces that mail.

2. The binding

wrangler.toml already carries it, at the top level and under [env.dev]:

[[send_email]]
name = "EMAIL"

The sender address is set in server/email/mailer.js (FROM, [email protected]). On your own domain, change it there to an address on the zone you onboarded.

3. The recovery key

Forgot-password on the email-and-password door rests on one P-256 key pair. At sign-up the device seals its recovery secret to the public half; only the private half opens it again. The private half lives in the secret store, never in the database.

node worker/mint-key.mjs   # prints three secrets; take RECOVERY_JWK
npx wrangler secret put RECOVERY_JWK

Paste the whole JSON object on one line.

  • Lose it and every email account loses forgot-password for good. Passwords still work.
  • Leak it and a stolen database becomes a set of recoverable accounts.
  • Never reuse it as the OIDC signing key.

The public half is served at GET /email/recovery-key:

{"jwk":{"kty":"EC","crv":"P-256","x":"...","y":"..."}}

4. Texts (optional)

Install the TextBee gateway app on a phone with a SIM, then:

npx wrangler secret put TEXTBEE_API_KEY
npx wrangler secret put TEXTBEE_DEVICE_ID

The provider posts each text to https://api.textbee.dev/api/v1/gateway/devices/<device id>/send-sms. The text reads 123456 is your code. It expires in 10 minutes., followed by a @yoursite.example #123456 line so phones offer to fill the code on that site only.

5. The schema

The email door's three tables are in server/email/email.sql, already part of both database scripts:

npm run worker:db:local
npm run worker:db:remote

What it sends

Plain text only, no HTML part, no tracking, no marketing.

Door Message Sent to Subject
Standard Code (sign-up, sign-in, address change) the address being proved 123456 is your code
Standard Someone signed up with an address that already has an account that account's address Someone tried to sign up with this address
Standard Address change started the old address Your sign-in address is being changed
Standard Address change done the old address Your sign-in address was changed
Email and password Sign-up code the new address 123456 is your Omxus code
Email and password Reset link the account's address Reset your Omxus password

On the standard door, a phone number gets the same messages by text.

The code is in the subject so phones offer to fill it, and on the first line so a preview shows it. Codes last 10 minutes and allow 5 tries. The reset link is /email/reset#t=<token>: the token sits in the fragment, so it never reaches a server log or a referrer header. It works once and lasts 15 minutes.

When sending fails

Answer Meaning
503 email_unavailable Standard door, no EMAIL binding.
503 sms_unavailable Standard door, no TextBee secrets.
502 send_failed Standard door: the code was stored but delivery failed. Try again.
502 mail_failed Email-and-password door: delivery failed. No account is made.
404 on /email/* The email-and-password door is not mounted: binding or RECOVERY_JWK missing.

Local development

Nothing leaves your machine.

  • Tests use createRecordingMailer() and createRecordingSms(), which keep each message in an array so a test reads the code out of it.
  • wrangler dev has no verified domain. Run it with --var ISSUER:http://localhost:8787 --var ACCOUNTS_LOG_CODES:1 and each code email's address and subject print to the console. The switch refuses to start the Worker on any issuer that is not http://localhost or http://127.0.0.1, so a deployed provider never logs codes.
  • To send real mail from wrangler dev, add remote = true to the send_email block, send only to yourself, and take it out before deploying.

Limits

Cloudflare starts a new sender on a modest daily quota that grows with its record. Per message: 50 recipients, 5 MiB. Mail to a verified destination address on your own account is free and counts against no quota, which suits testing.