家計簿アプリを作る #78:予算オーバー時のメール通知

家計簿アプリ作成シリーズの第78回です。月間の総予算を超えたときに、登録しているメールアドレス宛に通知メールを送る機能を追加します。

Cloudflare Email Routingは今回の用途に合わない

最初はCloudflareのメール送信機能を検討しましたが、Email Routingは「受信したメールを別のアドレスへ転送する」ための機能で、send_email bindingを使ってWorkerから送信することもできるものの、送信先を事前にダッシュボードで検証済み登録する必要があります。「不特定多数のユーザーへ自由に通知メールを送る」という今回の用途には合わないため、外部のメール送信サービス(Resend)のSDKを使うことにしました。

npm install resend

Resendのドメイン検証と送信元・送信先の違い

実装後、以下のエラーが出ました。

[Resend API Error]: {
  status: 403,
  error: { message: 'The kakeibo.app domain is not verified. ...' }
}

これは送信元アドレスのドメインを検証する必要がある、という意味でした。送信先(受信者)のドメインは検証不要です。独自ドメインをまだ持っていなかったので、今回はResendが提供するテスト用送信元 onboarding@resend.dev を使いました。ただしこのアドレスは、Resendアカウントに登録済みの自分自身のメールアドレス宛にしか送信できないという制約があります。

ハマりポイント:SDKはエラーを例外として投げない

resend.emails.send()try/catch で囲んでいたのに、APIエラー(ドメイン未検証など)が発生してもエラー処理に入らず、DBには「送信済み」の記録が残ってしまう不具合がありました。

原因は、ResendのSDKがエラーを例外として投げず、{ data, error } という戻り値(Result型のパターン)で返す設計だったことです。

// ❌ エラーが例外として投げられないのでcatchに入らない
try {
  await resend.emails.send({ ... });
} catch (e) { ... }

// ✅ 戻り値のerrorを明示的にチェックする
const { error } = await resend.emails.send({ ... });
if (!error) {
  // 送信成功時のみ「送信済み」を記録する
  await db.insert(budgetAlerts).values({ userId: locals.user!.id, month: currentMonth });
} else {
  console.error('メール送信エラー:', error);
}

二重送信の防止

「今月分は既に通知済みか」を記録する budgetAlerts テーブルを新設し、userId+month の組み合わせで判定しています。定期収支の自動登録で使った「登録済みIDをSetで持って判定する」考え方と同じ発想です。

export const budgetAlerts = sqliteTable('budget_alerts', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull(),
  month: text('month').notNull(),
});

ハマりポイント:絞り込み条件が集計値に混入する

予算オーバー判定に使っていた expense は、実は検索・カテゴリ絞り込みの影響を受けたフィルタ後の値でした。ユーザーが検索やカテゴリで絞り込んだ状態でページを開くと、絞り込み後の少ない金額で判定されてしまい、本当は予算オーバーしているのに通知が送られない、という不具合につながる可能性がありました。

// ✅ 絞り込みの影響を受けない、その月全体の支出合計を別途計算する
const monthOnlyTransactions = allTransactions.filter(t => t.date.startsWith(selectedMonth ?? ''));
const { expense: monthlyExpense } = calculateTotals(monthOnlyTransactions);

const overMonthlyBudget = monthlyBudget && monthlyExpense > monthlyBudget.amount;

同じ理由で、画面上の「月間の予算の消化率」プログレスバーも、検索・カテゴリを変えるたびに数字がころころ変わってしまう問題がありました。ただし「支出合計」カード自体は、絞り込み条件を反映することが元々の仕様だったため、totalExpense(フィルタ後)はそのままにし、プログレスバー専用に monthlyExpense(フィルタの影響を受けない)というフィールドを別に用意して使い分けました。

「合計」を意味する値でも、用途によって『絞り込みを反映すべきか』が異なる、という点を意識する必要がありました。

まとめ

ポイント 内容
Cloudflare Email Routingの適性 受信転送が主目的で、不特定多数への送信には不向き
Resendの送信元ドメイン検証 検証が必要なのは送信元。テスト用はonboarding@resend.dev
SDKのエラーハンドリング 例外を投げず{ data, error }を返す設計。errorを明示的にチェックする
二重送信防止 userId+monthの組み合わせで送信済みかを記録する
集計値と絞り込みの関係 用途(表示 or 判定)によって、絞り込みを反映すべきかどうかを見極める
← トップページに戻る