0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Resend + Vercel Cron + Prisma で「重複なし」メール通知を実装する

0
Posted at

はじめに

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で自動解析し、全銀フォーマットの振込データを生成するサービスです。よかったら覗いてみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?