Rails 「Action Mailer × Gmail × Heroku」でメール送信機能を実装する

Railsアプリケーションでメール送信機能を実装する方法を説明します。
Herokuの本番環境では、SendGridを用いることが多いと思いますが、今回は開発/本番環境どちらもGmailを使って実装します。
なお、HerokuとGoogleのアカウント取得、およびHerokuでのアプリケーションセットアップが完了している前提で進めます。

環境

ステップ 1: 環境変数の設定

1.1: Googleでアプリパスワードを取得

以下の順にアクセスして、アプリパスワードを取得します。

  1. Googleアカウントを管理
  2. セキュリティ
  3. Googleにログインする方法 > 2段階認証プロセス
  4. アプリパスワード で任意のApp nameを入力しパスワードを作成

1.2: gem dotenv-rails をインストール

セキュリティを確保するために、Gmailのユーザー名とパスワードは環境変数に保存します。
Railsでアプリケーションでgem 'dotenv-rails'をGemfileに追加し、bundle installを実行して、dotenvをアプリケーションにインストールします。

1.3: .envファイルにメールアドレスとパスワードを設定

以下のように.envファイルに環境変数を追加しましょう。

EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-email-password

your-email-passwordには、先ほどGoogleアカウントで作成したアプリパスワードを記述します。

ステップ 2: Action Mailerの設定

今回は、ユーザーが商品を購入したら、購入明細をメールで送信するようにします。
app/mailers/checkout_mailer.rbメーラークラスを作成し、購入確認メールを定義します。

class CheckoutMailer < ApplicationMailer
  default from: ENV['EMAIL_USER']

  def checkout_confirmation(order)
    @order = order
    mail(to: @order.billing_address.email, subject: '購入が完了しました')
  end
end

メールの送信元として、先ほど.envファイルに記述した環境変数を設定します。
checkout_confirmation(order)では、引数で購入内容の情報を取得します。
インスタンス変数@orderにすることで、メールの本文で使用できるようになります。 mail(to: )には購入内容の情報から、購入者のメールアドレスを設定します。

ステップ 3: メールビューの作成

HTMLとテキスト形式の両方でメールを作成します。
これはapp/views/checkout_mailerディレクトリに配置します。

HTML

<!DOCTYPE html>
<html>
<head>
    <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
</head>
<body>
    <h1>購入が完了しました</h1>
    <p>購入明細:</p>
    <ul>
        <% @order.order_items.each do |item| %>
            <li><%= item.product.name %> - <%= item.quantity %> x <%= number_to_currency(item.price) %></li>
        <% end %>
    </ul>
    <p>Total: <%= number_to_currency(@order.total_price) %></p>
</body>
</html>

テキスト形式

購入が完了しました
購入明細:

<% @order.order_items.each do |item| %>
  <%= item.product.name %> - <%= item.quantity %> x <%= number_to_currency(item.price) %>
<% end %>

Total: <%= number_to_currency(@order.total_price) %>

ステップ 4: 開発環境の設定

開発環境では、config/environments/development.rbにメール送信の設定を追加します。

config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'smtp.gmail.com',
  port: 587,
  domain: 'gmail.com',
  user_name: ENV['EMAIL_USER'],
  password: ENV['EMAIL_PASSWORD'],
  authentication: 'plain',
  enable_starttls_auto: true
}

ステップ 5: 本番環境の設定

本番環境でHerokuを使用する場合、config/environments/production.rbに以下のように設定を追加します。

config.action_mailer.default_url_options = { host: 'https://your-app-name.herokuapp.com', protocol: 'https' }
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'smtp.gmail.com',
  port: 587,
  domain: 'gmail.com',
  user_name: ENV['EMAIL_USER'],
  password: ENV['EMAIL_PASSWORD'],
  authentication: :plain,
  enable_starttls_auto: true
}

また、Herokuの管理画面(Setting > Config Vars)にて環境変数(EMAIL_USER, EMAIL_PASSWORD)を追加します。

ステップ 6: メール送信のトリガーを設定

今回は、購入が完了したタイミングでメールを送信します。

OrdersController内で、注文が正常に保存された後にメールを送信するようにします。

if @order.save
  CheckoutMailer.checkout_confirmation(@order).deliver_now
  # その他の処理...
end

先ほど作成したcheckout_confirmationアクションに、保存したorderを引数として渡すことで、購入内容をメールで使用できます。

ステップ 7: テストとデプロイ

開発環境でメールが正しく送信されることを確認したら、本番環境にデプロイします。

これで、RailsアプリケーションからGmailを介してメールを送信することができます。

【Rails】flash[:notice]とか書くから、flashのキーはシンボルだと思ってたら「文字列」だった

Railsでの開発中に遭遇した不具合について、原因と解決策を投稿します。

環境

背景

