James君!〜独自 Matcher を書く

基底クラス org.apache.mailet.GenericMatcher

さてようやくプログラミングだ...ここまで長かったな。

まずは Matcher の書き方の説明だ。Matcher は org.apache.mailet.GenericMatcher を継承して書く。まあ、こんなものだ。

package org.apache.mailet;

import javax.mail.MessagingException;
import java.util.Collection;

public abstract class GenericMatcher implements Matcher, MatcherConfig {
    MatcherConfig config = null;

    // 破棄時に呼ばれる
    public void destroy() {
        //Do nothing
    }

    // オプションを取得する。null ならオプションはない。
    public String getCondition() {
        return config.getCondition();
    }

    // MatcherConfig を取得する
    public MatcherConfig getMatcherConfig() {
        return config;
    }

    // MailetContext を取得する
    // 実は MailetContext == org.apache.james.James クラスである!
    public MailetContext getMailetContext() {
        return getMatcherConfig().getMailetContext();
    }

    // 適当な情報を返す。author とか version とか copyright とかである。
    public String getMatcherInfo() {
        return "";
    }

    // Matcher 名を返す
    public String getMatcherName() {
        return config.getMatcherName();
    }


    // James 起動時に呼ばれる。初期化をさせればよい。
    public void init(MatcherConfig newConfig) throws MessagingException {
        config = newConfig;
        init();
    }

    
    public void init() throws MessagingException {
        //Do nothing... can be overridden
    }

    // ログを出力する
    public void log(String message) {
        StringBuffer logBuffer = 
            new StringBuffer(256)
                    .append(getMatcherName())
                    .append(": ")
                    .append(message);
        getMailetContext().log(logBuffer.toString());
    }

    public void log(String message, Throwable t) {
        StringBuffer logBuffer = 
            new StringBuffer(256)
                    .append(getMatcherName())
                    .append(": ")
                    .append(message);
        getMailetContext().log(logBuffer.toString(), t);
    }

    // 問題はこれを実装するんである。
    public abstract Collection match(Mail mail) throws MessagingException;
}

というわけだから、独自 Matcher で実装すべきメソッドは、

  1. public Collection match(Mail mail) throws MessagingException; ←当然
  2. public void init() throws MessagingException; ←初期化。実装しているもの多し。
  3. public String getMatcherInfo(); ←実装メリットはある。

くらいなものである。あと当然、「お役立ちメソッド」として、

String getCondition();
オプションを取得する
void log( String message, [Throwable t] );
ログする

を用意していてくれているので、使い倒してやってくれたまえ。

match() メソッド

まあ、match() メソッド以外はそうそう悩むものでもなかろう。match() メソッドは早い話、引数で mail を貰って、「有効な受取人」のCollection を返す仕様である。まあ、ここらへん、簡単なサンプルを見るのが早かろう。たとえば、All Matcher だったら...

public class All extends GenericMatcher {
    public Collection match(Mail mail) {
        return mail.getRecipients();
    }
}

と、mail.getRecipients() で受取人を取得して、それを何もせずに返しているわけだ。じゃあ、この Recipients とは何か?というのが問題だな。これは、org.apache.mailet.Mail の実装クラスである、org.apache.james.core.MailImpl を見ると、単に To: ヘッダを拾っているだけである。が、まあ複数の宛先があることも考慮して、Collection になっているようだ。要するに、Matcher が「一致するんでその Mailet の処理をさせたい」のならば、単に mail.getRecipient() を return し、「Mailet の処理をさせたくない」んだったら、null を返せばいい。

そこらへん、もう少しちゃんとした処理のある Matcher を見てみよう。例えば InSpammerBlacklist だと、

public class InSpammerBlacklist extends GenericMatcher {
    String network = null;  // オプションから設定される、Blacklist 管理サービス

    public void init() throws MessagingException {
        network = getCondition(); // オプションを取得
    }

