MailFlute

DBFluteプロジェクトが提供するライブラリ MailFlute のページ。

MailFluteとは?

MailFlute は、メールテンプレート機能を備えたシンプルなメール送信ライブラリです。

DBFlute の FreeGen と組み合わせて利用することで、タイプセーフでメールパラメーターを指定できる、堅いツールになります。 DBFluteライクなメール送信ライブラリと言えるでしょう。

1. メールテンプレートを書く
パラメータコメント形式でテンプレートを作成、拡張子は.dfmail
2. FreeGenを叩く
FreeGenがメールテンプレートを読み込んで、クラスを自動生成
3. タイプセーフ実装
自動生成されたクラスを使って、タイプセーフにメール送信の実装

MailFluteのアーキテクチャ

MailFlute Architecture

LastaFluteでの利用例

LastaFluteとの組み合わせが、MailFlute利用の典型例となります。(ドキュメントも、それを前提に)

まず、src/main/resources/mail配下に、dfmailのメールテンプレートを書きます。 外だしSQLに非常によく似たパラメータコメント形式 (pmfile形式) で書きます。

e.g. 新しい会員の登録を想定したメールテンプレート @welcome_member.dfmail
/*
 [New Member's Registration]
 The member will be formalized after click.
*/
subject: Welcome to your sign up, /*pmb.memberName*/
>>>
Hello, /*pmb.memberName*/

How are you?
/*IF pmb.birthdate != null*/
Wonderful birthdate! /*pmb.birthdate*/
/*END*/

Delivery Address: /*pmb.address:orElse('none')*/

Thanks

そして、FreeGenを叩き...

e.g. Manageで 12 (freegen) を叩く @Command
...$ sh manage.sh 12

mylasta.mail配下に自動生成されたクラスで実装します。 クラス名は、テンプレートファイルをキャメルケースにしたものになります。(welcome_member.dfmail なら WelcomeMemberPostcard)

e.g. 自動生成されたクラスで実装 @Java
WelcomeMemberPostcard.droppedInto(postbox, postcard -> {
    postcard.setFrom("from@example.com", LABELS_OFFICE_MAIL);
    postcard.addTo("to@example.com");
    postcard.setMemberName("sea");
    postcard.setBirthdate(birthdate);
    postcard.addReplyTo("replyto@example.com");
});

メールテンプレート

拡張子は .dfmail で、パラメーターコメント形式で書きます。

Body Meta

コメントや subject など、メールBodyのメタ情報 (Body Meta) を >>> 区切りで定義します。

*HeaderComment
メールに関する開発用コメント、タイトルと説明がある
*Title
メールを一行で要約する開発用コメント、[...]形式で書く
*Description
メールを自由に説明する開発用コメント、Titleの下に書く
*subject:
メールの件名、文言の中で動的パラメーターが利用できる
option:
メールのオプション、HTMLメールも飛ばすなら option: +html
-- !![type] [name]!!
自動判別できないプロパティを明示的宣言をして自動生成
e.g. Body Meta の属性が勢ぞろい @welcome_member.dfmail
/*
 [New Member's Registration]
 The member will be formalized after click.
*/
subject: Welcome to your sign up, /*pmb.memberName*/
option: +html
-- !!List<Integer> seaList!!
>>>
Hello, /*pmb.memberName*/

...

ヘッダーコメント (タイトル、説明)

Title や Description は必須です。メールテンプレートには必ずタイトルと説明を入れます。 形式も決まっています。必ず /* [タイトル] 説明 */ という形式でないと、FreeGenも実行時もエラーで落ちます。

e.g. ヘッダーコメントの形式 @dfmail
/*
 [タイトル]
 説明
*/
subject: ...
...

タイトルと説明は、あくまで開発用コメントで、実装に送信されるメールの件名(subject)とは別です。

subject: (メールの件名)

さて、こちらは実際にメールされて受信者が目にするメールの件名です。

件名の中でも、パラメータコメントを使うことができます。

e.g. 件名の中で、パラメータコメント @dfmail
/*
 [タイトル]
 説明
*/
subject: Hello, /*pmb.memberName*/. How are you?
...

option: (メールのオプション)

option: +html
HTMLメールも飛ばすときに、Plainテキスト側のテンプレートの option: に指定します。
もし、Plainテキストのテンプレートファイルの welcome_member.dfmail だとしたら、HTMLメールのテンプレートファイルは、同じディレクトリに welcome_member_html.dfmail となります。
HTMLメールのテンプレートファイルには、ヘッダーを付与できません。(一行目から本文を書きます)
e.g. HTMLメールのテンプレートファイルの置き場所 @Directory
src/main/resources
 |-mail
 |  |-member
 |  |  |-welcome_member_html.dfmail // HTML text
 |  |  |-welcome_member.dfmail      // plain text
