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]:
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:
4. Texts (optional)¶
Install the TextBee gateway app on a phone with a SIM, then:
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:
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()andcreateRecordingSms(), which keep each message in an array so a test reads the code out of it. wrangler devhas no verified domain. Run it with--var ISSUER:http://localhost:8787 --var ACCOUNTS_LOG_CODES:1and each code email's address and subject print to the console. The switch refuses to start the Worker on any issuer that is nothttp://localhostorhttp://127.0.0.1, so a deployed provider never logs codes.- To send real mail from
wrangler dev, addremote = trueto thesend_emailblock, 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.