EmDash で Brevo を使ってメールを送信する方法

背景
EmDash は Cloudflare 発の Astro ベースの CMS で、認証方式としてパスキーとメール magic link をデフォルトでサポートしている。pnpm dev でローカル開発しているときは EmDash 内蔵の emdash-console-email プラグインがメールをコンソール出力して送信を擬似的に再現してくれるので困らないが、本番 (Cloudflare Workers) にデプロイした途端、ログイン画面でメールアドレスを送ると次のエラーが返ってくる。
Email is not configured. Magic link authentication requires an email provider.
組み込みの emdash-console-email は import.meta.env.DEV === true のときだけ自動で activate される設計になっており、本番ではメール送信プロバイダのプラグインを別途用意する必要がある。
EmDash の email pipeline は email:deliver という exclusive hook で動いており、ちょうど 1 つのプラグインがメール配送を引き受ける。プロバイダプラグインが何もインストールされていない場合、上のエラーになる。
既存プラグインの調査
npm で emdash plugin email などをキーワードに検索すると、サードパーティ製のメールプロバイダプラグインが 2 系統見つかる。
| パッケージ | メンテナ | カバー範囲 |
|---|---|---|
emdash-plugin-resend |
maikunari (個人) | Resend 専用 |
emdash-smtp (trusted) |
masonjames (個人) | SMTP, Brevo, Amazon SES, Elastic Email など |
emdash-smtp-marketplace (sandboxed) |
masonjames (個人) | 同上 |
@emdash-cms/ 公式名前空間にメールプラグインは存在しない (2026/05 時点)。
実際にこれらを試してみると、Cloudflare Workers にデプロイする前提では以下の問題があった。
emdash-smtp(trusted) は依存関係にnodemailerを持つ。nodemailer は内部で Node.js のnet/tlsを使うため、Cloudflare Workers ランタイムには持っていけない。デプロイは通っても実行時に死ぬ。emdash-smtp-marketplace(sandboxed) は 0.3.4 のリリース時にdist/が同梱されておらず、src/*.tsだけが入っていた。EmDash の sandboxed plugin は pre-built JavaScript しか受け付けない (TypeScript ソースはビルド時に拒否される) ので、そのままではロード時にエラーになる。emdash-plugin-resendはパッケージング自体は健全で動くが、Resend 専用なので Brevo は使えない。
ということで、Brevo の HTTP API を呼ぶ専用プラグインを自作する方向に倒した。
EmDash のプラグイン作成エージェントスキル
EmDash プロジェクトのテンプレートには .agents/skills/ 配下に creating-plugins という Claude エージェント向けの skill が標準で同梱されている。中身は以下のような体系的なドキュメント。
- プラグインの 2 種類のフォーマット (standard / native) と sandboxed / trusted の違い
definePlugin({ hooks, routes })の 2 つのエントリポイント (descriptor / sandbox-entry) の役割分担- 利用可能な capability 一覧 (
email:provide,network:fetch,read:content等) とctxへの露出 - Storage と KV の使い分け
- Block Kit による admin UI 定義
- Portable Text のカスタムブロック型登録
これを Claude Code に読み込ませた上で「Brevo HTTP API を email:deliver で呼ぶプラグインを書いて」と指示すると、descriptor と sandbox-entry の組をほぼそのまま使える状態で出してくれる。EmDash 自体が "AI agent-first" を意識して作られているのが伝わってくる。
なぜ Brevo か
Brevo (旧 Sendinblue) を選んだ理由は次のとおり。
- 無料枠で 1 日 300 通 (月 9,000 通) 送れる。クレジットカード登録不要。個人サイトのトランザクショナルメールなら十分に収まる
- HTTP API がある。Cloudflare Workers のような Node.js が無いランタイムでは TCP の SMTP は実装が難しいので、HTTP で叩ける送信元が必須
- 送信元ドメインの DKIM / SPF / DMARC のセットアップ手順が整備されている
代替案との比較。
- SendGrid: 以前は月 100 通の Free Forever プランがあったが、現在は無料プランが廃止されている。個人ハンズオンや低トラフィックのサイトには選びづらい
- Amazon SES: 単価は最安だが、バウンスメール / 苦情のサプレッション管理を怠るとすぐサンドボックスに戻される or アカウント停止される という運用負荷の印象が強い。個人で雑に使う対象ではないので、大量配信が必要になるまでは基本選ばない
ハンズオンや小規模本番は Brevo、大量配信が必要になったら SES の運用フローを別途組む、という棲み分けで運用している。
プラグイン実装
最終的に書いたプラグインは 100 行程度。EmDash の definePlugin() で email:deliver フックを exclusive: true で登録し、その中で Brevo HTTP API を fetch で叩くだけ。
ディレクトリ構造
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 出力
└── scripts/
└── configure-brevo.sh # API キーを 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"] は EmDash 内部で hooks.email-transport:register にリネームされる。これを宣言していないと、email:deliver フックは登録時に silent に skip される (packages/core/src/plugins/hooks.ts の HOOK_REQUIRED_CAPABILITY を参照)。最初知らずに踏んだ落とし穴。
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()}`);
}
},
},
},
});
ポイントを 2 つ。
- エンドポイントのパスは
/v3/smtp/emailと smtp が入っているが、実態は HTTP REST API。SMTP プロトコル (TCP port 587) は使っていない。Cloudflare Workers では Node.js の TCP socket を持ち込めないが、Cloudflare 独自のcloudflare:socketsAPI でも別にこのプラグインからは触っていない。素直にfetchだけ - 認証ヘッダーは
api-key。X-API-Keyではない (Brevo の独特な命名)
astro.config.mjs への組み込み
import { brevoPlugin } from "emdash-plugin-brevo";
export default defineConfig({
integrations: [
emdash({
plugins: [brevoPlugin()], // ※ sandboxed: ではなく plugins: に入れる
// ...
}),
],
});
sandboxed: ではなく plugins: に入れる理由 (落とし穴)
最初は隔離度の高い sandboxed: 配列に入れた。Worker Loader で別 isolate に閉じ込められるので、3rd party を載せる時のお作法としては正しいはず…と思ったが動かなかった。
調べると、EmDash 0.9 の実装では astro.config.mjs の sandboxed: 配列で宣言された plugin は exclusive hook (email:deliver, comment:moderate 等) を登録できない。packages/core/src/astro/middleware.ts の loadSandboxedPlugins:
const manifest = {
id: entry.id,
version: entry.version,
capabilities: entry.capabilities ?? [],
allowedHosts: entry.allowedHosts ?? [],
storage: entry.storage ?? {},
hooks: [], // ← 常に空
routes: [],
admin: {}
};
const plugin = await sandboxRunner.load(manifest, entry.code);
manifest.hooks が空でロードされるので、host 側の hook pipeline には登録されず、email:deliver の exclusive selection 候補にもならない。
マーケットプレイスから install されたプラグインは tarball に同梱されている manifest.json から hooks 配列を読むので、こちらは正しく登録される。つまり sandboxed: config 経由は exclusive hook を提供できない、というやや特殊な仕様。
メール送信プラグインは exclusive hook なので plugins: (trusted) に置くしかない。trusted モードなのでサンドボックスの隔離は得られないが、自作の自分のコードを乗せるだけなら問題なし。
API キーの保管
Cloudflare Worker secret で管理できると一番ありがたいが、EmDash の plugin context (ctx) には Worker の env オブジェクトが (sandboxed / trusted 問わず) 露出していない。代替の選択肢は次の 2 つ。
- プラグインの admin UI を Block Kit で実装して D1 に保存する。これが canonical なパターンで、
@emdash-cms/plugin-webhook-notifierもこれをやっている ctx.kvの裏側にあたる D1 のoptionsテーブルに直接 INSERT する。今回はハンズオンなのでこちらを採用
ctx.kv.get("config:apiKey") は内部的に D1 の options テーブルからキー plugin:emdash-plugin-brevo:config:apiKey の値 (JSON エンコード文字列) を引いてくる。なのでブートストラップシェルから直接 INSERT すれば十分。
#!/usr/bin/env bash
# scripts/configure-brevo.sh の抜粋
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:-}"
実行は単純に。
BREVO_API_KEY='xkeysib-...' \
BREVO_FROM_EMAIL='no-reply@example.com' \
BREVO_FROM_NAME='EmDash Test' \
./scripts/configure-brevo.sh
これで API キーは Worker のコードバンドルに焼き込まれず、Cloudflare D1 に at-rest 暗号化された状態で保管される。secret 管理としては完璧ではないが妥当な落とし所。
動作確認
magic-link/send API を叩いて wrangler tail でログを見ると、プラグインが 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 { ... }
メールも実際に届く。
SendGrid・SES でも同じ手順で書ける
今回 Brevo HTTP API を呼んでいる箇所は fetch 1 回 (~30 行) だけ。SendGrid なら https://api.sendgrid.com/v3/mail/send、SES なら AWS SDK の SendEmailCommand (aws4fetch で署名する) に置き換えれば動く。
EmDash の email:deliver フックインターフェースは provider 中立 ({ to, subject, text, html } のシンプルなメッセージ型) なので、自作プラグインで HTTP API を持つ送信サービスならどれでもラップできる。
まとめ
- EmDash で本番メール送信するには
email:deliverを実装したプラグインが必要 - 既存の
emdash-smtp-*系は Cloudflare Workers との互換性に難 (nodemailer 依存 / dist 同梱漏れ)、emdash-plugin-resendは Resend 専用 - 自作するのが結局一番早かった。EmDash の
creating-pluginsskill を Claude Code に読ませると、descriptor と sandbox-entry の枠組みは数分で出る - Brevo は無料枠 1 日 300 通で HTTP API がある、ハンズオンや小規模本番に最適
- exclusive hook を提供する場合
plugins:(trusted) に入れる必要がある (sandboxed:config 経由では登録されない仕様) - API キーは D1 の
optionsテーブルにplugin:<id>:<key>の規約で書き込めばctx.kv.getで読める - 同じ要領で SendGrid / SES 専用プラグインも書ける
開発相談をお待ちしています。