LDD '10 Winter: メールフィルタの作り方 - Rubyで作るmilter - 2010-02-15 - ククログ

ククログ

株式会社クリアコード > ククログ > LDD '10 Winter: メールフィルタの作り方 - Rubyで作るmilter

LDD '10 Winter: メールフィルタの作り方 - Rubyで作るmilter

先日、LOCAL DEVELOPER DAY '10 WinterでRubyでmilterを作る方法について話してきました。どのタイミングでどのmilterプロトコルのコマンドが発行されるかについても説明しているので、Rubyではなく(libmilterを使って)Cでmilterを実装する場合にも参考になる部分があるはずです。むしろ、Rubyとmilterの組み合わせについて話している部分は薄めです。これは、Rubyそのものとmilterの仕組みを理解していればRubyとmilterを組み合わせることは容易だからです。

メールフィルタの作り方 - Rubyで作るmilter

少しgroongaについてもふれています。

それでは、ダイジェストで資料の内容を紹介します。完全版はリンク先を見てください。資料のPDF・ソースもリンク先にあります。

内容

話題

具体的にRubyでmilterを作る話に入る前に、まず、前提となる知識を確認します。

はじめにメールフィルタ、次にメールフィルタの仕組みの1つであるmilterについて簡単に説明します。その後、一度milterから離れてSMTPについて説明します。これはmilterの動作を理解するためにはSMTPの動作も知っておく必要があるからです。SMTPの動作を確認したらそれをふまえてmilterの具体的な動作を説明します。

ここまできたらRubyでmilterを作るための下準備は整っているはずです。実際に1つRubyでmilterを作ってみます。

ゴール

今日のみなさんのゴール

説明の途中にいくつか確認ポイントがあります。それぞれの技術は他の技術をベースになりたっているので、ベースとなっている技術をおさえていくことが、理解してしっくりくるためのコツです。

それぞれの確認ポイントをゴールとして最終的な「Rubyでmilterを作れる」ようになるゴールまでたどりついてください。

メールフィルタ

メールフィルタ

メールシステムとは外部とユーザ間でメールを配信するシステムです。すべてのメールシステムではそのままメールをやりとりするのではなく、メールを配信するまでのあいだに、メールに対してなんらかの処理を実行します。つまり、すべてのメールシステムにはメールフィルタ機能が備わっています。

MTAのプラグインとする方法

MTAにプラグイン

メールシステムでメールフィルタを実現する方法はいくつかありますが、その1つがMTA(メールサーバ)のプラグインとして実現する方法です。この方法のメリットはMTAを変更せずにメールフィルタ機能を変更できることです。milterはこのタイプで動作するメールフィルタです。

メールフィルタのまとめ

メールフィルタのまとめ

メールフィルタはメールシステムが持っている必須機能の1つです。その実現方法としてMTAのプラグインとして実現する方法があり、milterもその方法で実現されているメールフィルタです。

それでは、milterの概要について説明します。

milterについて

milter?

milterの名前の由来は「mail filter」です。milterは汎用的なメールフィルタの仕組みのため、同じメールフィルタを異なるMTAと一緒に使うことができます。

Sendmailを用いているメールシステムではmilterを利用していることが多く、milterをサポートした商用のメールフィルタも多く存在します。最近ではPostfixのmilterサポートがリリース毎に改善されていっているため、Postfixを用いたメールシステムでもmilterを利用するケースが徐々に増えています。

milterシステム

milter関連用語

「milter」は文脈によって異なるものを指すことがあります。そこで、ここでは混乱を避けるために異なる名前で呼ぶことにします。

まず、メールフィルタそのものを「milter」と呼びます。

メールフィルタとMTAは別プロセスで動作するため、プロセス間通信でフィルタ対象のメールやフィルタ結果などをやりとりする必要があります。そのやりとりのきまりを「milterプロトコル」と呼びます。

そして、「milter」と「milterプロトコル」をサポートしたMTAを含んだメールフィルタの仕組み全体を「milterシステム」と呼びます。

「milter」といった場合は「メールフィルタそのもの(ここでいうmilter)」という意味で使う場合と、「メールフィルタの仕組み(ここでいうmilterシステム)」という意味で使われる場合が多いです。「milter」という単語が使われている場合はどちらの意味かを判断できるようになってください。

milterプロトコルはSMTPと密接に関連したプロトコルです。そのため、milterプロトコルについて説明する前に、SMTPについて確認します。

