Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

June 23, 2019

Last updated November 5, 2023

Extending Devise Series - Confirmation Emails

Continuing my Let's Build with Ruby on Rails - Extending Devise series I take a look at adding confirmation emails to a given Ruby on Rails application.

Download the source code

Download the Kickoff Tailwind Template I reference in this tutorial (optional):
https://github.com/justalever/kickoff_tailwind

In this guide, you'll learn how to install Devise, configure it with default settings, and then later extend it to include a feature called :confirmable.

To summarize, :confirmable appends three new fields to your User table (or whatever model you chose to initialize Devise with) in the database. From there, once a new user signs up to the app they are sent a confirmation email. That email then contains a link with a unique token that once clicked signals to your application to create the new user account. The perk of this is increased security. The person signing up will likely be entering their own email address. In doing so they can successfully visit their inbox to see the new confirmation email that is unique to their own email and session inside the application.

Testing E-mails locally

Testing e-mail delivery locally with Ruby on Rails comes with some configuration workarounds. I make use of a gem called mailcatcher.me which acts as a buffer on a separate port to intercept any e-mails sent locally in your given Ruby on Rails app. Devise also has a configuration required for the config/environments/development.rb file. Ultimately those settings are as follows:

# config/environments/development.rb

Rails.application.configure do
  ...
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }
end

Mailcacher is a daemon that runs in the background. Once installed you can run $ mailcatcher to get it up and running. If you don't have it installed you can do so by running $ gem install mailcatcher.

On top of that configuration, we need to install the Devise gem:

# Gemfile

gem 'devise', '~> 4.6', '>= 4.6.2'

Then run:

$ bundle install

After Devise is installed we can run a generator provided by the gem

$ rails generate devise:install

This creates a new initializer file within config/initializers. I'll change two options in that file.

# config/initializers/devise.rb

Devise.setup do |config|
  ...

  # ==> Mailer Configuration
  # Configure the e-mail address which will be shown in Devise::Mailer,
  # note that it will be overwritten if you use your own mailer class
  # with default "from" parameter.
  config.mailer_sender = '[email protected]'

  ...

  # If true, requires any email changes to be confirmed (exactly the same way as
  # initial account confirmation) to be applied. Requires additional unconfirmed_email
  # db field (see migrations). Until confirmed, new email is stored in
  # unconfirmed_email column, and copied to email column on successful confirmation.
  config.reconfirmable = false

  ...

end

With those settings saved, we can generate the resource (model) that Devise will append it's logic to. In most cases, this is often the User model but any model would suffice. Think Account, Admin, Profile, etc...

$ rails generate devise User

This will create a user.rb file within app/models/user.rb that contains some logic by default. This generator also creates a new routing mechanism in our routes file for devise to hook in to.

The main thing to take notice of is the migration file this command generates in db/migrate/. Mine looks like the following.

# db/migrate/20190622200524_devise_create_users.rb

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.string   :current_sign_in_ip
      # t.string   :last_sign_in_ip

      ## Confirmable
      # t.string   :confirmation_token
      # t.datetime :confirmed_at
      # t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at


      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

Notice the commented out declarations. By default, Devise ships with these ready to roll if you just created a new install of the gem. You can simply uncomment those fields you require and run rails db:migrate. In our specific case we only nee the confirmable fields. So I'll uncomment all but the last. My file now looks like the following:

# db/migrate/20190622200524_devise_create_users.rb

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.string   :current_sign_in_ip
      # t.string   :last_sign_in_ip

      ## Confirmable
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      # t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at


      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

Be sure to save that file and then run:

$ rails db:migrate

