Rubyでmilter開発

Rubyでmilter開発 — Rubyバインディングのチュートリアル

このドキュメントについて

milter managerが提供するライブラリを用いてRubyでmilterを開発する方法を説明します。

インストール

Rubyでmilterを開発する場合はconfigure時に--enable-ruby-milterオプションを指定します。Debian GNU/Linux、Ubuntu、CentOS用のパッケージでは専用のパッケージがあるのでそれをインストールします。

Debian GNU/Linux、Ubuntuの場合:

% sudo aptitude -V -D -y install libmilter-toolkit-ruby1.8

CentOSの場合:

% sudo yum install -y ruby-milter-toolkit

パッケージがない環境では以下のようにconfigureに--enable-ruby-milterオプションを指定してください。

% ./configure --enable-ruby-milter

インストールが成功しているかは以下のコマンドで確認できます。

% ruby -r milter -e 'p Milter::TOOLKIT_VERSION'
[1, 5, 0]

バージョン情報が出力されればインストールは成功しています。

概要

Rubyで開発したmilterは以下のようになります。

require 'milter'

class Session < Milter::ClientSession
  def initialize(context)
    super(context)
    # 初期化処理
  end

  def connect(host, address)
    # ...
  end

  # その他のコールバック定義
end

command_line = Milter::Client::CommandLine.new
command_line.run do |client, _options|
  client.register(Session)
end

それでは、指定された正規表現を含むメールを拒否するmilterを作ってみましょう。

コールバック

イベントが発生する毎にmilterのコールバックメソッドが呼び出されます。ほとんどのイベントには付加情報があります。イベントの付加情報の受け渡し方法は2種類あります。1つはコールバックの引数として渡される方法で、もう1つはマクロとして渡される方法です。マクロについては後述します。ここではコールバックの引数として渡される情報についてだけ説明します。

以下がコールバックメソッドとその引数の一覧です。一覧を見た後に、今回のmilterで必要なコールバックを選びます。

connect(host, address)

SMTPクライアントがSMTPサーバに接続したときに呼ばれます。

host は接続してきたSMTPクライアントのホスト名で、 address はアドレスです。

例えば、localhostから接続した場合は以下のようになります。

host

"localhost"

address

inet:45875@[127.0.0.1] を表している Milter::SocketAddress::IPv4 オブジェクト。

helo(fqdn)

SMTPクライアントがHELOまたはEHLOコマンドを送ったときに呼ばれます。

fqdn はHELO/EHLOで報告したFQDNです。

例えば、「EHLO mail.example.com」とした場合は以下のようになります。

fqdn

"mail.example.com"

envelope_from(from)

SMTPクライアントがMAIL FROMコマンドを送ったときに呼ばれます。

from はMAIL FROMで報告した送信元アドレスです。

例えば、「MAIL FROM: <user@example.com>」とした場合は以下のようになります。

from

"<user@example.com>"

envelope_recipient(to)

SMTPクライアントがRCPT TOコマンドを送ったときに呼ばれます。複数回RCPT TOコマンドを送った場合は複数回呼ばれます。

to はRCPT TOで報告した送信先アドレスです。

例えば、「RCPT TO: <user@example.com>」とした場合は以下のようになります。

to

"<user@example.com>"

data

SMTPクライアントがDATAコマンドを送ったときに呼ばれます。

header(name, value)

送信するメールの中にあるヘッダーの数だけ呼ばれます。

name はヘッダーの名前で、 value は値です。

例えば、「Subject: Hello!」というヘッダーがあった場合は以下のようになります。

name

"Subject"

value

"Hello!"

end_of_header

送信するメールのヘッダー部分が終わったら呼ばれます。

body(chunk)

送信するメールの本文が送られてきたら呼ばれます。本文が小さいときは1回だけ呼ばれますが、大きい場合はいくつかの塊に分割されて複数回呼ばれます。

chunk は分割された本文です。

例えば、本文が「Hi!」だけであれば、1回だけ呼ばれて、以下のような値になります。

chunk

"Hi!"

end_of_message

SMTPクライアントがデータ終了を表す「<CR><LF>.<CR><LF>」を送ったときに呼ばれます。

利用するコールバック

今回作るmilterは定された正規表現を含むメールを拒否するmilterです。正規表現はSubjectまたはメッセージ本文にマッチさせることにします。とすると、必要になるコールバックはヘッダー毎に呼び出されるheaderとメッセージ本文毎に呼び出されるbodyです。雛形は以下のようになります。

require 'milter'

class MilterRegexp < Milter::ClientSession
  def initialize(context, regexp)
    super(context)
    @regexp = regexp
  end

  def header(name, value)
    # ... Subjectをチェック
  end

  def body(chunk)
    # chunkをチェック
  end
