HEARTBEATS

OSS紹介アドベントカレンダー の19日目の記事です。

こんにちは、滝澤です。

去年の今頃もメール関連の記事(Null MXについて)を書いていましたが、再びメール関連の記事を書きます。

今回は、メーリングリストのバウンスメールをバウンスメール解析ライブラリSisimaiを使って解析し、その出力を集計して、Slackに通知するようにした事例を紹介します。

Sisimaiとは

公式サイトの説明が簡潔なので引用します。

SisimaiはbounceHammerの後継となるバウンスメール(エラーメール)解析ライブラリ (PerlモジュールとRuby Gem)であり、RFC5322に準拠した バウンスメールを解析し、JSONなどの構造化されたデータとして出力します。

Sisimaiはライブラリとして利用することも、次のようにコマンドラインで利用することもできます。

$ ruby -rsisimai -e 'puts Sisimai.dump($*.shift)' /vagrant/testmail/Maildir/new/test1.eml | jq .
[
  {
    "catch": "",
    "token": "eabd4f87a11414307a05d921aa4a35d30f7560c5",
    "lhost": "mail.example.org",
    "rhost": "aspmx.l.google.com",
    "alias": "bar@example.com",
    "listid": "foo.example.org",
    "reason": "userunknown",
    "action": "failed",
    "subject": "[example 02944] SUBJECT",
    "messageid": "201711171806.1234567890.foo@example.org",
    "replycode": "550",
    "smtpagent": "Email::Postfix",
    "softbounce": 0,
    "smtpcommand": "RCPT",
    "destination": "example.com",
    "senderdomain": "example.com",
    "feedbacktype": "",
    "diagnosticcode": "550-5.1.1 The email account that you tried to reach does not exist. Please try 550-5.1.1 double-checking the recipient's email address for typos or 550-5.1.1 unnecessary spaces. Learn more at 550 5.1.1 https://support.google.com/mail/?p=NoSuchUser k91si2462783pld.456 - gsmtp",
    "diagnostictype": "SMTP",
    "deliverystatus": "5.1.1",
    "timezoneoffset": "+0900",
    "addresser": "bar@example.com",
    "recipient": "bar@example.com",
    "timestamp": 1510909615
  }
]

この例では、解析結果がJSON形式で出力されています。 この出力結果から、List-Idヘッダの値が foo.example.org であるメーリングリストからのメールが、宛先 bar@example.com への配送時に宛先不明("userunknown")という理由で応答コード"550"で拒否されたことがわかります。

Sisimaiを使うまでの動機

弊社ハートビーツでは、お客様との連絡やアラートメールの通知先として、ご契約いただいているサービス毎にメーリングリストを作成し、お客様の担当者のメールアドレスを登録して運用しています。 メーリングリストを運用していると、メーリングリストから配送されたメールが様々な理由により拒否され、バウンスメールとしてメーリングリストの管理者宛に戻ってくることがあります。

一般的にメーリングリストマネージャーには、バウンスメールを何回か受け取ったら自動的にメンバーから削除する仕組みを持つものがあります。しかし、宛先不明以外の理由(*1)で拒否されることもあるため、サービスの一環として提供しているメーリングリストの場合は、自動的にメンバーから削除するのは好ましくありません。そのため、バウンスメールを受け取ったら、一つ一つ拒否された理由を確認して対応を行う必要があります。

  • *1) レートリミットによる拒否、DMARCポリシーによる拒否、ループ検出等

このバウンスメールが溜まったメールボックスをウェブメールで確認する運用にはなっていましたが、そんな運用が回るわけもなく、バウンスメールが放置されている状況がありました。 完全に放置するのは問題だろうと思い、私が気が向いたときにバウンスメールを直接確認して、それぞれの担当者に拒否理由を伝えて対応してもらうことを何回かやっていました。

やっていたことは次の3つです。

  1. バウンスメールの解析(目grep)
  2. バウンスメールの集計(ワンライナーで色々)
  3. 担当者への通知(Slackでメンションを送る)

普通に自動化できそうな内容ですね。 そこで、これらの処理を自動化することにしました。 このとき、バウンスメールを解析するにはどうしようかなと考えたときに、真っ先に思い浮かんだのがバウンスメール解析ライブラリのSisimaiでした。

それでは自動化を行っていった流れを紹介しましょう。

開発言語の選定

やりたいことは次の3つです。

  1. バウンスメールの解析
  2. バウンスメールの集計
  3. 担当者への通知

「1. バウンスメールの解析」はSisimaiでできます。 「2. バウンスメールの集計」と「3. 担当者への通知」は少しコードを書く必要があります。

