# Email

> Every message the site sends — receipts, decisions, sign-in links, your notifications — through Cloudflare Email Sending, Resend or the log, via an outbox.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/email/

---

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.

> **Free edition**
>
> The free edition has no Worker, so it sends no mail; there is nothing to configure.

## 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" 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`.

## Setup

**Cloudflare Email Sending**

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.

   ```sh title="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.

   ```jsonc title="wrangler.jsonc"
   "send_email": [{ "name": "EMAIL" }]
   ```

   ```sh title="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.

   ```ts title="site.config.ts"
   providers: {
     email: 'cloudflare',
     emailFrom: 'Acme Directory <hello@acme.com>',
   },
   ```

   ```sh title="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.

**Resend**

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.

1. Add the domain `emailFrom` will use in the Resend dashboard, add the DNS records it gives
   you, and wait for "Verified"; an unverified sender is refused with a 403.

2. Create an API key with Sending access only (it begins `re_`) and store it.

   ```sh title="Terminal"
   pnpm cf:secrets RESEND_API_KEY
   ```

3. Set the provider.

   ```ts title="site.config.ts"
   providers: {
     email: 'resend',
     emailFrom: 'Acme Directory <hello@acme.com>',
     adminNotify: 'cloudflare-verified',
   },
   ```

   `adminNotify: 'cloudflare-verified'` sends your own notifications through the `EMAIL` binding
   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 with
   `pnpm exec wrangler email routing addresses create you@example.com`; without the binding it
   falls back to Resend.

4. `pnpm check:config`, then deploy.

**console**

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

| 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

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

| 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

| Field | Default | What it changes |
| --- | --- | --- |
| [`providers.email`](https://zerodirs.com/docs/configure/reference/#providers) | `'console'` | the transport for submitter mail |
| [`providers.emailFrom`](https://zerodirs.com/docs/configure/reference/#providers) | `''` | the `From` header; required unless `console`, and on the enabled domain |
| [`providers.adminNotify`](https://zerodirs.com/docs/configure/reference/#providers) | `'same'` | where your own notifications and the digest go |
| [`admin.email`](https://zerodirs.com/docs/configure/reference/#admin) | — | their recipient; `site.contactEmail` when unset |
| [`queue.digestHourUtc`](https://zerodirs.com/docs/configure/reference/#queue) | `9` | when the digest goes out; `null` switches it off |
| [`features.accounts`](https://zerodirs.com/docs/configure/reference/#features) | `false` | on, and `login-link` exists |

## Files

- wrangler.jsonc the `send_email` binding and its comment
- .dev.vars.example `RESEND_API_KEY` and its instructions
- site.config.ts the `providers` block
- scripts/
  - cloudflare-setup.ts drops the binding without a sending domain
- src/
  - server/
    - email/
      - 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
      - templates/
        - 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
  - actions/
    - submit.ts the flush after a submission
    - auth.ts the inline flush for sign-in links
  - db/
    - schema.ts the `email_outbox` table
- tests/unit/
  - email-wiring.test.ts every provider gets the binding

## Related

- [Plans and payments](https://zerodirs.com/docs/submissions/payments/) — The receipt a priced tier gets only once the money lands.
- [Submitter accounts](https://zerodirs.com/docs/submissions/accounts/) — Sign-in by emailed link, and Google as an option.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/#bindings) — The EMAIL binding next to D1 and R2, and where RESEND_API_KEY goes.
- [Configure email (recipe)](https://zerodirs.com/docs/agents/recipes/configure-email/) — The skill that ships with the starter and walks an agent through the same setup.
