概要
アプリ開発中に、メール送信機能実装で遭遇した課題と解決方法について備忘録として残します。
環境
Rails 8.0 / Sidekiq / letter_opener
発生した課題
初期実装では、メール送信処理とデータベースの送信フラグ更新が分離されており、以下の問題に当たってしまいました。
- コンソールで
NotificationMailer.visit_reminder(n).deliver_now
を実行してもメールは送信されるが、is_sent
フラグが更新されない - メール送信の成功/失敗とデータベースの状態が同期していない
やってみたアプローチ
1: トランザクションでの送信と更新の統合をする
まず、コンソールで送信とフラグ更新を一括実行する方法を確認
Notification.transaction do
NotificationMailer.visit_reminder(n).deliver_now
n.update!(is_sent: true)
end
実行結果:
TRANSACTION (0.3ms) BEGIN
Delivered mail ...
Notification Update (0.4ms) UPDATE "notifications" SET "is_sent" = TRUE
TRANSACTION (1.4ms) COMMIT
=> true
上記から、
- メール送信が成功した場合のみ
is_sent
がtrue
に更新される - 送信に失敗した場合はロールバックされ、
is_sent
はfalse
のまま - データベースの整合性が保たれる
2: Jobでの責務集約
次に、この処理をJob内に組み込んで責務を集約
class VisitReminderJob < ApplicationJob
queue_as :default
def perform
visits = Visit.where(visit_date: Time.zone.tomorrow).includes(:notifications)
visits.each do |visit|
visit.notifications
.where(is_sent: false, due_date: Time.zone.tomorrow)
.find_each do |notification|
Notification.transaction do
NotificationMailer.visit_reminder(notification).deliver_now
notification.update!(is_sent: true)
end
end
end
end
end
3: 動作確認
テストデータの作成
u = User.first
d = Department.first
v = u.visits.create!(
visit_date: Date.tomorrow,
hospital_name: "テスト会社",
appointed_at: Time.zone.now,
purpose: "テスト目的",
department_id: d.id
)
n = u.notifications.create!(
title: "【リマインド】#{v.hospital_name}",
description: "明日の予定です\n日付: #{v.visit_date}",
due_date: Date.tomorrow,
visit: v,
is_sent: false
)
Job実行:
VisitReminderJob.perform_now
実行結果:
NotificationMailer#visit_reminder: processed outbound mail
Delivered mail ...
Notification Update (0.3ms) UPDATE "notifications" SET "is_sent" = TRUE
TRANSACTION (0.2ms) COMMIT
Performed VisitReminderJob from Sidekiq(default) in 90.79ms
重要
deliver_nowと、deliver_laterの選択について
Job内では deliver_now
を使用した
# Job内での推奨実装
NotificationMailer.visit_reminder(notification).deliver_now
# deliver_laterだと「Job内からさらに別のJobを積む」ことになり整合性が崩れる
Controller側の責務分離
Controller側は「通知レコード作成とJob呼び出し」のみに責務を限定したい
def create
@item = current_user.items.build(item_params)
if @item.save
notification = current_user.notifications.create!(
title: "...",
description: "...",
due_date: @item.scheduled_date - 1.day,
item: @item,
is_sent: false
)
# Job に委譲
ReminderJob.perform_later
redirect_to items_path, notice: "予定を保存しました"
else
render :new, status: :unprocessable_entity
end
end
まとめ
- 責務を分ける: メール送信と状態更新は密接に関連するため、同じ処理単位(Job)内で実行
- トランザクションを活用してみる: 送信成功時のみデータベースを更新することで整合性を保持
-
Job内で同期処理をする: Job内では
deliver_now
を使い、送信と更新を同期的に実行 - 試しに動かす: 実際にJobを動かして期待通りの動作を確認することの重要性
結果
- メール送信とデータの更新がちゃんとセットで動くようになった。
- 「メールは送れたのにDBが更新されない」「DBだけ更新されたのにメールは届かない」といったズレは直せたみたい、
初学者のため、内容が間違えていたらすいません。
また、いい書き方やアプローチの仕方がありましたら、コメントいただけるとありがたいです。