レシピ · 7手 · 1プロンプト実装

メール送信(Resend + テンプレ + Slack通知)を追加する

既存イベント app に @event/mailer を配線し、テンプレ描画 + テスト送信できる admin メール画面を動かす(RESEND 未設定でもプレビューは動く fail-safe)

前提: new-event解説 ch.36module: メール

このレシピは @event/mailer(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。

設計の要: 送信は必ず fail-safe。RESEND_API_KEY 未設定でも例外を投げず、プレビューだけが動く (createMailer({ onMissingKey:"noop" }))。dev で実際に外部へメールは飛ばない。

1. 依存を追加

apps/<app>/package.json の dependencies に追加:

"@event/mailer": "*"

apps/<app>/next.config.tstranspilePackages 配列に "@event/mailer" を足す。 (starter には既に両方入っている。)

2. 機能を有効化

apps/<app>/src/event.config.ts の features で:

mailer: true,

FEATURES に mailer descriptor が無ければ追加する(mailer は既に FeatureKey として認識される):

{
  key: "mailer",
  label: "メール",
  description: "Resend transactional + 運用メール + テンプレ + Slack通知(@event/mailer)",
  nav: [
    { id: "mail", group: "コミュニケーション", label: "メール", href: "/admin/mail", code: "MAIL", order: 1 },
  ],
  env: [
    { name: "RESEND_API_KEY", required: false, description: "Resend 送信キー。未設定ならプレビューのみ(送信スキップ)" },
    { name: "MAIL_FROM", required: false, description: '差出人(例 "Event <onboarding@resend.dev>")' },
    { name: "MAIL_REPLY_TO", required: false, description: "返信先アドレス(任意)" },
    { name: "MAIL_BRAND_NAME", required: false, description: "メール HTML shell のブランド名" },
    { name: "MAIL_BRAND_COLOR", required: false, description: "eyebrow / 見出しのアクセント色" },
    { name: "SLACK_WEBHOOK_URL", required: false, description: "運用 Slack 通知(未設定なら無言 no-op)" },
  ],
  manualChapters: [36],
},

3. mailer の配線ファイル

apps/<app>/src/lib/mailer.ts:

import {
  createMailer,
  invitationTemplate,
  otpTemplate,
  notifyTemplate,
  keyValueBlocks,
  postToSlack,
  type Brand,
  type MailTemplate,
  type SendResult,
} from "@event/mailer";

/** サーバ専用(resend / process.env を使う)。client から import しないこと。 */

const brand: Brand = {
  name: process.env.MAIL_BRAND_NAME ?? "EVENT",
  color: process.env.MAIL_BRAND_COLOR ?? "#9CA877",
};

/** app 共有 mailer。key 未設定は noop(例外を投げず skipped:true を返す)。 */
export const mailer = createMailer({
  from: process.env.MAIL_FROM ?? "Event <onboarding@resend.dev>",
  replyTo: process.env.MAIL_REPLY_TO,
  brand,
  onMissingKey: "noop", // dev fail-safe
});

type AnyTemplate = MailTemplate<unknown>;

export const MAIL_TEMPLATES = {
  invitation: { label: "招待", template: invitationTemplate as AnyTemplate },
  otp: { label: "認証コード", template: otpTemplate as AnyTemplate },
  notify: { label: "運用通知", template: notifyTemplate as AnyTemplate },
} as const;

export type MailTemplateKey = keyof typeof MAIL_TEMPLATES;

export function isMailTemplateKey(k: string): k is MailTemplateKey {
  return Object.prototype.hasOwnProperty.call(MAIL_TEMPLATES, k);
}

export function renderMail(key: MailTemplateKey, data: unknown): { subject: string; html: string } {
  const { template } = MAIL_TEMPLATES[key];
  return {
    subject: template.subject(data, mailer.context),
    html: template.html(data, mailer.context),
  };
}

export interface SendMailResult {
  ok: boolean;
  previewOnly: boolean;
  id?: string;
  reason?: string;
}

export async function sendMail(key: MailTemplateKey, to: string, data: unknown): Promise<SendMailResult> {
  if (!mailer.isConfigured()) {
    return { ok: true, previewOnly: true, reason: "no_api_key" };
  }
  const res: SendResult = await mailer.send(MAIL_TEMPLATES[key].template, to, data);
  return { ok: res.ok, previewOnly: !!res.skipped, id: res.id, reason: res.reason };
}

/** 運用 Slack 通知(SLACK_WEBHOOK_URL 未設定なら無言 no-op)。 */
export function notifySlack(title: string, entries: Record<string, string | undefined | null>) {
  return postToSlack(process.env.SLACK_WEBHOOK_URL, keyValueBlocks(title, entries));
}

4. 送信 / プレビュー API ルート

apps/<app>/src/app/api/admin/mail/send/route.ts:

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { isMailTemplateKey, renderMail, sendMail } from "@/lib/mailer";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const RATE = { windowMs: 60_000, max: 10 };
const hits = new Map<string, number[]>();
function rateLimited(ip: string): boolean {
  const now = Date.now();
  const arr = (hits.get(ip) ?? []).filter((t) => now - t < RATE.windowMs);
  arr.push(now);
  hits.set(ip, arr);
  return arr.length > RATE.max;
}

type Body = { template?: string; data?: unknown; to?: string; preview?: boolean; website?: string };

export async function POST(req: Request) {
  if (!features.isEnabled("mailer")) {
    return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
  }
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) {
    return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
  }

  let body: Body;
  try {
    body = (await req.json()) as Body;
  } catch {
    return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
  }

  const key = typeof body.template === "string" ? body.template : "";
  if (!isMailTemplateKey(key)) {
    return NextResponse.json({ ok: false, error: "unknown_template" }, { status: 400 });
  }
  const data = body.data ?? {};

  let rendered: { subject: string; html: string };
  try {
    rendered = renderMail(key, data);
  } catch (e) {
    const reason = e instanceof Error ? e.message : String(e);
    return NextResponse.json({ ok: false, error: "render_failed", reason }, { status: 400 });
  }

  if (body.preview) {
    return NextResponse.json({ ok: true, previewOnly: true, ...rendered });
  }

  // honeypot
  if (typeof body.website === "string" && body.website.length > 0) {
    return NextResponse.json({ ok: true, previewOnly: true, reason: "honeypot", ...rendered });
  }
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
    req.headers.get("x-real-ip") ||
    "local";
  if (rateLimited(ip)) {
    return NextResponse.json({ ok: false, error: "rate_limited" }, { status: 429 });
  }

  const to = typeof body.to === "string" ? body.to.trim() : "";
  if (!to || !to.includes("@")) {
    return NextResponse.json({ ok: false, error: "invalid_to", previewOnly: true, ...rendered }, { status: 400 });
  }

  const result = await sendMail(key, to, data);
  return NextResponse.json({ ...result, ...rendered });
}

