アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

バウンスメール処理

こんにちは、tahara です。

Rails システムでのバウンスメール処理ってどうするのがいいんでしょう? ベステプラクティスではないかもしれませんが、弊社でのバウンスメール処理方法を書いてみたいと思います。

まずメーラークラスで return_path にバウンスメール受信用のアドレスを指定します。

class Notifier < Jpmobile::Mailer::Base # ActionMailer::Base
  include BouncedMailFilter
  default(from: 'from@expamle.com', return_path: 'bounce@example.com')
  ...
end

上記メーラークラスで include しているクラスでは、 バウンスメール DB にメールアドレスが登録されているかチェックして、 登録済みのアドレスにはメールを送信しないようにしています。

module BouncedMailFilter

  def mail(*args)
    m = super
    return m unless m.to.size == 1

    email = m.to.first
    if not_deliver?(email)
      m.perform_deliveries = false
    end
    m
  end

  private

  def not_deliver?(email)
    return true if email =~ /@example.com\z/
    BouncedMail.where(email: email).exists?
  end
end

return_path に指定したメアドの ~/.forward で、バウンスメールを ruby に食べさせるようにします。 ここで rails runner とか使おうとすると遅すぎて話にならないので、こんな方式にしています。 またそれと同時に admin@example.com にも転信して、普通にバウンスメールを受信できるようにしておきす。

admin@example.com
"| ruby /var/www/outing/current/bin/mailforward.rb"

ruby に食べさせたメールは次のコードで Rails アプリに POST されます。

#!/usr/bin/env ruby

require 'net/https'

class MailForward

  def self.post(mail)
    http = Net::HTTP.new('localhost', 11223)
    http.use_ssl = true
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    request = Net::HTTP::Post.new('/bounced_mails')
    request.set_form_data({ mail: mail })
    response = http.start {
      http.request(request)
    }
    if response.code.to_i == 200
      return 0
    else
      return 1
    end
  end
end

exit(MailForward.post(STDIN.read))

Rails アプリは POST されたバウンスメールを読んで、 配信できなかったメールアドレスを上記の BouncedMailFilter でチェックしている DB に登録します。

「バウンスメールを読んで」のところが問題で、SMTP サーバによってバウンスメールの中味が違ったりします。 とりま、次のようなコードで処理しています。 完全に検出することはできませんが、運用上問題ないくらいには検出できていると思われます。

class BouncedMail < ActiveRecord::Base

  def self.receive(mail)
    analyzer = BounceAnalyzer.new(mail)
    if analyzer.bounced?
      unless BouncedMail.where(email: analyzer.email).exists?
        BouncedMail.create!(email: analyzer.email,
                            reason: analyzer.reason)
      end
    end
  end

  class BounceAnalyzer

    attr_reader :email

    def initialize(raw_mail)
      @mail = Mail.new(raw_mail)
      if @mail.parts.size == 3
        @summary_part = Mail.new(@mail.parts[0].body)
        @status_part = Mail.new(@mail.parts[1].body)
        @email = parse_email(@status_part.body.to_s)
      end
    end

    def bounced?
      case
      when !@email
        false
      when domain_reject?
        false
      when user_unknown?, host_not_found?, in_reply_to_rcpt_to?
        true
      else
        false
      end
    end

    def reason
      @status_part.body.to_s
    end

    private

    def domain_reject?
      @mail.body.to_s =~ /in\s+reply\s+to\s+end\s+of\s+DATA\s+Command/mi
    end

    def in_reply_to_rcpt_to?
      @summary_part.body.to_s =~ /in\s+reply\s+to\s+RCPT\s+TO\s+command/mi
    end

    def host_not_found?
      @status_part.body.to_s =~ /Host\s+or\s+domain\s+name\s+not\s+found/mi
    end

    def parse_email(body)
      case body
      when /Original-Recipient:\s*(?:rfc822;)?\s*(.+@.+)/i
        $1
      when /Final-Recipient:\s*(?:rfc822;)?\s*(.+@.+)/i
        $1
      else
        nil
      end
    end

    def user_unknown?
      case @status_part.body.to_s
      when /Status:\s*5\.1\.1/i, /User unknown/i, /Unknown user/i
        true
      else
        false
      end
    end
  end
end

うーん、きれいじゃないですね。 きれいな gem の出現を待ちます。 でも、こんなコードでもバウンスメールは大はばに減りました (^_^)v

最後に、弊社ではデザイナエンジニアを募集しております。 まずはお話だけでも。よろしくお願いします!