Sending email from EmDash with Brevo

2026-05-06 03:11 (55 minutes ago)
Sending email from EmDash with Brevo

Background

EmDash is a Cloudflare-backed Astro-based CMS that supports passkey and email magic-link authentication out of the box. During local development with pnpm dev, the built-in emdash-console-email plugin handles delivery by simply printing emails to the console, which is fine. The moment you deploy to production on Cloudflare Workers and try to log in, however, you get this:

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

The bundled emdash-console-email is auto-activated only when import.meta.env.DEV === true. In production you have to bring your own email provider plugin.

EmDash's email pipeline is driven by an exclusive hook named email:deliver — exactly one plugin claims it and handles delivery. With no provider plugin installed, you hit the error above.

Surveying the existing plugins

Searching npm for things like emdash plugin email turns up two third-party providers.

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

Nothing in the @emdash-cms/ official namespace covers email (as of 2026/05).

Trying these in a Cloudflare Workers target:

  • emdash-smtp (trusted) depends on nodemailer. Nodemailer reaches into Node's net / tls, neither of which exists on the Workers runtime. The deploy succeeds, the runtime crashes
  • emdash-smtp-marketplace (sandboxed) ships only src/*.ts in 0.3.4 — the publish missed the dist/ build step. EmDash's sandboxed plugin loader strictly refuses TypeScript source (it requires pre-built JavaScript), so the package fails at load time
  • emdash-plugin-resend is properly packaged and works, but only speaks Resend. No Brevo support

So the path forward was a small custom plugin that calls the Brevo HTTP API.

EmDash's plugin-creation agent skill

The EmDash project template ships a Claude agent skill called creating-plugins under .agents/skills/. It's a fully structured reference covering:

  • The two plugin formats (standard / native) and the difference between sandboxed / trusted
  • The two definePlugin({ hooks, routes }) entry points (descriptor / sandbox-entry) and what each is for
  • The full capability list (email:provide, network:fetch, read:content, ...) and how each one shows up on ctx
  • Storage and KV patterns
  • Block Kit admin UI definitions
  • Custom Portable Text block-type registration

Pointing Claude Code at this skill and asking "write an email:deliver plugin that calls the Brevo HTTP API" produces a working descriptor + sandbox-entry pair almost ready to deploy. EmDash is clearly built with an "AI-agent-first" philosophy and it shows.

Why Brevo

Brevo (formerly Sendinblue) was the pick for these reasons.

  • Generous free tier: 300 emails/day (9,000/month). No credit card required. Plenty for the transactional traffic of a personal site
  • HTTP API available. On a runtime without Node.js (like Cloudflare Workers), raw SMTP over TCP is impractical, so an HTTP-callable provider is a hard requirement
  • DKIM / SPF / DMARC setup for the sender domain is well documented

Comparison with the alternatives:

  • SendGrid: used to have a "Free Forever" 100/day plan, which is now gone. Hard to justify for personal projects or low-traffic sites
  • Amazon SES: cheapest unit price by far, but the operational overhead is real — neglecting bounce / complaint suppression management gets your account thrown back into the sandbox or paused. Not a "set it and forget it" choice unless you're already running real email infrastructure

So for hands-on work and small production sites I use Brevo, and reach for SES only when the volume justifies investing in proper email ops.

Plugin implementation

The final plugin is about 100 lines. EmDash's definePlugin() registers an email:deliver hook with exclusive: true, and the handler calls the Brevo HTTP API via fetch.

Layout

The plugin lives inside the project as a pnpm workspace package.

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 internally renamed to hooks.email-transport:register. Without this declaration, the email:deliver hook is silently skipped at registration time (see HOOK_REQUIRED_CAPABILITY in packages/core/src/plugins/hooks.ts). Subtle trap I tripped on first.

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 things to call out.

  • The path is /v3/smtp/email with smtp in it, but this is a regular HTTP REST API. SMTP (TCP port 587) is not used here. Cloudflare Workers can't speak Node's TCP, and even though Cloudflare's own cloudflare:sockets API exists, this plugin doesn't need it — pure fetch is enough
  • The auth header is api-key, not X-API-Key (Brevo's idiosyncratic 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)

I started by putting it in the more isolated sandboxed: array. Worker Loader runs each sandboxed plugin in its own V8 isolate, which is the correct posture for third-party code. It didn't work.

Digging into EmDash 0.9, plugins declared via astro.config.mjs's sandboxed: array cannot register exclusive hooks (email:deliver, comment:moderate, etc.). The relevant code in packages/core/src/astro/middleware.ts's loadSandboxedPlugins:

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 manifest's hooks array is hard-coded to empty, so the host hook pipeline never learns about the plugin's hooks and the plugin can't be picked as the exclusive email:deliver provider.

Marketplace-installed plugins ship a manifest.json inside the tarball with the real hooks array, so they work. The sandboxed: config path simply can't provide exclusive hooks today — a quietly specific limitation.

Email providers need an exclusive hook, so they have to live in plugins: (trusted). You lose sandbox isolation, but for first-party code you wrote yourself that's fine.

Storing the API key

Ideally I'd manage the key as a Cloudflare Worker secret, but EmDash's plugin context (ctx) doesn't expose the Worker env object — neither in sandboxed nor trusted mode. Two practical options.

  1. Build a Block Kit admin UI for the plugin and write to D1 from there. This is the canonical pattern, and it's what @emdash-cms/plugin-webhook-notifier does
  2. Insert directly into the D1 options table that backs ctx.kv. Quick and dirty, and what I went with for hands-on work

ctx.kv.get("config:apiKey") reads the JSON-encoded string from options under the key plugin:emdash-plugin-brevo:config:apiKey. So a small bootstrap shell script does the job.

#!/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:-}"

Invocation is straightforward.

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

The key never lands in the Worker bundle. It sits at rest in Cloudflare's D1 with at-rest encryption. Not as nice as a real Worker secret, but a reasonable compromise.

Verifying it works

Hit the magic-link send API and follow the wrangler tail.

$ 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 mail actually shows up.

The same approach works for SendGrid or SES

The Brevo-specific code is one fetch call (~30 lines). For SendGrid replace the URL with https://api.sendgrid.com/v3/mail/send, and for SES use the AWS SDK's SendEmailCommand (signed via aws4fetch on Workers).

EmDash's email:deliver hook interface is provider-agnostic (the message is just { to, subject, text, html }), so a custom plugin can wrap any HTTP-callable email service.

Wrap-up

  • Sending real email from EmDash in production needs a plugin that implements email:deliver
  • The existing emdash-smtp-* packages have Cloudflare Workers compatibility issues (nodemailer dependency / missing dist), and emdash-plugin-resend is Resend-only
  • Writing your own was the fastest path. The creating-plugins skill bundled with EmDash gets a Claude agent through the descriptor / sandbox-entry scaffolding in minutes
  • Brevo's free tier of 300 emails/day plus its HTTP API makes it ideal for hands-on or small production
  • A plugin that registers an exclusive hook has to live in plugins: (trusted). The sandboxed: config path silently strips hooks
  • The API key fits comfortably into D1's options table at plugin:<id>:<key>, readable via ctx.kv.get
  • The same shape of plugin works for SendGrid, SES, or any other HTTP-callable email service
Please rate this article
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive