Sending email from EmDash with Brevo

2026-05-06 03:11 (5 months ago)
Exclusive Hook
Play a song themed on this article

Background

EmDash is an Astro-based CMS from Cloudflare. By default it supports passkeys and email magic links for authentication. During local development with pnpm dev, EmDash's built-in emdash-console-email plugin prints emails to the console to simulate sending, so there is no problem. But as soon as you deploy to production (Cloudflare Workers), submitting an email address on the login screen returns this error.

Email is not configured. Magic link authentication requires an email provider.

The built-in emdash-console-email is designed to activate automatically only when import.meta.env.DEV === true. In production you need to provide a separate email provider plugin.

EmDash's email pipeline runs on an exclusive hook called email:deliver, and exactly one plugin takes charge of email delivery. If no provider plugin is installed, you get the error above.

Surveying the existing plugins

Searching npm with keywords like emdash plugin email turns up two families of third-party email provider plugins.

Package Maintainer Coverage
emdash-plugin-resend maikunari (individual) Resend only
emdash-smtp (trusted) masonjames (individual) SMTP, Brevo, Amazon SES, Elastic Email, etc.
emdash-smtp-marketplace (sandboxed) masonjames (individual) Same as above

There is no email plugin in the official @emdash-cms/ namespace (as of 2026/05).