flashを使って以下のようにメッセージを表示していました。

<% if flash[:notice] %>
  <div class="alert alert-primary">
    <%= flash[:notice] %>
  </div>
<% end %>

flash[:notice]だけではなく、flash[:alert]を受け取った場合もメッセージを表示します。

<% if flash[:notice] %>
  <div class="alert alert-primary">
    <%= flash[:notice] %>
  </div>
<% end %>

<% if flash[:alert] %>
  <div class="alert alert-danger">
    <%= flash[:alert] %>
  </div>
<% end %>

しかし、上記のように種類が増えるにつれてif文を増やしていくのは、少し冗長的なので、case文を使ってもう少しスッキリさせてみます。

<% flash.each do |type, message| %>
  <% case type
    when :notice %>
    <div class="alert alert-primary">
      <%= message %>
    </div>
  <% when :error %>
    <div class="alert alert-danger">
      <%= message %>
    </div>
  <% end %>
<% end %>

不具合の内容

既述の case文を使った書き方にすると、なぜかflashが画面に表示されなくなってしまいました。

不具合の原因

flashflash[:notice] = "message"のように指定するため、flashのキーはシンボルかと思っていました。
しかし、実際に確認してみると、キーは「文字列」になっていることがわかります。

{"alert"=>"An error occurred: Quantity must be greater than 0"}

そのため、シンボルで指定すると条件に一致しません。

これはHashWithIndifferentAccessというRailsの機能で、シンボルと文字列の両方をキーとして受け取れるが、内部的にはそれを「文字列」として保存する仕様によるものだと思います。

解決策

この問題を解決するためには、case文でflashのキーを指定する際に「文字列」で指定する必要があります。

<% flash.each do |key, message| %>
  <% case key
    when "notice" %>
    <div class="alert alert-primary">
      <%= message %>
    </div>
  <% when "alert" %>
    <div class="alert alert-danger">
      <%= message %>
    </div>
  <% end %>
<% end %>

なお、このcase文はヘルパーメソッドとして、外部で定義した方がよりビューが綺麗になりそうです。

# app/helpers/application_helper.rb
def flash_class(type)
  case type
    when "notice" then "alert alert-primary"
    when "alert" then "alert alert-danger"
    # 他のflashタイプもここに追加できます
  end
end
<% flash.each do |type, message| %>
  <div class="<%= flash_class(type) %>">
    <%= message %>
  </div>
<% end %>

【Rails7】.to_iメソッドと||演算子でカート内の商品数が0になる不具合

Railsでセッションベースのカート機能を実装する際に遭遇した不具合について、原因と解決策を投稿します。

環境

背景

アプリケーション内にて、商品一覧ページから「add to cart」ボタンを押すと、該当の商品を「1つ」カートに追加する機能の実施を試みました。

def add_item
  item_id = params[:item_id]
  quantity = params[:quantity].to_i || 1

商品詳細ページからは任意の数量を指定してカートに追加できる仕様にするため、以下のようにquantityに数値を入れます。

  • paramsでquantityが渡ってくる -> その数値を入れる
  • paramsでquantityが渡ってこない -> 1を入れる

なお、quantityを数値として管理したいため、.to_iメソッドを使用しています。

不具合の内容

カート内の商品数量がすべて0に設定されてしまうという問題が発生しました。

不具合の原因

.to_iメソッドの仕様によるものでした。

商品一覧ページからカートに商品を追加した場合はparams[:quantity]nilになるため、1がquantityに入れられるはずです。

しかし.to_iメソッドは、params[:quantity]nilか空文字の場合、これを0と解釈してしまうようです。

そのため、

quantity = params[:quantity].to_i || 1

||演算子の左側が真になる(0になる)ので、カート内の商品数が0になってしまっていたようです。

解決策

この問題を解決するためには、
まず.present?メソッドでparams[:quantity]の存在を確認し、その後適切に値を設定する必要があります。

三項演算子を使う
quantity = params[:quantity].present? ? params[:quantity].to_i : 1
if文を使う
if params[:quantity].present?
  quantity = params[:quantity].to_i
else
  quantity = 1
end

【Rails7】 名前空間付きコントローラにおけるlink_to 削除でRoutingErrorになった

管理者向けの商品削除機能を実施するために、名前空間付きコントローラー(今回のケースではadmin/products)でlink_toメソッドを使用しました。

この際、名前空間の存在を意識せずに実装するとRoutingErrorになったので、原因と解消方法を投稿します。


目次


環境

背景

ディレクトリ構造:

app/
|-- controllers/
|   |-- admin/
|   |   `-- products_controller.rb
|   `-- products_controller.rb
|-- views/
|   |-- admin/
|   |   `-- products/
|   |       `-- index.html.erb
|   `-- products/
|       `-- index.html.erb
...

ルーティング:

