JavaMail with Apache Velocity メール送信 Servlet

インターネットマガジンサイトで商品を購入すると,購入確認のメールが送られて来る。販売会社にも受注メールが送付され,以降,入金・発送等の手続きがなされる,というものだろう。今日は,この注文者と受注者とに注文内容の電子メールを送信する Java サーブレットについて記す。

JavaMail

Java サーブレットから電子メールを操作するには,JavaMail を用いるのが標準的な解決策だろう。かつては JavaBeans Activation Framework (JAF) とセットで使う必要があったが,Java SE 6 以降では JavaMail mail.jar だけでよくなった。Oracle JavaMail ダウンロードページからアーカイブをダウンロード・解凍し,mail.jar をクラスパスに追加する。今回使用した版は 1.4.7 である。サーブレットで使うということで,WEB-INF/lib/ 下に mail.jar を格納して使用した。

Apache Velocity

定型的な文面に対して状況に応じた変更を加えて文書を作成する場合,文書の型をテンプレートとして用意し可変部をセットする方法が便利である。Java ライブラリにもこうした処理を支援するテンプレートエンジンがいくつかある。Apache Velocity はその代表といってよいツールだろう。

Velocity ダウンロードページ から Engine のバイナリ最新版アーカイブ(1.7)を取得・解凍し,velocity-1.7-dep.jar をクラスパスに追加する。今回は,WEB-INF/lib/ 下にこれを格納して使用した。

処理概念

次のようなモデルを想定する。二日酔書店サイト(もちろん架空の書店)で書籍を注文カートに登録し,フォームから注文者名,E-mail アドレス,郵便番号,住所を指示してサブミットすることにより,サーバが注文者と書店にメールを送る。書籍注文情報がサーブレット mailTransfer クラスに POST され,このサーブレットは,mailContents クラスで生成した文面をメール送信する。mailContents クラスは Velocity テンプレートに注文者の指定項目を埋め込んで,注文者宛確認メール及び書店受注通知メールを作成する。

処理概念図を以下に示す。

20140504-mailflow.png
メール送信サーバ処理概念図

JavaScript による注文データのサブミット

ブラウザで実行される書籍注文情報 POST 処理 JavaScript は以下のようなものである。JSON 形式で Cookie に格納された書籍情報配列と,フォーム入力情報とをサーバ URL /order/mailtransfer に対して POST する。jQuery $.ajax() 関数を用いている。もちろん,書籍注文カートへの追加(ここではブラウザの Cookie にストアしておく方式を仮定している。その是非については問わないでいただきたい),カート書籍注文フォーム表示などの前段階の手続きが必要だが,これらは省いてある。

// 書籍注文処理
// フォームと Cookie 書籍情報とから注文メールを送信する
function mailOrder() {
    // Cookie から書籍情報取得(JSON 形式): jquery.cookie 前提,詳細割愛
    // books JSON 配列 
    // [{"title": "t1", "author": "a1", "price": p1, "item": i1}, 
    //  {"title": "t2", "author": "a2", "price": p2, "item": i2},...]
    var books = $.cookie("bookcart");
    // Ajax Communication to send mail
    $.ajax({
        url: "/order/mailtransfer", // Java Servlet Application URL
        type: "POST", 
        dataType: "text", 
        cache: false,
        // POST されるべきフォーム入力データ及び書籍データ
        data: { "email": $("#mailaddr").val(),      // 注文者 E-mail アドレス
                "ordername": $("#ordername").val(), // 注文者名
                "postcode": $("#zipcode").val(),    // 注文者郵便番号
                "postaddr": $("#address").val(),    // 注文者住所
                "order": books }                    // 注文書籍情報 (JSON)
    }).done(function(text, textStatus, jqXHR) {         // 通信成功
        // メール送信結果を表示: errorAlert 詳細割愛
        if (text.match(/^OK/)) {
            errorAlert("注文処理", "注文確認メールを送信いたしました。" + 
                       "二日酔書店よりご連絡をさしあげます。");
            // 送信成功時注文書籍情報 Cookie を削除: 詳細割愛
            deleteCookie();
        } else {
            errorAlert("注文処理エラー", "注文確認メールの送信に失敗しました。" +
                       "お手数ですが,電子メールにて二日酔書店までご連絡ください。");
        }
    }).error(function(jqXHR, textStatus, errorThrown) { // 通信失敗
        errorAlert("通信エラー", 
                   "status: " + textStatus + "\nthrown: " + errorThrown);
    }).complete(function(jqXHR, textStatus) {           // 通信完了
        window.status = "mailTransfer completed, textStatus: " + textStatus;
    });
}