end

command_line = Milter::Client::CommandLine.new
command_line.run do |client, _options|
  # バイアグラを含むメールを拒否
  client.register(MilterRegexp, /viagra/i)
end

Subjectのチェック

まず、Subjectをチェックしましょう。

class MilterRegexp < Milter::ClientSession
  # ...
  def header(name, value)
    case name
    when /\ASubject\z/i
      if @regexp =~ value
        reject
      end
    end
  end
  # ...
end

ヘッダー名(name)がSubjectのときに、ヘッダーの値(value)が指定された正規表現(@regexp)にマッチしていれば拒否(reject)しています。自然に書けていますね。

動作確認

それでは、実際に動かして試してみましょう。

現在は、以下のようになっているはずです。

require 'milter'

class MilterRegexp < Milter::ClientSession
  def initialize(context, regexp)
    super(context)
    @regexp = regexp
  end

  def header(name, value)
    case name
    when /\ASubject\z/i
      if @regexp =~ value
        reject
      end
    end
  end

  def body(chunk)
    # chunkをチェック
  end
end

command_line = Milter::Client::CommandLine.new
command_line.run do |client, _options|
  # バイアグラを含むメールを拒否
  client.register(MilterRegexp, /viagra/i)
end

この状態ですでにmilterとして実行可能です。milter-regexp.rbというファイル名で保存した場合、以下のように実行します。-vオプションは詳細なログを出力するためのオプションで、動作を確認しやすいようにつけています。

% ruby milter-regexp.rb -v

milterはデフォルトではフォアグラウンドで動作します。別の端末からアクセスして動作を確認しましょう。

milterのテストには milter-test-client が便利です。Rubyで実装されたmilterはデフォルトで「inet:20025@localhost」で起動するので、そのアドレスに接続します。

% milter-test-server -s inet:20025
status: pass
elapsed-time: 0.00254348 seconds

正常に接続できた場合は以上のように「status: pass」と表示されます。milterを起動している端末も確認してみましょう。以下のように表示されているはずです。

[2010-08-01T05:44:34.157419Z]: [client][accept] 10:inet:55651@127.0.0.1
[2010-08-01T05:44:34.157748Z]: [1] [client][start]
[2010-08-01T05:44:34.157812Z]: [1] [reader][watch] 4
[2010-08-01T05:44:34.157839Z]: [1] [writer][watch] 5
[2010-08-01T05:44:34.158050Z]: [1] [reader] reading from io channel...
[2010-08-01T05:44:34.158140Z]: [1] [command-decoder][negotiate]
[2010-08-01T05:44:34.158485Z]: [1] [client][reply][negotiate] #<MilterOption version=<6> action=<add-headers|change-body|add-envelope-recipient|delete-envelope-recipient|change-headers|quarantine|change-envelope-from|add-envelope-recipient-with-parameters|set-symbol-list> step=<no-connect|no-helo|no-envelope-from|no-envelope-recipient|no-end-of-header|no-unknown|no-data|skip|envelope-recipient-rejected>>
[2010-08-01T05:44:34.158605Z]: [1] [client][reply][negotiate][continue]
[2010-08-01T05:44:34.158895Z]: [1] [reader] reading from io channel...
[2010-08-01T05:44:34.158970Z]: [1] [command-decoder][header] <From>=<<kou+send@example.com>>
[2010-08-01T05:44:34.159092Z]: [1] [client][reply][header][continue]
[2010-08-01T05:44:34.159207Z]: [1] [reader] reading from io channel...
[2010-08-01T05:44:34.159269Z]: [1] [command-decoder][header] <To>=<<kou+receive@example.com>>
[2010-08-01T05:44:34.159373Z]: [1] [client][reply][header][continue]
[2010-08-01T05:44:34.159485Z]: [1] [reader] reading from io channel...
[2010-08-01T05:44:34.159544Z]: [1] [command-decoder][body] <71>
[2010-08-01T05:44:34.159656Z]: [1] [client][reply][body][continue]
[2010-08-01T05:44:34.159774Z]: [1] [reader] reading from io channel...
[2010-08-01T05:44:34.159842Z]: [1] [command-decoder][define-macro] <E>
[2010-08-01T05:44:34.159882Z]: [1] [command-decoder][end-of-message] <0>
[2010-08-01T05:44:34.159941Z]: [1] [client][reply][end-of-message][continue]
[2010-08-01T05:44:34.160034Z]: [1] [command-decoder][quit]
[2010-08-01T05:44:34.160081Z]: [1] [agent][shutdown]
[2010-08-01T05:44:34.160118Z]: [1] [agent][shutdown][reader]
[2010-08-01T05:44:34.160162Z]: [1] [reader][eof]
[2010-08-01T05:44:34.160199Z]: [1] [reader] shutdown requested.
[2010-08-01T05:44:34.160231Z]: [1] [reader] removing reader watcher.
[2010-08-01T05:44:34.160299Z]: [1] [writer][shutdown]
[2010-08-01T05:44:34.160393Z]: [0] [reader][dispose]
[2010-08-01T05:44:34.160452Z]: [client][finisher][run]
[2010-08-01T05:44:34.160492Z]: [1] [client][finish]
[2010-08-01T05:44:34.160536Z]: [1] [client][rest] []
[2010-08-01T05:44:34.160578Z]: [sessions][finished] 1(+1) 0

