Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to pass additional data to devise mailer?

I have a rails app that can handle many subdomains, and I have multiple live versions running with different domain names. This causes the URLs to be anywhere between

  • mywebsite.com
  • company1.mywebsite.com
  • company1.mytestwebsite.com

Devise has mailers that use links for resetting passwords and such. These links are sometimes incorrect and send me to the incorrect website because the host name is sometimes different from that of the the default URL from /config/environments/production.rb:

config.action_mailer.default_url_options = { host: 'mywebsite.com' }

How do I pass request.host from the controller to the Devise mailer?

If I had the host, I could create the links that send the user to the correct website

like image 359
Cruz Nunez Avatar asked Mar 16 '18 19:03

Cruz Nunez


1 Answers

Just add one file to /config/initializers and overwrite the devise controller

The file:

# config/initializers/devise_monkeypatch.rb
module Devise
  module Models
    module Recoverable
      module ClassMethods
        # extract data from attributes hash and pass it to the next method
        def send_reset_password_instructions(attributes = {})
          data = attributes.delete(:data).to_unsafe_h
          recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
          recoverable.send_reset_password_instructions(data) if recoverable.persisted?
          recoverable
        end
      end

      # adjust so it accepts data parameter and sends it to next method
      def send_reset_password_instructions(data)
        token = set_reset_password_token
        send_reset_password_instructions_notification(token, data)
        token
      end

      # adjust so it accepts data parameter and sends to next method
      protected def send_reset_password_instructions_notification(token, data)
        send_devise_notification(:reset_password_instructions, token, data: data)
      end
    end
  end

  Mailer.class_eval do
    # extract data from options and set it as instance variable
    def reset_password_instructions(record, token, opts={})
      @token = token
      @data = opts.delete :data
      devise_mail(record, :reset_password_instructions, opts)
    end
  end
end

Generate the controller and the views

rails g devise:controllers users -c=passwords
rails g devise:views

Edit the routes

# config/routes.rb
devise_for :users, controllers: {
  passwords: 'users/passwords'
}

Edit the create action

class Users::PasswordsController < Devise::PasswordsController
  def create
    params[:user][:data] = { host: request.url.remove(request.path) }
    super
  end
end

Edit the view

<p>
  <%= link_to 'Change my password', 
      edit_password_url(
        @resource, reset_password_token: @token, host: @data[:host]
      ) 
  %>
</p>

Explanation below:

Looking at the source code, these are the methods that are used to get from controller to mailer

# the controller calls send_reset_pasword_instructions on class
Devise::PasswordsController#create
  resource_class.send_reset_password_instructions(resource_params)

# class finds instance and calls send_reset_password_instructions on instance
Devise::Models::Recoverable::ClassMethods
  def send_reset_password_instructions(attributes = {})
    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
    recoverable.send_reset_password_instructions if recoverable.persisted?
    recoverable
  end

Devise::Models::Recoverable
  # instance calls send_reset_password_instructions_notification
  def send_reset_password_instructions
    token = set_reset_password_token
    send_reset_password_instructions_notification(token)
    token
  end

  # instance calls send_devise_notification
  protected def send_reset_password_instructions_notification(token)
    send_devise_notification(:reset_password_instructions, token, {})
  end

Devise::Models::Authenticatable
  # instance calls mailer
  protected def send_devise_notification(notification, *args)
    message = devise_mailer.send(notification, self, *args)
    # Remove once we move to Rails 4.2+ only.
    if message.respond_to?(:deliver_now)
      message.deliver_now
    else
      message.deliver
    end
  end

  # mailer
  protected def devise_mailer
    Devise.mailer
  end

class Devise::Mailer
  # mailer sets @token
  def reset_password_instructions(record, token, opts={})
    @token = token
    devise_mail(record, :reset_password_instructions, opts)
  end

All you need is to set another instance variable in the last method, but you have to edit the other methods to successfully pass the data.

Here is a comparison of original vs. changes needed:

Devise::PasswordsController
  # original, will stay the same
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

  # override to add data
  def create
    params[:user][:data] = request.url
    super
  end

Devise::Models::Recoverable::ClassMethods
  # original, will be overwritten
  def send_reset_password_instructions(attributes = {})
    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
    recoverable.send_reset_password_instructions if recoverable.persisted?
    recoverable
  end

  # extract data from attributes hash and pass it to the next method
  def send_reset_password_instructions(attributes = {})
    data = attributes.delete :data
    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
    recoverable.send_reset_password_instructions(data) if recoverable.persisted?
    recoverable
  end

Devise::Models::Recoverable
  # original, will be overwritten
  def send_reset_password_instructions
    token = set_reset_password_token
    send_reset_password_instructions_notification(token)
    token
  end

  # adjust so it accepts data parameter and sends it to next method
  def send_reset_password_instructions(data)
    token = set_reset_password_token
    send_reset_password_instructions_notification(token, data)
    token
  end

  # original, will be overwritten
  protected def send_reset_password_instructions_notification(token)
    send_devise_notification(:reset_password_instructions, token, {})
  end

  # adjust so it accepts data parameter and sends to next method
  protected def send_reset_password_instructions_notification(token, data)
    send_devise_notification(:reset_password_instructions, token, data: data)
  end

Devise::Models::Authenticatable
  # original, stays the same
  protected def send_devise_notification(notification, *args)
    message = devise_mailer.send(notification, self, *args)
    # Remove once we move to Rails 4.2+ only.
    if message.respond_to?(:deliver_now)
      message.deliver_now
    else
      message.deliver
    end
  end

  # original, stays the same
  protected def devise_mailer
    Devise.mailer
  end


class Devise::Mailer
  # extract data from options and set it as instance variable
  def reset_password_instructions(record, token, opts={})
    @token = token
    @data = opts.delete[:data]
    devise_mail(record, :reset_password_instructions, opts)
  end

Remove the code that remains unchanged as well as the controller code because we will change that in another file. Wrap it into a neat little module and adjust it so we add methods to classes and we pass Hash objects instead of Parameter objects.

Here is the final version

module Devise
  module Models
    module Recoverable
      module ClassMethods
        # extract data from attributes paramater object and convert it to hash
        # and pass it to the next method
        def send_reset_password_instructions(attributes = {})
          data = attributes.delete(:data).to_unsafe_h
          recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
          recoverable.send_reset_password_instructions(data) if recoverable.persisted?
          recoverable
        end
      end

      # adjust so it accepts data parameter and sends it to next method
      def send_reset_password_instructions(data)
        token = set_reset_password_token
        send_reset_password_instructions_notification(token, data)
        token
      end

      # adjust so it accepts data parameter and sends to next method
      protected def send_reset_password_instructions_notification(token, data)
        send_devise_notification(:reset_password_instructions, token, data: data)
      end
    end
  end

  Mailer.class_eval do
    # extract data from options and set it as instance variable
    def reset_password_instructions(record, token, opts={})
      @token = token
      @data = opts.delete :data
      devise_mail(record, :reset_password_instructions, opts)
    end
  end
end

Answered this myself because I couldn't find a solution online even though there are others who also asked about this. Posted this on Stack Overflow to maximize helping others out with the same problem.

😂👌

like image 185
Cruz Nunez Avatar answered Oct 22 '22 01:10

Cruz Nunez