@event/mailer は 3リポ(xdeal-lp mail.ts(React Email 招待/OTP)と notify.ts(inline HTML 運用通知 + Slack))に散っていたメール実装を lib のみ・UI 非同梱 の 1 パッケージへ統合したもの。escapeHtml とブランド HTML shell を各々 1 つに畳み、ブランドを token 注入({ name, color, logoUrl, ... })にして XDEAL 固定を撤去した。React Email 依存を撤去し、テンプレートはスロット式の純関数に、resend は optional peerDependency にしてある。
createMailer — 送信の唯一の入口
createMailer({ from, replyTo?, brand, resendApiKey?, onMissingKey?, log? }) が Mailer を返す。brand は resolveBrand で既定色(ink #14140F / bg #ECE7DD / accent など)を埋めて確定する。Resend クライアントは初回送信時に 1 度だけ生成してメモ化する。
export const mailer = createMailer({
from: process.env.MAIL_FROM ?? "Event <onboarding@resend.dev>",
brand: { name: "EVENT", color: "#9CA877" },
onMissingKey: "noop", // ← fail-safe の要
});
Mailer の面: send<D>(template, to, data, opts?) / sendRaw(to, subject, html, opts?) / brand / context(テンプレが受け取る TemplateContext = shell / ctaButton / escapeHtml / escapeAttr)/ isConfigured()(キーの有無だけ判定)。
fail-safe — キー未設定は例外にしない
これがこのパッケージの中心設計。onMissingKey は "noop"(既定)か "throw":
"noop":RESEND_API_KEYが無ければ 送らずに{ ok:false, skipped:true, reason:"no_api_key" }を返し、log有効時に warn するだけ。dev では外部へメールが飛ばない。"throw": OTP など「送信必須」なメールを明示エラーにしたいときだけ選ぶ。
app 側は mailer.isConfigured() を見て「未設定 → プレビューのみ」を UI に出し、送信ヘルパは skipped を previewOnly:true に正規化する。SendResult は { ok, id?, skipped?, reason? }。送信の try/catch は Resend の error も例外も握って { ok:false, reason } に畳むため、呼び出し側で送信が原因で 500 になることはない。
テンプレート — スロット式の純関数
テンプレは defineTemplate<D>({ subject(d,ctx), html(d,ctx), text?(d,ctx) })。ブランドは ctx(= mailer.context)から来るのでテンプレ本体にブランド語彙は現れない。同梱の 3 つ:
invitationTemplate(InvitationData:acceptUrl必須、invitedByName?/expiresLabel?/ctaLabel?/note?)otpTemplate(OtpData:code必須、expiresLabel?)notifyTemplate(NotifyData:title/intro必須、eyebrow?/sections?({ heading, items[] })/ctaLabel?+ctaUrl?/subject?)
描画は送信と分離できる — template.subject(data, mailer.context) / template.html(data, mailer.context) を直接呼べば HTML 文字列が得られる(プレビュー用)。shell は renderShell(brand, parts) が table ベースのメールセーフな header/body/footer を組み、ctaButton(brand, label, url) がダーク CTA を出す。任意テンプレは createTemplateRegistry に register して名前引きもできる。
Slack 通知 — Block Kit ビルダー
運用通知は best-effort。slackBlocks(header / section / fields / context / divider / linkButton)と keyValueBlocks(title, entries)(value が空のエントリは自動除外)で Block Kit を組み、postToSlack(webhookUrl, payload) で Incoming Webhook に POST する。webhook URL が空なら無言 no-op({ ok:false, skipped:true, reason:"no_webhook" })— メール同様、未設定を fatal にしない。
await postToSlack(process.env.SLACK_WEBHOOK_URL, keyValueBlocks("新規予約", {
氏名: name, セッション: sessionTitle, 人数: String(count),
}));
app への配線(starter 参照)
server 専用の lib/mailer.ts に createMailer とテンプレレジストリ(invitation / otp / notify)+ renderMail / sendMail(キー無しは previewOnly:true)を置く。/admin/mail(server component、adminAuth + features.isEnabled("mailer") ガード)が client 子 MailComposer を載せ、テンプレ選択 + 変数入力を 300ms debounce で POST /api/admin/mail/send { preview:true } に投げ、返る subject / html を iframe に反映する。「テスト送信」は preview なし + honeypot(website) + 簡易 rate-limit で送る。
重要な境界: @event/mailer は client component から import しない(resend が client bundle に混ざる)。描画も送信も API route(server)に委譲し、client は fetch だけを持つ。配線一式は recipe add-mailer に完全コードで載っている。