何も出力されていない場合はそもそもmilterに接続できていません。milterが起動しているか、milter-test-serverに正しいアドレスを指定しているかを確認してください。

それでは、Subjectに「viagra」と含んだメールの場合の動作を確認しましょう。「--header 'Subject:Buy viagra!!!'」というオプションを指定することでそのようなメールの動作を再現します。

% milter-test-server -s inet:20025 --header 'Subject:Buy viagra!!!'
status: reject
elapsed-time: 0.00144477 seconds

「status: reject」とでているので、期待通り拒否していることが確認できます。

milterの端末の方にも以下のようなログがでているはずです。

...
[2010-08-01T05:49:49.275257Z]: [2] [command-decoder][header] <Subject>=<Buy viagra!!!>
[2010-08-01T05:49:49.275405Z]: [2] [client][reply][header][reject]
...

Subjectヘッダーのときにrejectしていることがわかります。

MTAなしでmilterをテストできるコマンドや詳細なログ出力など、milter managerはmilterの開発に便利なツール・ライブラリを提供しています。

メッセージ本体のチェック

次にメッセージ本体をチェックしましょう。

class MilterRegexp < Milter::ClientSession
  def body(chunk)
    if @regexp =~ chunk
      reject
    end
  end
end

メッセージ本文の一部(chunk)が指定された正規表現(@regexp)にマッチしていれば拒否(reject)しています。こちらも自然に書けていますね。

試してみましょう。milter-test-serverは「--body」オプションでメッセージ本文を指定できます。

% tool/milter-test-server -s inet:20025 --body 'Buy viagra!!!'
status: reject
elapsed-time: 0.00195496 seconds

「status: reject」となっているので、期待通り動作しています。

問題点

このmilterは説明のために簡略化されているため、いくつか問題点があります。例えば、以下のようなメールに対しては期待通り動きません。

  1. ヘッダーの値がMIMEエンコードされている場合。例えば、「=?ISO-2022-JP?B?GyRCJVAlJCUiJTAlaRsoQnZpYWdyYQ==?=」はデコードすると「バイアグラviagra」になるが、この場合は正規表現にマッチしないため、拒否しない。

  2. メッセージ本体で単語が複数のチャンクにまたがった場合。例えば、1つめのチャンクで「via」がきて2つめのチャンクで「gra」がきた場合は正規表現にマッチしないため、拒否しない。

ヘッダーの値に関しては以下のようにNKFなどを使ってMIMEエンコードをデコードすれば解決できます。

require 'nkf'

class MilterRegexp < Milter::ClientSession
  # ...
  def header(name, value)
    case name
    when /\ASubject\z/i
      if @regexp =~ NKF.nkf("-w", value)
        reject
      end
    end
  end
  # ...
end

メッセージ本体に関しては、メッセージ本文を全部受信した後にもチェックする方法があります。

class MilterRegexp < Milter::ClientSession
  ...
  def initialize(context, regexp)
    super(context)
    @regexp = regexp
    @body = ""
  end

  def body(chunk)
    if @regexp =~ chunk
      reject
    end
    @body << chunk
  end

  def end_of_mesasge
    if @regexp =~ @body
      reject
    end
  end
  ...
end

複数のチャンクにわかれた状態をテストするためには以下のように複数回「--body」オプションを指定します。

% milter-test-server -s inet:20025 --body 'Buy via' --body 'gra!!!'
status: reject
elapsed-time: 0.00379063 seconds

このように複数のチャンクにわかれてしまった場合でも期待通りに動きます。

ただし、これではすべてのメッセージをメモリ上に置いてしまうなど、効率の問題があります。また、メッセージ本文がBASE64でエンコードされている場合も動作しないという問題があります。これらは、ストリームとして処理したり、Content-Typeヘッダーの値などを確認した上でメッセージ本文を処理したりする必要があります。

メールを解析するライブラリとして Mail があるので、それを使うとよいでしょう。

まとめ

Rubyでmilterを作る方法について、実際にmilterを作りながら説明しました。Rubyを使うと簡単にmilterを実装できるので、ぜひ使ってみてください。