    public Collection match(Mail mail) {
        String host = mail.getRemoteAddr(); // メール送信元IPアドレス
        try {
            // それを逆順にしてつなげる
            StringBuffer sb = new StringBuffer();
            StringTokenizer st = new StringTokenizer(host, " .", false);

            while (st.hasMoreTokens()) {
                sb.insert(0, st.nextToken() + ".");
            }

            // 結局 host="133.32.81.132"、network = "query.bondedsender.org"
            // だったら、sb = "132.81.32.133.query.bondedsender.org" になる。
            sb.append(network);

            // これで DNS を lookup する
            org.apache.james.dnsserver.DNSServer.getByName(sb.toString());

            // で、DNS が応答すれば、"133.32.81.132" は query.bondesender.org
            // のブラックリストに載っているスパマだということになる。
            return mail.getRecipients();
        } catch (UnknownHostException uhe) {
            // 逆に例外を投げたら、ブラックリストにはないことになる。
            // DNS でエラーが生じるケースも、結局ひっかからないな。
            return null;
        }
    }
}

org.apache.mailet.mail クラス

でまあ、match() メソッドの引数に渡って来るのが、org.apache.mailet.mail クラスである。これは要するに James で定義されたクラス(interface)なので、多分実装クラスは org.apache.james.core.MailImpl だ...ということは、少しこのクラスのインターフェイスを知っておいた方がいい。

javax.mail.MimeMessage getMessage() throws MessagingException;
void setMessage(MimeMessage message);
メールメッセージ自体を返す。MimeMessage 自体は javax.mail パッケージのものなので、ドキュメントはそっちの方を見てくれ。
Collection getRecipients();
メールの受取人を返す。戻り値は実際には org.apache.mailet.MailAddress クラスの均質コレクションであり、MailAddress オブジェクトは String getHost(); でホスト部だけ、String getUser(); でアカウント名だけ、String toString(); でメアド自体を取得できる。テキトーに Iterator でも使って実アドレスを取得したまえ。これが Collection になっているのは、複数の宛先があるケースに対応しているわけである。
MailAddress getSender();
これは実際の送り元を取得する。言い替えれば、From: ヘッダとか Reply-To: ヘッダではなくて、SMTP の MAIL FROM: で渡される送り元を取得する。
String getRemoteHost();
String getRemoteAddr();
送り元のホスト名・IPアドレスを返す。
String getState();
void setState(String state);
でここからは実メール自体の情報ではなくて、James が独自に設定する情報たちである。State はメール処理の「状態」であり、これらはやはり Mail クラスで定義されたシンボル値「GHOST」「ERROR」「DEFAULT」を返す。「GHOST=メール処理終了」「ERROR=エラーメール」「DEFAULT=処理中」ということになり、実質上「パイプラインの上での状態」を示している。だから、メールのパイプライン処理を終了させるのなら、これのセッタを使って、次のようにすればいい。
mail.setState( Mail.GHOST );
String getErrorMessage();
void setErrorMessage(String msg);
James のメール処理中に生じたエラー内容を取得/設定する。
Serializable getAttribute(String name);
Iterator getAttributeNames();
boolean hasAttributes();
Serializable removeAttribute(String name);
void removeAllAttributes();
Serializable setAttribute(String name, Serializable object);
Matcher のところで述べた「Mail Attribute」を操作する。インターフェイスを見て見当が付くと思うが、要するに Hashtable である。

ということは、特に何かのヘッダを参照したいのなら、次のようにするわけである。