...

自動判別できないプロパティの宣言

外だしSQLのSql2Entityと同じような感じで、自動判別できないプロパティが存在するので、その場合は明示的に宣言をします。

e.g. 自動判別できないプロパティの宣言 @dfmail
...
subject: Welcome to your sign up, /*pmb.memberName*/
-- !!List<Integer> seaList!!
>>>
Hello, /*pmb.memberName*/
...

FORコメントで使うプロパティが代表的な例です。

パラメータコメント (外だしSQLライク)

DBFluteの外だしSQLと同じようなパラメータコメントが使えます。(同じようなというか、DBFluteのクラスをそのまま使っています)

SQLとは用途が違うので、若干使い方が変わります。

  • バインド変数コメントのテスト値は不要 e.g. /*pmb.sea*/
  • 埋め込み変数コメントとバインド変数コメントに違いはない (埋め込みは使わなくてOK)
  • SQLに特化した機能は使うことはない (LikeSearchとか)
  • 空文字は空文字のままキープされる (外だしSQLのParameterBeanは空文字をnull扱いしている)

orElseオプション

さらに、MailFluteでは、バインド変数コメントで値が null だったときのデフォルト値を入れることができるようになっています。 プロパティのオプションで orElse('xxx') を利用します。

e.g. 値が null だったといのデフォルト値、memberNameがなければ Unknown と表示 @dfmail
...
Hello, /*pmb.memberName:orElse('Unknown')*/

メール上で表データを表現

メールで表データのようなものを表示したい場合は、Beanを回すFORループを使います。

  • 1. テンプレートに List<XxxBean> の宣言をする
  • 2. XxxBean を Postcard と同じパッケージに作る
  • 3. テンプレートで、FORコメントでXxxBeanをループさせる

※特に Bean をという名前である必要はありません。

例えば、(src/main/resources/mail/sea配下に) sea.dfmail というメールテンプレートで、SeaRowBean という表の一行を表すBeanクラスを使うとしたら...

1. テンプレートに List<XxxBean> の宣言をする

List<SeaRowBean> 型で、リストのプロパティを宣言します。(そして、FreeGen)

e.g. SeaRowBean のリストを引数のプロパティとして宣言 @sea.dfmail
/*
 [Welcome to Sea]
 to sea's guest
*/
subject: ...
-- !!List<SeaRowBean> seaList!!
>>>
Hello,
...

2. SeaRowBean を Postcard と同じパッケージに作る

SeaRowBean を SeaPostcard の隣に作ります。(postcard が bean を import なしで参照するため)

e.g. メールで使うBeanクラスの置き場所 @Directory
src/main/java
 |-org.docksidestage
 |  |-mylasta
 |  |  |-mail
 |  |  |  |-sea
 |  |  |  |  |-SeaPostcard.java // 自動生成されたPostcard
 |  |  |  |  |-SeaRowBean.java  // 自分で作ったBeanクラス
...

※メールの中で使うBeanは、publicフィールドではなく getter/setter でプロパティ定義します。 (DBFluteのロジックを使っているので、DBFluteのポリシーになっています)

e.g. メールで使うBeanクラスはgetter/setter @Java
...
public String getShowName() {
    return showName;
}
public String getStage() {
    return stage;
}

3. テンプレートで、FORコメントでXxxBeanをループさせる

FORコメントで seaList をループさせて、それぞれのプロパティを利用します。

e.g. FORコメントでBeanをループ、ここでは単なるカンマ区切りで @sea.dfmail
Hello,

/*FOR pmb.seaList*/
/*#current.showName*/, /*#current.stage*/
/*END*/
...

非同期でメール送信

非同期メールのおおまかな概要

MailFluteには、非同期でメール送信するオプションが用意されています。 そのオプションが指定されると、メールの送信処理だけが非同期になります。 (厳密には、テンプレート読み込みと、メッセージ文字列の作成などの準備処理は同期的で、最後の送信処理だけ非同期)

MailFlute自体は、非同期の処理自体は持っていないので、必ず利用しているフレームワークが提供します。 よって、MailFluteのオプションを利用しているフレームワークがどのように利用するかで実装や挙動が変わります。 例えば、LastaFluteとの組み合わせであれば、LastaFlute の AsyncManager が、メールの非同期の実装となっています。 (LastaFlute の Example で、すでにその連携のための実装がされています)

ここでは、LastaFluteとの組み合わせのときの例で説明します。

明示的に非同期?デフォルトで非同期?

非同期戦略は、大きく二つに分かれます。

明示的に非同期
メール送信する側で明示的に非同期を指定する
デフォルトで非同期
仕組みで暗黙的に非同期を指定して、メール送信する側では意識しない

