📧

Ruby on Rails 7のAction MailerでSendgridを使う方法+List-Unsubscribe-Postヘッダー

2024/06/20に公開

はじめに

Sendgridの登録、APIキーの取得が終わっている状態からの説明となります。
APIキーはRailsで使えるようにcredentialsに登録しておきましょう。

メールテンプレートの作成方法やMailerの作成は、公式のドキュメントがあるため割愛します。

今回gemはsendgrid-rubyを使用します。

バージョン

rails: 7.0.8.1
sendgrid-ruby: 6.7.0

経緯

余談ですが、これまでプロジェクトではsendgrid-actionmailerというgemを使っていました。
昨今のGoogleのワンクリック配信停止の対応で、メールヘッダーにList-Unsubscribe-Postを設定する必要があったため改修していたところ、
上記のgemではメールヘッダーにList-Unsubscribe-Postを設定することができませんでした。
本gemはメンテナンスされていないこととスター数が少ないことからsendgrid-ruby gemへ乗り換えを実行しました。
結論ヘッダーも設定され既存機能にも影響のない形で乗り換えることができました。

全体のコード

配置するディレクトリはプロジェクトに合わせて変更してください。

backend/config/initializers/sendgrid.rb
# frozen_string_literal: true

require 'send_grid_mail'

ActionMailer::Base.add_delivery_method :sendgrid, SendGridMail, api_key: Rails.application.credentials.dig(:sendgrid, :api_key)
backend/config/environments/production.rb
  config.action_mailer.delivery_method = :sendgrid
backend/app/mailers/send_grid_mail.rb
# frozen_string_literal: true

class SendGridMail < ActionMailer::Base
  require 'sendgrid-ruby'
  include SendGrid

  def initialize(settings)
    @settings = settings
    super()
  end

  def deliver!(mail)
    personalization = personalization(mail)
    sg_mail = sendgrid(mail, personalization)
    sg = SendGrid::API.new(api_key: @settings[:api_key], host: @settings[:api_host])
    response = sg.client.mail._('send').post(request_body: sg_mail.to_json)
    Rails.logger.info("SendGridMail#deliver! response: #{response.to_json}")
  rescue StandardError => e
    Rails.logger.error(e)
  end

  private

  def personalization(mail)
    personalization = SendGrid::Personalization.new
    personalization.subject = mail.subject

    Array(mail.to).each do |email|
      personalization.add_to(SendGrid::Email.new(email:))
    end

    Array(mail.cc).each do |email|
      personalization.add_cc(SendGrid::Email.new(email:))
    end

    Array(mail.bcc).each do |email|
      personalization.add_bcc(SendGrid::Email.new(email:))
    end

    mail.header.fields.each do |field|
      personalization.add_header(SendGrid::Header.new(key: field.name, value: field.value)) if %w[List-Unsubscribe-Post List-Unsubscribe].include?(field.name)
    end

    personalization
  end

  def sendgrid(mail, personalization)
    sg_mail = SendGrid::Mail.new
    sg_mail.from = SendGrid::Email.new(email: mail.from)
    sg_mail.subject = mail.subject
    sg_mail.add_content(SendGrid::Content.new(type: 'text/html', value: mail.body.parts[1].body.raw_source))
    sg_mail.add_personalization(personalization)

    if mail[:categories].present?
      mail[:categories].unparsed_value.each do |category|
        sg_mail.add_category(SendGrid::Category.new(name: category))
      end
    end

    sg_mail
  end