mailTransfer Java クラス

JavaMail におけるメール処理は,SMTP 接続情報(ホスト名,ポート番号,認証情報等)を Properties インスタンスにセットし,送受信者メールアドレス,メールサブジェクト,メール本文テキストを Message インスタンスにセットし,SMTP セッションを通してメールを送信する,というのが基本である。

これら入力として必要なデータについて mailTransfer は,POST された書籍注文情報をサーブレットのリクエストパラメータから,書店送信者情報と SMTP 関連情報等をデプロイメントディスクリプタ(web.xml)の初期パラメータから,それぞれ,取り出す。

注文者宛に注文内容を確認する確認メールと,書店担当者宛の受注内容通知メールとの二種類を送信する。

メールの文字コードは UTF-8 としている。JavaMail の解説記事においては,「日本語メールの文字コードは ISO-2022-JP とする必要がある」なんて記しているのがあまりに多い(選択肢のひとつでしかないのに「必要」というのはいかがなものか)。これはメール転送文字コードとして伝統的な 7-bit char を想定するのが安全だったためである。昔は確かに 8 ビット目を落としてしまうメール転送ノードがあったのかも知れないが,いまではほとんど起こらないことを心配するよりも,JIS X 0208 文字セットにないがゆえの文字化けをこそ現実問題として考えるべきである。

上記処理の過程で,メールサブジェクト,メール本文テキストを作成するに,mailContents クラスに委ねている。

サーブレット・クラス mailTransfer のコードは以下のとおり。これはこのままきちんとコンパイルの通る Java コードである。

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.text.*;
import java.util.*;
import java.util.regex.*;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import org.apache.log4j.Logger; 
 
/**
 * <pre>
 *  mailTranser 二日酔書店 メール送信
 *  Copyright(c) 2014, isao yasuda, All Rights Reserved.
 * </pre>
 * @author 安田  功 (Isao YASUDA)
 * @version $Id: mailTransfer.java 25 2014-05-03 15:48:25Z isao $
*/
public class mailTransfer extends HttpServlet
{
    /** 注文番号 */
    private static int ordernum = 0;
    /** 送信元情報 */
    private static String from  = "eigyobu@futsukayoi.com";
    private static String pname = "二日酔書店営業部";
    /** SMTP情報 */
    private static String host;
    private static String port;
    private static String username;
    private static String password;
    /** 許容ドメイン */
    private static String authsite = "futsukayoi.com";
    /** ドメインチェック用正規表現パターン */
    private static Pattern dmptn;
    /** Log4j */
    private static Logger logger =
        Logger.getLogger(mailTransfer.class.getName());
 
    /** 初期化 */
    public void init() throws ServletException 
    {   
        // デプロイメントディスクリプタから初期パラメータを取得する.
        String p = null;
        p = getInitParameter("serialnumber");
        if (p != null) { ordernum = Integer.parseInt(p); p = null; }
        p = getInitParameter("mailfrom");
        if (p != null) { from = p;  p = null; }
        p = getInitParameter("mailpname");
        if (p != null) { pname = p; p = null; }
        p = getInitParameter("smtphost");
        if (p != null) { host = p;  p = null; }
        p = getInitParameter("smtpport");
        if (p != null) { port = p;  p = null; }
        p = getInitParameter("smtpusername");
        if (p != null) { username = p; p = null; }
        p = getInitParameter("smtppassword");
        if (p != null) { password = p; p = null; }
        p = getInitParameter("authsite");
        if (p != null) { authsite = p; }
        dmptn = Pattern.compile(authsite);
 
        // 開始メッセージ(Tomcat log)
        logger.info("Mail Transfer initialization." +
                    "\n- mail send address: " + from +
                    "\n- mail sender name:  " + pname +
                    "\n- SMTP host name:    " + host +
                    "\n- SMTP port number:  " + port +
                    "\n- SMTP username:     " + username +
                    "\n- authorized site:   " + authsite);
    }
 
