Customers Mail CloudのWebhookは2種類あります。
- メール受信時
- メール送信時
メール受信時のWebhookはその名の通り、メールを受け取った際に任意のURLをコールするものです。この記事では添付ファイル付きメールを受け取った際のWebhook処理について解説します。
フォーマットはマルチパートフォームデータ
Webhookの形式として、JSONとマルチパートフォームデータ(multipart/form-data)が選択できます。この二つの違いは、添付ファイルがあるかどうかです。JSONの場合、添付ファイルは送られてきません。今回のようにメールに添付ファイルがついてくる場合は、後者を選択してください。
送信されてくるデータについて
メールを受信すると、以下のようなWebhookが送られてきます(データは一部マスキングしています)。JSONにしていますが、実際にはmultipart/form-data
です。
{ "filter": "info@smtps.jp", "headers": [ {name: 'Return-Path', value: '<user@example.com>'}, : {name: 'Date', value: 'Thu, 27 Apr 2023 15:56:26 +0900'} ], "subject": "Webhookのテスト", "envelope-to": "user@smtps.jp", "server_composition": "sandbox", "html": "<div dir=\\\\\\\\\\\\\\\\"ltr\\\\\\\\\\\\\\\\">Webhookのテスト用メールです。<div>...</div></div>", "text": "Webhookのテスト用メールです。\\\\\\\\\\\\\\\\r\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\r\\\\\\\\\\\\\\\\n--\\\\\\\\\\\\\\\\r\\\\\\\\\\\\\\\\n...", "envelope-from": "info@smtps.jp", "attachments": 1, "attachment1": "...." }
AWS Lambdaの準備
AWS Lambdaで新しい関数を作成します。その際、条件として以下を指定します。
- ランタイム
Java 21 - 関数 URL を有効化
チェックを入れる - 認証タイプ
NONE
関数の作成が完了したら、関数コードを編集します。ベースになるコードは以下の通りです。
package com.example.lambda; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import java.util.Map; public class HelloLambda implements RequestHandler<Map<String, Object>, String> { @Override public String handleRequest(Map<String, Object> event, Context context) { return "OK"; } }
Javaのコード
処理は src/main/java/com/example/lambda/HelloLambda.java
に記述します。Lambda の標準インタフェースを実装しています。handleRequest が本体で、event にリクエストデータが入ります。
package com.example.lambda; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import java.util.Map; public class HelloLambda implements RequestHandler<Map<String, Object>, String> { @Override public String handleRequest(Map<String, Object> event, Context context) { return "OK"; } }
全体の流れ
今回紹介するコードは、以下の流れで動きます。
- API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す
- MultipartStream を使って各パートを解析
- フィールドデータは変数に格納、添付ファイルは一時ファイルとして保存
- 最後に処理結果をログに出力
MultiPlatformInputの定義
受信したデータは、MultiPlatformInput クラスに格納します。フィールド名は、Customers Mail Cloudのドキュメントを参考にしています。
import java.util.List; import java.io.File; import java.util.HashMap; import java.util.List; import java.util.Map; public class MultiPlatformInput { public String filter; public List<Header> headers; public String subject; public String envelopeTo; public String envelopeFrom; public String serverComposition; public String html; public String text; public int attachments; // 添付ファイルの格納先(フィールド名 → File) public Map<String, File> attachmentFiles = new HashMap<>(); public static class Header { public String name; public String value; } }
API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す
API Gateway からは Base64 でエンコードされているためデコードします。以下のように、event
から body
を取り出し、Base64 デコードを行います。
そして、Content-Type Content-Type: multipart/form-data; boundary=xxxxx
から boundary を取り出します
String encodedBody = (String) event.get("body"); byte[] data = Base64.getDecoder().decode(encodedBody); Map<String, String> headers = (Map<String, String>) event.get("headers"); String contentType = headers.get("content-type"); ParameterParser parser = new ParameterParser(); parser.setLowerCaseNames(true); String boundary = parser.parse(contentType, ';').get("boundary");
MultipartStream を使って各パートを解析
Apache Commons FileUpload の MultipartStream を使って各パートを解析します。MultipartStream は、マルチパートデータをストリームとして扱うことができるクラスです。Lambda 環境だとこれが一番お手軽だと思います。
MultipartStream multipartStream = new MultipartStream(new ByteArrayInputStream(data), boundary.getBytes(StandardCharsets.UTF_8)); boolean nextPart = multipartStream.skipPreamble(); MultiPlatformInput input = new MultiPlatformInput();
フィールドデータは変数に格納、添付ファイルは一時ファイルとして保存
各パートを処理していきます。まず、ヘッダーを読み込み、フィールド名を取得します。次に、ボディデータを読み込みます。フィールド名が attachment
で始まる場合は、添付ファイルとして一時ファイルに保存します。それ以外の場合は、フィールドデータとして変数に格納します。
while (nextPart) { String headersStr = multipartStream.readHeaders(); String fieldName = extractFieldName(headersStr); ByteArrayOutputStream out = new ByteArrayOutputStream(); multipartStream.readBodyData(out); byte[] partData = out.toByteArray(); if (fieldName != null && fieldName.startsWith("attachment")) { File tempFile = File.createTempFile(fieldName + "_", ".bin"); Files.write(tempFile.toPath(), partData); input.attachmentFiles.put(fieldName, tempFile); } else { String value = new String(partData, StandardCharsets.UTF_8); assignField(input, fieldName, value); } nextPart = multipartStream.readBoundary(); }
上記コードの assignField
メソッドは、フィールド名に応じて適切な変数に値を格納します。たとえば、server_composition
フィールドは input.serverComposition
に格納されます。
private void assignField(MultiPlatformInput input, String fieldName, String value) { switch (fieldName) { case "filter" -> input.filter = value; case "subject" -> input.subject = value; case "text" -> input.text = value; case "html" -> input.html = value; case "envelope-to" -> input.envelopeTo = value; case "envelope-from" -> input.envelopeFrom = value; case "server_composition" -> input.serverComposition = value; case "attachments" -> { try { input.attachments = Integer.parseInt(value.trim()); } catch (NumberFormatException ignored) {} } case "headers" -> { // 必要に応じて JSONデコードや特殊処理が可能 System.out.println("Raw headers field received."); } default -> System.out.println("Unknown field: " + fieldName); } }
最後に処理結果をログに出力
今回は処理結果をログに出力します。件名やフィルタ、添付ファイルの数などを表示します。
System.out.println("Subject: " + input.subject); System.out.println("Filter: " + input.filter); System.out.println("Attachments saved: " + input.attachmentFiles.size()); for (Map.Entry<String, File> entry : input.attachmentFiles.entrySet()) { String name = entry.getKey(); File file = entry.getValue(); System.out.println("Attachment: " + name + " saved at " + file.getAbsolutePath() + " (" + file.length() + " bytes)"); }
以下はログのサンプルです。
2025-04-11T03:48:51.831Z Subject: [Not Virus Scanned] テキストメールテスト 2025-04-11T03:48:51.831Z Filter: attach@smtps.jp 2025-04-11T03:48:51.848Z Attachments saved: 2 2025-04-11T03:48:51.909Z Attachment: attachments saved at /tmp/attachments_17218775667608299774.bin (1 bytes) 2025-04-11T03:48:51.909Z Attachment: attachment1 saved at /tmp/attachment1_2920800266383840560.bin (60270 bytes)
全体のコード
今回のコードの全体図です。
// MultiPlatformInput.java package com.example.lambda; import java.util.List; import java.io.File; import java.util.HashMap; import java.util.List; import java.util.Map; public class MultiPlatformInput { public String filter; public List<Header> headers; public String subject; public String envelopeTo; public String envelopeFrom; public String serverComposition; public String html; public String text; public int attachments; // 添付ファイルの格納先(フィールド名 → File) public Map<String, File> attachmentFiles = new HashMap<>(); public static class Header { public String name; public String value; } } // HelloLambda.java package com.example.lambda; // AWS Lambda を使うためのインポート import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; // multipart/form-data を扱うためのライブラリ import org.apache.commons.fileupload.MultipartStream; import org.apache.commons.fileupload.ParameterParser; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; import java.util.HashMap; import java.util.Map; // Lambda 関数の本体クラス。リクエストを受け取って文字列を返す。 public class HelloLambda implements RequestHandler<Map<String, Object>, String> { // Lambda が呼ばれた時に実行されるメソッド @Override public String handleRequest(Map<String, Object> event, Context context) { try { // リクエストボディ(Base64エンコードされたデータ)を取得 String encodedBody = (String) event.get("body"); // Base64 デコードしてバイナリデータに変換 byte[] data = Base64.getDecoder().decode(encodedBody); // Content-Type ヘッダーから boundary を取得(マルチパート用) Map<String, String> headers = (Map<String, String>) event.get("headers"); String contentType = headers.get("content-type"); ParameterParser parser = new ParameterParser(); parser.setLowerCaseNames(true); // ヘッダー名を小文字で扱う String boundary = parser.parse(contentType, ';').get("boundary"); // マルチパートのデータを扱うための MultipartStream を作成 MultipartStream multipartStream = new MultipartStream(new ByteArrayInputStream(data), boundary.getBytes(StandardCharsets.UTF_8)); // 最初のデータ部分へ移動 boolean nextPart = multipartStream.skipPreamble(); // データを格納するオブジェクトを用意 MultiPlatformInput input = new MultiPlatformInput(); // すべてのパート(フィールドやファイル)を順番に処理 while (nextPart) { // 各パートのヘッダー情報を読み取る String headersStr = multipartStream.readHeaders(); // フィールド名(name=...の値)を抽出 String fieldName = extractFieldName(headersStr); // パートの本体データを読み取る ByteArrayOutputStream out = new ByteArrayOutputStream(); multipartStream.readBodyData(out); byte[] partData = out.toByteArray(); // 添付ファイルの場合の処理 if (fieldName != null && fieldName.startsWith("attachment")) { // 一時ファイルを作成してデータを書き込む File tempFile = File.createTempFile(fieldName + "_", ".bin"); Files.write(tempFile.toPath(), partData); // ファイルを input オブジェクトに保存 input.attachmentFiles.put(fieldName, tempFile); } else { // 文字列データの場合は UTF-8 で文字列に変換 String value = new String(partData, StandardCharsets.UTF_8); // フィールド名に応じたプロパティに値をセット assignField(input, fieldName, value); } // 次のパートがあるか確認 nextPart = multipartStream.readBoundary(); } // 処理結果を標準出力に表示(デバッグ用) System.out.println("Subject: " + input.subject); System.out.println("Filter: " + input.filter); System.out.println("Attachments saved: " + input.attachmentFiles.size()); // 保存された添付ファイルの情報を出力 for (Map.Entry<String, File> entry : input.attachmentFiles.entrySet()) { String name = entry.getKey(); File file = entry.getValue(); System.out.println("Attachment: " + name + " saved at " + file.getAbsolutePath() + " (" + file.length() + " bytes)"); } // 正常終了時は "OK" を返す return "OK"; } catch (Exception e) { // 例外が発生した場合はエラー内容を表示し "ERROR" を返す e.printStackTrace(); return "ERROR"; } } // Content-Disposition ヘッダーから name パラメータを取り出すメソッド private String extractFieldName(String headers) { for (String line : headers.split("\r\n")) { // ヘッダーの行が Content-Disposition から始まっているか確認(大文字小文字は無視) if (line.toLowerCase().startsWith("content-disposition")) { ParameterParser parser = new ParameterParser(); parser.setLowerCaseNames(true); // パラメータ名は小文字で扱う // name パラメータの値を返す return parser.parse(line, ';').get("name"); } } // 該当するフィールドがなければ null を返す return null; } // フィールド名に応じて MultiPlatformInput のプロパティに値をセットするメソッド private void assignField(MultiPlatformInput input, String fieldName, String value) { switch (fieldName) { case "filter" -> input.filter = value; case "subject" -> input.subject = value; case "text" -> input.text = value; case "html" -> input.html = value; case "envelope-to" -> input.envelopeTo = value; case "envelope-from" -> input.envelopeFrom = value; case "server_composition" -> input.serverComposition = value; case "attachments" -> { try { // 文字列を整数に変換して格納 input.attachments = Integer.parseInt(value.trim()); } catch (NumberFormatException ignored) { // 数字に変換できない場合は無視 } } case "headers" -> { // headers フィールドは特殊処理が必要な場合があるため、ひとまず表示だけ System.out.println("Raw headers field received."); } default -> System.out.println("Unknown field: " + fieldName); } } }
Webhookの結果は管理画面で確認
Webhookでデータが送信されたログは管理画面で確認できます。送信時のAPIキー設定など、HTTPヘッダーを編集するといった機能も用意されていますので、運用に応じて細かなカスタマイズが可能です。
まとめ
メールと連携したシステムはよくあります。通常、メールサーバを立てて、その中で処理することが多いのですが、メールサーバが落ちてしまうとシステムが稼働しなくなったり、メール文面の解析が煩雑でした。Customers Mail Cloudを使えばそうした手間なくJSONで処理できて便利です。
添付ファイルまで処理対象にしたい時には、この方法を利用してください。