public Collection match( Mail mail ) {
   String cc_header = mail.getMessage().getHeader( "Cc", null );

実例:本文正規表現 Matcher TextRegex

だったらまあ、少し筆者オリジナルの実例。本文内容に対して Perl5風パターンマッチをして一致すれば「Match!」という matcher である。こういう風に使う。

<mailet match="TextRegex=[Jj]ames-\d" class="***" />

この時、やや気になるのは「行指向マッチング」をさせたい..というところだ。例えば、「james123」という「行」にマッチしたい場合、正規表現は /^james123$/ となる...が、少し問題がある。それはメールの本文のラインターミネーターが、インターネット規約で「\r\n」であり、これがそのまま javax.mail パッケージの本文取得メソッドで変えってくる。ゆえに、正規表現の行末指定は /\r$/ にならなくてはならないが、それを Matcher オプションで含めなくてはならない、とするのはウザったい。だから少しオプションの「検索文字列」を特殊ケースでいじっている。

そのため、

<mailet match="TextRegex=^[Jj]ames-\d$" class="***" />

という書き方でちゃんと「james-1234」という「行」にマッチするようにしてあるので、ご確認いただきたい。

なお、ここで「メールのテキストすべてを抜き出す」という操作が必要だ。これは他の Mailet & Matcher でも有用なので、共用ライブラリとしてまとめることにした。jp.or.nurs.sug.james.SugUtil である。

package jp.or.nurs.sug.james;

import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternMatcher;
import org.apache.oro.text.regex.PatternCompiler;
import org.apache.oro.text.regex.Perl5Matcher;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Perl5Substitution;
import org.apache.oro.text.regex.Util;

import java.util.Properties;

import java.io.InputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.BufferedReader;

import javax.mail.internet.MimeUtility;
import javax.mail.MessagingException;

import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMultipart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimePart;

public class SugUtil {
    private static PatternMatcher matcher = new Perl5Matcher();
    private static PatternCompiler compiler = new Perl5Compiler();

    /* クラスパスからリソースファイルを読み込む */
    public static Properties findResource( String filename ) throws MessagingException {
        Properties prop = new Properties();
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        InputStream is = loader.getResourceAsStream( filename );
        try {
            prop.load( is );
        } catch( Exception e ) {
            throw new MessagingException( "property load", e );
        }
        return prop;
    }

    /* タグ抜き正規表現置換 */
    public static String delTag( String text ) throws MessagingException {
        Pattern pattern;
        try {
            pattern = compiler.compile( "<[^>]+>" );
        } catch( MalformedPatternException e ) {
            throw new MessagingException( "ORO pattern ERROR", e );
        }
        return Util.substitute( matcher, pattern, 
                new Perl5Substitution( "", Perl5Substitution.INTERPOLATE_ALL),
                text, Util.SUBSTITUTE_ALL );
    }

    /* メールから本文のみを抽出する。SJIS -> Base64 にも対応 */
    /* とりあえずデコードのバグがあるので、IOExceptionを受けた場合は空文字列
       を返す。これは Base64エンコードされたSJISメールで、UTF8に変換できない
       ものがあるケースで起きるので、対応しようがない。Unicode仕様と SJIS
       デコーダの問題なので手が出ない... */
    public static String getAllBody( MimePart part ) throws MessagingException {
        try {
            StringBuffer buff = new StringBuffer();
            buff = matchAsType( part, buff );
            return buff.toString();
        } catch( IOException e ) {
            return "";
        }
    }

    // 以下下請け
    private static StringBuffer matchAsType( MimePart part, StringBuffer buff ) 
                                       throws MessagingException, IOException {
        String text = null;
        if( part.isMimeType( "text/plain" ) ) {  // プレーンメール
            text = getRealText( part );
            buff.append( text );
        } else if( part.isMimeType( "text/html" ) ) {  // HTMLメール
            text = getRealText( part );
            buff.append( text );
        } else if( part.isMimeType( "multipart/mixed" ) ) { // マルチパート
            MimeMultipart mp = (MimeMultipart)part.getContent();
            buff.append( mp.getBodyPart(0).getContent().toString() );
        } else if( part.isMimeType( "multipart/alternative" ) ){ // マルチパート
            MimeMultipart mp = (MimeMultipart)part.getContent();
            int num = mp.getCount();
            for( int i = 0; i < num; i++ ) {
                MimeBodyPart body = (MimeBodyPart)mp.getBodyPart(i);
                buff = matchAsType(body, buff);
            }
        }
        return buff;
    }

    private static String getRealText( MimePart part ) throws MessagingException, IOException {
        String [] encoding = part.getHeader( "Content-Transfer-Encoding" );
        if( encoding != null && encoding[0].equals( "base64" ) ) {
            if( part instanceof MimeMessage ) {
                return decode64( ((MimeMessage)part).getRawInputStream() );
            } else if( part instanceof MimeBodyPart ) {
                return decode64( ((MimeBodyPart)part).getRawInputStream() );
            } else {
                return part.getContent().toString();
            }
        } else {
            if( part instanceof MimeMessage ) {
                return decode( ((MimeMessage)part).getRawInputStream() );
            } else if( part instanceof MimeBodyPart ) {
                return decode( ((MimeBodyPart)part).getRawInputStream() );
            } else {
                return part.getContent().toString();
            }
        }
    }

    
    private static String decode( InputStream is ) throws MessagingException, IOException {
        StringBuffer buff = new StringBuffer();
        InputStreamReader isr = new InputStreamReader( is, "JISAutoDetect" );
        BufferedReader br = new BufferedReader( isr );
        String line;
        while( (line = br.readLine()) != null ) { 
            buff.append( line + "\n" );
        }
        return buff.toString();
    }

    private static String decode64( InputStream is ) throws MessagingException, IOException {
        StringBuffer buff = new StringBuffer();
        InputStream ret = MimeUtility.decode( is, "base64" );
        InputStreamReader isr = new InputStreamReader( ret, "JISAutoDetect" );
        BufferedReader br = new BufferedReader( isr );
        String line;
        while( (line = br.readLine()) != null ) { 
            buff.append( line + "\n" );
        }
        return buff.toString();
    }
}

まあ、ここでは Base64 でエンコードされているクセに、JIS ではないエンコーディングをしてくるクセの悪いMUAの対策で、ちょっと複雑になっている。だって Content-TYpe に Shift-jis とも書かないケースがあるのである....はっきり言って、RFC違反である。

じゃあ、SugUtil を使う TextRegex Matcher はこうだ。これもやはり Jakarta ORO を使っている。

package jp.or.nurs.sug.james.matchers;

import org.apache.mailet.GenericMatcher;
import org.apache.mailet.Mail;
/* Jakarta ORO は James で使っているので、SAR-INF/lib/ に jakarta-oro-*.jar が
   あるはずである。*/
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.Perl5Matcher;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

import java.util.Collection;

import jp.or.nurs.sug.james.SugUtil;

public class TextRegex extends GenericMatcher {
    Pattern pattern;  // オプションより
    Perl5Matcher matcher = new Perl5Matcher();  // 正規表現照合器

    /* 行末マッチのために、少しいじる...
       要するにメール本文は \r\n で終るので、そのまま /$/ でマッチ
       しない。だから、/$/ を /\r$/ に修正する */
    private String replace_doller( String s ) {
        StringBuffer ret = new StringBuffer();
        int at = 0;
        int ind;
        while( (ind = s.indexOf("$", at)) >= 0 ) {
            ret.append( s.substring( at, ind ) + "\\r$" );
            at = ind + 1;
        }
        ret.append( s.substring( at ) );
        return ret.toString();
    }

    public void init() {
        String cond = getCondition();
        if( cond.indexOf( "$" ) >= 0 ) {
            cond = replace_doller( cond );
        }
        try {
            pattern = new Perl5Compiler().compile( cond, Perl5Compiler.READ_ONLY_MASK |
                                                   Perl5Compiler.MULTILINE_MASK );
        } catch( Exception e ) {
            log( "オプションの正規表現が間違っています:" + cond );
            e.printStackTrace();
            pattern = null;
        }
    }

    public Collection match(Mail mail) throws MessagingException {
        if( pattern == null ) { return null; }

        MimeMessage message = mail.getMessage();
        String text = SugUtil.getAllBody( message );
        if( text == null || text.equals("") ) {
            throw new MessagingException( "Content is EMPTY!(or cannot abstract REAL contents)" );
        }

        // 「含む」照合
        if( matcher.contains( text, pattern ) ) {
            return mail.getRecipients();
        } else {
            return null;
        }
    }
}

例外を投げたら....

まあ、少し気になるのは、Matcher が例外を投げたらどうなるんだろう??という疑問だ。実際、match() は MessagingException を投げてもいい。果たして例外の結果、パイプライン処理はどうなるのか...というと例えばこんな Matcher を書いてテストしてみればいい。

package jp.or.nurs.sug.james.matchers;

import org.apache.mailet.GenericMatcher;
import org.apache.mailet.Mail;
import javax.mail.MessagingException;

import java.util.Collection;

public class Thrower extends GenericMatcher {
    public Collection match(Mail mail) throws MessagingException{
        throw new MessagingException( "Exception throw TEST!" );
    }
}

その結果は、

  1. Matcher が例外を投げたら、その <mailet> の実行は中止。
  2. メールはエラー扱いとなって、error Processor による処理に分岐。
  3. 結果として、james-2.2.0/apps/james/var/mail/error リポジトリに保存。
  4. ちなみにログは apps/james/logs/spoolmanager-*.log に出る。通常の Mailetログが apps/james/logs/mailet-*.log に出るのだが、例外によるログはここに出ちゃう。

ということになる。だから、Thrower Matcher を使ったもの以降のパイプラインはまったく実行されない。...しかし、プログラミングミスなどで、例外を投げるとなると、ログの在処が違う...というのは嫌らしいな。まあ、こんな感じに、

public class SomeMailet extends GenericMatcher {
    public Collection match(Mail mail) throws MessagingException{
        try {
              .................
        } catch( Exception e ) {
             // ログレベルが INFO で気持ち悪いが、仕方ない...
             log( "例外見っけ!", e );
             throw new MessagingException( "Exception caught", e );
        }
    }
}

と MessagingException でラップするのも良かろう。で、このラップは別なメリットもホントはあるんだぞ...

今説明したのはは「デフォルトの動作」に過ぎない。ホントはその Matcher と結びつけられた <mailet> の側で、もう少し柔軟な処理が可能である。それは次節で改めて触れる

基底クラス org.apache.mailet.GenericRecipientMatcher

あと、特に「受取人が複数ある可能性」という面で言うと、James ではもう少しコンビニな基底クラスも用意していてくれている。org.apache.mailet.GenericRecipientMatcher がそれだ。これを基底クラスにしている Matcher も多い。

package org.apache.mailet;

import javax.mail.MessagingException;
import java.util.Collection;
import java.util.Iterator;
import java.util.Vector;

public abstract class GenericRecipientMatcher extends GenericMatcher {
    // こいつは上書きせずに、
    public final Collection match(Mail mail) throws MessagingException {
        Collection matching = new Vector();
        for (Iterator i = mail.getRecipients().iterator(); i.hasNext(); ) {
            MailAddress rec = (MailAddress) i.next();
            if (matchRecipient(rec)) {
                matching.add(rec);
            }
        }
        return matching;
    }

    // こっちを実装する
    public abstract boolean matchRecipient(MailAddress recipient) throws MessagingException;
}

要するに、matchRecipient() が false を返せば、そのメールの recipient からその宛先は削除される、ということだ。たとえば、RecipientIsLocal だと、

public class RecipientIsLocal extends GenericRecipientMatcher {
    public boolean matchRecipient(MailAddress recipient) {
        MailetContext mailetContext = getMailetContext();
        // recipient のホスト名がローカルのもので、
        // かつ ローカルユーザとして定義されている場合に true
        return mailetContext.isLocalServer(recipient.getHost())
            && mailetContext.isLocalUser(recipient.getUser());
    }
}

という具合になるのである。まあ、このくらいのサンプルを見ておけば、大概の Matcher は楽勝だろう。とはいえ、ちょっとここらへんのメカニズムについては、後でもう少し詳しく見てやる。



copyright by K.Sugiura, 1996-2006