    /** リクエスト処理 */
    public void doPost(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException
    {
        /** ブラウザ情報の取得 */
        String ip = req.getRemoteAddr();          // IP アドレス
        String ua = req.getHeader("User-Agent");  // User Agent
        String rf = req.getHeader("Referer");     // Referer
        logger.info("Client: " + ip + ", " + ua + ", " + rf);
 
        /** リファラドメインチェック */
        Matcher dm = dmptn.matcher(rf);
        if (dm.find()) {
            logger.info(ip + ", " + rf + " authorized.");
        } else {
            logger.info(ip + ", " + rf + " not authorized.");
            return; // 許可されていないページからの要求は無視
        }
 
        /** 注文番号IDの生成 */
        Date odate = new Date();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmm");
        StringBuffer oid = new StringBuffer(sdf.format(odate) + ":");
        DecimalFormat df = new DecimalFormat("000000");
        oid.append(df.format(ordernum++));
        String orderid = oid.toString();
 
        /** リクエストパラメータの取り出し */
        String to    = (String) req.getParameter("email");
        String name  = (String) req.getParameter("ordername");
        String pcode = (String) req.getParameter("postcode");
        String paddr = (String) req.getParameter("postaddr");
        String order = (String) req.getParameter("order");
        logger.info(ip + ", " + ua + ", " + rf + 
                    "; e-mail: " + to + 
                    "; order-id: " + orderid + 
                    "; order: " + order);
 
        /** メールプロパティのセット */
        Properties props = new Properties();
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.starttls.enable", "true");
        props.put("mail.smtp.host", host);
        props.put("mail.host", host);
        props.put("mail.from", from);
        props.put("mail.smtp.port", port);
 
        /** メール送信 */
        StringBuffer resp = new StringBuffer();
        Message message;
        Session session;
 
        /** 送信内容生成(テンプレートに変数を埋め込み) */
        mailContents mc = null;
        try {
            mc = new mailContents(
                orderid, from, to, name, pcode, paddr, order);
        } catch (Exception e) {
            logger.info("mailContents error: " + e.getMessage());
            e.printStackTrace();
        }
 
        /** 注文者宛確認メール */
        String mcsub   = mc.getConfirmSubject();
        String confirm = mc.getConfirmContents();
 
        session = Session.getInstance(props,
            new javax.mail.Authenticator() {
                protected PasswordAuthentication getPasswordAuthentication() {  
                    return new PasswordAuthentication(username, password);  
                }
            });
        try {
            // MimeMessage オブジェクト生成
            message = new MimeMessage(session);
            // Set From: header field of the header.
            message.setFrom(new InternetAddress(from, pname, "UTF-8"));
            // Set To: header field of the header.
            message.setRecipients(Message.RecipientType.TO,
                                  InternetAddress.parse(to));
            // Set Content-Type of the header.
            message.setHeader("Content-Type", "text/plain; charset=UTF-8");
            // Set Subject: header field
            message.setSubject(mcsub);
            // Now set the actual message
            message.setText(confirm);
            // Send message
            Transport.send(message);
            logger.info("Sent confirm message: " + orderid);
            resp.append("OK|");
        } catch (Exception e) {
            logger.info("Confirm mail transfer error: " + e.getMessage());
            e.printStackTrace();
            resp.append("ER|");
        }
 
        /** 書店宛受注通知メール */
        String mnsub  = mc.getNoticeSubject();
        String notice = mc.getNoticeContents();
 
        session = Session.getInstance(props,
            new javax.mail.Authenticator() {
                protected PasswordAuthentication getPasswordAuthentication() {  
                    return new PasswordAuthentication(username, password);  
                }
            });
        try {
            // MimeMessage オブジェクト生成
            message = new MimeMessage(session);
            // Set From: header field of the header.
            message.setFrom(new InternetAddress(from, pname, "UTF-8"));
            // Set To: header field of the header.
            message.setRecipients(Message.RecipientType.TO,
                                  InternetAddress.parse(from));
            // Set Content-Type of the header.
            message.setHeader("Content-Type", "text/plain; charset=UTF-8");
            // Set Subject: header field
            message.setSubject(mnsub);
            // Now set the actual message
            message.setText(notice);
            // Send message
            Transport.send(message);
            logger.info("Sent notice message: " + orderid);
            resp.append("OK");
        } catch (Exception e) {
            logger.info("Notice mail transfer error: " + e.getMessage());
            e.printStackTrace();
            resp.append("NG");
        }
 
        /** 処理結果をブラウザに返却 */
        res.setContentType("text/plain; charset=UTF-8");
        res.setHeader("Cache-Control", "no-cache");
        res.getWriter().write(resp.toString());
        logger.info(ip + " processing ended.");
 
    }
 
    /** 終了 */
    public void destroy() 
    {
        logger.info("mailTransfer Terminate. Last order number: " + ordernum);
    }
 
}

mailTransfer サーブレットのためのデプロイメントディスクリプタは以下のとおり。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE web-app
PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd">
<!-- -*- coding: utf-8; mode: xml; -*-
mailTransfer web.xml デプロイメントディスクリプタ
Copyright (c) 2014, isao yasuda, All Rigths Reserved.
$Id: web.xml 20 2014-05-02 20:12:14Z isao $
-->
 
<web-app>
  <!-- 説明 -->
  <display-name>二日酔書店書籍メール送信 mailTransfer</display-name>
  <description>二日酔書店ホームページ・注文メール</description>
 
