AWS SDK for Javaを使ってCcのみのメールをAmazon SESで送信した場合の挙動の違いを調査

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。サービスグループの武田です。

今回はJavaからAmazon SES経由でメールを送信した際、想定と違う挙動をした部分があったので調査しました。その調査レポートとなります。

今回の背景

JavaのアプリケーションからSES経由で Ccのみを設定したメール を送信すると、なぜかToにもそのアドレスが自動で入ってしまうという現象に遭遇しました。「あれ、メールってCcのみで送信できないんだっけ?それともSESの仕様?」ということですぐには原因がわからなかったため、気合い入れて調査することにしました。

環境

今回の検証環境です。

  • OS
    • macOS High Sierra
  • AWS CLI
    • aws-cli/1.14.0 Python/3.6.3 Darwin/17.5.0 botocore/1.8.4
  • IDE
    • IntelliJ IDEA 2018.1 (Ultimate Edition)
  • Java
    • 9
  • Spring Boot
    • 2.0.1
  • AWS SDK for Java
    • 1.11.251

またAWS用にdefaultプロファイルが作成されており、アクセストークンなどは設定済みであるとします。

メールの仕様を確認

まずは 自身の想定 が本当に正しいのか、メールの仕様を確認します。

メールを送信する場合、宛先フィールドとしてToCcBccの3つが使用できます。そのほかにも多数のフィールドがありますが、必須とされているのはごく少数です。

インターネットメッセージのフォーマットを定めているRFC2822から引用します。

The only required header fields are the origination date field and the originator address field(s). All other header fields are syntactically optional.

必須のフィールドは送信日付と送信者で、ほかはオプションです。もちろん宛先がないメールは送信できませんので、実際にはToCcBccのいずれかが入っている必要はあります。

というわけで、メールのToは必須でない です。言い換えれば、Ccのみでもメールは送信できるはずです。ここテストに出ますよ。

(一斉メール送信をしたいんだけど宛先を知られたくない場合、Bccに全員分の宛先を入れて送信する、なんてことをしてる方もいるのではないでしょうか)

SESの仕様を確認

次にSESの仕様として、Ccのみのメールを受信したら自動的にToに入れてしまうことを疑いました。これは実際にSESを使ってメール送信を試してみればいいですね。

まずはSESを利用してメール送信できるようにセットアップします。初期設定については優良なエントリが多数ありますのでここでは省略します。なおリージョンはバージニア北部(us-east-1)で設定しました。

Amazon SESによるメール送信環境の構築と実践

ちなみにマネジメントコンソールのSend a Test Emailからテストメールが送信できますが、このダイアログではToが必須になっています。後で分かりますが、これはマネジメントコンソールの仕様です。

それではAWS CLIを使ってメールを送信してみます。今回は次のようなコマンドを実行しました。xxx@example.comは実際にはちゃんとしたメールアドレスです。

aws ses send-email --region us-east-1 --from xxx@example.com --cc xxx@example.com --subject 'cc only test mail' --text 'test message.'

コマンドが成功したらメールを確認してみます(この時点で失敗する場合は何らかの設定が不足していると思われます)。

Toが空のメールが受信できています。つまりSESは勝手にToを付与しませんし、Toのないメールも送信できることがわかりました。

AWS SDK for Javaの探索

これで切り分けができました。SESは余計なことをしていませんので、原因はそれより手前、つまりメール送信するJavaのコードということになります。調べてみるとメールを送信する実装は複数あるようですので、それぞれ個別に検証を進めました。今回は次の3パターンです。

  1. AWS SDK for Javaが提供しているJavaMailSenderの実装を利用する
  2. AmazonSimpleEmailServiceを利用する
  3. JavaMailSenderを拡張し、AWSJavaMailTransportを利用する

まずはSpring Bootのプロジェクトを作成します。IntelliJのSpring Initializrウィザードを利用します。

プロジェクト名はsestestとしました。プロジェクトタイプはGradleです。

依存ライブラリはMailAWS Coreを選択しますが、実際にはこれでは足りないので後から追加します。

