AWS SDK for Javaを使ってCcのみのメールをAmazon SESで送信した場合の挙動の違いを調査
こんにちは。サービスグループの武田です。
今回は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
プロファイルが作成されており、アクセストークンなどは設定済みであるとします。
メールの仕様を確認
まずは 自身の想定 が本当に正しいのか、メールの仕様を確認します。
メールを送信する場合、宛先フィールドとしてTo
、Cc
、Bcc
の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.
必須のフィールドは送信日付と送信者で、ほかはオプションです。もちろん宛先がないメールは送信できませんので、実際にはTo
、Cc
、Bcc
のいずれかが入っている必要はあります。
というわけで、メールのToは必須でない です。言い換えれば、Cc
のみでもメールは送信できるはずです。ここテストに出ますよ。
(一斉メール送信をしたいんだけど宛先を知られたくない場合、Bcc
に全員分の宛先を入れて送信する、なんてことをしてる方もいるのではないでしょうか)
SESの仕様を確認
次にSESの仕様として、Cc
のみのメールを受信したら自動的にTo
に入れてしまうことを疑いました。これは実際にSESを使ってメール送信を試してみればいいですね。
まずはSESを利用してメール送信できるようにセットアップします。初期設定については優良なエントリが多数ありますのでここでは省略します。なおリージョンはバージニア北部(us-east-1)で設定しました。
ちなみにマネジメントコンソールのSend a Test Email
からテストメールが送信できますが、このダイアログではTo
が必須になっています。後で分かりますが、これはマネジメントコンソールの仕様です。
それではAWS CLIを使ってメールを送信してみます。今回は次のようなコマンドを実行しました。[email protected]
は実際にはちゃんとしたメールアドレスです。
aws ses send-email --region us-east-1 --from [email protected] --cc [email protected] --subject 'cc only test mail' --text 'test message.'
コマンドが成功したらメールを確認してみます(この時点で失敗する場合は何らかの設定が不足していると思われます)。
To
が空のメールが受信できています。つまりSESは勝手にTo
を付与しませんし、To
のないメールも送信できることがわかりました。
AWS SDK for Javaの探索
これで切り分けができました。SESは余計なことをしていませんので、原因はそれより手前、つまりメール送信するJavaのコードということになります。調べてみるとメールを送信する実装は複数あるようですので、それぞれ個別に検証を進めました。今回は次の3パターンです。
- AWS SDK for Javaが提供している
JavaMailSender
の実装を利用する AmazonSimpleEmailService
を利用するJavaMailSender
を拡張し、AWSJavaMailTransport
を利用する
まずはSpring Boot
のプロジェクトを作成します。IntelliJのSpring Initializr
ウィザードを利用します。
プロジェクト名はsestest
としました。プロジェクトタイプはGradle
です。
依存ライブラリはMail
とAWS 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 = "[email protected]"; private static final String CC2 = "[email protected]"; private static final String FROM = "[email protected]"; 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
に指定したメールアドレスのうちのひとつが自動で入っています。これが遭遇した現象です!それではこれも原因を追ってみましょう。少し長いので箇条書きにします。
SesMailSender#send(SimpleMailMessage)
呼び出し- 実際に実行されるのはJavaMailSenderImpl#send(SimpleMailMessage)
- 処理はJavaMailSenderImpl#send(SimpleMailMessage...)に委譲
- 型を変換してL321でJavaMailSenderImpl#doSend(MimeMessage[], Object[])呼び出し
- L435でJavaMailSenderImpl#connectTransport()を呼び出して
Transport
を取得。この中でオーバーライドしたgetTransport(Session)
が呼び出されている - L462でAWSJavaMailTransport#sendMessage(Message, Address[])呼び出し
- L96でAWSJavaMailTransport#collateRecipients(Message, Address[])呼び出し
さて長いこと追ってきましたが、最後のcollateRecipients
メソッドはメール受信者の照合を行っているメソッドで、アドレス重複の削除などをしています。そしてこのメソッドの最後にはコメントが……。
Simple E-mail needs at least one TO address, so add one if there isn't one
な、なるほど?どうやらTo
が空だった場合には、意図的にCc
かBcc
からアドレスをひとつ取り出してTo
に設定しているようです。しかし冒頭でSESの仕様を確認したときはTo
なしでも大丈夫でしたが、どこかで仕様変更されたのでしょうか(有識者求む)。
ちなみにこの部分のコードはSESをサポートした初期のコード(7年前!)からのようです。
Version 1.1.4 of the AWS Java SDK · aws/aws-sdk-java@90bc55e
まとめ
「なぜか自動的にToにアドレスが入ってしまう」という現象から、原因の切り分け、追求をしていきました。具体的な解決策は別にして、いったん原因もわかったのでひと段落できました。
実際にコードを書いて原因の切り分けをして……という作業はたいへんですが楽しいですね。
さて4月もおしまいですね。だんだん暑い日も多くなってきましたが、まずはゴールデンウィークを満喫しましょう!
以上、調査レポートでした。