このレシピは @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.ts の transpilePackages 配列に "@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 — プレビューが主役):
yarn workspace <app> dev/admin/mailを開く(dev は bypass でログイン不要)- テンプレを切り替え・変数を入れると右の iframe に HTML プレビューが即時反映される
- 「テスト送信(プレビューのみ)」を押すと
{ ok:true, previewOnly:true, reason:"no_api_key" } が返り、UI に「送信をスキップしました」
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>…" }
本番(送信を有効化):
RESEND_API_KEYとMAIL_FROM(検証済みドメインの from)を設定- テスト送信すると
{ ok:true, previewOnly:false, id:"…" }が返り、実際にメールが届く
完了条件: typecheck が通り、dev でプレビューが即時に出て、RESEND_API_KEY 未設定時は テスト送信が previewOnly:true を返す(例外を投げない)。