プロジェクトが作成されたらbuild.gradleに必要なライブラリを追加します。SESはCoreに含まれていないため個別に追加します。またJAXBはパターン3を動作させるために必要です。

dependencies {
    compile('org.springframework.boot:spring-boot-starter-mail')
    compile('org.springframework.cloud:spring-cloud-starter-aws')
    compile('com.amazonaws:aws-java-sdk-ses')
    compile('javax.xml.bind:jaxb-api')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

Javaの実行クラスは3パターン共通として次のものを使用します。runメソッドの中を変えながら動作確認していきます。

package com.example.sestest;

// importは省略

@SpringBootApplication
public class SestestApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(SestestApplication.class, args);
    }

    private static final String CC1 = "xxx+cc1@example.com";
    private static final String CC2 = "xxx+cc2@example.com";
    private static final String FROM = "xxx@example.com";
    private static final String SUBJECT = "cc only test mail";
    private static final String TEXT = "test message.";

    @Override
    public void run(String... args) throws Exception {

    }

}

パターン1:AWS SDK for Javaが提供しているJavaMailSenderの実装を利用する

それではさっそく見ていきましょう。パターン1の実装コードは次のようになります。

    @Autowired
    private JavaMailSender javaMailSender;

    @Override
    public void run(String... args) throws Exception {
        // パターン1
        SimpleMailMessage mailMessage1 = new SimpleMailMessage();
        mailMessage1.setCc(CC1, CC2);
        mailMessage1.setFrom(FROM);
        mailMessage1.setSubject(SUBJECT);
        mailMessage1.setText(TEXT);

        javaMailSender.send(mailMessage1);
    }

実行してみます。

java.lang.IllegalStateException: Failed to execute CommandLineRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:800) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:781) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:335) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1255) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1243) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    at com.example.sestest.SestestApplication.main(SestestApplication.java:29) [main/:na]
Caused by: java.lang.NullPointerException: null
    at com.amazonaws.services.simpleemail.model.Destination.withToAddresses(Destination.java:126) ~[aws-java-sdk-ses-1.11.251.jar:na]
    at org.springframework.cloud.aws.mail.simplemail.SimpleEmailServiceMailSender.prepareMessage(SimpleEmailServiceMailSender.java:93) ~[spring-cloud-aws-context-2.0.0.RC1.jar:2.0.0.RC1]
    at org.springframework.cloud.aws.mail.simplemail.SimpleEmailServiceMailSender.send(SimpleEmailServiceMailSender.java:66) ~[spring-cloud-aws-context-2.0.0.RC1.jar:2.0.0.RC1]
    at org.springframework.cloud.aws.mail.simplemail.SimpleEmailServiceMailSender.send(SimpleEmailServiceMailSender.java:55) ~[spring-cloud-aws-context-2.0.0.RC1.jar:2.0.0.RC1]
    at com.example.sestest.SestestApplication.run(SestestApplication.java:53) [main/:na]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:797) [spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]
    ... 5 common frames omitted

NullPointerExceptionで落ちてますね。どうやらパターン1ではCcのみでメールが送れないようです。もう少し原因を追求してみます。

javaMailSenderにDIされているのはSimpleEmailServiceJavaMailSenderのインスタンスです。これがどこで作られているかというとMailSenderAutoConfiguration#javaMailSender(AmazonSimpleEmailService)です。

実際の挙動ですが、スタックトレースを見てみるとSimpleEmailServiceJavaMailSenderの親クラスのメソッドSimpleEmailServiceMailSender#prepareMessage(SimpleMailMessage)の中で呼び出している、Destination#withToAddresses(String...)のL126で例外が発生しています。

引数のtoAddressesをnullチェックしていないことでNullPointerExceptionが発生しているわけですね(仕様かバグかは不明)。

結論として、パターン1ではCcのみのメールは送信できませんでした。

パターン2:AmazonSimpleEmailServiceを利用する