すべてのメールが非同期で送信でOKというのであれば、デフォルトで非同期にしてしまいましょう。

LastaFlute の Example (harbor, maihamaなど) では、最初からデフォルトで非同期になっています。 なので、LastaFlute の Example でスタートアップしたときは、asyncのオプションのことを気にする必要性がありません。

LastaFluteで明示的に非同期メール

非同期でメールを送信するオプションが用意されています。例えば、LastaFluteでは Postcard に async() というメソッドがあり、それを呼ぶと非同期になります。 (ただ、LastaFlute の Example では、デフォルトで非同期になっているので、その仕組みを外してなければ、このメソッドを呼び出す必要はありません)

e.g. LastaFluteでのPostcardにおける async() の実装 @Java
WelcomeMemberPostcard.droppedInto(postbox, postcard -> {
    postcard.setFrom("from@example.com", LABELS_OFFICE_MAIL);
    postcard.addTo("to@example.com");
    postcard.setMemberName("sea");
    postcard.setBirthdate(birthdate);
    postcard.async();
});

LastaFluteでデフォルトで非同期メール

LastaFlute の Example ではこちらになっています。もう、メール送信側のプログラムでは何も気にせずメール送信しても、非同期で送信されます。

ただし、メール送信に失敗しても例外は非同期スレッドの中で処理されますから、呼び出し側のトランザクションをロールバックすることはできません。 (その必要性がないことが前提です。そして、多くの場合において、その必要性がないことを想定しています)

UTFlute で UnitTest

UTFlute (for LastaFlute) を使っていれば、メール送信のテストがしやすくなります。

reserveMailAssertion() でアサート予約

Actの実行をする前に、reserveMailAssertion() でアサート予約をしておきます。 すると、UnitTestが終わる直前に予約したアサートが実行されます。メール本文に対するチェックが可能です。

e.g. UnitTestでメール送信のアサート @Java
// ## Arrange ##
SeaAction action = new SeaAction(); // メールを飛ばしているAction
inject(action);
reserveMailAssertion(mailData -> {
    // SeaPostcardのメールが飛んでるはず、そして、メール本文にseaが含まれているはず
    mailData.required(SeaPostcard.class).forEach(message -> {
        message.assertPlainTextContains("sea");
    });
});

// ## Act ##
JsonResponse response = action.index(); // ここでメールを飛ばしているはず

// ## Assert ##
// (もうここでは、メールに関するアサートは特になし)
...

メール送信が非同期(Async)になっていても利用できます。 (asyncされるのはメールの送信処理だけであって、アプリで送信を行ったこと自体は記録され、メール本文も保持されているので問題なく利用できる)

mailData.required(...class) にて、指定した Postcard のメールが飛んでいることをアサートしています。 そして、そのメールで送信したメッセージが送信回数分が戻り値として取得できます。

message.assertPlainTextContains("...") にて、メール本文に対するアサートができます。 様々なメソッドが用意されているので、業務的なアサートを適度にやっていくと良いでしょう。

required...()
存在することをチェックしながら、その値を戻り値で取得
assert...Contains()
そのテキストに、指定された文字列が存在するはず

メールをどのくらいアサートするか?

やり始めたらキリがないものですが、少なくとも "適切な状態で送られたかどうか?" そして "重要な動的項目が想定通りメール本文にあるか?" などをチェックすると良いでしょう。

バインド変数コメントは値がnullならエラーになり、IFコメントも文法エラーで実行時に厳しくチェックされ、メール送信処理が行われたことを保証するだけでもチェックの効果は高いです。 メールは変なのが飛んでもしまうと業務上の後始末が大変なので、UnitTestに割ける時間が少なくても、最低限のチェックだけでもしておきたいものです。

コードリーディングのヒント

郵便局のストーリーを思い浮かべて

MailFluteでは、コードレベルでの拡張ポイントが多く用意されています。 コードの概要を知っておくと拡張がしやすくなるでしょう。

MailFluteは、郵便局をテーマにクラスデザインがされています。

Postcard
はがき: 一通メールの表現
PostOffice
郵便局: メール送信の手続きをもろもろ行う
DeliveryDepartment
配達部署: 駐車場(接続Provider)と人事(諸々Factory)を保持
PostalPersonnel
人事の人: 諸々のクラスのFactory
Receptionist
受付の人: メールテンプレートをファイルから読み込んだり
Proofreader
文章校正の人: メールのパラメーターコメントを解釈して書き換える
PostalParkingLot
駐車場: 郵便バイク(SMTPサーバーへの接続)を複数保持
PostalMotorbike
郵便バイク: 一つのSMTPサーバーへの接続
Postie
郵便配達員: メールを実際に送信する
  1. はがきが、郵便局に送られてきて...
  2. 配達部署の人事が、受付と文章校正の人を連れてきて...
  3. 受付が、受付業務(テンプレートの読み込みなど)を行い...
  4. 文章校正が、校正業務(パラメーターコメントの解釈など)を行い...
  5. 配達部署の駐車場(複数の接続保持)にある郵便バイク(一つの接続)を用意して...
  6. 配達部署の人事が、そのバイクに合う郵便配達員を連れてきて...
  7. 郵便配達員にはがきを配達させる