end
backend/spec/mailers/send_grid_mail_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe SendGridMail do
  let(:settings) { { api_key: 'YOUR_API_KEY', api_host: 'YOUR_API_HOST' } }
  let(:mail) do
    Mail.new do
      from 'from@example.com'
      to 'to@example.com'
      body 'Test Body'
      html_part do
        body 'Test Body'
      end
    end
  end
  let(:sendgrid_mail) { SendGrid::Mail.new }

  before do
    allow(SendGrid::API).to receive(:new).and_return(double(client: double(mail: double(_: double(post: true))))) # rubocop:disable RSpec/VerifiedDoubles
    allow(SendGrid::Mail).to receive(:new).and_return(sendgrid_mail)
  end

  describe '#initialize' do
    it 'sets the settings' do
      send_grid_mail = described_class.new(settings)
      expect(send_grid_mail.instance_variable_get(:@settings)).to eq(settings)
    end
  end

  describe '#deliver!' do
    let(:send_grid_mail) { described_class.new(settings) }

    before do
      allow(send_grid_mail).to receive(:deliver!).and_return(true)
    end

    it 'sends the mail using SendGrid API' do
      expect(send_grid_mail.deliver!(mail)).to be(true)
    end

    it 'raises an error when the API call fails' do
      allow(send_grid_mail).to receive(:deliver!).and_raise(StandardError)

      expect { send_grid_mail.deliver!(mail) }.to raise_error(StandardError)
    end
  end

  describe '#personalization' do
    it 'returns a SendGrid personalization object' do
      send_grid_mail = described_class.new(settings)
      personalization = send_grid_mail.send(:personalization, mail)

      expect(personalization).to be_a(SendGrid::Personalization)
      expect(personalization.subject).to eq(mail.subject)
    end

    it 'adds recipients to personalization' do
      mail.to = ['to1@example.com', 'to2@example.com']
      mail.cc = ['cc1@example.com', 'cc2@example.com']
      mail.bcc = ['bcc1@example.com', 'bcc2@example.com']

      send_grid_mail = described_class.new(settings)
      personalization = send_grid_mail.send(:personalization, mail)

      expect(personalization.tos).to eq([{ 'email' => 'to1@example.com' }, { 'email' => 'to2@example.com' }])
      expect(personalization.ccs).to eq([{ 'email' => 'cc1@example.com' }, { 'email' => 'cc2@example.com' }])
      expect(personalization.bccs).to eq([{ 'email' => 'bcc1@example.com' }, { 'email' => 'bcc2@example.com' }])
    end

    it 'adds headers to personalization' do
      mail.header['List-Unsubscribe-Post'] = 'unsubscribe-post@example.com'
      mail.header['List-Unsubscribe'] = 'unsubscribe@example.com'

      send_grid_mail = described_class.new(settings)
      personalization = send_grid_mail.send(:personalization, mail)

      expect(personalization.headers).to eq(
        { 'List-Unsubscribe-Post' => 'unsubscribe-post@example.com',
          'List-Unsubscribe' => 'unsubscribe@example.com' }
      )
    end
  end

  describe '#sendgrid' do
    before do
      allow(SendGrid::Email).to receive(:new).and_return(sendgrid_mail.from)
    end

    it 'returns a SendGrid mail object' do
      send_grid_mail = described_class.new(settings)
      sg_mail = send_grid_mail.send(:sendgrid, mail, SendGrid::Personalization.new)

      expect(sg_mail).to be_a(SendGrid::Mail)
      expect(sg_mail.from).to eq(SendGrid::Email.new(email: 'from@example.com'))
      expect(sg_mail.subject).to eq(mail.subject)
      expect(sg_mail.contents).to eq([{ 'type' => 'text/html', 'value' => 'Test Body' }])
      expect(sg_mail.personalizations).to eq([{}])
    end

    it 'adds categories to SendGrid mail' do
      mail[:categories] = %w[category1 category2]

      send_grid_mail = described_class.new(settings)
      sg_mail = send_grid_mail.send(:sendgrid, mail, SendGrid::Personalization.new)

      expect(sg_mail.categories).to eq(%w[category1 category2])
    end
  end
end

以上でサーバーの起動と同時にSendGridMailが初期化され、ActiomMailerとして使用することができます。おまけにRspecを置いておきます。

詰まったところ

ローカルで動かして取れる値と、実際のサーバーで動かして取れる値に違いがあり、
何度もデバッグしないといけないところが大変でした。

具体的な箇所で言うと

mail.header.fields.each do |field|
  personalization.add_header(SendGrid::Header.new(key: field.name, value: field.value)) if %w[List-Unsubscribe-Post List-Unsubscribe].include?(field.name)
end

sg_mail.add_content(SendGrid::Content.new(type: 'text/html', value: mail.body.parts[1].body.raw_source))

この部分がプロジェクトごと(メールテンプレートやheadersの設定方法)によって変わってくると思われるので注意してください。

成果

成功するとメールヘッダーに値が設定されていることが確認できました。

メールタイトル横に「メーリングリストの登録解除」というボタンがGmailでは表示されるとのことですが、検証環境などでは表示されませんでした。

このボタンに関してはネットにいろいろと情報がありますが、しっかりとヘッダーが設定されていれば問題ないと思われるので引き続き状況を監視してみたいと思います。

Discussion