5. admin メール画面(server 枠 + client 子)

apps/<app>/src/app/admin/mail/page.tsx(server component。adminAuth + feature ガード):

import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { mailer } from "@/lib/mailer";
import { MailComposer } from "./MailComposer";

export const dynamic = "force-dynamic";

export default async function MailPage() {
  if (!features.isEnabled("mailer")) notFound();
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");

  const configured = mailer.isConfigured();
  return (
    <main style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
      <h1 style={{ fontSize: 26 }}>メール</h1>
      <p style={{ fontSize: 13, opacity: 0.65 }}>
        {configured
          ? "RESEND_API_KEY 設定済み — テスト送信は実際に届きます。"
          : "RESEND_API_KEY 未設定 — 送信はスキップされプレビューのみ動きます。"}
      </p>
      <MailComposer configured={configured} />
    </main>
  );
}

apps/<app>/src/app/admin/mail/MailComposer.tsx("use client"。@event/mailer は import しない — 描画も送信も API に委譲し、resend を client bundle へ混ぜない):

"use client";
import { useEffect, useRef, useState } from "react";

// TEMPLATES / buildData / inputStyle は starter の MailComposer.tsx をそのまま流用する。
// 要点だけ抜粋:
//  - テンプレ選択(invitation/otp/notify)+ フィールド入力 → buildData で各 Data に整形
//  - 入力を 300ms debounce して POST /api/admin/mail/send { preview:true } → subject/html を iframe.srcDoc
//  - 「テスト送信」は { to, website(honeypot) } を付けて preview なしで POST
//  - previewOnly:true が返れば「送信スキップ(プレビューのみ)」と表示

(フルコードは starter の apps/starter/src/app/admin/mail/MailComposer.tsx を複製する。 テンプレのフィールドは InvitationData(acceptUrl 必須)/ OtpData(code 必須)/ NotifyData(title・intro 必須、sections は heading + 改行区切り items で 1 つ組む)。)

6. インストールと型チェック

monorepo root で:

yarn install
yarn workspace <app> typecheck

7. 検証

dev(RESEND_API_KEY 未設定で OK — プレビューが主役):

  1. yarn workspace <app> dev
  2. /admin/mail を開く(dev は bypass でログイン不要)
  3. テンプレを切り替え・変数を入れると右の iframe に HTML プレビューが即時反映される
  4. 「テスト送信(プレビューのみ)」を押すと

{ ok:true, previewOnly:true, reason:"no_api_key" } が返り、UI に「送信をスキップしました」

  1. curl -X POST localhost:<port>/api/admin/mail/send -H 'content-type: application/json' -d '{"template":"otp","data":{"code":"123456"},"preview":true}'

{ ok:true, previewOnly:true, subject:"…認証コード", html:"<!doctype html>…" }

本番(送信を有効化):

完了条件: typecheck が通り、dev でプレビューが即時に出て、RESEND_API_KEY 未設定時は テスト送信が previewOnly:true を返す(例外を投げない)。