With that in place we can refer to the schema.rb file in the db/ directory for what our Database currently has in place:
Here' mine.

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `rails
# db:schema:load`. When creating a new database, `rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2019_06_22_200524) do

  create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.datetime "remember_created_at"
    t.string "confirmation_token"
    t.datetime "confirmed_at"
    t.datetime "confirmation_sent_at"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

end

With this migrated we can now hook into the confirmation_token, confirmation_sent_at, and confirmed_at fields on the users database table that don't come by default.

One final addition is within your main layout file. I'll add the following to app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>DeviseConfirmableExample</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <%= notice %>
    <%= alert %>
    <%= yield %>
  </body>
</html>

I've added the <%= notice %> and <%= alert %> lines to display any feedback devise/our app has.

Creating Authenticated routes

Even though we are close to being complete we still need a way to restrict users from some path prior to logging in. I'll create two new controllers that will feature basic index actions and views. These are merely for example purposes and serve no real dynamic data but you can learn how to lock down more advanced scenarios using the code I've utilized here.

$ rails g controller home index
$ rails g controller dashboard index

The idea behind these two controllers is that the home controller will be a public facing page anyone can view whether they are signed in or not and the dashboard controller is only for logged in users.

Routing

Up until now, we have only basic routing in place inside config/routes.rb. I prefer to make this more extendable in the event we want to expand to more than the index action for both the dashboard or home controllers in the future. In doing so I've updated my routes file to the following:

# config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  resources :dashboard, only: [:index]
  resources :home, only: [:index]
  root to: "home#index"
end

Here I defined a resources declaration for both the dashboard and home routes. This is a handy way to generate fully RESTful routing if we so desire in the future. To keep too many routes from getting generated unnecessarily we can pass an only: [:index] option to signal the framework to only create the url/path helpers for the given resources' action. You can view all your routes by running rails routes or heading to localhost:3000/rails/info/routes if you have rails server running.

We also made a new root path which tells our app what page is indeed the entry point root to: "home#index declares the index action on the home controller our entry point.

Finally devise_for :users is another handy method that generates all the necessary routing that the Devise gem needs to operate.

Making Confirmable Work

I'll update our generic controllers to reflect the feature we are building. The home_controller.rb file will go unchanged but feature this code.

# app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index
  end
end

The dashboard_controller.rb file will be similar to the home_controller.rb file with one exception. I'll add an action that comes from the devise gem called authenticate_user!. This acts as a gatekeeper to those not signed in that visit a specific action in the controller. In the dashboard's case, I don't want any logged out user to see the page so we can simply write before_action :autheticate_user!. If we wanted to only block a handful of actions instead of every action you could write something like before_action :authenticate_user!, only:[:edit, :destroy] for example.

class DashboardController < ApplicationController
  before_action :authenticate_user! 

  def index
  end
end

Besides the migration file we modified earlier, nothing has really changed from the average Devise installation on a Rails application. We need a way to hook into the confirmable option to tell both our app and Devise what to do next. This means inheriting some controller logic from the devise gem and passing some options at runtime.

To do this we can start by modifying our routes slightly:

Rails.application.routes.draw do
  devise_for :users, controllers: {
    confirmations: 'confirmations'
  }
  resources :dashboard, only: [:index]
  resources :home, only: [:index]
  root to: "home#index"
end

The devise_for method accepts quite a few options. One of those is a hash of controllers of which you can signal to the gem that you too have controller logic that Devise needs to make use of.

Since we are extending the confirmation logic we can pass our own confirmations controller as an option here. This means that Devise will look for a controller inside app/controllers for a confirmations_controller.rb file. Naming is important here. The name you pass as a string inside your routes must match the controller name in app/controllers/.

So we can create that file. Inside you'll notice a little different syntax.

class ConfirmationsController < Devise::ConfirmationsController

  private
    def after_confirmation_path_for(resource_name, resource)
      sign_in(resource)
      dashboard_path
    end
end

The confirmations controller we create needs to inherit the parent confirmations controller from the Devise gem. Think of this as a subclass of a subclass. Inside the class, I created a private method that Devise understands called after_confirmation_path_for. We can pass in the resource_name, and resource to get information on the person who recently confirmed their account. If they created an account, visited their email, and clicked on the link to confirm we can use the token to find the given resource and officially grant them access to the application. After they get signed in we can tell designate where they head next. I added the dashboard_path as the place I want to take the user.

Testing our work

You'll need both rails server and mailcatcher running at this point. Assuming all is well in the app you can create a new user by heading to localhost:3000/users/sign_up. There will be a new form (that's really ugly) that you can supply your email, password, and password confirmation on. Once you click sign up you should see a new email show up in mailcatcher. Within the email, you can visit the link to activate your account.

Congrats if you made it this far! Confirmable should be working well. In your production environment, you obviously wouldn't use mailcatcher but a third-party email service provider like Postmark, Sendgrid, etc... Those configurations can be added to config/environments/production.rb just like we did with the development configurations. Ruby on Rails makes it quite easy to hook into other services and spend next to no time on configuration and more time where it matters, on your features and products.

The Series So Far

Shameless plug time

I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Sign up to get notified today!

Follow @hello_rails and myself @justalever on Twitter.

Link this article
Est. reading time: 12 minutes
Stats: 19,449 views

Categories

Collection

Part of the Ruby on Rails collection

Products and courses