  <!-- mailTransfer サーブレット -->
  <servlet>
    <servlet-name>mailTransfer</servlet-name>
    <servlet-class>mailTransfer</servlet-class>
 
    <!-- 初期設定パラメータ -->
    <!-- 注文番号初期シリアル設定値 -->
    <init-param>
      <param-name>serialnumber</param-name>
      <param-value>100</param-value>
    </init-param>
    <!-- メール送信者アドレス -->
    <init-param>
      <param-name>mailfrom</param-name>
      <param-value>tantou@futsukayoi.com</param-value>
    </init-param>
    <!-- メール送信者名 -->
    <init-param>
      <param-name>mailpname</param-name>
      <param-value>二日酔書店営業部</param-value>
    </init-param>
    <!-- SMTP ホスト名 -->
    <init-param>
      <param-name>smtphost</param-name>
      <param-value>smtp.futsukayoi.com</param-value>
    </init-param>
    <!-- SMTP ポート番号 -->
    <init-param>
      <param-name>smtpport</param-name>
      <param-value>587</param-value>
    </init-param>
    <!-- SMTP ユーザ名 -->
    <init-param>
      <param-name>smtpusername</param-name>
      <param-value>tantou@futsukayoi.com</param-value>
    </init-param>
    <!-- SMTP パスワード -->
    <init-param>
      <param-name>smtppassword</param-name>
      <param-value>p@33vv0rd</param-value>
    </init-param>
    <!-- 実行許可サイト -->
    <init-param>
      <param-name>authsite</param-name>
      <param-value>localhost|futsukayoi.com</param-value>
    </init-param>
 
    <load-on-startup>1</load-on-startup>
  </servlet>
 
  <!-- mailTransfer サーブレットのマッピング -->
  <servlet-mapping>
    <servlet-name>mailTransfer</servlet-name>
    <url-pattern>/mailtransfer</url-pattern>
  </servlet-mapping>
 
</web-app>

mailContents Java クラス

mailContents は Velocity テンプレートに対して,要求元から与えられた変数を埋め込んで,メールサブジェクト及び本文テキストを生成するクラスである。

Velocity エンジンを用いて,テンプレートの可変部に値を埋め込んで文書を生成する基本は,以下のとおりに整理できるだろう。