続いてパターン2はラップされてないサービスを直接使います。実装コードは次のようになります。

    @Override
    public void run(String... args) throws Exception {
        // パターン2
        AmazonSimpleEmailService client = AmazonSimpleEmailServiceClientBuilder.standard()
                .withRegion(Regions.US_EAST_1).build();
        SendEmailRequest request = new SendEmailRequest()
                .withDestination(new Destination()
                        .withCcAddresses(CC1, CC2))
                .withMessage(new Message()
                        .withSubject(new Content()
                                .withCharset("UTF-8")
                                .withData(SUBJECT))
                        .withBody(new Body()
                                .withText(new Content()
                                        .withCharset("UTF-8")
                                        .withData(TEXT))))
                .withSource(FROM);
        client.sendEmail(request);
    }

実行してみます。

今度は問題なく、Toが空のメールを受信しました。

パターン2ではCcのみのメールを送信できました。

パターン3:JavaMailSenderを拡張し、AWSJavaMailTransportを利用する

パターン1とパターン2は、エントリの始めに書いた「今回の背景」とは異なる挙動となりました。察しのよい読者はお気付きでしょうが、パターン3が今回の本丸となります。

Springが提供しているJavaMailSenderImplは、JavaMailSenderのデフォルト実装であるとともに、継承して#getTransport(Session)メソッドをオーバーライドすることで、低レイヤの切り替えができるようになっています。

実装コードは次のようになります。

    @Override
    public void run(String... args) throws Exception {
        // パターン3
        SesMailSender sesMailSender = new SesMailSender();

        SimpleMailMessage mailMessage3 = new SimpleMailMessage();
        mailMessage3.setCc(CC1, CC2);
        mailMessage3.setFrom(FROM);
        mailMessage3.setSubject(SUBJECT);
        mailMessage3.setText(TEXT);

        sesMailSender.send(mailMessage3);
    }

    private class SesMailSender extends JavaMailSenderImpl {
        @Override
        protected Transport getTransport(Session session) throws NoSuchProviderException {
            return new AWSJavaMailTransport(session, null);
        }
    }

実行してみるとエラーはありません。メールを確認してみます。

Toに、Ccに指定したメールアドレスのうちのひとつが自動で入っています。これが遭遇した現象です!それではこれも原因を追ってみましょう。少し長いので箇条書きにします。

  1. SesMailSender#send(SimpleMailMessage)呼び出し
  2. 実際に実行されるのはJavaMailSenderImpl#send(SimpleMailMessage)
  3. 処理はJavaMailSenderImpl#send(SimpleMailMessage...)に委譲
  4. 型を変換してL321でJavaMailSenderImpl#doSend(MimeMessage[], Object[])呼び出し
  5. L435でJavaMailSenderImpl#connectTransport()を呼び出してTransportを取得。この中でオーバーライドしたgetTransport(Session)が呼び出されている
  6. L462でAWSJavaMailTransport#sendMessage(Message, Address[])呼び出し
  7. L96でAWSJavaMailTransport#collateRecipients(Message, Address[])呼び出し

さて長いこと追ってきましたが、最後のcollateRecipientsメソッドはメール受信者の照合を行っているメソッドで、アドレス重複の削除などをしています。そしてこのメソッドの最後にはコメントが……。

Simple E-mail needs at least one TO address, so add one if there isn't one

な、なるほど?どうやらToが空だった場合には、意図的にCcBccからアドレスをひとつ取り出してToに設定しているようです。しかし冒頭でSESの仕様を確認したときはToなしでも大丈夫でしたが、どこかで仕様変更されたのでしょうか(有識者求む)。

ちなみにこの部分のコードはSESをサポートした初期のコード(7年前!)からのようです。

Version 1.1.4 of the AWS Java SDK · aws/aws-sdk-java@90bc55e

まとめ

「なぜか自動的にToにアドレスが入ってしまう」という現象から、原因の切り分け、追求をしていきました。具体的な解決策は別にして、いったん原因もわかったのでひと段落できました。

実際にコードを書いて原因の切り分けをして……という作業はたいへんですが楽しいですね。

さて4月もおしまいですね。だんだん暑い日も多くなってきましたが、まずはゴールデンウィークを満喫しましょう!

以上、調査レポートでした。