ガイド 第36

メール

Resend transactional + operational + テンプレ + Slack通知

@event/mailer

@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 を返す。brandresolveBrand で既定色(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 に出し、送信ヘルパは skippedpreviewOnly: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 つ:

  • invitationTemplateInvitationData: acceptUrl 必須、invitedByName? / expiresLabel? / ctaLabel? / note?
  • otpTemplateOtpData: code 必須、expiresLabel?
  • notifyTemplateNotifyData: 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 を出す。任意テンプレは createTemplateRegistryregister して名前引きもできる。

Slack 通知 — Block Kit ビルダー

運用通知は best-effort。slackBlocksheader / 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.tscreateMailer とテンプレレジストリ(invitation / otp / notify)+ renderMail / sendMail(キー無しは previewOnly:true)を置く。/admin/mail(server component、adminAuth + features.isEnabled("mailer") ガード)が client 子 MailComposer を載せ、テンプレ選択 + 変数入力を 300ms debouncePOST /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 に完全コードで載っている。