見積書を作ってPDFにできたら、次は取引先に届けたくなります。ただ、メールの送信を「ボタンを押した画面の中」で行うと、相手のメールサーバーが遅いだけで画面が固まり、失敗すれば画面ごとエラーになります。この第6回では、Ruby on Rails 8で作っている見積・請求管理システムに、見積書のPDFをメールで送る機能を、送信を裏側に回す形(非同期)で入れます。
前回の第5回:見積書をPDFで出力するで作ったPDFを、そのままメールに添付します。
「見積書をメールで送るとき、送信ボタンを押してから画面が固まったり、送れたのか分からなくなったりして困る」
先に結論です。メール送信は「送信の予約」だけを画面で行い、実際の送信はSolid Queue(ソリッドキュー)という仕組みに任せるのが定石です。 Rails 8では、予約の書き方が deliver_later の1語で済み、Redisなどの追加サーバーも要りません。さらに「一時的な失敗は間隔をあけて再試行する」「宛先が存在しない失敗は諦める」を設定しておけば、送れたかどうかを見積書に記録できます。
この記事では、非同期にする理由、Action Mailer(メール送信の部品)での実装、再試行の設定、自動テストまでを、実際に動かしたコードで解説します。
メール送信を「画面の中」でやってはいけない理由
メールの送信は、自分のシステムの外にあるメールサーバーとのやりとりです。相手の都合で、時間がかかったり失敗したりします。
| 方式 | 動き | 起きやすい問題 |
|---|---|---|
| 同期(その場で送る) | ボタンを押した画面の処理の中で送信し、終わるまで待たせる | 相手サーバーが遅いと画面が固まる。失敗すると画面がエラーになり、再送の手間も利用者に回る |
| 非同期(予約して後で送る)※今回採用 | 画面は「送信を受け付けました」とすぐ返す。送信は別のプロセスが順番に処理する | 「送れたか」を別の方法で確認する仕組みが要る(今回は見積書に送信日時を記録) |
非同期にすると、利用者は待たされず、失敗しても裏側で自動的にやり直せます。その代わり「予約を溜めておく場所」と「予約を処理する担当」が必要になります。これを担うのがSolid Queueです。
仕組み:予約を溜める場所と、処理する担当
Rails 8では、予約(ジョブ)の溜め場所としてデータベースを使うSolid Queueが標準になりました。従来は、この目的のためにRedisという別のサーバーを立てるのが一般的でした。
📰 出典:Solid Queue(Rails公式のGitHubリポジトリ)
Solid Queueの公式の説明では、データベースを使ってジョブ(バックグラウンドで行う処理)を管理する仕組みとされています。サーバーの種類が増えないため、小さな業務システムでは構成と運用がシンプルになります。このサンプルのGemfileにも solid_queue が入っており、本番用の設定(config/environments/production.rb)で使うよう指定済みです。
流れは次のとおりです。
- 利用者が「見積書をメールで送る」ボタンを押す
- コントローラーが
deliver_laterで送信を予約し、画面はすぐ「受け付けました」と返す - Solid Queueのワーカー(予約を処理する担当プロセス)が予約を取り出し、PDFを作ってメールを送る
- 送れたら、見積書に送信日時と宛先を記録する
実装1:メーラー(メールの雛形)を作る
Action Mailer(Railsのメール送信の部品)では、メールの種類ごとに「メーラー」というクラスを作ります。見積書用のメーラーは次の内容です。
# app/mailers/estimate_mailer.rb
class EstimateMailer < ApplicationMailer
# 送信に成功したときだけ、送信日時と宛先を見積書に残す(失敗時は残らない)
after_deliver :record_delivery
def issued
@estimate = params.fetch(:estimate)
@to = params.fetch(:to)
pdf = EstimatePdf.new(@estimate)
attachments[pdf.filename] = { mime_type: "application/pdf", content: pdf.render }
mail to: @to, subject: "【見積書】#{@estimate.title}(#{@estimate.number})"
end
private
def record_delivery
@estimate.update_columns(sent_at: Time.current, sent_to: @to)
end
end
ポイントは3つです。
- PDFは第5回の
EstimatePdfをそのまま使う:金額の計算もPDFの描画も1か所のままなので、画面・PDF・メールで金額がずれません。 - 添付のPDFは送信時に作る:予約の時点ではなく、実際に送るタイミングで作ります。予約から送信までの間に見積書が修正されても、送られるのは最新の内容です。
after_deliver(送信が成功した後に呼ばれる処理)で記録する:失敗したときは記録されないので、「送信済み」の表示が嘘になりません。
メール本文は app/views/estimate_mailer/issued.text.erb に、宛名・件名・見積番号・税込合計・有効期限を入れたテキストで書いています。送信元のアドレスは、コードに直接書かず環境変数で差し替えます。
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
# 送信元は環境変数で差し替える(本番は自社ドメインのアドレスにする)
default from: -> { ENV.fetch("MAIL_FROM", "mitsumori@example.com") }
layout "mailer"
end
実装2:送信を予約するボタンを作る
見積書の送信状況を残すため、estimates テーブルに送信日時(sent_at)と宛先(sent_to)の列を足します。
# db/migrate/20261003130000_add_sending_columns_to_estimates.rb
class AddSendingColumnsToEstimates < ActiveRecord::Migration[8.1]
def change
add_column :estimates, :sent_at, :datetime
add_column :estimates, :sent_to, :string
end
end
コントローラーには deliver という操作を足し、ルーティングに post :deliver, on: :member を加えます(URLは /estimates/1/deliver)。
# app/controllers/estimates_controller.rb(抜粋)
# メール送信は「予約」だけして画面をすぐ返す。送信の成否は見積書の sent_at で確認する
def deliver
to = params[:to].presence || @estimate.customer.email
if to.blank? || to !~ URI::MailTo::EMAIL_REGEXP
redirect_to @estimate, alert: "送信先のメールアドレスが未登録、または形式が正しくありません。", status: :see_other
else
EstimateMailer.with(estimate: @estimate, to: to).issued.deliver_later
redirect_to @estimate, notice: "#{to} への送信を受け付けました。数分以内に送信されます。", status: :see_other
end
end
deliver_later が「予約」を意味します。同じ処理を deliver_now に変えると、その場で送る同期方式になります。画面の文言を「送信しました」ではなく「受け付けました」にしているのは、この時点ではまだ送れていないからです。
権限は、第2回で作った仕組みを使います。この操作は can_edit? の権限(担当者・管理者)を持つ人だけが行えます。閲覧のみのユーザーが直接URLを叩いても、予約されないことをテストで確認しています。
詳細画面には、送信先を入力できるフォームと、送信済みの表示を足しています(app/views/estimates/show.html.erb)。送信先の初期値は取引先マスタのメールアドレスで、押すと確認ダイアログが出ます。誤送信は取り消せないため、確認を1回挟んでいます。
実装3:失敗したときのやり直しを決める
メールの失敗には、性質の違う2種類があります。
| 失敗の種類 | 例 | 方針 |
|---|---|---|
| 一時的な失敗 | 相手サーバーが混雑している、ネットワークが一瞬切れた | 時間をあけて再試行する |
| 恒久的な失敗 | 宛先のアドレスが存在しない | 何度やっても直らないので諦め、記録を残す |
この方針を、メール送信用のジョブに設定します。
# config/initializers/mail_delivery_job.rb
Rails.application.config.after_initialize do
ActionMailer::MailDeliveryJob.class_eval do
retry_on Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Net::SMTPServerBusy,
wait: :polynomially_longer, attempts: 5
discard_on Net::SMTPSyntaxError, Net::SMTPFatalError do |job, error|
Rails.logger.error("[mail] 再試行しても直らない失敗のため中止: #{job.arguments.first} #{error.class}")
end
end
end
retry_on:指定した失敗のとき再試行します。wait: :polynomially_longerは、待ち時間を回数に応じて長くしていく指定です(Railsのソース内の説明では、実行回数の4乗に近い秒数を待つ方式)。最大5回までです。discard_on:指定した失敗のときは再試行せずに中止し、ログに残します。
📰 出典:Rails公式ガイド「Active Job の基礎」
この設定は、Railsの標準のジョブ(ActionMailer::MailDeliveryJob)に後から付けています。メーラーごとにジョブを作り直さなくて済む反面、プロジェクト全体のメールに効く設定になる点は覚えておいてください。
動作確認:自動テストで固める
メールは実際に送ると相手に届いてしまうため、テストでは「送らずに中身だけ確かめる」設定(テスト用の配送方法)を使います。今回は、次の7つの挙動をテストにしました。
- 宛先・件名が正しく、PDFが添付されている
- 送信に成功すると、送信日時と宛先が見積書に残る
- 送信ボタンは「予約」だけで、その場ではメールが送られない
- 宛先の形式が不正なときは予約されない
- 閲覧のみのユーザーは送信できない
- 一時的な失敗(相手サーバー混雑)では、待ち時間つきの再試行が予約され、送信済みにならない
- 恒久的な失敗(宛先が受け付けられない)では、再試行せずに中止される
一時的な失敗のテストは、わざと必ず失敗する配送方法を用意して確かめています。
# test/mailers/estimate_mailer_test.rb(抜粋)
test "一時的な失敗(相手サーバー混雑)は再試行が予約され、送信済みにならない" do
EstimateMailer.with(estimate: @estimate, to: "info@sakura.example.com").issued.deliver_later
with_failing_delivery(Net::SMTPServerBusy.new("busy")) { run_enqueued_mail_job_once }
retry_job = queue_adapter.enqueued_jobs.last
assert_equal 1, queue_adapter.enqueued_jobs.size # 待ち時間つきで再投入された
assert retry_job["scheduled_at"].present?
assert_nil @estimate.reload.sent_at
end
全体で67件のテストがすべて通り、RuboCop(コードの書き方のチェック)も指摘なしでした(Ruby 3.3・Rails 8.1・PostgreSQL 16の環境、執筆時点:2026年10月)。
確認できていないこと:この環境では、実際のメールサーバー(SMTP)への送信と、本番と同じ構成でSolid Queueのワーカーを常駐させた状態での送信は試していません。上のテストは、Railsの標準のテスト用の仕組みで「予約される」「失敗時にやり直しが予約される」ことまでを確かめたものです。本番で使う前に、送信用のメールサービスの設定と、ワーカーを動かした状態での送信確認が必要です。
つまずきやすい点・注意点
- ワーカーが動いていないと、メールは永遠に送られない:予約はデータベースに溜まるだけです。本番では、ワーカー(
bin/jobs)を動かす、またはWebサーバーのPumaの中でSolid Queueを動かす設定(このサンプルでは環境変数SOLID_QUEUE_IN_PUMA)が必要です。どれを使うかは、第9回のデプロイで扱います。 - 本番用のSMTP設定は、この連載では省略している:実際に送るには、メール送信サービスの接続情報を
config/environments/production.rbに設定します。接続情報は、第2回までと同じく、コードに書かず環境変数や認証情報の管理機能で扱います。 - 「送れた」とは限らない:メールサーバーが受け取ったことと、相手が読めたことは別です。迷惑メールに分類されたり、受信側の設定で弾かれたりする場合があります。送信元の認証設定(SPF・DKIMなど)は、本番の前に開発会社や情シス担当と確認してください。
- 今回は省略したもの:送信履歴を複数回分残す機能、送信失敗時の画面での通知、メール本文のHTML化、宛先の複数指定は扱っていません。
発注者向けメモ:メール送信機能を依頼するときの確認点
「見積書をメールで送る」は簡単そうに見えますが、見積もりを左右する論点がいくつかあります。依頼前に決めておくと、手戻りが減ります。
発注者がやることチェックリスト
- ☐ 送信元のメールアドレス(会社のドメイン)を決め、使ってよいか情シスや契約中のメールサービスの管理者に確認する
- ☐ 取引先ごとの宛先(担当者・CC)の持ち方を決める(1件か複数か)
- ☐ 送ったメールの記録を、どこまで・何年残すか決める(経理や税理士にも確認する)
- ☐ 「送れなかったとき」に誰が気づき、誰が対応するかを決める
- ☐ 本文の文面(定型文)と署名を用意する
開発会社への質問例
- 「メール送信は、画面を待たせない作り(非同期)になっていますか。送信が失敗したとき、自動でやり直しますか」
- 「送れたかどうかは、システム上でどう確認できますか。失敗したときの通知はどうなりますか」
- 「送信用のメールサービスは何を使い、月額の費用や送信数の上限はどうなりますか」
- 「迷惑メールに入りにくくするための設定(SPF・DKIMなど)は、見積もりに含まれていますか」
- 「誤って別の取引先に送ってしまうことを防ぐ工夫(確認画面など)はありますか」
工数が増えやすいのは、複数の宛先やCC・BCCの管理、送信履歴を詳しく残す機能、メール本文を取引先ごとに変える機能、メールの開封確認です。最初は「1件の見積書を1つの宛先に送る」形で始め、運用しながら足していく進め方が現実的です。
「メール送信ひとつにも、非同期・やり直し・送信元の設定と、決めることがあると分かりました。見積もりで聞く質問が増えました」
まとめと次回予告
第6回では、見積書をPDF添付でメール送信する機能を、Solid Queueで非同期にして作りました。要点は、画面では deliver_later で「予約」だけ行うこと、一時的な失敗は再試行し恒久的な失敗は諦める方針を決めておくこと、送れたときだけ記録を残すことの3つです。
次回の第7回は「受注から請求書へ変換する」です。見積書が受注になったら請求書に変換する流れを、状態遷移(見積→受注→請求)と請求番号の採番、インボイス制度の記載事項とあわせて作ります。
この連載の記事一覧
この記事は連載「Railsで作る見積・請求管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Railsで作る見積・請求管理システム 第0回】Rails 8の雛形を動かして、Excel見積からの脱却を始める
- 【Railsで作る見積・請求管理システム 第1回】取引先と品目のマスタを作る(scaffoldと入力チェック)
- 【Railsで作る見積・請求管理システム 第2回】ログインを付ける(Rails 8標準の認証と権限の分け方)
- 【Railsで作る見積・請求管理システム 第3回】見積書と明細を作る(Hotwireで明細行をその場で追加)
- 【Railsで作る見積・請求管理システム 第4回】金額・消費税・端数処理を正しく計算する(税率ごとに1回だけ丸める)
- 【Railsで作る見積・請求管理システム 第5回】見積書をPDFで出力する(Prawnで日本語フォントを扱う)
- 【Railsで作る見積・請求管理システム 第6回】見積書をメールで送る(Solid Queueで送信を非同期にする)(この記事)
- 【Railsで作る見積・請求管理システム 第7回】受注から請求書へ変換する(状態遷移と請求番号、インボイスの記載事項)
- 【Railsで作る見積・請求管理システム 第8回】入金管理と督促の一覧を作る(一部入金・期限超過の抽出)










コメント
コメント一覧 (1件)
[…] 前回の第6回:見積書をメールで送るでは、メール送信を裏側に回しました。今回は見積書の「状態」を管理し、請求書をデータとして持つところまでを作ります。 […]