namespace :admin do
    resources :products
  end
  resources :products, only: %i[index show]

コントローラ:

# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Product.all
  end

  def show
    @product = Product.find(params[:id])
  end
end

# app/controllers/admin/products_controller.rb

class Admin::ProductsController < ApplicationController
  before_action :set_product, only: %i[ edit update destroy ]

# 中略 #

  def destroy
    @product.destroy
    redirect_to admin_products_path, flash: { notice: "#{@product.name}を削除しました"}
  end

  private

  def set_product
    @product = Product.find_by(id: params[:id])
  end

end

一般ユーザー向けにはdomain/productsで商品一覧ページを表示させ、管理者向けにdomain/admin/productsで管理画面内の商品一覧ページを表示させ、そこで削除機能を実装します。

エラーの内容

今回、管理画面内の商品一覧ページに下記のような削除リンクを設けました。

app/views/admin/products/index.html.erb

<% @products.each do |product| %>
~~
 <%= link_to product, data: { turbo_method: :delete, turbo_confirm: '削除してもよろしいですか?'} %>
~~
<% end %>

link_to の引数に、インスタンスを渡し、turbo_method: :deleteを指定することで、destroyアクションの実行を期待しました。

しかし、以下のようなエラーが出てしまいました。

ActionController::RoutingError (No route matches [DELETE] "/products/[:id]"):

ルーティングも設定しているのに、ルーティングエラーが発生とは・・・。

エラーの原因

エラーの内容をよく見ると[DELETE] "/products/[:id]")ということで、admin/products/[:id]ではなく、一般ユーザー向けに作成したproductsのパスになっていました。

そもそも、link_toメソッドの引数にモデルオブジェクト(インスタンス)を渡すと、そのオブジェクトのshowアクションにマッピングされるURL(例えば /products/1)が生成されます。

そのため、link_toメソッドでは引数にデルオブジェクト(インスタンス)を渡し、turbo_method: :deleteを指定するだけで、特定の商品を削除できるはずでした。

しかし、上記のコードでは、名前空間を考慮せずに標準の商品コントローラーへのパスを構築してしまうため、admin配下の商品コントローラにルーティングされるよう明示的に記述する必要があったようです。

link_toがあまりにもスマートな機能だからといって、考えなしにインスタンスを指定するのはよくありませんでした。。。

解決策

この問題を回避するためには、link_toメソッドに明示的にパスを指定する必要があります。

具体的には、以下の2通りがあります

# 名前空間を明示的に指定したパスヘルパー
<%= link_to admin_product_path(product), data: { turbo_method: :delete, turbo_confirm: '削除してもよろしいですか?'} %>
# ポリモーフィックURLヘルパー
<%= link_to [ :admin, product ], data: { turbo_method: :delete, turbo_confirm: '削除してもよろしいですか?'} %>

参考:https://runebook.dev/ja/docs/rails/actiondispatch/routing/polymorphicroutes

いずれにしても、admin配下のproductsコントローラを使うことを明示的に指定しないといけません。

【Ruby / Rails】URI.openを使うと、RuboCopで警告が出た

Railsで開発をしている際に、外部から画像データを参照するためにopen-uriを使用しました。

require 'open-uri'

image_url = "https://example.com/image.jpg"
image_data = URI.open(image_url).read

しかし、RuboCopで警告が出たので、最終的にNet::HTTPに切り替えました。

1. RuboCopの警告内容

警告内容は以下の通りです。

C: Security/Open: The use of URI.open is a serious security risk.

この警告は、URI.openの使用が重大なセキュリティリスクを持つ可能性があるため、注意が必要であることを示しています。

2. なぜURI.openはリスクがあるのか?

open-uriは非常に便利なライブラリですが、特に外部からの入力をそのままURI.openに渡すと、意図しないファイルやリソースが開かれる可能性があります。

これにより、悪意のある攻撃者によってサーバが危険にさらされる可能性があります。

参考:https://qiita.com/jabba/items/7e018139fff586b4a06d

3. URI.parse#openを使用する

ドキュメントを確認すると、URI.parse#openを使用すれば警告は解除されるようでした。

require 'open-uri'

image_url = "https://example.com/image.jpg"
image_data = URI.parse(image_url).open.read

これで実際に警告は解除されました。

4. Net::HTTPを使用する

open-uriではなく、Net::HTTPを使用してデータを取得します。

元のopen-uriを使用したコード:

require 'open-uri'

image_url = "https://example.com/image.jpg"
image_data = URI.open(image_url).read

Net::HTTPを使用したコードへの変更:

require 'net/http'
require 'uri'

image_url = "https://example.com/image.jpg"
url = URI.parse(image_url)

response = Net::HTTP.get_response(url)
if response.is_a?(Net::HTTPSuccess)
  image_data = response.body
else
  puts "Failed to download image: #{response.message}"
