Files
cloudflare_temp_email/vitepress-docs/docs/en/guide/config-send-mail.md
jiaxin ebeb94ed23 fix: auto initialize default send balance (#985)
* fix: auto initialize default send balance

* fix: tighten send access auto init flow

* refactor: centralize send balance state

* fix: separate legacy repair from admin control in send balance

Add an `address_sender.source` column to distinguish legacy / auto /
user / admin rows. `ensureDefaultSendBalance` now only repairs rows
with `source IS NULL`, so admin-disabled and user-requested rows are
never overwritten. Admin POST writes tag `source = 'admin'`; new
auto-init inserts tag `'auto'`; `requestSendMailAccess` inserts tag
`'user'`.

Bumps DB_VERSION to v0.0.8 with the usual `PRAGMA table_info` guarded
ALTER, plus a standalone SQL patch under db/.

Adds E2E regressions: legacy repair path, admin-disabled rows stay
disabled across settings and send, send after admin deletion
auto-initializes a fresh row.

* fix: drop runtime legacy repair; backfill source='legacy' on migrate

Pre-v0.0.8 schema cannot distinguish legacy request-send-access
remnants from admin-disabled rows — both share `balance = 0,
enabled = 0`. Letting ensureDefaultSendBalance repair that shape on
upgrade could silently re-enable an admin-disabled row.

Remove the runtime repair path entirely:

- `ensureDefaultSendBalance` now uses `ON CONFLICT(address) DO NOTHING`;
  existing rows are never touched.
- The v0.0.8 migration (and the matching SQL patch) backfills every
  pre-existing row with `source = 'legacy'`, making pre-migration
  state explicitly off-limits to runtime auto-init.
- E2E: flip the legacy test to the negative direction — a
  `source='legacy'` zero-balance row stays untouched by settings
  reads and send attempts. Harden `resetSenderToLegacy` to return
  404 when `meta.changes < 1`.
- Update changelog and docs: legacy/admin-disabled rows must be
  restored manually via the admin UI.

* refactor: collapse send balance auto-init to missing-row insert

Per review feedback: the runtime guarantee we actually need is
"create an address_sender row when one is missing, leave existing
rows alone". Once `ensureDefaultSendBalance` switched to
`ON CONFLICT DO NOTHING`, the `source` column, the v0.0.8 migration,
and the `resetSenderToLegacy` test endpoint became dead weight —
the DO NOTHING path already protects admin-disabled and admin-edited
rows without any provenance metadata.

- Drop `address_sender.source` and the v0.0.8 migration; revert
  DB_VERSION to v0.0.7. No schema change ships with this PR.
- Strip the `source` field from `ensureDefaultSendBalance`,
  `requestSendMailAccess`, and the admin-update path.
- Remove the `/admin/test/reset_sender_to_legacy` test endpoint and
  its E2E helper; the negative legacy-repair test it served is no
  longer needed because the runtime no longer touches existing rows.
- E2E coverage stays focused on the three guardrails: missing-row
  auto-init, admin-disabled rows stay disabled, admin deletion
  triggers a fresh re-insert.
- Tighten changelog and docs to "auto-initialize missing rows".

* docs: align common-issues with missing-row-only auto-init

The FAQ entries for "DEFAULT_SEND_BALANCE set but still No balance"
still described the old behaviour of repairing legacy
`balance = 0 && enabled = 0` rows. Rewrite both zh and en rows to
match the current runtime: only addresses with no existing
`address_sender` row get auto-initialised; legacy, admin-disabled,
and admin-edited rows must be restored manually through the admin
console.
2026-04-20 12:40:14 +08:00

6.7 KiB

Configure Email Sending

::: tip Recommended Use Cloudflare send_email binding as the default send channel. Bind SEND_MAIL and finish Email Routing onboarding, then the Worker can send to any external address directly.

Workers Paid includes 3,000 messages/month, then $0.35 per 1,000 messages. :::

Send Channel Priority

Each /api/send_mail request matches channels in order; the first hit sends:

Order Condition Channel Deducts balance
1 SEND_MAIL bound AND recipient in verifiedAddressList Cloudflare binding (compat mode) No
2 RESEND_TOKEN or RESEND_TOKEN_<DOMAIN> set Resend API Yes
3 SMTP_CONFIG has entry for current domain worker-mailer SMTP Yes
4 SEND_MAIL bound (none of the above) Cloudflare binding (recommended primary) Yes
None of the above Throws

Note

Binding send failures return an error directly.

Only available when deploying via CLI. Add to wrangler.toml:

# Send emails via the Cloudflare send_email binding
send_email = [
   { name = "SEND_MAIL" },
]

[!warning] Important The binding name must be SEND_MAIL — different from Cloudflare's official SEND_EMAIL example.

After the following steps, you can send to any external address directly:

  1. Enable Email Routing on the domain in the Cloudflare Dashboard and complete onboarding
  2. Add the send_email binding shown above to wrangler.toml
  3. Deploy the Worker

No additional env var is required.

Send Emails Using Resend

Register at https://resend.com/domains and add DNS records according to the instructions.

Create an api key on the API KEYS page.

Then execute the following command to add RESEND_TOKEN to secrets:

Note

If you find this troublesome, you can also put it directly in plain text under [vars] in wrangler.toml, but this is not recommended

If you deployed through the UI, you can add it under Variables and Secrets in the Cloudflare UI interface.

# Switch to worker directory
cd worker
wrangler secret put RESEND_TOKEN

If you have multiple domains with different api keys, you can add multiple secrets in wrangler.toml, named RESEND_TOKEN_ + <UPPERCASE DOMAIN WITH . REPLACED BY _>, for example:

wrangler secret put RESEND_TOKEN_XXX_COM
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ

Send Emails Using SMTP

The format of SMTP_CONFIG is as follows. The key must be your own sending domain, and the value is the SMTP configuration.

For SMTP configuration format details, refer to zou-yu/worker-mailer

[!warning] Important The JSON key (e.g. your-domain.com in the example below) must be replaced with your own domain — the domain configured in your DOMAINS variable. This is one of the most common configuration mistakes. Do not copy the example domain directly.

{
    "your-domain.com": {
        "host": "smtp.example.com",
        "port": 465,
        "secure": true,
        "authType": [
            "plain",
            "login"
        ],
        "credentials": {
            "username": "your-smtp-username",
            "password": "your-smtp-password"
        }
    }
}

Field Reference:

Field Description
key (e.g. your-domain.com) Your sending domain, must match a domain configured in DOMAINS
host SMTP server address, e.g. smtp.mailgun.org, smtp.gmail.com, or your self-hosted SMTP server
port SMTP port, typically 465 (SSL) or 587 (STARTTLS)
secure Whether to use SSL/TLS. Set to true for port 465, false for port 587
authType Authentication method, typically ["plain", "login"]
credentials.username SMTP server login username
credentials.password SMTP server login password

If you have multiple domains using different SMTP services, add multiple keys in the same JSON:

{
    "domain-a.com": {
        "host": "smtp.mailgun.org",
        "port": 465,
        "secure": true,
        "authType": ["plain", "login"],
        "credentials": { "username": "user@domain-a.com", "password": "xxx" }
    },
    "domain-b.com": {
        "host": "smtp.gmail.com",
        "port": 465,
        "secure": true,
        "authType": ["plain", "login"],
        "credentials": { "username": "user@gmail.com", "password": "app-password" }
    }
}

Then execute the following command to add SMTP_CONFIG to secrets:

Note

If you find this troublesome, you can also put it directly in plain text under [vars] in wrangler.toml, but this is not recommended

If you deployed through the UI, you can add it under Variables and Secrets in the Cloudflare UI interface.

# Switch to worker directory
cd worker
wrangler secret put SMTP_CONFIG

Send Balance Mechanism

Users need a send balance to send emails. The balance mechanism works as follows:

  1. Auto-initialize Default Quota: When DEFAULT_SEND_BALANCE > 0, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
  2. Manual Request: If DEFAULT_SEND_BALANCE = 0, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
  3. Unlimited Sending: The following methods can bypass balance checks:
    • Add the address to the "No Limit Send Address List" in the admin console
    • Configure the NO_LIMIT_SEND_ROLE environment variable to specify roles that can send without limits

Note

DEFAULT_SEND_BALANCE only inserts an initial quota for addresses that do not yet have an address_sender row (ON CONFLICT DO NOTHING); existing rows — including admin-disabled or admin-edited ones — are never modified by the runtime path. Restoring a previously disabled or pre-existing address must go through the admin console (enable + set balance).

Layer 1 (verifiedAddressList hit) does not deduct balance, but it still counts toward send limits; layers 2/3/4 all deduct balance.

Send limits apply to all send channels, including admin send endpoints.

Daily and monthly windows are calculated in UTC.

The current limit implementation is a soft guard. It is suitable for routine quota control, but it should not be treated as a strict hard-stop cost gate under database errors or high concurrency.

Send Emails to Authenticated Forwarding Addresses on Cloudflare

Typical use case: non-onboarded domains or Workers free-tier users.

In this compatibility mode, mail is sent via SEND_MAIL binding only when the recipient is in the admin Verified Address List.