Как mkdev сделал рефакторинг отправки писем

Illustration of a relaxed person with hands behind head, coffee mug, pens, crumpled paper, and origami figure on desk, suggesting a creative or brainstorming session. Illustration of a relaxed person with hands behind head, coffee mug, pens, crumpled paper, and origami figure on desk, suggesting a creative or brainstorming session.

Одно интересное нововведение в Rails 5.1 – это параметризованные мейлеры, которые предоставили возможность использовать в мейлерах фильтры и параметры в значениях по умолчанию. Эти две наиполезнейшие фичи позволили нам провернуть небольшой, но очень эффективный рефакторинг, о котором и пойдет речь.

Мейлеры до рефакторинга

Есть разные мейлеры, которые не использует параметризацию. Например, NotificationsMailer, который уведомляет учеников и менторов о важных событиях: состоянии подписки, оплате и т.п.

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  def student_inactive_notice(user)
    @user = user
    I18n.locale = @user.locale
    mail subject: default_i18n_subject,
         to: @user.email
  end

  def new_payment_for_mentor(user, amount)
    @user = user
    @amount = amount
    I18n.locale = @user.mentor.locale
    mail subject: t('.subject'),
         to: @user.mentor.email
  end

  # ...

end

Для шаблона каждого метода есть две локализации (русская и английская). С помощью I18n.locale в теле любого из методов задается язык письма, а также вызывается метод mail(headers = {}, &block). В последний передаются разные аргументы, для большинства из которых есть значения по-умолчанию. В примере выше из заголовков мы используем: :subject - тема письма, :to - адрес электронной почты получателя, :from - адрес электронной почты отправителя. Для указания темы письма в большинстве мейлерах используется метод default_i18n_subject(interpolations = {}) -- это тоже самое, что вызвать t('.subject').

Все классы-мейлеры наследуются от ApplicationMailer, который сыграет ключевую роль в статье. До рефакторинга он выглядел вот так:

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base
  default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>"
end

Также имеется модель, где происходит вызов методов экземпляра нашего NotificationsMailer. Все письма отправляются в фоне с помощью deliver_later(options = {}), тем самым используя Active Job. Если вы раньше использовали для этой же цели метод delay(options = {}), то самое время от него отказать по причине его несовместимости с параметризованными мейлерами.

# app/models/user.rb

class User < ApplicationRecord

  ...
  class << self
    def notify_inactive
      students.where("last_active_at < ?", 2.days.ago).find_each do |user|
        NotificationsMailer.student_inactive_notice(user).deliver_later
        # ...
      end
    end
  end
  ...

end

Само собой все мейлеры покрыты тестами. Они довольно однообразные и приводить их код я не буду.

Теперь приступим к безумному (поверьте (: ) рефакторингу, чтобы увидеть изящество параметризованных мейлеров!

Рефакторинг

Вынесем из NotificationsMailer заголовки from:, subject: и to:, поместив их внутри ApplicationMailer в хеш default(value = nil), опции которого по умолчанию будут доступны для всех мейлеров.

Далее определим фильтр before_action(names, block) для задания локали и получателя письма. В итоге получим бодипозитивный ApplicationMailer:

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base

  default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>",
          subject: -> { default_i18n_subject },
          to: -> { @recipient.email }

  before_action :load_recipient

  private

  def load_recipient
    @recipient = params[:recipient]

    I18n.locale = if @recipient.is_a?(User)
                    @recipient.locale
                  elsif @recipient.user
                    @recipient.user.locale
                  else
                    :en
                  end
  end

end

Все, получили локаль для всех мейлеров в одном месте. Удобно? Еще и DRY.

Но что ещё круче так это то, как теперь будет выглядеть NotificationsMailer. Да и, поверьте, другие ничем не хуже!

Думаю, лишним не будет пояснить, что переменные экземпляров, определяемые из значений хеша params нужны для шаблонов.

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  def student_inactive_notice
    mail
  end

  def new_student_for_mentor
    @student, @memo, @amount = params[:student], params[:memo], params[:amount]
    mail
  end

  # ...

end

Вам не видно, но после рефакторинга код NotificationsMailer уменьшился на 35% и стал гораздо понятнее. Разных мейлеров на mkdev много, поэтому можно представить как всё стало хорошо с ними в целом.

Вернемся к коду, пришло время исправить отправку мейлеров в моделях.

Нужно всего лишь перечислить через запятую пары "ключ: значение" в методе with(params), который предоставляет параметры мейлеру.

NotificationsMailer.with(recipient: user).student_inactive_notice # создается объект ActionMailer::MessageDelivery

В итоге получаем такую модель:

# app/models/user.rb

class User < ApplicationRecord

  ...
  class << self
    def notify_inactive
      students.where("last_active_at < ?", 2.days.ago).find_each do |user|
        NotificationsMailer.with(recipient: user).student_inactive_notice.deliver_later
        # ...
      end
    end
  end
  ...

end

Вот и закончили рефакторинг - пора подвести черту.

Ранее параметры, передаваемые в Action Mailer, были доступны только в теле методов мейлера. С появлением параметризованных мейлеров эта проблема решена. Теперь мейлеры могут вызываться с параметрами для, например, извлечения общего поведения, как было продемонстрировано выше - и это прекрасно! Мейлеры становятся тоньше, более удобочитаемыми, но в то же время остаются такими же практичными.

Настройки оповещений

Также хотелось бы поделиться, что этот рефакторинг позволил с легкостью внедрить настройки (разрешить/запретить) оповещений по почте. Сейчас достаточно лишь в одном месте вызвать policy, чего невозможно было бы достигнуть без параметризованных мейлеров :) Между прочим, эти настройки мы используем не только для одного мейлера.

Сейчас покажу с помощью кода как это было реализовано.

Добавим общий метод мейлеров mail_if_subscribed(*args).

# app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base

  ...
  private

  def mail_if_subscribed(*args)
    mail *args if NotificationsPolicy.new(@recipient, self.class, self.action_name).notify?
  end
  ...

end

Параметр self.class нужен, чтобы указать на каком конкретно мейлере нужно вызвать policy, в self.action_name - на каком методе мейлера. В то время как *args используется для перезаписи дефолтных заголовков, передаваемых в mail.

Теперь, переписав NotificationsMailer, получаем:

# app/mailers/notifications_mailer.rb

class NotificationsMailer < ApplicationMailer

  ...
  def student_subscription_expires
    mail_if_subscribed
  end

  def mentor_subscription_expires
    @student = params[:student]
    mail_if_subscribed
  end
  ...

end

На этом все, вот теперь точно все!