---
title: "Send Email from an EmDash Site: Cloudflare Email Sending & SMTP"
description: "Set up EmDash email on Cloudflare Workers: onboard the sender domain, add the send_email binding, activate cloudflareEmail, plus the emdash-smtp and free Brevo path."
date: 2026-10-05
categories: ["self-hosting"]
tags: ["emdash","cloudflare","smtp"]
---

import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

An EmDash site sends outbound mail for magic-link sign-in, team invitations, account recovery and comment notifications, plus whatever a plugin needs to deliver (contact forms, for one). Without a configured provider the admin still works, but every flow that sends mail returns `503 EMAIL_NOT_CONFIGURED`, and invite links have to be copied by hand. The [Cloudflare deploy guide](/deploy-emdash-cloudflare-workers/) flagged this and deliberately skipped the fix. This is that guide; if you have not deployed yet, that guide and the [EmDash CMS tutorial](/emdash-cms-tutorial/) come first.

On Cloudflare Workers there is a native path with no external account and no API key: the `send_email` binding backed by Cloudflare Email Sending, wired into EmDash with the first-party `cloudflareEmail()` plugin. It is what runs on [emdashhq.com](https://emdashhq.com/), my EmDash hub for tutorials, themes and plugins, and every command below is one I ran there. There is also a second path, the community `emdash-smtp` plugin, which is the real answer for anyone on Workers Free or off Cloudflare entirely. This article covers both, then finishes with the contact-forms plugin I built on top of the working setup.

<Button text="EmDash on GitHub" link="https://github.com/emdash-cms/emdash" variant="solid" color="blue" size="md" icon="arrow-right" />

## Cloudflare email in three lanes

Cloudflare has several features with "email" in the name that do different jobs. Separating them first saves a confused hour later.

**Email Routing** receives mail addressed to your domain and forwards it somewhere else. `dragos@emdashhq.com` is a routing rule: mail sent there lands in a real mailbox behind it (a Gmail account, in my case). Routing is free, unlimited, and available on every plan. Cloudflare does not host mailboxes, and routing alone cannot send anything.

**Email Sending to arbitrary recipients** is the transactional outbound service, the thing a CMS needs for magic links and invites. It requires Workers Paid ($5/month). Each account gets 3,000 outbound emails per month included in the plan, then $0.35 per 1,000 beyond that. Messages that hard-bounce still count toward quota; messages rejected at the API boundary (bad payload, suppression list) do not.

**Sending to verified destination addresses** is the exception that works on every plan, Workers Free included. A verified destination address is one you proved you own in the Email Routing settings, the same addresses routing rules forward to. Before any domain is onboarded for Email Sending, a `send_email` binding can only deliver to those addresses, and only from an address on a routing domain you own. Those sends are always free and never count against quota or daily limits. Good for testing and "send me a copy" flows, useless for a CMS that mails arbitrary users.

|  | Workers Free | Workers Paid ($5) |
| --- | --- | --- |
| Receive + forward inbound mail (Email Routing) | Unlimited | Unlimited |
| Send to verified destination addresses | Free | Free (does not count toward quota) |
| Send to any recipient (Email Sending) | Not available | 3,000/month included, then $0.35 per 1,000 |
| Daily quota | n/a | Starts conservative, scales with sender reputation |

<Notice type="warning" title="MailChannels tutorials are outdated">
Older tutorials send Worker email through MailChannels for free. That integration ended; MailChannels is no longer a Cloudflare partner and those instructions fail silently or with binding errors today. Email Sending is the replacement, and on a paid plan it is simpler than MailChannels ever was.
</Notice>

## How EmDash delivers email

EmDash delegates sending to one active provider plugin. The pipeline is short: a feature (auth, invites, comments, a plugin) produces a message, email hooks can transform or cancel it, and the provider plugin's `email:deliver` handler does the send.

- **Development.** `astro dev` auto-activates a built-in console provider when nothing else is selected. It sends nothing; it logs each message to the terminal and keeps the last 100 in memory. You can list them at `GET /_emdash/api/dev/emails` while signed in. Magic links work in dev by copying the URL out of the log.
- **Production.** The console provider is compiled out. If no provider plugin is active, `Settings > Email` in the admin shows no provider and sends fail until one is installed, activated and selected.
- **On Cloudflare.** `cloudflareEmail()` from `@emdash-cms/cloudflare/plugins` delivers through the Worker's `send_email` binding. Credentials are the binding plus the onboarded domain; there is no token to store or rotate.
- **Anywhere else.** The community [`emdash-smtp`](https://github.com/masonjames/emdash-smtp) plugin family covers generic SMTP and hosted providers (SES, Brevo, Mailgun, Postmark, Resend, SendGrid, Zoho and more). Jump to [Option 2](#option-2-smtp-and-hosted-providers-with-emdash-smtp).

The admin has a **Send test email** button under `Settings > Email` that exercises the whole pipeline and surfaces provider errors directly. It is the fastest verification step in this article. The full mechanism is also documented in the [EmDash email guide](https://docs.emdashcms.com/guides/email/).

## Setting up Cloudflare Email Sending on emdashhq.com

### Step 1: onboard the sender domain

Sending requires the sender's domain to be onboarded to Email Service. The dashboard path is **Compute > Email Service > Email Sending > Onboard Domain**. The CLI does the same job:

```bash
npx wrangler email sending enable emdashhq.com
```

Onboarding creates DNS records automatically:

| Record | Name | Purpose |
| --- | --- | --- |
| MX x3 | `cf-bounce.emdashhq.com` | Route bounce messages back to Cloudflare |
| TXT (SPF) | `cf-bounce.emdashhq.com` | Authorize Cloudflare to send for the domain |
| TXT (DKIM) | `cf-bounce._domainkey.emdashhq.com` | Sign outbound mail |
| TXT (DMARC) | `_dmarc.emdashhq.com` | Policy record (`p=reject` here) |

Verify the state and the exact records Cloudflare expects:

```bash
npx wrangler email sending settings emdashhq.com
npx wrangler email sending dns get emdashhq.com
```

And verify what is actually live in DNS:

```bash
dig +short MX cf-bounce.emdashhq.com
dig +short TXT cf-bounce.emdashhq.com
dig +short TXT cf-bounce._domainkey.emdashhq.com
dig +short TXT _dmarc.emdashhq.com
```

On emdashhq.com all four were already present before the code change. DNS on Cloudflare's own resolver typically propagates in minutes. Any address on the onboarded domain is an accepted sender; you do not register `hello@` or `dragos@` individually.

<Notice type="warning" title="Watch for duplicate SPF records">
Two identical `v=spf1` TXT records on the same hostname is an RFC violation that produces a permerror in strict receivers. Email Routing adds one SPF record to the apex and Email Sending adds one to `cf-bounce`. If the apex ends up with a duplicate after repeated onboarding runs, delete the extra in the DNS dashboard.
</Notice>

### Step 2: add the binding

In `wrangler.jsonc`, alongside the existing D1, R2 and KV bindings:

```jsonc
"send_email": [
	{
		"name": "EMAIL"
	}
],
```

The name is what the plugin looks up on the Worker environment. `EMAIL` is the default; a different name has to be passed to the plugin as its `binding` option. The full builder API behind the binding (attachments, custom headers, 50-recipient cap) is in the [Workers API docs](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/).

### Step 3: register the plugin

In `astro.config.mjs`:

```js
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";

emdash({
	// database, storage, objectCache, migrations...
	plugins: [
		siteScriptsPlugin(),
		cloudflareEmail({
			from: { email: "hello@emdashhq.com", name: "EmDash HQ" },
			replyTo: "dragos@emdashhq.com",
		}),
	],
});
```

Two options matter:

- `from` sets the sender. A bare string or `{ email, name }`. The domain must be onboarded or every send fails with `E_SENDER_NOT_VERIFIED`. A `hello@`-style address reads better than a personal mailbox on system mail.
- `replyTo` is where human replies go. With a `hello@` or `no-reply` sender, point it at a mailbox that exists so replies do not get lost. A message-level `replyTo` overrides this.

There is no `wrangler secret` step. The binding is the credential: only your Worker can use it. This mirrors the email section of the [EmDash Cloudflare deployment doc](https://docs.emdashcms.com/deployment/cloudflare/).

### Step 4: deploy

```bash
npm run deploy
```

The site's deploy script builds, verifies the migration manifest against production D1, uploads the Worker and warms the page cache. A `send_email` binding needs no provisioning; it attaches to whatever Worker declares it.

If the deploy is gated by pending core migrations (the case right after upgrading `emdash`), apply them first:

```bash
npx wrangler email sending settings emdashhq.com   # optional re-check
npx emdash migrate --status --wrangler-config wrangler.jsonc
npx emdash migrate --wrangler-config wrangler.jsonc
npm run deploy
```

### Step 5: activate and test in the admin

Registration makes the plugin available; it is not active yet.

1. Open `https://emdashhq.com/_emdash/admin` and go to **Extensions**. Activate the Cloudflare email plugin.
2. Open **Settings > Email**. If it is the only provider, EmDash selects it automatically; otherwise pick it.
3. Click **Send test email** to an address you can check.

A green result means the whole path works: admin, hook pipeline, binding, Email Sending, DNS and the recipient's filters.

You can also test the binding without involving EmDash at all, which isolates Cloudflare-side problems from CMS-side ones:

```bash
npx wrangler email sending send \
	--from "hello@emdashhq.com" \
	--to "dragos@emdashhq.com" \
	--subject "Email Sending smoke test" \
	--text "If you can read this, the binding and domain are fine."
```

### Step 6 (optional): verify a real flow

Request a magic link for a second admin account, or trigger an invite from the Users page. The mail should arrive signed by `cf-bounce._domainkey.emdashhq.com` with `Reply-To` set to the configured address.

## Limits and behavior worth knowing

- 50 recipients max per message across `to`, `cc`, `bcc`. EmDash system mail is one recipient at a time, far under the cap.
- 5 MiB total message size including attachments (25 MiB when sending only to verified destination addresses).
- New accounts start with a conservative daily quota that grows automatically with sending history and reputation. A limit-increase form exists for jumping the queue.
- A suppression list blocks addresses that hard-bounced or complained. Sends to suppressed addresses are rejected at the API boundary (and do not bill), or silently dropped for the remaining recipients if **Drop suppressed recipients** is on.
- Outbound sends appear as **dropped** in the Email Routing dashboard summary even when they delivered. That view is for inbound. Track sends under [Email Service observability](https://developers.cloudflare.com/email-service/observability/) instead.
- Delivery is at the mercy of SPF/DKIM/DMARC passing. Onboarding writes all three; if mail lands in spam, check those records first, then sender reputation.
- 30 Email Routing + Email Sending domains max per zone, 200 routing rules per domain, 200 verified destination addresses per account. Full numbers on the [limits page](https://developers.cloudflare.com/email-service/platform/limits/) and [pricing page](https://developers.cloudflare.com/email-service/platform/pricing/).

## Troubleshooting EmDash email

Symptom, cause, fix. The first two rows are the ones that hit most people.

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `503 EMAIL_NOT_CONFIGURED` | No active provider | Activate the plugin under Extensions, select it under Settings > Email |
| `E_SENDER_NOT_VERIFIED` / `E_SENDER_DOMAIN_NOT_AVAILABLE` | `from` domain not onboarded, or DNS still propagating | `wrangler email sending enable <domain>`; re-check with `dns get` and `dig` |
| `E_RECIPIENT_NOT_ALLOWED` | Domain not onboarded and recipient is not a verified destination | Onboard the domain, or only send to verified addresses |
| `E_DAILY_LIMIT_EXCEEDED` / `E_RATE_LIMIT_EXCEEDED` | New-account quota or burst limit | Wait for the rolling window; request an increase for production volume |
| `E_RECIPIENT_SUPPRESSED` | Address is on the suppression list (bounced or complained before) | Remove it from the suppression list in Email Service settings |
| Sends work in dev, fail in prod | Dev used the console provider | The console provider never runs in production; finish Step 5 |
| Mail lands in spam | SPF/DKIM/DMARC problem or fresh sender reputation | `dig` the four records; warm the domain with low-volume real mail |
| Duplicate SPF records on apex | Repeated onboarding runs | Delete the extra TXT in the DNS dashboard |
| Form submissions saved but never emailed | Plugin stores first, emails through the provider second | Fix the provider; resend from the submissions page |

## Option 2: SMTP and hosted providers with emdash-smtp

The binding is not the only way. The community-maintained [`emdash-smtp`](https://github.com/masonjames/emdash-smtp) plugin family routes EmDash mail through generic SMTP or a hosted provider's API. It needs EmDash 1.0.1 or later and ships in two packages:

- `emdash-smtp` is the trusted install. It covers the full provider catalog plus generic SMTP and local sendmail.
- `emdash-smtp-marketplace` is the sandbox-safe variant for registry/marketplace installs. It runs under `sandboxed:` and needs a `sandboxRunner` (`sandbox()` from `@emdash-cms/cloudflare`, plus the `LOADER` binding) in the EmDash config.

### Providers

Most providers run over HTTPS, so they work identically on Workers and Node: Amazon SES, Brevo, Elastic Email, Emailit, Mailchimp Transactional, MailerSend, Mailgun, Mailjet, Postmark, Resend, SendGrid, SMTP2GO, SparkPost. Google/Gmail, Microsoft 365 and Zoho Mail use their APIs with OAuth tokens (direct token, or client credential plus refresh token, which the plugin refreshes at send time). Generic SMTP and local sendmail exist only in the trusted package and belong to Node/self-hosted deployments; raw SMTP sockets are not the Workers path.

### Setup

```bash
npm install emdash-smtp
```

```js
import { emdashSmtp } from "emdash-smtp";

emdash({
	// database, storage...
	plugins: [emdashSmtp()],
});
```

Deploy, then in the admin open the plugin's settings (Plugins > SMTP Providers), pick a provider and paste its credentials. EmDash stores provider credentials as plugin settings in the database, so rotating a key is an admin edit, not a redeploy. Select SMTP under **Settings > Email** and use **Send test email** as before.

### The free-SMTP option: Brevo

Brevo is the pragmatic pick when cost matters more than staying inside Cloudflare. Its Free plan is free forever, no card, and includes transactional email over SMTP and API at 300 sends per day (roughly 9,000/month if spread evenly). Past the daily cap, transactional mail queues in a retry buffer of up to 1,000 instead of hard-failing. That dwarfs a CMS's actual need: magic links, invites and comment notices are single-digit emails a day on most sites. Current numbers are on [Brevo's limits page](https://help.brevo.com/hc/en-us/articles/208580669).

Because Brevo goes through its HTTP API in `emdash-smtp`, it works on Workers Free too. That is the real unlock: a site on the $0 plan cannot use `send_email` to arbitrary recipients at all, but Brevo's free tier covers the entire CMS mail load without touching the Workers bill.

Setup outline:

1. Create a Brevo account and add `emdashhq.com` as a sender domain. Brevo issues its own SPF/DKIM records to add in Cloudflare DNS; they coexist with the Email Sending records since Brevo uses its own DKIM selector and hostname entries.
2. Generate an API key (SMTP & API > API Keys) or an SMTP key (`smtp-relay.brevo.com`, port 587).
3. Paste it into the plugin's Brevo provider settings, select SMTP under Settings > Email, send a test.

## Put it to work: contact forms that actually send

Once a provider is active, every feature that calls `ctx.email.send()` rides it. The concrete example on emdashhq.com is [@bitdoze.com/emdashhq-contact-forms](https://plugins.emdashcms.com/plugins/@bitdoze.com/emdashhq-contact-forms), a contact-form plugin I published to the EmDash registry. It never talks to an email service itself; it hands messages to whichever provider you configured above, which is how an EmDash plugin should behave. The [source lives in the emdashhq.com repo](https://github.com/bitdoze/emdashhq.com/tree/main/plugins/emdashhq-contact-forms).

What you get:

- A **Forms** admin page: create and edit forms, manage fields (text, email, tel, url, number, textarea, select, checkbox), reorder, enable/disable.
- A **Submissions** page: every entry stored with page, IP, country and user-agent; filter by form/status/email result, mark read or archive, resend the notification, copy as CSV.
- An **Email log** page: every send attempt with status, duration and the provider's error message verbatim. This doubles as the debugging view for everything in this article; the `E_*` errors from the troubleshooting table show up here.
- A dashboard widget (unread count, active forms, recent submissions), anti-spam (honeypot, minimum-fill-time trap, per-IP hourly rate limit) and optional retention pruning via the cron.

It is a **sandboxed** plugin, so it runs in an isolated Worker and needs the same sandbox runner as any marketplace plugin: `sandboxRunner: sandbox()` plus the `LOADER` binding and an exported `PluginBridge` class, per the [plugin sandbox docs](https://docs.emdashcms.com/deployment/plugin-sandbox/). That means Workers Paid, the same wall the [deploy guide](/deploy-emdash-cloudflare-workers/) covers. If you took the Brevo path because you are on Workers Free, this plugin is not reachable either way; that is a sandboxing limit, not an email limit.

<Tabs>
<Tab name="Install from the registry">

The recommended path. With a sandbox runner configured, open **Registry** in the admin, search `@bitdoze.com/emdashhq-contact-forms` and select **Install**. The plugin declares the `email:send` capability, which EmDash shows you for review before anything installs.

</Tab>
<Tab name="Install from npm">

```bash
npm install emdashhq-contact-forms
```

Register the generated descriptor in `astro.config.mjs` under `sandboxed`, not `plugins`:

```js
import { sandbox } from "@emdash-cms/cloudflare";
import contactForms from "emdashhq-contact-forms";

emdash({
	sandboxRunner: sandbox(),
	sandboxed: [
		contactForms, // descriptor object, not a factory call
	],
});
```

Activate it under **Plugins**.

</Tab>
</Tabs>

Then wire the page block. EmDash block types live in the site seed, a plugin cannot inject one, so two files ship in the package's `site/` directory to copy:

1. `site/block-type.json` goes into `blockTypes` in `seed/seed.json`, plus `"contact_form"` in the page field's `validation.allowedTypes` (skip that if the field has no `allowedTypes`, everything is already allowed).
2. `site/ContactForm.astro` goes into `src/components/blocks/`, mapped in your block component map as `contact_form: ContactForm`.

Reseed so the block type reaches the database:

```bash
npx emdash seed seed/seed.json --database <dev-db> --no-content --on-conflict=update
```

In the admin: set a default notification email under Plugins > Contact forms > Settings, create a form with a slug (`contact`), then add a **Contact form** block in the page editor and type the slug in. Subject templates accept `{form}`, `{site}` and any submitted `{field_key}`; replies to notification mail go to the visitor's first `email` field via `replyTo`. With no provider configured, submissions still get stored, they just do not get emailed, which is the right failure mode.

<Button text="Contact Forms on the EmDash registry" link="https://plugins.emdashcms.com/plugins/@bitdoze.com/emdashhq-contact-forms" variant="solid" color="blue" size="md" icon="arrow-right" />
<Button text="Plugin source on GitHub" link="https://github.com/bitdoze/emdashhq.com/tree/main/plugins/emdashhq-contact-forms" variant="outline" color="gray" size="md" icon="arrow-right" />

## Which email path should you pick

- **On Workers Paid, low volume, want zero third parties.** `send_email` binding plus `cloudflareEmail()`. One binding, one plugin call, nothing to rotate.
- **On Workers Free, or want delivery tracking, templates, or a bigger free allowance.** `emdash-smtp` with Brevo (free, 300/day) or another HTTP provider.
- **High volume.** Compare $0.35 per 1,000 over Cloudflare's included 3,000/month against provider tiers. Dedicated IPs and warmup are a separate procurement question either way.
- **Not on Cloudflare.** `emdash-smtp` is the only path; there is no `send_email` binding on Node.

## FAQ: EmDash email

<Accordion label="Can EmDash send email on Workers Free?" group="faq" expanded="true">
Only to verified destination addresses, which covers testing and "send me a copy" flows, not magic links to arbitrary users. The usable free path is `emdash-smtp` pointed at an HTTP provider like Brevo, whose free tier (300 sends/day) covers a CMS's real mail load without touching the Workers bill.
</Accordion>

<Accordion label="What is the difference between Email Routing and Email Sending?" group="faq">
Routing is inbound: it receives mail at your domain and forwards it to a mailbox you own. Sending is outbound: it delivers transactional mail from a Worker to arbitrary recipients. They are configured in different places and priced differently. Routing is free on every plan; Sending to anyone needs Workers Paid.
</Accordion>

<Accordion label="Why do magic links return 'Email is not configured'?" group="faq">
Production Workers have no default email delivery, so auth flows return `503 EMAIL_NOT_CONFIGURED` until a provider plugin is installed, activated and selected under Settings > Email. In dev the console provider handles it silently, which is why it only breaks after deploy. Passkeys and invite copy-links work in the meantime.
</Accordion>

<Accordion label="Can I still use MailChannels for free Worker email?" group="faq">
No. The MailChannels integration ended when the partnership did, and tutorials built on it now fail silently or with binding errors. Cloudflare Email Sending is the replacement on paid; a free HTTP provider through `emdash-smtp` is the replacement on free.
</Accordion>

<Accordion label="Does the Contact Forms plugin work on Workers Free?" group="faq">
No, for a different reason than email. It is a sandboxed plugin, so it needs the `LOADER` binding and `sandboxRunner`, and Dynamic Workers is Workers Paid only. On a free plan the npm `emdash-smtp` path still fixes your system mail (magic links, invites); the forms plugin specifically is a paid-plan feature.
</Accordion>

<Accordion label="My send shows as 'dropped' in the Email Routing dashboard. Did it fail?" group="faq">
Probably not. That summary is the inbound view, and outbound sends are counted as dropped there even when they delivered fine. Track real sends under Email Service observability in the dashboard, or in the plugin's email log if you are using Contact Forms.
</Accordion>

## Closing

On Workers Paid, EmDash email is a three-part job: onboard the domain, add `send_email` to `wrangler.jsonc`, register `cloudflareEmail()` and activate it in the admin. No secrets, no third-party account, and 3,000 sends a month before you pay a cent past the $5 plan. On Workers Free or off Cloudflare, `emdash-smtp` plus Brevo's free tier covers the same CMS mail load for $0. Either way the proof is the same: install Contact Forms, submit the form, watch the email log.

That is part 5 of the EmDash CMS series: outbound email, both ways.

<Button text="Back to the Cloudflare deploy guide" link="/deploy-emdash-cloudflare-workers/" variant="outline" color="gray" size="md" />