無理矢理ですがやってしまいました。(実際の郵便局で文章校正されたらたまげるでしょう)

ちなみに、LastaFluteでは、Postbox (郵便ポスト) というクラスも付け加えられています。 郵便ポストから郵便局にはがきが送られるようになっています。(ディベロッパーは、PostboxにPostcardを投函します)

郵便局の配達の流れ

このようになっています。当てはめながら読んでみてください。

e.g. PostOffice@deliver() @Java
protected final SMailDeliveryDepartment deliveryDepartment;

public PostOffice(SMailDeliveryDepartment deliveryDepartment) {
    if (deliveryDepartment == null) {
        throw new IllegalArgumentException("The argument 'deliveryDepartment' should not be null.");
    }
    this.deliveryDepartment = deliveryDepartment;
}

public void deliver(Postcard postcard) {
    postcard.officeCheck();

    final SMailReceptionist receptionist = fetchReceptionist(postcard);
    receptionist.accept(postcard); // make body text (may be from body file)

    proofreadIfNeeds(postcard); // make complete text

    final SMailPostalMotorbike motorbike = fetchMotorbike(postcard);
    final SMailPostie postie = fetchPostie(postcard, motorbike);
    postie.deliver(postcard);
}

郵便局の大元、DeliveryDepartment

PostOffice (郵便局) のコンストラクタでは、必ず DeliveryDepartment (配達部署) を受け取るようにしています。つまり、"配達部署" が郵便局の仕事を決定付ける大きな役割を持っています。

そして、DeliveryDepartment (配達部署) のコンストラクタでは、必ず PostalParkingLot (駐車場) と PostalPersonnel (人事) を受け取るようにしています。

e.g. SMailDeliveryDepartment constructor @Java
public SMailDeliveryDepartment(SMailPostalParkingLot parkingLot, SMailPostalPersonnel personnel) {
    assertArgumentNotNull("parkingLot", parkingLot);
    assertArgumentNotNull("personnel", personnel);
    this.parkingLot = parkingLot;
    this.personnel = personnel;
}

PostalParkingLot (駐車場) は、SMTPへの接続を司る PostalMotorbike (郵便バイク) を保持していますし、PostalPersonnel (人事) は、受付や文章校正に郵便配達員を決定しています。

つまり、どのような PostalParkingLot (駐車場) と PostalPersonnel (人事) を用意するか次第で、PostOffice (郵便局) の挙動をいかようにでも変更することができるのです。

例えば、LastaFluteでは、この DeliveryDepartment (配達部署) を生成する Creator をアプリで用意して、それぞれのアプリにフィットした郵便局を構築できるようにしています。

配達業務の支配者、PostalPersonnel

PostalPersonnel (人事) は、配達業務を司る人たちを決定します。つまり、どのように配達を行うのか?自由自在です。

特に、PostalPersonnel (人事) インターフェースの実装クラスである、DogmaticPostalPersonnel (独善的な人事) には、様々な業務変更ポイントが用意されています。

e.g. SMailDogmaticPostalPersonnel constructor @Java
public SMailDogmaticPostalPersonnel() {
    receptionist = createReceptionist();
    proofreader = createProofreader();
    cancelFilter = createCancelFilter();
    addressFilter = createAddressFilter();
    subjectFilter = createSubjectFilter();
    bodyTextFilter = createBodyTextFilter();
    asyncStrategy = createAsyncStrategy();
    retryStrategy = createRetryStrategy();
    labelStrategy = createLabelStrategy();
    loggingStrategy = createLoggingStrategy();
    mailHeaderStrategy = createMailHeaderStrategy();
    internetAddressCreator = createInternetAddressCreator();
}

createメソッドをオーバーライドすることで、受付や文章校正の拡張も去ることながら、様々な Filter や Strategy などを拡張することができます。

実際に、LastaFlute の DeliveryDepartment (配達部署) の Creator ([App]MailDeliveryDepartmentCreator) では、DogmaticPostalPersonnel (独善的な人事) の生成時に、createSubjectFilter() や createAsyncStrategy() などがオーバーライドされ、LastaFluteとしての振舞いを実現しています。 (テスト時は件名に[Test]が付与されたり、非同期実行ができるようにしたりなど)