Customers Mail CloudのWebhookは2種類あります。
- メール受信時
- メール送信時
メール送信時は、送信したメールに対してステータスが変わったタイミングで通知が送られるものです。
その際、 application/json
を指定しない設定ができます。この時のデータがどうなっているのか紹介します。
<!—more—>
AWS Lambdaの準備
AWS Lambdaで新しい関数を作成します。その際、条件として以下を指定します。
- ランタイム
.NET 8 (C#/F#/PowerShell) - 関数 URL を有効化
チェックを入れる - 認証タイプ
NONE
関数の作成が完了したら、関数コードを作成します。これは、ローカルで実行します。
dotnet tool install -g Amazon.Lambda.Tools dotnet new lambda.EmptyFunction --name ExampleCS
このコマンドを実行すると、以下のような構成でプロジェクトが作成されます。
. ├── src │ └── ExampleCS │ ├── aws-lambda-tools-defaults.json │ ├── ExampleCS.csproj │ ├── Function.cs │ └── Readme.md └── test └── ExampleCS.Tests ├── ExampleCS.Tests.csproj └── FunctionTest.cs 4 directories, 6 files
この中の、 Function.cs
に処理を記述します。ベースになるコードは以下の通りです。実際に処理を行うのは FunctionHandler
メソッドです。
using Amazon.Lambda.Core; // Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. [assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace ExampleCS; public class Function { /// <summary> /// A simple function that takes a string and does a ToUpper /// </summary> /// <param name="input">The event for the Lambda function handler to process.</param> /// <param name="context">The ILambdaContext that provides methods for logging and describing the Lambda environment.</param> /// <returns></returns> public string FunctionHandler(string input, ILambdaContext context) { return input.ToUpper(); } }
また、Lambdaの管理画面にて、ハンドラーの設定を ExampleCS::ExampleCS.Function::FunctionHandler
に変更します。
受け取るWebhookの設定
管理画面にて、受け取るWebhookを設定できます。設定は以下が用意されています。
- Bounces
- bounced(エラーメールを受け取る)
- Deliveries
- queued(キューに入ったタイミング)
- succeeded(送信完了)
- failed(送信失敗)
- deferred(送信延期)
この中で application/json
を指定できます。指定しなかった場合、データは application/x-www-form-urlencoded
にて送信されます。本記事ではこの場合を想定しています。
送信されてくるデータについて
メール送信した直後
メール送信を行うと、そのデータがキューに入ります。そして、以下のようなWebhookが送られてきます(データは一部マスキングしています)。データは分かりやすいようにJSONにしていますが、実際には異なりますので注意してください。
{ "event_type": "deliveries", "server_composition": "pro", "event": '{"deliveries":[{"reason":"","sourceIp":"100.100.100.1","returnPath":"info@return.pro.smtps.jp","created":"2023-01-25 14:03:06","subject":"メールマガジンのテスト","apiData":"","messageId":"<031a32d4-06cd-b1ae-9526-011c0b9f1296@example.com>","from":"info@example.com","to":"user@example.jp","senderIp":"","status":"queued"}]}' }
メール送信完了時
Customers Mail Cloudからメール送信処理が行われると、ステータスが succeeded
になったWebhookが送られてきます。
{ "event_type": "deliveries", "server_composition": "pro", "event": '{"deliveries":[{"reason":"","sourceIp":"","returnPath":"info@return.pro.smtps.jp","created":"2023-01-25 14:03:09","subject":"メールマガジンのテスト","apiData":"","messageId":"<031a32d4-06cd-b1ae-9526-011c0b9f1296@example.com>","from":"info@example.com","to":"user@example.jp","senderIp":"100.100.100.3","status":"succeeded"}]}' }
メール送信失敗時(メールアドレス形式に問題がある場合)
メールアドレスの形式に問題があるなど、送信処理が失敗した場合には以下のようなWebhookが送られてきます。
{ "event_type": "bounces", "server_composition": "pro", "event": '{"bounces":[{"reason":"host unknown","returnPath":"info@return.pro.smtps.jp","created":"2023-01-25 14:05:15","subject":"メールマガジンのテスト","apiData":"","messageId":"<8f902ee7-ae65-8711-48a8-2f708cb14205@example.com>","from":"info@example.com","to":"user@example","status":"1"}]}' }
メール送信失敗時(送信先サーバーからエラーが返ってくる場合)
ユーザーが存在しない、メールボックスがいっぱいなど送信先サーバーからエラーが返ってきた場合には、以下のようなJSONが返ってきます。
{ "event_type": "deliveries", "server_composition": "pro", "event": '{"deliveries":[{"reason":"550 5.1.1 The email account that you tried to reach does not exist. Please try 5.1.1 double-checking the recipient's email address for typos or 5.1.1 unnecessary spaces. Learn more at 5.1.1 <https://support.google.com/mail/?p=NoSuchUser> b197-20020a621bce000000b0058b80756b07si311029pfb.3 - gsmtp (in reply to RCPT TO)","sourceIp":"","returnPath":"info@return.pro.smtps.jp","created":"2023-01-25 14:06:06","subject":"メールマガジンのテスト","apiData":"","messageId":"<9e7e564c-ac83-8cd8-2cb4-b9ff2a9f168d@example.com>","from":"info@example.com","to":"no-user@example.jp","senderIp":"100.100.100.3","status":"failed"}]}' }
エラーとしてのWebhookも送られてきます。上記のものと event_type
が異なるので注意してください。
{ "event_type": "bounces", "server_composition": "pro", "event": '{"bounces":[{"reason":"550 5.1.1 The email account that you tried to reach does not exist. Please try 5.1.1 double-checking the recipient's email address for typos or 5.1.1 unnecessary spaces. Learn more at 5.1.1 <https://support.google.com/mail/?p=NoSuchUser> b197-20020a621bce000000b0058b80756b07si311029pfb.3 - gsmtp (in reply to RCPT TO)","returnPath":"info@return.pro.smtps.jp","created":"2023-01-25 14:06:07","subject":"メールマガジンのテスト","apiData":"","messageId":"<9e7e564c-ac83-8cd8-2cb4-b9ff2a9f168d@example.com>","from":"info@example.com","to":"no-user@example.jp","status":"2"}]}' }
C#のコード
処理は ExampleCS/src/ExampleCS/Function.cs
に記述します。
using Amazon.Lambda.Core; [assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace ExampleCS; public class Function { /// <summary> /// A simple function that takes a string and does a ToUpper /// </summary> /// <param name="input">The event for the Lambda function handler to process.</param> /// <param name="context">The ILambdaContext that provides methods for logging and describing the Lambda environment.</param> /// <returns></returns> public string FunctionHandler(string input, ILambdaContext context) { return "ok"; } }
また、データの入力を格納するクラス MailWebhookFormData
を用意します。クラスのプロパティはCustomers Mail Cloudのドキュメントを参考にしています。
using Amazon.Lambda.Core; using Amazon.Lambda.APIGatewayEvents; using System.Text.Json; using System.Text.Json.Serialization; // Lambda関数のJSON入力を.NETクラスに変換するためのアセンブリ属性 [assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace ExampleCS; /// <summary> /// Webhookから受信するメールイベントのルートオブジェクト(Form-URLEncoded形式) /// event_typeとserver_compositionは文字列、eventフィールドはJSON文字列として受信されます /// </summary> public class MailWebhookFormData { /// <summary> /// イベントタイプ("deliveries" または "bounces") /// </summary> [JsonPropertyName("event_type")] public string EventType { get; set; } = string.Empty; /// <summary> /// サーバー構成(例:"pro") /// </summary> [JsonPropertyName("server_composition")] public string ServerComposition { get; set; } = string.Empty; /// <summary> /// イベントデータのJSON文字列 /// この文字列を更にパースしてEventDataオブジェクトに変換する必要があります /// </summary> [JsonPropertyName("event")] public string EventJson { get; set; } = string.Empty; } /// <summary> /// イベントデータのコンテナ /// deliveriesまたはbouncesのいずれかを含みます /// </summary> public class EventData { /// <summary> /// 配信イベントのリスト(event_type が "deliveries" の場合) /// </summary> [JsonPropertyName("deliveries")] public List<MessageEvent>? Deliveries { get; set; } /// <summary> /// バウンスイベントのリスト(event_type が "bounces" の場合) /// </summary> [JsonPropertyName("bounces")] public List<MessageEvent>? Bounces { get; set; } } /// <summary> /// メールイベントの詳細情報 /// 配信イベントとバウンスイベントの両方に対応した統合クラス /// </summary> public class MessageEvent { /// <summary> /// イベントの理由やステータス詳細 /// 配信の場合:配信理由、バウンスの場合:バウンス理由(例:"host unknown") /// </summary> [JsonPropertyName("reason")] public string Reason { get; set; } = string.Empty; /// <summary> /// 送信元IPアドレス(配信イベントの場合のみ) /// </summary> [JsonPropertyName("sourceIp")] public string SourceIp { get; set; } = string.Empty; /// <summary> /// リターンパス(返信先メールアドレス) /// </summary> [JsonPropertyName("returnPath")] public string ReturnPath { get; set; } = string.Empty; /// <summary> /// イベント作成日時 /// </summary> [JsonPropertyName("created")] public string Created { get; set; } = string.Empty; /// <summary> /// メールの件名 /// </summary> [JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty; /// <summary> /// API関連データ /// </summary> [JsonPropertyName("apiData")] public string ApiData { get; set; } = string.Empty; /// <summary> /// メッセージID(一意識別子) /// </summary> [JsonPropertyName("messageId")] public string MessageId { get; set; } = string.Empty; /// <summary> /// 送信者メールアドレス /// </summary> [JsonPropertyName("from")] public string From { get; set; } = string.Empty; /// <summary> /// 受信者メールアドレス /// </summary> [JsonPropertyName("to")] public string To { get; set; } = string.Empty; /// <summary> /// 送信者IPアドレス(配信イベントの場合のみ) /// </summary> [JsonPropertyName("senderIp")] public string SenderIp { get; set; } = string.Empty; /// <summary> /// ステータス /// 配信の場合:配信ステータス(例:"queued", "sent") /// バウンスの場合:バウンスステータスコード(例:"1") /// </summary> [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; }
全体の流れ
処理の流れは以下の通りです。まず、 APIGatewayProxyRequest
を受け取り、リクエストボディをパースします。その後、 ParseFormUrlEncodedData
メソッドにて、 MailWebhookFormData
と EventData
オブジェクトに変換します。
MailWebhookFormData webhookFormData; EventData eventData; (webhookFormData, eventData) = ParseFormUrlEncodedData(request.Body, request.IsBase64Encoded, context);
ParseFormUrlEncodedData
メソッドは、リクエストボディをデコードし、URLエンコードされたフォームデータをパースして MailWebhookFormData
と EventData
オブジェクトに変換します。
event
フィールドはJSON文字列として受信されるため、さらに EventData
オブジェクトにデシリアライズします。
リクエストボディのパース
/// <summary> /// Form-URLEncodedデータを解析してC#オブジェクトに変換します /// eventフィールドのJSON文字列も同時にパースします /// </summary> /// <param name="body">リクエストボディ</param> /// <param name="isBase64Encoded">ボディがBase64エンコードされているかどうか</param> /// <param name="context">Lambda実行コンテキスト</param> /// <returns>解析されたWebhookデータとイベントデータ</returns> private static (MailWebhookFormData webhookData, EventData eventData) ParseFormUrlEncodedData(string body, bool isBase64Encoded, ILambdaContext context) { try { // Form-URLEncodedデータをパース var formData = new MailWebhookFormData(); var eventData = new EventData(); context.Logger.LogInformation($"IsBase64Encoded: {isBase64Encoded}"); context.Logger.LogInformation($"Original body: {body}"); // Base64デコードが必要な場合 string rawBody = body; if (isBase64Encoded) { try { var base64Bytes = Convert.FromBase64String(body); rawBody = System.Text.Encoding.UTF8.GetString(base64Bytes); context.Logger.LogInformation($"Base64 decoded body: {rawBody}"); } catch (Exception ex) { context.Logger.LogError($"Failed to decode Base64: {ex.Message}"); // Base64デコードに失敗した場合でも、元の文字列で試行を続ける } } // URLデコードして key=value ペアに分割 var urlDecodedBody = Uri.UnescapeDataString(rawBody); context.Logger.LogInformation($"URL decoded body: {urlDecodedBody}"); var keyValuePairs = urlDecodedBody.Split('&'); foreach (var pair in keyValuePairs) { var keyValue = pair.Split('=', 2); // 最大2つに分割(値に=が含まれる可能性があるため) if (keyValue.Length == 2) { var key = Uri.UnescapeDataString(keyValue[0]); var value = Uri.UnescapeDataString(keyValue[1]); switch (key) { case "event_type": formData.EventType = value; break; case "server_composition": formData.ServerComposition = value; break; case "event": formData.EventJson = value; // eventフィールドのJSON文字列をパース context.Logger.LogInformation($"Parsing event JSON: {value}"); eventData = JsonSerializer.Deserialize<EventData>(value) ?? new EventData(); break; default: context.Logger.LogInformation($"Unknown field: {key} = {value}"); break; } } } return (formData, eventData); } catch (JsonException ex) { context.Logger.LogError($"JSON parsing error in event field: {ex.Message}"); throw new Exception($"Failed to parse event JSON: {ex.Message}"); } }
ログ出力
今回は、最後にデバッグ用にログ出力を行います。 context.Logger
を使用して、処理したイベントの情報を出力します。
context.Logger.LogInformation($"Processed event type: {webhookFormData.EventType}"); context.Logger.LogInformation($"Processed server composition: {webhookFormData.ServerComposition}"); if (eventData.Deliveries != null) { context.Logger.LogInformation($"Number of deliveries: {eventData.Deliveries.Count}"); context.Logger.LogInformation($"First delivery subject: {eventData.Deliveries.FirstOrDefault()?.Subject ?? "N/A"}"); } if (eventData.Bounces != null) { context.Logger.LogInformation($"Number of bounces: {eventData.Bounces.Count}"); context.Logger.LogInformation($"First bounce subject: {eventData.Bounces.FirstOrDefault()?.Subject ?? "N/A"}"); }
出力例は以下の通りです。
2025-06-06T07:10:05.048Z 9c7c503d-633c-49b3-8f5f-d86e6371a6ca info Processed event type: bounces 2025-06-06T07:10:05.048Z 9c7c503d-633c-49b3-8f5f-d86e6371a6ca info Number of bounces: 1 2025-06-06T07:10:05.048Z 9c7c503d-633c-49b3-8f5f-d86e6371a6ca info First bounce subject: メールマガジンのテスト
全体のコード
全体のコードは以下の通りです。
using Amazon.Lambda.Core; using Amazon.Lambda.APIGatewayEvents; using System.Text.Json; using System.Text.Json.Serialization; // Lambda関数のJSON入力を.NETクラスに変換するためのアセンブリ属性 [assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace ExampleCS; /// <summary> /// Webhookから受信するメールイベントのルートオブジェクト(Form-URLEncoded形式) /// event_typeとserver_compositionは文字列、eventフィールドはJSON文字列として受信されます /// </summary> public class MailWebhookFormData { /// <summary> /// イベントタイプ("deliveries" または "bounces") /// </summary> [JsonPropertyName("event_type")] public string EventType { get; set; } = string.Empty; /// <summary> /// サーバー構成(例:"pro") /// </summary> [JsonPropertyName("server_composition")] public string ServerComposition { get; set; } = string.Empty; /// <summary> /// イベントデータのJSON文字列 /// この文字列を更にパースしてEventDataオブジェクトに変換する必要があります /// </summary> [JsonPropertyName("event")] public string EventJson { get; set; } = string.Empty; } /// <summary> /// イベントデータのコンテナ /// deliveriesまたはbouncesのいずれかを含みます /// </summary> public class EventData { /// <summary> /// 配信イベントのリスト(event_type が "deliveries" の場合) /// </summary> [JsonPropertyName("deliveries")] public List<MessageEvent>? Deliveries { get; set; } /// <summary> /// バウンスイベントのリスト(event_type が "bounces" の場合) /// </summary> [JsonPropertyName("bounces")] public List<MessageEvent>? Bounces { get; set; } } /// <summary> /// メールイベントの詳細情報 /// 配信イベントとバウンスイベントの両方に対応した統合クラス /// </summary> public class MessageEvent { /// <summary> /// イベントの理由やステータス詳細 /// 配信の場合:配信理由、バウンスの場合:バウンス理由(例:"host unknown") /// </summary> [JsonPropertyName("reason")] public string Reason { get; set; } = string.Empty; /// <summary> /// 送信元IPアドレス(配信イベントの場合のみ) /// </summary> [JsonPropertyName("sourceIp")] public string SourceIp { get; set; } = string.Empty; /// <summary> /// リターンパス(返信先メールアドレス) /// </summary> [JsonPropertyName("returnPath")] public string ReturnPath { get; set; } = string.Empty; /// <summary> /// イベント作成日時 /// </summary> [JsonPropertyName("created")] public string Created { get; set; } = string.Empty; /// <summary> /// メールの件名 /// </summary> [JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty; /// <summary> /// API関連データ /// </summary> [JsonPropertyName("apiData")] public string ApiData { get; set; } = string.Empty; /// <summary> /// メッセージID(一意識別子) /// </summary> [JsonPropertyName("messageId")] public string MessageId { get; set; } = string.Empty; /// <summary> /// 送信者メールアドレス /// </summary> [JsonPropertyName("from")] public string From { get; set; } = string.Empty; /// <summary> /// 受信者メールアドレス /// </summary> [JsonPropertyName("to")] public string To { get; set; } = string.Empty; /// <summary> /// 送信者IPアドレス(配信イベントの場合のみ) /// </summary> [JsonPropertyName("senderIp")] public string SenderIp { get; set; } = string.Empty; /// <summary> /// ステータス /// 配信の場合:配信ステータス(例:"queued", "sent") /// バウンスの場合:バウンスステータスコード(例:"1") /// </summary> [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; } /// <summary> /// AWS Lambda関数のメインクラス /// メールWebhookのForm-URLEncodedデータを処理します /// </summary> public class Function { /// <summary> /// Lambda関数のエントリーポイント /// API Gatewayからのapplication/x-www-form-urlencodedリクエストを受け取り、メールイベントを処理します /// </summary> /// <param name="request">API Gatewayからのリクエスト情報</param> /// <param name="context">Lambda実行時の情報(ログ出力などに使用)</param> /// <returns>処理結果のJSON文字列</returns> public static string FunctionHandler(APIGatewayProxyRequest request, ILambdaContext context) { try { MailWebhookFormData webhookFormData; EventData eventData; (webhookFormData, eventData) = ParseFormUrlEncodedData(request.Body, request.IsBase64Encoded, context); context.Logger.LogInformation($"Processed event type: {webhookFormData.EventType}"); context.Logger.LogInformation($"Processed server composition: {webhookFormData.ServerComposition}"); if (eventData.Deliveries != null) { context.Logger.LogInformation($"Number of deliveries: {eventData.Deliveries.Count}"); context.Logger.LogInformation($"First delivery subject: {eventData.Deliveries.FirstOrDefault()?.Subject ?? "N/A"}"); } if (eventData.Bounces != null) { context.Logger.LogInformation($"Number of bounces: {eventData.Bounces.Count}"); context.Logger.LogInformation($"First bounce subject: {eventData.Bounces.FirstOrDefault()?.Subject ?? "N/A"}"); } return "ok"; } catch (Exception ex) { context.Logger.LogError($"Error processing webhook data: {ex.Message}"); return JsonSerializer.Serialize(new { error = "Internal server error", details = ex.Message }); } } /// <summary> /// Form-URLEncodedデータを解析してC#オブジェクトに変換します /// eventフィールドのJSON文字列も同時にパースします /// </summary> /// <param name="body">リクエストボディ</param> /// <param name="isBase64Encoded">ボディがBase64エンコードされているかどうか</param> /// <param name="context">Lambda実行コンテキスト</param> /// <returns>解析されたWebhookデータとイベントデータ</returns> private static (MailWebhookFormData webhookData, EventData eventData) ParseFormUrlEncodedData(string body, bool isBase64Encoded, ILambdaContext context) { try { // Form-URLEncodedデータをパース var formData = new MailWebhookFormData(); var eventData = new EventData(); context.Logger.LogInformation($"IsBase64Encoded: {isBase64Encoded}"); context.Logger.LogInformation($"Original body: {body}"); // Base64デコードが必要な場合 string rawBody = body; if (isBase64Encoded) { try { var base64Bytes = Convert.FromBase64String(body); rawBody = System.Text.Encoding.UTF8.GetString(base64Bytes); context.Logger.LogInformation($"Base64 decoded body: {rawBody}"); } catch (Exception ex) { context.Logger.LogError($"Failed to decode Base64: {ex.Message}"); // Base64デコードに失敗した場合でも、元の文字列で試行を続ける } } // URLデコードして key=value ペアに分割 var urlDecodedBody = Uri.UnescapeDataString(rawBody); context.Logger.LogInformation($"URL decoded body: {urlDecodedBody}"); var keyValuePairs = urlDecodedBody.Split('&'); foreach (var pair in keyValuePairs) { var keyValue = pair.Split('=', 2); // 最大2つに分割(値に=が含まれる可能性があるため) if (keyValue.Length == 2) { var key = Uri.UnescapeDataString(keyValue[0]); var value = Uri.UnescapeDataString(keyValue[1]); switch (key) { case "event_type": formData.EventType = value; break; case "server_composition": formData.ServerComposition = value; break; case "event": formData.EventJson = value; // eventフィールドのJSON文字列をパース context.Logger.LogInformation($"Parsing event JSON: {value}"); eventData = JsonSerializer.Deserialize<EventData>(value) ?? new EventData(); break; default: context.Logger.LogInformation($"Unknown field: {key} = {value}"); break; } } } return (formData, eventData); } catch (JsonException ex) { context.Logger.LogError($"JSON parsing error in event field: {ex.Message}"); throw new Exception($"Failed to parse event JSON: {ex.Message}"); } } }
まとめ
Webhookを使うことで、メール送信ステータスの変化に応じて通知を受け取れるようになります。メールと連携したシステムを開発する際に役立つでしょう。
AWS Lambdaの場合は application/json
を指定した方が全体として、受け取りやすい印象です。ぜひお試しください。なお、このWebhookはSMTP経由の場合、利用できます。