SMTPの概要

簡単?

SMTPは以下の4つのコマンドが基本となるシンプルなプロトコルです。

  • HELO
  • MAIL FROM
  • RCPT TO
  • DATA

まず、「HELO」で接続したSMTPクライアントの情報を伝えます。以下の例ではSMTPサーバ(MTA)からのメッセージは先頭に「<」をつけて示します。SMTPクライアントのメッセージは先頭に「>」をつけて示します。

% telnet localhost smtp
< 220 note-pc.example.com ESMTP Postfix (Ubuntu)
> HELO localhost.example.com
< 250 note-pc.example.com

挨拶が済んだらSMTPセッションのスタートです。1つのSMTPのセッションで複数のメールを送ることができます。「MAIL FROM」、「RCPT TO」、「DATA」で1つのメールを送ります。

まず、「MAIL FROM」で送信者を伝えます。

> MAIL FROM: <kou@example.com>
< 250 2.1.0 Ok

次に、「RCPT TO」で宛先を伝えます。

> RCPT TO: <info@example.com>
< 250 2.1.5 Ok

同じメールを複数の宛先に送ることもできます。その場合は「RCPT TO」を複数回実行します。

最後に「DATA」でメールの内容を伝えます。メールの最後は「.」だけの行になります。

> DATA
< 354 End data with <CR><LF>.<CR><LF>
> Subject: Hello
> From: <kou@example.com>
> To: <info@example.com>
> 
> This is a test mail!
> .
< 250 2.0.0 Ok: queued as 054C624FB

これで、1通のメールを送信できました。続けてメールを送信する場合はまた「MAIL FROM」から始めます。

SMTPセッションを終了する場合は「QUIT」です。

> QUIT
< 221 2.0.0 Bye

これで1つのSMTPセッションが終了しました。

milterプロトコルはSMTPと密接に関わっています。それでは、milterプロトコルの詳細を説明します。

SMTPとmilterプロトコル

SMTPとmilterプロトコル

milterプロトコルにもSMTPと同じようにコマンドがあります。そして、そのコマンドはSMTPのコマンドと対応したものになっています。まずSMTPのコマンドを説明したのはそのためです。

例えば、SMTPで「HELO」というコマンドが実行された場合、「HELO」に対応する「helo」というmilterプロトコルのコマンドが発行されます。このとき、SMTPクライアントが指定したHELOコマンドの引数がmilterに渡されます。

MTAはmilterにコマンドを送った後、milterからの返答があるまでSMTPクライアントには返答しません。つまり、milterが「helo」でrejectを返すことで、SMTPクライアントの「HELO」コマンドへの返答をrejectとすることができます。これにより、MTAがSMTPレベルでできることとほとんど同じことをmilterで実現できます。

milterプロトコルのコマンドとSMTPのコマンドの対応

コマンド: メタ情報

mitlerプロトコルのコマンドはほとんどSMTPのコマンドに対応していますが、milterプロトコルのコマンドの方がより細かくなっています。例と一緒にコマンドの対応を説明します。

SMTPでの最初のコマンドは「HELO」ですが、milterプロトコルでは「helo」よりも前にコマンドが発行されます。それが、SMTPクライアントがSMTPサーバに接続したときに発行される「connect」コマンドです。

「connect」コマンド以外はSMTPのコマンドとmilterプロトコルのコマンドは1対1で対応します。「envfrom」の「env」は「envelope」の略で、「封筒」という意味です。「envfrom」で「差出人」という意味、「envrcpt」で「宛先」という意味です。「rcpt」は「recipient」の略で「受信者」という意味です。

SMTPでは1つのメールを複数の宛先に送信できます。この場合、複数回「RCPT TO」を指定します。STMPで複数回「RCPT TO」が指定されるので、milterプロトコルでも「envrcpt」コマンドが複数回発行されます。

コマンド: DATA

SMTPの「DATA」コマンドはmilterプロトコルではより細かいコマンドに分解されています。

まず、「DATA」コマンド時にはmilterプロトコルの「data」コマンドがすぐに発行されます1。その後、SMTPクライアントはメール本体を送信しますが、「header」などのイベントはすぐには発生しません。SMTPクライアントがデータの終了を示す「.」のみの行を入力するまでは何も起きません。「.」のみの行が入力されると、MTA側でメール本文をパースして「header」、「eoh」(end of header: ヘッダーの終わり)、「body」、「eom」(end of message: メッセージの終わり)コマンドを発行します。もちろん、ヘッダーもパースしてあるので、MTAは「ヘッダー名」と「ヘッダー値」と分解した状態で情報を渡します。