  1. VelocityContext インスタンスを生成する
  2. VelocityEngine を初期化する。Properties をセットすることで,エンジンの振る舞いを制御できる
  3. getTemplate(template) メソッドでテンプレートファイルを読み込む
  4. VelocityContextput("key", value) メソッドでテンプレートの変数に値をセットする
  5. Template クラスの merge(context, writer) メソッドを発行し,テンプレートに変数を埋め込んだ完成形文書をライタに出力する
  6. 文書テキストを取り出す

ここでは Velocity テンプレートの格納ディレクトリを Properties: file.resource.loader.path プロパティにセットして Velocity エンジンを初期化している。サーブレット環境では,クラスファイルと同じ場所に Velocity テンプレートファイルを設置してもカレントとして参照できないので注意すべきである。

import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.IOException;
import java.util.Properties;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonReader;
import javax.json.JsonValue;
 
/**
 * <pre>
 *  mailContents 二日酔書店 メールコンテンツ生成
 *  Copyright(c) 2014, isao yasuda, All Rights Reserved.
 * </pre>
 * @author 安田  功 (Isao YASUDA)
 * @version $Id: mailContents.java 26 2014-05-03 16:20:52Z isao $
*/
public class mailContents
{
    /** メール・サブジェクト固定部 */
    private static final String subconfirm = "二日酔書店ご注文の確認 "; 
    private static final String subnotice = "[注文通知] "; 
    /** テンプレートファイル内容格納クラス */
    private Template tconfirm = null;
    private Template tnotice  = null;
    /** テンプレート変換時のコンテクストクラス */
    private VelocityContext context = new VelocityContext();
    /** Velocityエンジンクラス */
    private VelocityEngine engine = new VelocityEngine();
    /** テンプレートファイル格納ディレクトリ */
    private static final String vmdir = "/usr/local/etc/futsukayoi";
    /** 確認用テンプレートファイル */
    private static final String confirm_tmpl = "mail_confirm.vm";
    /** 通知用テンプレートファイル */
    private static final String notice_tmpl  = "mail_notice.vm";
    /** 注文番号 */
    private String orderid;
 
    /** Constructor */
    public mailContents(String orderid,  // 注文番号
                        String from,     // メール送信元アドレス
                        String to,       // メール送信先アドレス
                        String name,     // 注文者名
                        String pcode,    // 注文者郵便番号
                        String paddr,    // 注文者住所
                        String order)    // 注文書籍情報 (JSON形式)
        throws IOException, Exception {
 
        /** 注文情報セット: JSON形式書籍情報を明細テキストに整形 */
        this.orderid = orderid;
        JsonReader rdr = Json.createReader(new StringReader(order));
        JsonArray books = rdr.readArray();
        rdr.close();
        StringBuffer orders = new StringBuffer();
        int sum = 0;
        for (int i = 0 ; i < books.size(); i++) {
            JsonObject jobj = books.getJsonObject(i);
            orders.append(jobj.getString("title") + "/" + 
                          jobj.getString("author") + "/");
            int wpr = jobj.getInt("price");
            int wit = jobj.getInt("item");
            sum = sum + (wpr * wit);
            orders.append(String.valueOf(wpr) + "/" + 
                          String.valueOf(wit) + "\n");
        }
 
        /** Velocity engine 初期化(テンプレート所在,ログ無効化) */
        Properties p = new Properties();
        p.setProperty("file.resource.loader.path", vmdir);
        p.setProperty(VelocityEngine.RUNTIME_LOG_LOGSYSTEM_CLASS,
                      "org.apache.velocity.runtime.log.NullLogSystem");
        engine.init(p);
 
        /** テンプレート取得 */
        tconfirm = engine.getTemplate(confirm_tmpl, "UTF-8");
        tnotice  = engine.getTemplate(notice_tmpl,  "UTF-8");
 
        /** テンプレートの変数に値をセット */
        context.put("orderid", orderid);
        context.put("from",    from);
        context.put("to",      to);
        context.put("name",    name);
        context.put("pcode",   pcode);
        context.put("paddr",   paddr);
        context.put("orders",  orders.toString());
        context.put("sum",     sum);
    }
 
