Migrating Away from Devise Part 4: Email Confirmation Setup

Published: January 09, 2025
Part 4 of a multi-part series for moving away from devise to Rails' authentication generator
This is the fourth part of a multi-part series about moving from devise to the Rails generated authentication system

The app uses confirmable with devise to verify the account on creation including the ability to resent confirmation emails. Rails provides all the functionality needed to pull this off in a similar means as password recovery, but it'll have to be implemented manually. In regards to the User model, we are going to focus on the existing confirmed_at and confirmation_sent_at fields that already exist from the devise implementation.

Routes look like this, similar to how devise builds them. The show action is the landing point the user goes to when they click the link in their email. The new and create actions are to resent the email.

resources :confirmations, only: [:new, :create, :show], param: :token

The controller looks like this. It's similar to the password recovery controller built in the last step.

class ConfirmationsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create, with: lambda {
    redirect_to new_confirmation_path, alert: "Try again later."
  }

  before_action :set_user_by_token, only: [:show]

  def show
    @user.confirmed_at ||= Time.now.utc
    @user.save!
    redirect_to new_session_path, notice: "Your account has been successfully confirmed. Please sign in."
  end

  def new
  end

  def create
    if (user = User.find_by(email: params.expect(:email)))
      UserMailer.confirmation_instructions(user).deliver_later
    end

    redirect_to new_session_path,
                notice: "A confirmation link has been sent to your email address to confirm your account."
  end

  private

  def set_user_by_token
    @user = User.find_by_token_for!(:account_confirmation, params[:token])
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    redirect_to new_confirmation_path, alert: "Account confirmation link is invalid or has expired."
  end
end

The controller follows a similar flow as the PasswordController. A different email and redirect paths are used as well as what token is pulled from. When we confirm the account, we set confirmed_at once to prevent a user from being "confirmed multiple times." Also, instead of using the provided token lookup and generation methods that has_secure_password provides for password reset tokens, we're manually setting up an "account_confirmation" token.

To generate the token for a user, generates_token_for is called on the User model. It auto expires after confirmed_at is set to a different value (ie, from nil to a date).

generates_token_for :account_confirmation, expires_in: 30.minutes do
  confirmed_at.to_s
end

The UserMailer method and view looks like this which is based off of the password reset email...

def confirmation_instructions(user)
  @user = user
  return if @user.confirmed_at.present?

  @user.update!(confirmation_sent_at: Time.now.utc)

  mail subject: "Confirmation instructions", to: user.email
end
<p>Welcome <%= @user.username %>!</p>
<p>You can confirm your account email through the link below:</p>
<p><%= link_to "Confirm my account", confirmation_url(@user.generate_token_for(:account_confirmation)) %></p>

Two small changes compared to password reset. First the mailer method returns early (preventing sending the email) if the user is already confirmed. This allows for a standard no-op response where the callers only need to be concerned about enqueuing the email. Second, we stamp out confirmation_sent_at. This is more for debugging purposes and not user-visible.

Now that we have a single place to send the confirmation email with the confirmation token, calling the mailer can be reused during user sign up... which is the next thing to implement in part five.