このようにmilterプロトコルはSMTPと密接に関わっています。milterプロトコルのコマンドがわかれば、自分が必要な機能を持つmilterを実現するためにはどのコマンドを利用すればよいかを考えることができるでしょう。

milterサンプル: メール検索

扱うもの

説明用のサンプルとしてメール検索を実現するmilterを作成します。今回はSubject、From、Toと本文のみを扱うことにします。

メール検索を実現するために、全文検索エンジンとしてgroongaを、milterライブラリとしてmilter managerのRubyバインディングを使います。

groonga: カラム指向データストア

カラム指向

groongaは全文検索のためのインデックス作成機能だけではなく、データストアの機能も持っています。groongaのデータストアは列指向データベースマネジメントシステムで、関係データベース管理システムとは違い、レコード(行)毎にデータをまとめて持つのではなく、カラム(列)毎にデータをまとめて持っています。

このようにデータを持つと、同じカラムの複数の値へのアクセスを高速に行うことができます。このため、カラムの値を使った集計処理を高速に実行できます。集計処理とは、例えば、SQLでいうGROUP BYのような処理です。

集計処理を用いると絞り込み検索をしやすいユーザーインターフェイスを提供することができます。例えば、ショッピングサイトで商品に複数のタグがついているとします。このとき、同じタグがついている商品が何項目あるかを表示してリンクにします。1つも商品が属していないタグは表示しないようにすれば、ユーザは無駄な絞り込み操作を行わずにすみます。

全商品(123件)
タグ
  スポーツ(58件)← リンクにする
  映画(45件)    ← リンクにする
  食べ物(36件)  ← リンクにする
  旅行(0件)     ← 表示しない

この状態で「スポーツ」をクリックしたとします。

全商品(123件) > スポーツ(58件)
タグ
  スポーツ      ← 選択済みなので表示しない
  映画(26件)  ← リンクにする
  食べ物(0件) ← 表示しない
  旅行(0件)   ← 表示しない

このように、絞り込んだ後にがっかりするような操作を示さないことにより、絞り込み検索をしやすいユーザインターフェイスを作ることができます。がっかりするような操作かどうかを判断するために、同じ値を持つレコードの個数を数える、といった集計処理をしています。

groonga: バイナリパトリシアトライ

パトリシアトライ

groongaはキー管理のためのデータ構造としてハッシュテーブルとバイナリパトリシアトライを採用しています。バイナリパトリシアトライは基数木の一種です。

ここにB+木とパトリシアトライの説明を書く予定でしたが、もう、だいぶ長くなっているので省略します。また別の機会があれば紹介します。

パトリシアトライを利用すると効率よく最長一致検索を実現できます。これを試してみるためのサンプルアプリケーションを用意しました。

groongaでキーワード検出

リンク先ではキーワードを変えて試すことができます。

最長一致機能を利用してキーワード検出している部分のソースは以下の通りです。

target_text = "..."
keywords = request["keywords"].split

words = Groonga::PatriciaTrie.create(:key_type => "ShortText",
                                     :key_normalize => true)
keywords.each do |keyword|
  words.add(keyword)
end
tagged_text = words.tag_keys(target_text) do |record, word|
  "<span class='keyword'>#{word}</span>"
end

まず、パトリシアトライを作り、キーワードを登録します。Groonga::PatriciaTrieにはtag_keysという便利メソッドがあり、これを使うと「最長一致検索」→「キーワードにタグ付け」をより簡潔に記述することができます。

全体のソースはリンク先にあるソース一式の中に含まれています。

スキーマ

スキーマ: Messages

groongaのRubyバインディングであるRuby/groongaはスキーマ定義のためのドメイン固有言語を提供しています。

メールを保存するMessagesテーブルにはsubjectfromtobodyカラムを定義しています。今回は簡単のため、宛先は1つのみ扱うことにしています。

スキーマ: Terms

次に、高速に全文検索を行うために索引を作成します。Termsテーブルのキーに単語(ここではbigramを利用しているので1文字か2文字の文字列)、カラムにその単語が出現するMessagesレコードのID(とN-gramなので単語の出現位置)を保持します。

