Webhook APIを使って添付ファイル付きメールを処理する(AWS Lambda + Java)

Customers Mail CloudのWebhookは2種類あります。

  1. メール受信時
  2. メール送信時

メール受信時のWebhookはその名の通り、メールを受け取った際に任意のURLをコールするものです。この記事では添付ファイル付きメールを受け取った際のWebhook処理について解説します。

フォーマットはマルチパートフォームデータ

Webhookの形式として、JSONとマルチパートフォームデータ(multipart/form-data)が選択できます。この二つの違いは、添付ファイルがあるかどうかです。JSONの場合、添付ファイルは送られてきません。今回のようにメールに添付ファイルがついてくる場合は、後者を選択してください。

Webhook設定ダイアログ

送信されてくるデータについて

メールを受信すると、以下のような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";
    }
}

全体の流れ

今回紹介するコードは、以下の流れで動きます。

  1. API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す
  2. MultipartStream を使って各パートを解析
  3. フィールドデータは変数に格納、添付ファイルは一時ファイルとして保存
  4. 最後に処理結果をログに出力

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ヘッダーを編集するといった機能も用意されていますので、運用に応じて細かなカスタマイズが可能です。

Webhookログ

まとめ

メールと連携したシステムはよくあります。通常、メールサーバを立てて、その中で処理することが多いのですが、メールサーバが落ちてしまうとシステムが稼働しなくなったり、メール文面の解析が煩雑でした。Customers Mail Cloudを使えばそうした手間なくJSONで処理できて便利です。

添付ファイルまで処理対象にしたい時には、この方法を利用してください。

受信サーバ | Customers Mail Cloud