    /** 注文者宛確認メール・サブジェクト取得  */
    public String getConfirmSubject() {
        return subconfirm + orderid;
    }
 
    /** 書店宛通知メール・サブジェクト取得  */
    public String getNoticeSubject() {
        return subnotice + orderid;
    }
 
    /** 注文者宛確認メール・コンテンツ取得  */
    public String getConfirmContents() {
        StringWriter sw = new StringWriter();
        tconfirm.merge(context, sw);
        return sw.toString();
    }
 
    /** 書店宛通知メール・コンテンツ取得  */
    public String getNoticeContents() {
        StringWriter sw = new StringWriter();
        tnotice.merge(context, sw);
        return sw.toString();
    }
 
}

Velocity テンプレート

mailContents クラスで扱う二種類の Velocity テンプレートを以下に掲載しておく。

## -*- coding: utf-8; mode: text; -*-
## 二日酔書店 注文確認メール テンプレート
## $Id: mail_confirm.vm 21 2014-05-02 20:21:13Z isao $
 
$name 様
 
このたびは二日酔書店刊行書籍をご注文いただきありがとうございます。
 
以下の内容にて注文を承りました。
二日酔書店よりお手続き方法につきまして改めてご連絡を差しあげます。
なおお手続きの過程で品切れとなっている場合がありますことをご了承
ください。
 
【注文番号】: $orderid
 
【書籍情報】
-----------------------------------------------------
注文書籍/著者/単価/員数
-----------------------------------------------------
$orders
-----------------------------------------------------
         合 計                          ¥ $sum (※)
                          ※別途送料を申し受けいたします。
 
【送付先】:
郵便番号 $pcode
$paddr
 
このメールはホームページからのご案内であり,返信しないでください。
=====================================================
二日酔書店 http://www.futsukayoi.com/
〒101-XXXX 東京都千代田区猿楽町X-X-X 
電話: 03-3XXX-XXXX (営業部直通)
E-mail: $from
=====================================================

$xxx が可変部の変数であり,context.put() メソッドでこれに値をセットする。context.put("name", "山田太郎"); とすると,$name を記述したところが「山田太郎」に置き換わる。行頭の ## はコメント行であることを示す。

## -*- coding: utf-8; mode: text; -*-
## 二日酔書店 注文通知メール テンプレート
## $Id: mail_notice.vm 21 2014-05-02 20:21:13Z isao $
 
$name 様より,以下の内容にて注文を承りました。
直ちに在庫確認の上,$to 宛に取引連絡をお願いします。
 
【お名前】: $name
 
【メールアドレス】: $to 
 
【注文番号】: $orderid
 
【注文書籍情報】
-----------------------------------------------------
注文書籍/著者/単価/員数
-----------------------------------------------------
$orders
-----------------------------------------------------
         合 計                          ¥ $sum (※)
 
【送付先】:
郵便番号 $pcode
$paddr

今回の例では,単一の VelocityContext, VelocityEngine インスタンスを複数(注文確認メール用及び受注通知メール用)のテンプレートに関係付けている。テンプレートと一対一である必要はない。

参考文献

JavaMail API については,JavaMail API Documentation を参照。設計に際して読むべきガイドとしては JavaMail API Design Specification Version 1.4 をお勧めする。

Velocity のもっとも信頼できる文献はアーカイブに添付されたドキュメントである。User Guide が添付されている。バージョンは旧いが Apache Velocity User Guide 日本語訳も公開されている。

書籍としては以下。とくにオライリーの『Java ネットワークプログラミング』は,JavaMail に留まらない深い知見が得られるはずである。

現場で使えるJavaライブラリ
竹添直樹, 島本多可子, 小津美夕紀, 亀井隆司
翔泳社
Javaネットワークプログラミング 第2版
エリオット・ラスティ・ハロルド
オライリー・ジャパン