James君!〜DB連携Mailet

DB連携

とはいえ、皆さんがしたいのは、多分DB連携プレーだ。まあ、「空メールを受けて、そのメールアドレスをDB登録し、返信を出す」といったあたりだろう。ここらへんを少しやってみよう。

今時のことで、DBを生でいじることは少なくなっていようが、とりあえず DataSource によるコネクション・プーリング機能を、James は備えている。これはそもそも Phoenix(Avalon) が持っている機能であり、James ではメール保存自体をDBにさせるなんてことも簡単にできるようになっている。ま、このやり方は正規のドキュメントにもあって翻訳もなされているので、ここでは追求しない。

とはいえ、その副産物というか..で、James でコネクション・プーリングを管理しているわけだから、それに便乗させてもらおうじゃないか、というのが狙いだ。

まあ、設計しようか。これはどうやら2つの Mailet に割った方が良さそうだ。

  1. 特定のメールアドレスへのメールを受けて、DBに登録する。
  2. その処理の結果に応じて、DB から引き出される文言のメールを送信者に返す。

というわけで、1. は AddSenderToDB、2. は ReplyByResult という名前にでもしよう。こんな具合のパイプラインを作れば、再利用をふくめた要領がイイのではなかろうか。で、AddSenderToDB の結果(成功/既存Emailのため重複/エラー)の判定は、Mail Attribute でさせるのが良かろう。というわけで、Attribute ベースでパイプラインをつないでいくことにする。なので HasMailAttribute(WithValue) みたいな Matcher が活躍するわけだ。勿論、このパイプラインの中に、先ほどの Info Mailet を挟んでやれば、パイプラインの動き方を目で見て確認できる...という寸法である。

<!-- 宛先が「regist」ならば、DBに送信者を登録する -->
<mailet match="RecipientIs=regist@mydomain.jp" class="AddSenderToDB" >
   <!-- 使うデータソース名 -->
   <datasource>register</datasource>
   <!-- テーブル名と送信者アドレスが入るフィールドを指定する -->
   <table>regist_users</table>
   <key>email</key>
   <!-- これは使う Attribute の名前 -->
   <attribute>Register</attribute>
</mailet>

<!--
<!-- さっそく Info を使ってデバッグ! -->
<mailet match="RecipientIs=regist@mydomain.jp" class="Info">
  <message>After regist_users</message>
  <!-- ホントに Attribute が設定されてる?? -->
  <showAttribute>Register</showAttribute>
</mailet>
-->

<!-- 処理が成功したケースで、送信者に返信する -->
<mailet match="HasMailAttributeWithValue=Register,success" class="ReplyByResult" />
   <!-- この時文言などを reply テーブルから id=success をキーとして拾う -->
   <table>reply</table>
   <key>success</key>
</mailet>

<!-- まあ、空メールだから、処理が失敗するのはメールのダブりだけかな? -->
<mailet match="HasMailAttributeWithValue=Register,fail" class="ReplyByResult" />
   <!-- この時文言などを reply テーブルから id=fail をキーとして拾う -->
   <table>reply</table>
   <key>fail</key>
</mailet>

<!-- FATAL なエラーの場合 -->
<mailet match="HasMailAttributeWithValue=Register,error" class="ReplyByResult" />
   <!-- この時文言などを reply テーブルから id=error をキーとして拾う -->
   <table>reply</table>
   <key>error</key>
</mailet>

<!-- 念のために、アプリ管理者にメールを送る -->
<mailet match="HasMailAttribute=Register" class="Forward" />
   <forwardTo>mainterner@register_user.com</forwardTo>
   <!-- とりあえずリポジトリに保存することを想定 -->
   <passThrough>true</passThrough>
</mailet>

DBの用意