When I actually tried these, they had the following problems for a Cloudflare Workers deployment.

  • emdash-smtp (trusted) has nodemailer as a dependency. nodemailer uses Node.js net / tls internally, so it cannot be brought to the Cloudflare Workers runtime. The deploy goes through, but it dies at runtime.
  • emdash-smtp-marketplace (sandboxed) did not include dist/ in the 0.3.4 release. Only src/*.ts was in the package. EmDash's sandboxed plugins accept only pre-built JavaScript (TypeScript source is rejected at build time), so as it is, it fails with an error at load time.
  • emdash-plugin-resend is packaged properly and works, but it is Resend only, so Brevo cannot be used.

So I decided to write my own plugin that calls the Brevo HTTP API.

EmDash's plugin-creation agent skill

The EmDash project template comes with a skill for Claude agents called creating-plugins under .agents/skills/. It is a structured document covering the following.

  • The two plugin formats (standard / native) and the difference between sandboxed and trusted
  • The roles of the two entry points (descriptor / sandbox-entry) of definePlugin({ hooks, routes })
  • The list of available capabilities (email:provide, network:fetch, read:content, etc.) and how they are exposed on ctx
  • When to use Storage and when to use KV
  • Defining an admin UI with Block Kit
  • Registering custom block types for Portable Text

If you have Claude Code read this and then tell it "write a plugin that calls the Brevo HTTP API in email:deliver", it produces the descriptor and sandbox-entry pair in a few minutes, in a state you can use almost as is. You can tell that EmDash itself is built with "AI agent-first" in mind.

Why Brevo

I chose Brevo (formerly Sendinblue) for these reasons.

  • The free tier allows 300 emails per day (9,000 per month). No credit card registration is needed. That is enough for the transactional email of a personal site
  • It has an HTTP API. On a runtime without Node.js, such as Cloudflare Workers, SMTP over TCP is hard to implement, so a sender you can call over HTTP is required
  • The setup steps for DKIM / SPF / DMARC on the sending domain are well documented

Comparison with the alternatives.

  • SendGrid: It used to have a Free Forever plan with 100 emails per month, but the free plan has now been discontinued. Hard to choose for personal hands-on work or low-traffic sites
  • Amazon SES: The unit price is the lowest, but I have a strong impression of the operational burden: if you neglect suppression management for bounces and complaints, you are quickly put back in the sandbox or your account is suspended. It is not something to use casually as an individual, so I basically don't choose it until I need bulk sending

I use Brevo for hands-on work and small production sites, and when bulk sending becomes necessary I set up a separate SES operations flow. That is how I split them.

Plugin implementation

The plugin I ended up writing is about 100 lines. It registers an email:deliver hook with exclusive: true through EmDash's definePlugin(), and inside that it only calls the Brevo HTTP API with fetch.

Layout

It is placed inside the project as a local package of a pnpm workspace.

emdash-test-01/
├── astro.config.mjs
├── package.json
├── pnpm-workspace.yaml
├── packages/
│   └── emdash-plugin-brevo/
│       ├── package.json
│       ├── src/
│       │   ├── index.ts          # descriptor (build-time)
│       │   └── sandbox-entry.ts  # plugin definition (runtime)
│       └── dist/                 # esbuild output
└── scripts/
    └── configure-brevo.sh        # writes the API key into D1

Descriptor (src/index.ts)

import type { PluginDescriptor } from "emdash";

export interface BrevoPluginOptions {
  apiKey?: string;
  fromEmail?: string;
  fromName?: string;
}

export function brevoPlugin(options: BrevoPluginOptions = {}): PluginDescriptor {
  return {
    id: "emdash-plugin-brevo",
    version: "0.1.0",
    format: "standard",
    entrypoint: "emdash-plugin-brevo/sandbox",
    options,
    capabilities: ["email:provide", "network:fetch"],
    allowedHosts: ["api.brevo.com"],
    storage: {
      deliveryLogs: { indexes: ["status", "createdAt"] },
    },
  };
}

capabilities: ["email:provide"] is renamed to hooks.email-transport:register inside EmDash. If you don't declare it, the email:deliver hook is skipped without an error at registration (see HOOK_REQUIRED_CAPABILITY in packages/core/src/plugins/hooks.ts). At first I didn't know this and got stuck.

Plugin definition (src/sandbox-entry.ts)

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

const BREVO_ENDPOINT = "https://api.brevo.com/v3/smtp/email";

export default definePlugin({
  hooks: {
    "email:deliver": {
      exclusive: true,
      handler: async (event, ctx: PluginContext) => {
        const apiKey = await ctx.kv.get<string>("config:apiKey");
        const fromEmail = await ctx.kv.get<string>("config:fromEmail");
        if (!apiKey || !fromEmail) {
          throw new Error("Brevo plugin not configured");
        }
        const res = await ctx.http!.fetch(BREVO_ENDPOINT, {
          method: "POST",
          headers: {
            "api-key": apiKey,
            "content-type": "application/json",
          },
          body: JSON.stringify({
            sender: { email: fromEmail },
            to: [{ email: event.message.to }],
            subject: event.message.subject,
            textContent: event.message.text,
            htmlContent: event.message.html,
          }),
        });
        if (!res.ok) {
          throw new Error(`Brevo ${res.status}: ${await res.text()}`);
        }
      },
    },
  },
});

Two points.

  • The endpoint path is /v3/smtp/email, with smtp in it, but it is actually an HTTP REST API. The SMTP protocol (TCP port 587) is not used. You can't bring Node.js TCP sockets to Cloudflare Workers, and this plugin doesn't touch Cloudflare's own cloudflare:sockets API either. Just plain fetch
  • The authentication header is api-key. Not X-API-Key (Brevo's own peculiar naming)

Wiring it up in astro.config.mjs

import { brevoPlugin } from "emdash-plugin-brevo";

export default defineConfig({
  integrations: [
    emdash({
      plugins: [brevoPlugin()],   // NOTE: plugins:, not sandboxed:
      // ...
    }),
  ],
});

Why plugins: and not sandboxed: (a real trap)

At first I put it in the sandboxed: array, which has stronger isolation. Worker Loader confines it in a separate isolate, so I thought this should be the proper way to load third-party code... but it did not work.

When I looked into it, in the EmDash 0.9 implementation a plugin declared in the sandboxed: array of astro.config.mjs cannot register exclusive hooks (email:deliver, comment:moderate, etc.). loadSandboxedPlugins in packages/core/src/astro/middleware.ts looks like this.

const manifest = {
  id: entry.id,
  version: entry.version,
  capabilities: entry.capabilities ?? [],
  allowedHosts: entry.allowedHosts ?? [],
  storage: entry.storage ?? {},
  hooks: [],   // ← always empty
  routes: [],
  admin: {}
};
const plugin = await sandboxRunner.load(manifest, entry.code);

The plugin is loaded with manifest.hooks empty, so it is not registered in the host-side hook pipeline and does not become a candidate in the exclusive selection for email:deliver either.

A plugin installed from the marketplace reads its hooks array from the manifest.json bundled in the tarball, so that one is registered correctly. In other words, the sandboxed: config route cannot provide exclusive hooks, which is a somewhat unusual spec.

An email sending plugin is an exclusive hook, so the only place to put it is plugins: (trusted). Because it is trusted mode you don't get sandbox isolation, but if you are only loading your own code that you wrote yourself, that is not a problem.

Storing the API key

It would be best if the key could be managed as a Cloudflare Worker secret, but EmDash's plugin context (ctx) does not expose the Worker's env object (in either sandboxed or trusted mode). There are two alternatives.

  1. Implement the plugin's admin UI with Block Kit and save to D1. This is the canonical pattern, and @emdash-cms/plugin-webhook-notifier does it this way too
  2. INSERT directly into the D1 options table, which is what sits behind ctx.kv. This was hands-on work, so I went with this one

Internally, ctx.kv.get("config:apiKey") looks up the value (a JSON-encoded string) of the key plugin:emdash-plugin-brevo:config:apiKey in the D1 options table. So a direct INSERT from a bootstrap shell script is enough.

#!/usr/bin/env bash
# scripts/configure-brevo.sh (excerpt)
PLUGIN_ID="emdash-plugin-brevo"

run_set() {
  local key="$1" raw_value="$2"
  local full_key="plugin:${PLUGIN_ID}:${key}"
  local json_value
  json_value=$(printf '%s' "$raw_value" | jq -Rs .)
  npx wrangler d1 execute emdash-test-01 --remote --command \
    "INSERT INTO options (name, value) VALUES ('${full_key}', '${json_value//\'/\'\'}') ON CONFLICT(name) DO UPDATE SET value = excluded.value;"
}

run_set "config:apiKey" "$BREVO_API_KEY"
run_set "config:fromEmail" "$BREVO_FROM_EMAIL"
run_set "config:fromName" "${BREVO_FROM_NAME:-}"

Running it is simple.

BREVO_API_KEY='xkeysib-...' \
BREVO_FROM_EMAIL='no-reply@example.com' \
BREVO_FROM_NAME='EmDash Test' \
./scripts/configure-brevo.sh

With this, the API key is not baked into the Worker's code bundle and is stored in Cloudflare D1, encrypted at rest. It is not perfect as secret management, but it is a reasonable compromise.

Verifying it works

If you call the magic-link/send API and watch the logs with wrangler tail, you can see the plugin calling Brevo.

$ curl -sS -X POST "https://emdash-test-01-production.example.workers.dev/_emdash/api/auth/magic-link/send" \
    -H "content-type: application/json" \
    -d '{"email":"admin@example.com"}'
{"data":{"success":true,"message":"If an account exists for this email, a magic link has been sent."}}

$ npx wrangler tail emdash-test-01-production --format=pretty
POST .../api/auth/magic-link/send - Ok
  (info) [plugin:emdash-plugin-brevo] Brevo send ok { ... }

The email actually arrives too.

The same approach works for SendGrid or SES

The part that calls the Brevo HTTP API here is only one fetch (~30 lines). For SendGrid, replace it with https://api.sendgrid.com/v3/mail/send; for SES, replace it with the AWS SDK's SendEmailCommand (signed with aws4fetch), and it will work.

EmDash's email:deliver hook interface is provider-neutral (a simple message type, { to, subject, text, html }), so with your own plugin you can wrap any sending service that has an HTTP API.

Categories

Archive