はじめに
SaaSを個人開発していると「期日が近い請求書をメールで通知したい」「毎朝レポートを自動送信したい」といった定期通知の要件が出てきます。
本記事では Resend(メール送信)× Vercel Cron Jobs(定期実行)× Prisma(重複防止) を組み合わせて、実用的なメール通知システムを実装する方法を解説します。
実際に toruca.app(請求書自動解析・振込データ生成サービス)の「支払期日通知機能」で使用している実装です。
完成形のイメージ
- 毎日 JST 9:00(UTC 0:00)に自動実行
- 「支払期日が3日後の請求書」「支払期日が今日の請求書」を対象
- ユーザーごとにまとめてメール送信(複数件あれば1通に集約)
- 一度送ったメールは絶対に再送しない(DB で管理)
- Cron の不正呼び出しを
CRON_SECRETで防御
技術スタック
| ツール | 役割 | 料金 |
|---|---|---|
| Resend | メール送信 | 無料: 3,000通/月 |
| Vercel Cron Jobs | 定期実行 | 無料: 2ジョブまで |
| Prisma + PostgreSQL | 送信履歴管理 | Supabase Free tier |
実装ステップ
1. Resend セットアップ
npm install resend
resend.com でアカウント作成 → API Key 発行 → Domains にカスタムドメインを追加(DNS 確認)。
// lib/email/client.ts
import { Resend } from "resend";
let resendClient: Resend | null = null;
export function getResend(): Resend {
if (!resendClient) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
resendClient = new Resend(apiKey);
}
return resendClient;
}
export const FROM_ADDRESS =
process.env.EMAIL_FROM ?? "サービス通知 <noreply@yourdomain.com>";
シングルトンにするのは、サーバーレス環境でのコネクション数を抑えるためです。
2. Prisma スキーマ(送信履歴テーブル)
重複送信を防ぐために「何の通知をいつ送ったか」を記録するテーブルを追加します。
// schema.prisma
model Invoice {
id String @id @default(uuid()) @db.Uuid
// ... 既存フィールド
notificationLogs NotificationLog[]
}
model NotificationLog {
id String @id @default(cuid())
invoiceId String @map("invoice_id") @db.Uuid
type String // "3days_before" | "due_today"
sentAt DateTime @default(now()) @map("sent_at")
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@unique([invoiceId, type]) // ← これが重複防止の核心
@@map("notification_logs")
}
@@unique([invoiceId, type]) により、同じ請求書に同じ種類の通知を2回送ろうとすると DB レベルで弾かれます。
npx prisma db push
3. メールテンプレート
// lib/email/templates/due-date.ts
export interface DueInvoice {
id: string;
supplierName: string | null;
amount: number | null;
dueDate: Date;
}
export function buildDueDateEmail(
invoices: DueInvoice[],
type: "3days_before" | "due_today"
): { subject: string; html: string } {
const isToday = type === "due_today";
const accentColor = isToday ? "#dc2626" : "#d97706"; // 赤 or オレンジ
const subject = isToday
? `【toruca】本日支払期日の請求書があります(${invoices.length}件)`
: `【toruca】3日後に支払期日の請求書があります(${invoices.length}件)`;
const rows = invoices
.map((inv) => {
const amount = inv.amount
? `¥${inv.amount.toLocaleString("ja-JP")}`
: "—";
const due = inv.dueDate.toLocaleDateString("ja-JP", {
year: "numeric",
month: "long",
day: "numeric",
});
return `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb">
${inv.supplierName ?? "(仕入先未設定)"}
</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right">
${amount}
</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center">
${due}
</td>
</tr>`;
})
.join("");
const html = `
<!DOCTYPE html>
<html lang="ja">
<body style="font-family:sans-serif;background:#f9fafb;margin:0;padding:24px">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
<div style="background:${accentColor};color:#fff;padding:20px 24px">
<h1 style="margin:0;font-size:18px">
${isToday ? "⚠️ 本日が支払期日です" : "📅 支払期日が3日後に迫っています"}
</h1>
</div>
<div style="padding:24px">
<p>${invoices.length}件の請求書が対象です。</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead>
<tr style="background:#f3f4f6">
<th style="padding:8px 12px;text-align:left">仕入先</th>
<th style="padding:8px 12px;text-align:right">金額</th>
<th style="padding:8px 12px;text-align:center">期日</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div style="margin-top:24px;text-align:center">
<a href="https://toruca.app/export"
style="background:#2563eb;color:#fff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600">
振込データを出力する →
</a>
</div>
</div>
<div style="padding:16px 24px;background:#f9fafb;font-size:12px;color:#6b7280">
このメールは toruca.app から自動送信されています。
</div>
</div>
</body>
</html>`;
return { subject, html };
}
4. Cron API エンドポイント
// app/api/notifications/due-date/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { getResend, FROM_ADDRESS } from "@/lib/email/client";
import { buildDueDateEmail } from "@/lib/email/templates/due-date";
export const runtime = "nodejs";
export const maxDuration = 60;
// Vercel が自動設定する CRON_SECRET でリクエストを検証
function verifyCronSecret(request: Request): boolean {
const auth = request.headers.get("authorization");
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return auth === `Bearer ${secret}`;
}
export async function GET(request: Request) {
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const threeDaysLater = new Date(today);
threeDaysLater.setDate(today.getDate() + 3);
// 期日当日 & 3日後の未払い・未通知請求書を取得
const [todayInvoices, threeDayInvoices] = await Promise.all([
fetchPendingInvoices(today, today, "due_today"),
fetchPendingInvoices(threeDaysLater, threeDaysLater, "3days_before"),
]);
const results = { sent: 0, skipped: 0, errors: 0 };
// ユーザー別に集約してメール送信
for (const [type, invoices] of [
["due_today", todayInvoices],
["3days_before", threeDayInvoices],
] as const) {
const byUser = groupByUser(invoices);
for (const [email, userInvoices] of Object.entries(byUser)) {
try {
const { subject, html } = buildDueDateEmail(userInvoices, type);
const resend = getResend();
await resend.emails.send({ from: FROM_ADDRESS, to: [email], subject, html });
// 送信記録(DB の UNIQUE 制約で重複は自動スキップ)
await prisma.notificationLog.createMany({
data: userInvoices.map((inv) => ({ invoiceId: inv.id, type })),
skipDuplicates: true,
});
results.sent++;
} catch (err) {
console.error(`Failed to send to ${email}:`, err);
results.errors++;
}
}
}
return NextResponse.json({ ok: true, ...results });
}
async function fetchPendingInvoices(from: Date, to: Date, type: string) {
return prisma.invoice.findMany({
where: {
dueDate: { gte: from, lte: to },
status: { in: ["confirmed", "exported"] },
paidAt: null,
// この type の通知をまだ送っていない請求書のみ
notificationLogs: { none: { type } },
},
include: { user: { select: { email: true } } },
});
}
function groupByUser(invoices: any[]) {
return invoices.reduce<Record<string, any[]>>((acc, inv) => {
const email = inv.user?.email;
if (!email) return acc;
(acc[email] ??= []).push(inv);
return acc;
}, {});
}
5. Vercel Cron 設定
// vercel.json
{
"framework": "nextjs",
"crons": [
{
"path": "/api/notifications/due-date",
"schedule": "0 0 * * *"
}
]
}
0 0 * * * は UTC の毎日 0:00 = JST 9:00 です。
Vercel は Cron ジョブを呼び出す際、Authorization: Bearer <CRON_SECRET> ヘッダーを自動付与します。CRON_SECRET は Vercel ダッシュボードの環境変数に自動設定されます(手動設定不要)。
6. Clerk (認証) からエンドポイントを公開除外
Next.js の認証ミドルウェアを使っている場合、Cron からの呼び出しはセッションを持たないため、公開ルートに追加が必要です。
// middleware.ts(または proxy.ts)
const isPublicRoute = createRouteMatcher([
// ... 既存の公開ルート
"/api/notifications(.*)", // ← 追加
]);
認証はエンドポイント内の verifyCronSecret() で行っているため、外部からの不正呼び出しは防げています。
重複防止の仕組み
請求書A(期日=今日)→ 初回実行: due_today 送信 → NotificationLog に記録
→ 翌日再実行: notificationLogs に due_today が存在 → WHERE 句で除外 → 送信されない
prisma.notificationLog.createMany({ skipDuplicates: true }) と @@unique([invoiceId, type]) の二重防御により、Cron が複数回誤実行されても再送は起きません。
動作テスト
Vercel ダッシュボードで CRON_SECRET の値を確認し、手動でエンドポイントを叩けます。
curl -H "Authorization: Bearer <CRON_SECRET>" \
https://yourdomain.com/api/notifications/due-date
レスポンス例:
{ "ok": true, "sent": 2, "skipped": 0, "errors": 0 }
まとめ
| ポイント | 実装方法 |
|---|---|
| 重複防止 | Prisma @@unique + skipDuplicates: true
|
| セキュリティ |
CRON_SECRET ヘッダー検証 |
| 複数件集約 | ユーザーメール単位でグルーピング |
| 定期実行 | Vercel Cron(無料枠: 2ジョブ) |
| コスト | Resend 無料枠(3,000通/月)で十分 |
Resend は日本語対応・ダッシュボードのUIが優秀・無料枠が太めで、個人開発のメール送信には最適です。SendGrid や SES の設定の煩雑さに疲れた方にぜひ試してみてください。
この記事で紹介した通知機能は toruca.app で実際に動いています。請求書の振込情報をAIで自動解析し、全銀フォーマットの振込データを生成するサービスです。よかったら覗いてみてください。