subjectカラムとbodyカラムでそれぞれに対して索引を作成しています。こうすることにより、「どこかに○○が含まれているメールを検索」といった検索だけではなく、「Subjectに○○が含まれているメールを検索」、「本文に○○が含まれているメールを検索」というような細かい検索ができるようになります。細かい検索が必要ない場合はMessagesテーブルに検索対象をすべて入れたカラムを1つ作り、そのカラムに対して索引を作成してもよいでしょう。

Groonga::Schema.define do |schema|
  schema.create_table("Messages") do |table|
    ...
    table.text("text")
  end

  schema.create_table("Terms",
                      :type => :patricia_trie,
                      :default_tokenizer => "TokenBigram",
                      :key_normalize => true) do |table|
    table.index("text")
  end
end

messages = Groonga["Messages"]
from = "kou@clear-code.com"
to = "info@clear-code.com"
body = "Hello Ruby and milter!"
text = "#{from} #{to} #{body}" # <- textに検索対象をまとめる
messages.add(:from => from
             :to => to,
             :body => body,
             :text => text)

query = "Ruby"
messages.select do |record|
  record["text"].match(query) # <- textカラムで全文検索
end

Rubyでmilterを作る

Rubyでmilter

データの保存・検索の仕組みはできたので、あとは、groongaのデータベースにメールを登録するだけです。

milter managerのRubyバインディングのAPIでは、ユーザがmilterプロトコルのコマンドに対応するメソッドを定義し、ライブラリ側がそのメソッドを呼び出します。今回必要な情報はヘッダーと本文にあります。そのため、今回のmilterは以下のようになります2

class ArchiveMilter < Milter::ClientSession
  def initialize
    @messages = Groonga["Messages"]
    @values = {}
    @encoding = nil
    @body = ""
  end

  def header(context, name, value)
    case name
    when /\A(Subject|From|To)\z/i
      key = $1.to_s.downcase
      utf8_value = NKF.nkf("-w", value)
      @values[key] = utf8_value
    when /\AContent-Transfer-Encoding\z/i
      @encoding = value
    end
  end

  def body(context, chunk)
    @body << chunk
  end

  def end_of_message(context)
    nkf_option = "-w"
    nkf_option << " -MB" if @encoding == "base64"
    @values["body"] = NKF.nkf(nkf_option, @body)
    @messages.add(@values)
  end
end

このように、Rubyでmilterを作るときは必要な処理の部分だけを記述するだけですみます。つまり、やりたいことを実現するためにどういうデータが必要で、どのタイミングでそのデータを手に入れられるかがわかれば、Rubyでmilterを作ることは簡単だということです。

登録したメールは以下のように検索・表示することができます。

query = "Ruby" # <- 検索キーワード
messages = Groonga["Messages"]
result = messages.select do |record|
  record["subject"].match(query) |
    record["body"].match(query)
end
result.sort([["_score", :desc]]).each do |message|
  puts "-" * 78
  puts "score: #{message.score}"
  puts "Subject: #{message.subject}"
  puts "From: #{message.from}"
  puts "To: #{message.to}"
  puts
  puts message.body
  puts "-" * 78
end

まとめ

Rubyでmilterを作る方法について説明しました。そのために必要な技術として、milterプロトコルの具体的な動作も説明しました。ここで説明されている内容を理解していれば、より詳細なmilter関連情報も理解しやすくなるでしょう。英語ですが、milterに関する情報はmilter.orgにまとまっています。より詳しい情報を知りたい場合はチェックするとよいでしょう。

札幌はやはりやさいい雰囲気に包まれていました。札幌Ruby会議02とは少し違う雰囲気でしたが、似ているとは感じました。

一度、札幌の人たちに会いに行ってみてはいかがでしょうか。

あわせて読みたい

  • 2010-02-14 - iakioの日記 - postgresqlグループ 「C言語でPostgreSQLを拡張する」というタイトルで石田さんが淡々とライブコーディングされていました。会場とやりとりをしながらコーディングする様子を見ていると、札幌っぽい雰囲気を感じることができるでしょう。
  1. 「data」コマンドはmilterプロトコルのバージョン4から追加されたコマンドなのでそれより古い2などを使っている場合は利用できません

  2. milter managerでは「eom」というような省略した名前を「end_of_message」という省略しない自己記述的な名前になっているので注意してください。