まあ、こんなとこだろう。上記のパイプライン内のオプションには、DBの接続情報は <datasource>register</datasource> として参照のかたちで書かれている。その実態は config.xml の別なところに書くわけである。とりあえず MySQL による保存(あと MSSQL に対応しているようだが...)の場合はこうだ。

   <database-connections>
      <data-sources>
         <data-source name="register" class="org.apache.james.util.dbcp.JdbcDataSource">
            <driver>org.gjt.mm.mysql.Driver</driver>
            <dburl>jdbc:mysql://127.0.0.1/register</dburl>
            <user>register</user>
            <password>register</password>
            <max>20</max>
         </data-source>
         <!-- 以下はメール自体をDB保存するための設定 -->
         <!--
         <data-source name="maildb" class="org.apache.james.util.dbcp.JdbcDataSource">
            <driver>org.gjt.mm.mysql.Driver</driver>
            <dburl>jdbc:mysql://127.0.0.1/mail?autoReconnect=true</dburl>
            <user>username</user>
            <password>password</password>
            <max>20</max>
         </data-source>
         -->
      </data-sources>
   </database-connections>

でまあ、MySQL で、次のようにDBを作れば良いだろう。

$ mysql -u root -phogehoge
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 2 to server version: 4.0.21

Type 'help;' or '\h' for help. Type '\c' to clear the buffer.

mysql> CREATE DATABASE register;
Query OK, 1 row affected (0.13 sec)

mysql> USE register;
Database changed

# regist_use テーブルを作る
mysql> CREATE TABLE regist_users ( 
    -> id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
    -> email VARCHAR(100) NOT NULL UNIQUE,
    -> regdate TIMESTAMP, # 登録時間
    -> name TEXT,     # 以下 James 上からはいじらないフィールドを適当に...
    -> ........
    -> PRIMARY KEY (id) );
Query OK, 0 rows affected (0.07 sec)

# reply テーブルを作る(文言)
mysql> CREATE TABLE reply (
    -> id TEXT,
    -> subject TEXT,
    -> content TEXT,
    -> fromaddr TEXT );
Query OK, 0 rows affected (0.00 sec)

# ユーザの作成と操作の許可
mysql> GRANT SELECT,INSERT ON register.regist_users TO 
    -> register@localhost IDENTIFIED BY 'register';
Query OK, 0 rows affected (0.11 sec)

mysql> GRANT select ON register.reply TO register@localhost;
Query OK, 0 rows affected (0.00 sec)

# reply テーブルにデータを入れておく
mysql> INSERT INTO reply VALUES( 'success', '登録完了!', 
     -> 'あなたのメールアドレスが○○システムに登録されました。今後ともご贔屓くださいませ。', 
     -> 'owner@mydomain.jp' );
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO reply VALUES( 'fail', 'すでに登録されています', 
     -> 'あなたはすでに○○システムのユーザとして登録されています。',
     -> 'owner@mydomain.jp' );
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO reply VALUES( 'error', '登録失敗!', 
     -> '○○システムへのあなたの登録は失敗しました。管理者まで御連絡下さい。',
     -> 'owner@mydomain.jp' );
Query OK, 1 row affected (0.00 sec)

DB接続基底クラス

こんな感じで準備はイイだろう。では、まずこの2つの Mailet はどっちも DB 接続をするので、面倒なので抽象基底クラスを作ってしまおう。

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

import javax.mail.MessagingException;

import org.apache.mailet.GenericMailet;
import org.apache.mailet.Mail;

import org.apache.avalon.framework.component.ComponentManager;
import org.apache.avalon.cornerstone.services.datasource.DataSourceSelector;
import org.apache.avalon.excalibur.datasource.DataSourceComponent;
import org.apache.james.Constants;

import java.sql.Connection;
import java.sql.Statement;

/* DB接続用の抽象基底クラス */
public abstract class AbstractDBMailet extends GenericMailet {
    private DataSourceComponent datasource;  // DataSource
    // オプションたち
    private String table;    // 使うテーブル
    private String key;      // テーブルのキー
    private String attribute;  // 使うアトリビュートの指定