end

上記の変更により、URLからのデータのダウンロードはNet::HTTPを使用して行われるようになります。

4. まとめ

Rails開発において、外部の画像データを取得する際にopen-uriを使用すると便利ですが、URI.openの使用にはセキュリティ上のリスクがあるとの警告がRuboCopから示されることがあります。

この警告は、外部からの入力をそのまま使用すると、意図しないリソースのアクセスが生じる可能性があるためです。

安全な取得のための代替策として、URI.parse#openNet::HTTPがありました。

『現場で使える Ruby on Rails 5 速習実践ガイド』感想ブログ

『現場で使える Ruby on Rails 5 速習実践ガイド』という書籍(以下、本書)に目を通しましたので、その感想を共有させていただきます。

結論

本書は、Ruby on Railsを学びたいと考えている初心者や、基礎を見直したい中級者にとって、非常に有益な内容が詰まっている一冊と言えます。
Rubyの基礎から、RailsMVC構造、CRUD操作、更には発展的な実装方法まで、幅広く学ぶことができます。

内容が難しくなる部分もありますが、実際の開発現場での知識やチーム開発のコツなど、実践的な情報も得られると感じました。

良かったところ

  1. Rubyの基礎解説
    Rubyの超基本的なところから学ぶことができます。Rubyが全く未経験の方には、少し解説が少ないかもしれませんが、復習には最適なボリュームです。

  2. わかりやすいデザインと図解
    デザインが読みやすく、図解も多く用意されているため、視覚的にも情報を取り込みやすいです。

  3. MVCの詳細解説
    Railsの特徴的なMVC構造について、非常に分かりやすく説明されています。実際にタスク管理ツールを作成しながら学べるのもポイント高いです。

  4. 実践的な補足情報
    これはどういうこと?と思う場面での注釈や、チーム開発の際のエッセンスなど、実務で役立つ情報が満載です。

学んだこと

  • Rubyの基礎:オブジェクトの概念や、クラス、モジュールなど
  • Railsの環境構築からMVCの基礎
  • CRUD操作の実装方法
  • 発展的な機能の実装:検索やソート、ページネーションなど
  • AjaxやTurbolinksの活用方法
  • チームでの開発やGitHubの利用方法
  • アップデート時の注意点や方法

難しかったこと

  • 本書の中で、複数人での開発の際のポイントが詳しく解説されていますが、実際に経験していないと理解が難しい部分も確かにありました。
  • また、発展的な内容が増える後半部分は、特に実装経験がないと想像しにくいかもしれません。

まとめ

『現場で使える Ruby on Rails 5 速習実践ガイド』は、内容が豊富で、実践的な情報も満載なので、これからRailsの開発を行なっていく上で参考にできる1冊だと感じました。

【Rails】newアクションでオブジェクトを作ったのに、なぜcreateアクションでも再度オブジェクトを作るのか

Railsの学習をしていて、newアクションで新規登録用のオブジェクトを作ったんだから、それをそのままcreateアクションで使ってくれよと思ったことがありました。

def create
  @user = User.new(user_params)
  if @user.save
    # 保存成功時の処理
  else
    # 保存失敗時の処理
  end
end

def new
  @user = User.new
end

かなり基本的なことではありますが、newアクションでオブジェクトを作ったのに、なぜcreateアクションでも再度オブジェクトを作るのかを改めて調べてみました。

結果:Webアプリのステートレスな性質によるもの

Webアプリケーションは基本的に「ステートレス」です。
つまり、各リクエストは独立しており、1つのリクエストで作成された変数やオブジェクトは、次のリクエストで自動的に保持されません。

newアクションとcreateアクション

例えば、以下のようなnewアクションがあるとします。

def new
  @user = User.new
end

newアクションは、ユーザーにデータ入力のためのフォームを表示する役割があります。
ここで生成された@userオブジェクトは、ビューでのフォーム表示のためだけに使われます。

# new.html.erb
<%= form_for @user do |f| %>
  <!-- フィールドの入力 -->
  <%= f.submit '登録' %>
<% end %>

一方、createアクションは、このフォームから送信されたデータを受け取って、データベースに保存する役割があります。
ここで新しいUserオブジェクトを生成する理由は、前述したステートレスな性質によるものです。
newアクションで生成された@userはこのcreateアクションでは利用できないため、新たにオブジェクトを生成して、フォームから送信されたデータを使ってデータベースに保存します。

def create
  @user = User.new(user_params)
  if @user.save
    # 保存成功時の処理
  else
    # 保存失敗時の処理
  end
end

まとめ

Railsnewアクションとcreateアクションは、Webアプリケーションがステートレスなため、それぞれ異なるリクエストとして独立して動作します。
そのため、createアクションで新たにオブジェクトを生成することで、データの保存を実現しています。