Skip to content

Email

Markdown

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.

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” when providers.rebuild is none.
  • 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 at queue.digestHourUtc, only while something waits.
  • login-link — the sign-in link, when features.accounts is 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.

  1. Enable sending on the domain emailFrom will 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.com
    pnpm exec wrangler email sending list

    The second command lists the domain once enabled; pnpm cf:setup runs the same check and leaves the binding out while the list is empty.

  2. Keep the binding. Put it back if cf:setup removed it, then regenerate the types.

    wrangler.jsonc
    "send_email": [{ "name": "EMAIL" }]
    Terminal
    pnpm cf:typegen

    No remote: true: pnpm dev and the tests get Miniflare’s emulation, and nothing is sent locally.

  3. 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:config

    site.config.ts OK — …. A sender in the wrong shape stops at site.config.ts › providers.emailFrom: must look like "Name <addr@example.com>".

  4. Deploy, with pnpm deploy or 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.

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> …

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.

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
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_email binding and its comment
  • .dev.vars.example RESEND_API_KEY and its instructions
  • site.config.ts the providers block
  • 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 EMAIL binding
        • 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
      • 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_outbox table
  • Directorytests/unit/
    • email-wiring.test.ts every provider gets the binding