A submitter gets a receipt, a decision and, with accounts on, a sign-in link; you get a
notification per submission and a daily digest while the queue is not empty. providers.email
in site.config.ts picks the transport — 'cloudflare', 'resend' or 'console' — and every
message is written to the email_outbox table before anything tries to send it.
The messages
Section titled “The messages”By template name:
submitted— the receipt: when a free listing lands, with the expected wait and how to skip the queue; again once a payment lands, as “Payment received” or “Upgrade received”.approved— the page it will have, live within minutes or “with the next publish” whenproviders.rebuildisnone.rejected— the reason you typed, verbatim.admin-new-submission— to you, per submission, with the tier, the payment state and a “Review it” link.admin-digest— to you, once a day atqueue.digestHourUtc, only while something waits.login-link— the sign-in link, whenfeatures.accountsis on.
Your own mail goes to admin.email, falling back to site.contactEmail.
The shipped default: the EMAIL binding, no API key, no second vendor. It needs the Workers
Paid plan; mail to a verified destination address is free on any plan.
-
Enable sending on the domain
emailFromwill use. The zone must be on Cloudflare DNS; the command adds the SPF and DKIM records itself.Terminal pnpm exec wrangler email sending enable acme.compnpm exec wrangler email sending listThe second command lists the domain once enabled;
pnpm cf:setupruns the same check and leaves the binding out while the list is empty. -
Keep the binding. Put it back if
cf:setupremoved it, then regenerate the types.wrangler.jsonc "send_email": [{ "name": "EMAIL" }]Terminal pnpm cf:typegenNo
remote: true:pnpm devand the tests get Miniflare’s emulation, and nothing is sent locally. -
Set the provider and the sender, on the domain you enabled.
site.config.ts providers: {email: 'cloudflare',emailFrom: 'Acme Directory <hello@acme.com>',},Terminal pnpm check:configsite.config.ts OK — …. A sender in the wrong shape stops atsite.config.ts › providers.emailFrom: must look like "Name <addr@example.com>". -
Deploy, with
pnpm deployor a push.
Email Routing on the same zone can forward admin.email to your inbox. Sent mail shows as
dropped in the Email Routing summary even when delivered; the Email Sending logs tell the truth.
For the Workers Free plan, or if you already use Resend: one fetch to its REST API, no SDK.
The free tier is 3,000 messages a month and a hard 100 a day.
-
Add the domain
emailFromwill use in the Resend dashboard, add the DNS records it gives you, and wait for “Verified”; an unverified sender is refused with a 403. -
Create an API key with Sending access only (it begins
re_) and store it.Terminal pnpm cf:secrets RESEND_API_KEY -
Set the provider.
site.config.ts providers: {email: 'resend',emailFrom: 'Acme Directory <hello@acme.com>',adminNotify: 'cloudflare-verified',},adminNotify: 'cloudflare-verified'sends your own notifications through theEMAILbinding to a verified destination address — free on any plan, and off the 100-a-day cap. It needs the binding kept, Email Routing on the zone, and the recipient verified withpnpm exec wrangler email routing addresses create you@example.com; without the binding it falls back to Resend. -
pnpm check:config, then deploy.
The default when the providers block is deleted. Nothing is sent: each message is logged as
email(console): to=… subject=… html=…b text=…b and its row is marked sent. /admin/
warns while this is the provider.
Verify
Section titled “Verify”| Check | Where | You should see |
|---|---|---|
| The config parses | pnpm check:config |
site.config.ts OK — …; the emailFrom rule fails here, not in production |
| Nothing is missing | /admin/ |
no email line in the configuration report |
| A message left | submit a test listing to your own address | the receipt within a minute |
| The rows | pnpm exec wrangler d1 execute DB --local --command "SELECT template, status, attempts, last_error, created_at, sent_at FROM email_outbox ORDER BY created_at DESC LIMIT 5" (--remote for production) |
status sent and a sent_at; otherwise last_error says why: cloudflare E_SENDER_NOT_VERIFIED — the domain is not enabled; resend HTTP 403 — the domain is not verified there, or the key is wrong; resend HTTP 429 — the daily cap |
| Per submitter | /admin/listings/<id>/, “Mail to this submitter” |
each row as template · status · attempts · date, with its last error |
| The hourly flush, locally | pnpm build, then pnpm exec wrangler dev, then curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*". Under pnpm dev (Astro’s dev server) that URL answers 404 |
one line in wrangler’s log: jobs: expireFeatured=0 flushOutbox=<n> … |
How it works
Section titled “How it works”Nothing is sent inline. A submission, a decision or a sign-in request writes a pending row
and returns; the same request flushes the outbox once the response is decided, so a sign-in
link arrives now. The hourly job flushes the rest, up to twenty rows per tick, one at a time —
twenty at once would burst Resend’s ten requests a second. Rows are rendered at send time, so a
copy fix reaches queued mail.
A provider never throws: a failed send is attempts + 1 and a last_error. After five
attempts the row is parked as failed, the dashboard says “N emails gave up after five
attempts. Open the listing and use Resend.”, and only that control moves it again, by queuing
a fresh message for the listing’s current state.
providers.email carries the submitter’s mail; providers.adminNotify decides where yours
goes: 'same' the same provider, 'cloudflare-verified' the EMAIL binding even beside
Resend, 'off' none. A missing credential degrades rather than fails: Resend without its key,
or Cloudflare without the binding, becomes the console provider.
emailFrom is required whenever the provider is not console and must look like
Name <addr@example.com>; replies go to site.contactEmail.
When it is off or degraded
Section titled “When it is off or degraded”| Configuration | A submitter sees | /admin/ reports |
Fix |
|---|---|---|---|
providers.email: 'console' |
no mail; rows are marked sent |
providers.email is "console": submitters get no mail, it is only logged. (warning) |
Set providers.email to "resend" or "cloudflare" in site.config.ts. |
'resend' without RESEND_API_KEY |
no mail; the console provider marks the rows sent |
Missing secret RESEND_API_KEY — run: wrangler secret put RESEND_API_KEY (local: add to .dev.vars). Queued mail stays in email_outbox. (error) |
pnpm cf:secrets RESEND_API_KEY |
'cloudflare' without the EMAIL binding |
no mail; rows marked sent |
nothing — only cf:setup says so, when it leaves the binding out |
enable the domain, restore the binding, pnpm cf:typegen, deploy |
'cloudflare', domain not enabled or Workers Free |
rows stay pending with cloudflare E_SENDER_NOT_VERIFIED: …, retried hourly, failed after five tries |
the failed-email line once they give up | enable the domain or the plan, then Resend from the listing |
'resend', domain unverified or over the daily cap |
rows stay pending with resend HTTP 403 … or resend HTTP 429 rate limited …, same retry path |
the same | verify the domain; the cap clears by itself |
adminNotify: 'cloudflare-verified' without the binding |
your notifications go through providers.email and count against its quota |
nothing | keep the binding |
emailFrom empty or malformed |
the build stops | — | site.config.ts › providers.emailFrom: required when providers.email is "resend" names the line |
Configuration
Section titled “Configuration”| Field | Default | What it changes |
|---|---|---|
providers.email |
'console' |
the transport for submitter mail |
providers.emailFrom |
'' |
the From header; required unless console, and on the enabled domain |
providers.adminNotify |
'same' |
where your own notifications and the digest go |
admin.email |
— | their recipient; site.contactEmail when unset |
queue.digestHourUtc |
9 |
when the digest goes out; null switches it off |
features.accounts |
false |
on, and login-link exists |
- wrangler.jsonc the
send_emailbinding and its comment - .dev.vars.example
RESEND_API_KEYand its instructions - site.config.ts the
providersblock Directoryscripts/
- cloudflare-setup.ts drops the binding without a sending domain
Directorysrc/
Directoryserver/
Directoryemail/
- index.ts the provider selector and
adminNotify - outbox.ts
enqueueEmail,flushOutbox, the five attempts - cloudflare.ts the
EMAILbinding - resend.ts one
fetch; the 429 and 403 messages - console.ts the log line
Directorytemplates/
- submitted.ts
- approved.ts
- rejected.ts
- admin-new-submission.ts
- admin-digest.ts
- login-link.ts
- layout.ts the shared shell
- index.ts the provider selector and
- jobs.ts the hourly
flushOutbox - secrets.ts the messages
/admin/shows
Directoryactions/
- submit.ts the flush after a submission
- auth.ts the inline flush for sign-in links
Directorydb/
- schema.ts the
email_outboxtable
- schema.ts the
Directorytests/unit/
- email-wiring.test.ts every provider gets the binding