まず、どの言語でコードを書くかという話になります。 SisimaiはPerlやRubyで書かれているため、ライブラリとして利用したい場合はPerlかRubyで書くことになります。 しかし、弊社内の推奨言語はPythonとGoなので、ライブラリとして利用することは諦めて、Pythonでコードを書くことにしました。

バウンスメールの解析

次のようにPythonからRubyを実行してSisimaiにメールを解析してもらい、出力されたJSON形式のデータをPythonで受け取って処理することにしました。

@staticmethod
def parse(mail_file, quiet=False):
    中略
    command_line = "ruby -rsisimai -e 'puts Sisimai.dump($*.shift)' %s" % (mail_file)
    process = subprocess.Popen(shlex.split(command_line),
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
    result, error = BounceNotifier.extract_info(stdout, stderr, mail_file)
    以下略

このコードではstdoutにJSON形式の文字列が格納されており、それを読み込んでPythonオブジェクト化したデータをresultに格納しています。

解析できないメール

Sisimaiでは指定した1通のメールを解析することも、メールボックスのディレクトリにあるメールを一括して解析することもできます。

Sisimaiの検証として、実際に発生した大量のバウンスメールをSisimaiに処理させてみたところ、解析できないメール(*2)や解析実行中にエラーが発生するメール(*3)がありました。 そのため、それらのメールを後で確認できるように、1通ずつ処理することにしました。解析処理に失敗したメールはそのファイル名を次のようにログ出力するようにしています。

Dec 18 10:12:13 mail bouncenotifier: Parse error: /opt/bounce/Maildir/new/1513349794.18304_0.mail.heartbeats.jp
Dec 18 10:12:13 mail bouncenotifier: The result of sisimai is empty.

メールを1通ずつ処理するということは、今回のケースではメール1通毎にPythonからRubyのプロセスをフォークすることになり、プロセスのフォークの負荷が気になります。試しに開発環境で1万通のメールを処理させたら数十分かかりましたし、処理中はCPU負荷も高いままになりました。 しかし、毎日処理するバウンスメールの数は多くても100通はいかないだろうという見込みの元に負荷を許容することにしました。

  • *2) 懐かしのメーリングリストマネージャーのfmlで発生する(ループ検知等の)エラーメールが解析できません。まあ、fmlなのでいまさら...... (2017年12月28日追記: Sisimai v4.22.3でfmlのバウンスメールも解析できるように修正されました)
  • *3) 特に深く追求していませんが、特定のメールで次のようなエラーが発生しています。変なものが紛れ込んでいたら仕方ないかな。(2017年12月28日追記: Sisimai v4.22.3で修正されました)
    • sisimai-4.22.2/lib/sisimai/data.rb:260:in 'gsub': invalid byte sequence in UTF-8 (ArgumentError)

バウンスメールの集計

Sisimai::Dataのデータ構造」(Sisimai)に説明がありますが、解析結果としてたくさんの情報が出力されます。

今回のケースではメーリングリストにおけるバウンスメールを集計するという目的のため、次の要素に絞って集計しました。

  • recipient (元メールの受信者メールアドレス)
  • alias (元メールでの変更前の受信者メールアドレス)
  • listid (元メールの List-Id ヘッダの値。メーリングリストのIDを示す。)
  • subject (元メールの Subject ヘッダの値)
  • diagnosticcode (バウンスしたときの応答メッセージ)
  • timestamp (バウンスした日時)

このとき、listidとrecipientの組み合わせを集計のキーにしました。

次に集計結果の出力の例を示します。

List-Id: foo.example.org
  宛先: bar@example.com
  応答: 550-5.1.1 The email account that you tried to reach does not exist. Please try 550-5.1.1 double-checking the recipient's email address for typos or 550-5.1.1 unnecessary spaces. Learn more at 550 5.1.1 https://support.google.com/mail/?p=NoSuchUser k91si2462783pld.456 - gsmtp
  件名: [example 02944] SUBJECT
  日時: 2017-11-17 09:06:55
  ファイル名: test1.eml
  同様なエラーの回数: 2

List-Idヘッダの値が foo.example.org であるメーリングリストから bar@example.com 宛てに配送されたメールでバウンスが発生しているという内容になります。 listidとrecipientの組み合わせを集計のキーとした結果として、同様なメールがこのメールも含めて2つあったことを示しています。

担当者への通知(Slack)

集計結果と説明文を合わせて、SlackのIncoming Webhooksを使って通知しました。

次の画像は開発用のもので私自身にメンションを行って通知した例です。

slack.png

以上のことを行うことにより、自動化が完了しました。

このスクリプトを毎朝実行して、各担当者に対応を行ってもらっています。

参考サイト