    // 初期化
    public void init() throws MessagingException {
	String ds = getInitParameter( "datasource" );
	if( ds == null ) {
	    throw new MessagingException( "datasource は必須です" );
	}
        try {
            // DataSource を取得する
            ComponentManager mgr 
                  = (ComponentManager)getMailetContext().getAttribute( 
                                      Constants.AVALON_COMPONENT_MANAGER );
            DataSourceSelector selector 
                  = (DataSourceSelector)mgr.lookup(
                                      DataSourceSelector.ROLE );
            datasource = (DataSourceComponent)selector.select( ds );
        } catch( Exception e ) {
            throw new MessagingException( "初期化失敗!", e );
        }

        // オプションを取得する
        table = getInitParameter( "table" );
        key   = getInitParameter( "key" );
        if( table == null || key == null ) {
            throw new MessagingException( "パラメータが足りません。table=" 
	                                   + table + " key=" + key );
        }
        attribute = getInitParameter( "attribute" );
    }

    // 外部から参照のため
    public String getTable() { return table; }
    public String getKey() { return key; }
    public String getAttribute() { return attribute; }

    // これはホントにメールに Attribute を設定するインターフェイス
    public void setMailAttribute( Mail mail, String val ) {
        if( attribute != null ) {
            mail.setAttribute( attribute, val );
        }
    }

    // 上書きすべき抽象メソッド
    public abstract boolean serviceWithDB( Mail mail, Statement st ) throws Exception;

    // 通常の service() でDB接続の処理をする
    public void service( Mail mail ) throws MessagingException {
	Connection con = null;
	Statement st = null;
        try {
            con = datasource.getConnection();
            st = con.createStatement();
            serviceWithDB( mail, st );
        } catch( Exception e ) {
            e.printStackTrace();
            // 例外をキャッチしたらアトリビュートは error
            setMailAttribute( mail, "error" );
	} finally {
	    try {
		if( st != null ) {
		    st.close();
		}
	    } catch( Exception e ) { /* NOP */ }
	    try {
		if( con != null ) {
		    con.close();
		}
	    } catch( Exception e ) { /* NOP */ }
        }
    }
}

まあ、問題は、

    // 初期化
    public void init() throws MessagingException {
	String ds = getInitParameter( "datasource" );
        ...
        try {
            // DataSource を取得する
            ComponentManager mgr 
                  = (ComponentManager)getMailetContext().getAttribute( 
                                      Constants.AVALON_COMPONENT_MANAGER );
            DataSourceSelector selector 
                  = (DataSourceSelector)mgr.lookup(
                                      DataSourceSelector.ROLE );
            datasource = (DataSourceComponent)selector.select( ds );
        } catch( Exception e ) {
            throw new MessagingException( "初期化失敗!", e );
        }

の部分だろう。これは要するに、GenericMailet#getMailetContext() によって、「Mailetコンテキスト」なるものが取得出来るのである。名前はおおげさだが、これは要するに Avalon の規約の中で、「アプリの全体を示すコンテキスト」をこれで取得できるわけである。現実的には Mailet の場合、この getMailetContext() で返るのは org.apache.james.James クラスのインスタンスだったりするわけだ。イキナリ大本のクラスが返っちゃうわけだが、これがいくつかの Attribute(Hashtable)を持っており、その Attribute の中に「ComponentManager」という他の Avalon コンポーネントを検索するためのインターフェイスがあるのである。

ここらへん後でしっかりと説明するが、Phoenix から見た時には James のようなアプリは、「いろいろな Avalon コンポーネントの集合体」に過ぎない。で、それらの「Avalon コンポーネントたち」は「一つ一つを完結したブロックとして」寄せ集めているわけだが、「それら同士の相互作用がやっぱ欲しい!」わけで、それを実現するのがこの ComponentManager.lookup( キー名 ) なのである。だから、「DataSourceSelector.ROLE」で定義された「名前」で ComponentManager を検索してやると、「DataSource を管理する DataSourceComponent」を取得できる。で、得られた DataSourceComponent から使う DataSource を引き出してやればOKだ。

org.apache.mailet.MailetContext の詳細

ちなみに GenericMailet#getMailetContext() で返るオブジェクト、org.apache.mailet.MailetContext には、他にも結構いろいろと便利なメソッドがあるので、これをちょっと見てみよう。実際には、getMailetContext() で返るオブジェクトは org.apache.james.James であり、大本のクラスが返ってしまう。だから、Mailet などの末端のクラスが、「全体の情報」を取得するのに使えるという、かなり Utility 的性格の強いものである。要するに、James クラスそのものにアクセスするのは「ちょっと...」というのがあるから、「アクセス可」なものだけを MailetContext で「一枚皮をかぶせて」いるわけだ。

void bounce(Mail mail, String message) throws MessagingException;
void bounce(Mail mail, String message, MailAddress bouncer) throws MessagingException;
メールをバウンスする。言い替えると、送られて来たメールに対して「通知」の意図で送信元に元メールを添付して送る、ということをこのメソッドはしてくれる。引数の mail は当然バウンスすべき元メール、message はメールの本文となるテキスト、bouncer はバウンス・メールの送信元(自分)とするメールアドレスである。
Collection getMailServers(String host);
引数のホストに対して、DNSの MX レコードを引いて、ドメインに対応したメールサーバのIPアドレスが返る。Collection の中身は String 型の表現である。
Iterator getSMTPHostAddresses(String domainName);
これも役割りは getMailServers と同様だが、戻りの Iterator の中身は org.apache.mailet.HostAddress で、toString() すると「smtp://133.21.231.21」のようなURL+IPアドレスの形式で表示される。
MailAddress getPostmaster();
自分のサイトの Postmaster として登録されたメールアドレスが返る。というか、これは単に config.xml の <postmaster> エレメントの中身を返すものである。
Object getAttribute(String name);
これはメールアトリビュートとは何の関係もない。要するに James オブジェクトが保持する、さまざまな情報を入れた Hashtable へのアクセスである。先ほど使った「『ComponentManager』という他の Avalon コンポーネントを検索するためのインターフェイス」も、ここに入っていた。他に次のようなものが入っており、記号定数として org.apache.james.Contants にこれらのキーが定義されている。()は Constants での記号定数の名前である。
AVALON_COMP_MGR(Constants.AVALON_COMPONENT_MANAGER)
中身は org.apache.avalon.framework.component.DefaultComonentManager で、他のコンポーネントに対する参照を得るのに使う。使いかたはさっき見た。
HELLO_NAME(Constants.HELLO_NAME)
String 型で、SMTPなどのプロトコルを使って送受信する際に、自分自身のサーバ名として名乗る名前。フツーは自分のマシン名になるが、config.xml の中で各プロトコルごとに設定項目がある。このアトリビュート値はそのデフォルトとして使われる。
SERVER_NAMES(Constants.SERVER_NAMES)
String を内容として持つ HashSet である。「自ホスト」として認識されるべきすべてのドメイン名が列挙されている。
Iterator getAttributeNames();
getAttribute() で参照可能なすべての名前を列挙する。
void setAttribute(String name, Object object);
Attribute に登録する。
void removeAttribute(String name);
Attribute から name の属性を削除する。
int getMajorVersion();
Mailet API のメジャーバージョンを返す。多分 2 である。
int getMinorVersion();
Mailet API のマイナーバージョンを返す。多分 1 である。
String getServerInfo();
サーバ情報を返す。というか「Apache JAMES」という文字列が返るだけだ。
boolean isLocalServer(String serverName);
ローカルなホストとして登録されたホスト名に一致するかどうかテストする。「ローカルなホスト」とは、要するに config.xml の <servernames> に登録されたホスト名(autodetect が属性として指定されていれば、自動で検出するものも含めて)のことである。
boolean isLocalUser(String userAccount);
アカウント名が、James に登録されたローカルユーザーであるかどうかをテストする。実際、RecipientIsLocal Matcher はコレを使ってテストしている。
void log(String message);
void log(String message, Throwable t);
Mailet用にログを取る。実際 GenericMatcher や GenericMailet での log() メソッドは、これを呼び出している。
void sendMail(MimeMessage msg) throws MessagingException;
void sendMail(MailAddress sender, Collection recipients, MimeMessage msg) throws MessagingException;
void sendMail(MailAddress sender, Collection recipients, MimeMessage msg, String state) throws MessagingException;
void sendMail(Mail mail) throws MessagingException;
これらは当然メールを送信する。要するに、新規に msg を内容とするメールを生成し、パイプラインを辿らせるわけだ。メールの recipients、sender にそれぞれ引数で受取人、送信者を設定できる。で、state はこのメール送信を始めるためのパイプライン上でのプロセッサを指定することができる、という仕様である。

Mailet クラスの実装

では、メールを受けて送信者をDB保存する Mailet だ。

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

import javax.mail.MessagingException;

import org.apache.mailet.Mail;
import org.apache.mailet.MailAddress;

import java.sql.Connection;
import java.sql.Statement;
import java.sql.SQLException;

// メールを受けて、送信者をDBに保存する
public class AddSenderToDB extends AbstractDBMailet {
    public boolean serviceWithDB( Mail mail, Statement statement ) throws Exception {
        // オプションを取る
        String key = getKey();
        String table = getTable();

        // 送信者を取得する
        MailAddress addr = mail.getSender();
        String sender = addr.toString();

        // SQL を構築する
        String query = "INSERT INTO " + table + "(" + key + 
             ",regdate) VALUES( \'" + sender + "\',CURRENT_TIMESTAMP())";
        try {
            // INSERT の実行
            statement.executeUpdate( query );
            setMailAttribute( mail, "success" );
            return true;
        } catch( SQLException e ) {
            // email がすでに存在する時には SQLException を投げてしまうので、
            if( e.toString().lastIndexOf( "Duplicate entry" ) >= 0 ) {
                // 馬鹿馬鹿しいが message を見てダブリかどうか判別
                setMailAttribute( mail, "fail" );
            } else {
                // こっちはホントのエラー
                e.printStackTrace();
                setMailAttribute( mail, "error" );
            }
            return false;
        }
    }
}

最後はDBから返信内容を拾って、返信を出す Mailet だ。ホントは返信にはいろいろとやり方があるのだが、ここで紹介するのは ServerTime Mailet が使っている一番やさしいやり方だ。ここで MailetContext#sendMail() を使っていることに注目されたい。

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

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

import org.apache.mailet.Mail;
import org.apache.mailet.MailAddress;

import java.sql.Connection;
import java.sql.Statement;
import java.sql.ResultSet;

/* 送信者にDBから取得した内容で返信する */
public class ReplyByResult extends AbstractDBMailet {
    String subject = null; // Subject:
    String body = null;    // メール本文
    String sender = null;  // 送信者

    public boolean serviceWithDB( Mail mail, Statement statement ) throws Exception {
        /* 返信はこれが一番簡単なやり方(ServerTime のやり方から)*/
        MimeMessage response = (MimeMessage)mail.getMessage().reply(false);
        if( subject == null ) {
            // くだらないが、DB の用意は service() でやっているので...
            String key = getKey();
            String table = getTable();

            // SQL の構築
            String query = "SELECT * FROM " + table + " WHERE id=\'" + key + "\'"; 
            // クエリ
            ResultSet rs = statement.executeQuery( query );
            while( rs.next() ) {
                // DB内容の取得
                subject = rs.getString( "subject" );
                body = rs.getString( "content" );
                sender = rs.getString( "fromaddr" );
            }
        }

        // 返信内容のセット
        response.setSubject( subject );
        response.setText( body );
        response.setFrom( new MailAddress(sender).toInternetAddress() );

        if (response.getAllRecipients() == null) {
            response.setRecipients( MimeMessage.RecipientType.TO, 
                                    mail.getSender().toString() );
        }

        // セットした内容を有効にする
        response.saveChanges();
        // 送る
        getMailetContext().sendMail(response);

        // あとは単なるログ
        String message =  "Sent mail by ReplyByResult\n" + 
            "\tTo: " + formatAddress(response.getAllRecipients()) + "\n" +
            "\tFrom: " + formatAddress(response.getFrom()) + "\n" +
            "\tSubject: " + response.getSubject() + "\n" +
            "\tbody:" + body + "\n";
        System.out.println( message );
        log( message );
        return true;
    }

    /* ログ用の下請け: Address 配列を1つの文字列にまとめる */
    private String formatAddress( Address [] array ) {
        String ret = "";
        String delim = "";
        for( int i = 0; i < array.length; i++ ) {
            ret += delim + array[i].toString();
            delim = ", ";
        }
        return ret;
    }
}

これで業務もバッチリだな。めでたし。



copyright by K.Sugiura, 1996-2006