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で新しい関数を作成します。その際、条件として以下を指定します。
- ランタイム
.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: 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
に変更します。
C#のコード
処理は ExampleCS/src/ExampleCS/Function.cs
に記述します。
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 "ok"; } }
全体の流れ
今回紹介するコードは、以下の流れで動きます。
データを扱うクラスの定義
API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す
- MultipartStream を使って各パートを解析
- フィールドデータは変数に格納、添付ファイルは一時ファイルとして保存
- 最後に処理結果をログに出力
データを扱うクラスの定義
マルチパートフォームデータを扱うクラス MultipartFormData
を定義します。
/// <summary> /// マルチパートフォームデータの解析結果を保存するクラス /// フォームの入力フィールドとアップロードされたファイルを分けて管理します /// </summary> public class MultipartFormData { /// <summary> /// テキスト形式の入力フィールドを保存(例:名前、メールアドレスなど) /// キー = フィールド名、値 = 入力された文字列 /// </summary> public Dictionary<string, string> Fields { get; set; } = []; /// <summary> /// アップロードされたファイルを保存 /// キー = フィールド名、値 = ファイルデータ /// </summary> public Dictionary<string, FileData> Files { get; set; } = []; } /// <summary> /// アップロードされたファイルの情報を保存するクラス /// ファイル名、形式、実際のデータを含みます /// </summary> public class FileData { /// <summary> /// アップロードされたファイルの名前(例:「報告書.xlsx」) /// </summary> public string FileName { get; set; } = string.Empty; /// <summary> /// ファイルの形式(例:「image/jpeg」「application/pdf」など) /// </summary> public string ContentType { get; set; } = string.Empty; /// <summary> /// ファイルの実際のデータ(バイト配列として保存) /// </summary> public byte[] Content { get; set; } = []; }
API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す
API Gateway からは Base64 でエンコードされているためデコードします。以下のように、event
から body
を取り出し、Base64 デコードを行います。
そして、Content-Type Content-Type: multipart/form-data; boundary=xxxxx
から boundary を取り出します
// Base64エンコードされているかどうかをログ出力 context.Logger.LogInformation($"IsBase64Encoded: {request.IsBase64Encoded}"); // マルチパートデータを解析(文字列からフィールドとファイルに分離) var formData = ParseMultipartFormData(request.Body, contentType, request.IsBase64Encoded);
ParseMultipartFormData
の実装は以下のようになります。
/// <summary> /// マルチパートフォームデータの本体を解析するメソッド /// 受信したデータを個別のフィールドとファイルに分離します /// </summary> /// <param name="body">リクエストボディの文字列</param> /// <param name="contentType">Content-Typeヘッダーの値</param> /// <param name="isBase64Encoded">データがBase64でエンコードされているかどうか</param> /// <returns>解析されたフォームデータ</returns> private static MultipartFormData ParseMultipartFormData(string body, string contentType, bool isBase64Encoded) { var formData = new MultipartFormData(); // Content-Typeからboundaryパラメータを抽出 // boundaryは各フィールドを区切る文字列です var boundary = ExtractBoundary(contentType); if (string.IsNullOrEmpty(boundary)) { throw new ArgumentException("Content-TypeヘッダーにBoundaryが見つかりません"); } // リクエストボディをバイト配列に変換 byte[] bodyBytes; if (isBase64Encoded) { // Base64でエンコードされている場合はデコード bodyBytes = Convert.FromBase64String(body); } else { // 通常の文字列の場合はUTF-8でバイト配列に変換 bodyBytes = Encoding.UTF8.GetBytes(body); } // boundaryを使ってデータを個別の部分に分割 var boundaryBytes = Encoding.UTF8.GetBytes("--" + boundary); var parts = SplitByBoundary(bodyBytes, boundaryBytes); // 分割された各部分を処理 foreach (var part in parts) { if (part.Length == 0) continue; // 空の部分はスキップ // 各部分からヘッダーと内容を分離 var (headers, content) = ParsePart(part); // Content-Dispositionヘッダーを取得 var disposition = headers.TryGetValue("Content-Disposition", out var disp) ? disp : ""; // form-dataかどうかをチェック if (disposition.Contains("form-data")) { // フィールド名を取得 var name = ExtractQuotedValue(disposition, "name"); // ファイル名を取得(ファイルアップロードの場合のみ存在) var filename = ExtractQuotedValue(disposition, "filename"); if (!string.IsNullOrEmpty(filename)) { // ファイルフィールドの場合 var fileContentType = headers.TryGetValue("Content-Type", out var ct) ? ct : "application/octet-stream"; // RFC 2047エンコードされたファイル名をデコード var decodedFileName = DecodeRfc2047Filename(filename); formData.Files[name] = new FileData { FileName = decodedFileName, ContentType = fileContentType, Content = content }; } else { // テキストフィールドの場合 formData.Fields[name] = Encoding.UTF8.GetString(content); } } } return formData; }
ExtractBoundary
は、Content-Type ヘッダーから boundary 値を抽出するメソッドです。以下のように実装します。
/// <summary> /// Content-Typeヘッダーからboundary値を取得します /// 例:「multipart/form-data; boundary=----WebKitFormBoundary123」から「----WebKitFormBoundary123」を抽出 /// </summary> /// <param name="contentType">Content-Typeヘッダーの値</param> /// <returns>boundary文字列</returns> private static string ExtractBoundary(string contentType) { // セミコロンで分割してパラメータ部分を取得 var parts = contentType.Split(';'); foreach (var part in parts) { var trimmed = part.Trim(); // boundary=で始まる部分を探す if (trimmed.StartsWith("boundary=")) { // "boundary="の部分(9文字)を除いた文字列を返す return trimmed[9..]; } } return string.Empty; }
SplitByBoundary
は、バイト配列を boundary で分割するメソッドです。以下のように実装します。
private static List<byte[]> SplitByBoundary(byte[] data, byte[] boundary) { var parts = new List<byte[]>(); var start = 0; while (start < data.Length) { var index = IndexOf(data, boundary, start); if (index == -1) break; if (start > 0) { var partLength = index - start; if (partLength > 2) // Skip \r\n { var part = new byte[partLength - 2]; Array.Copy(data, start, part, 0, partLength - 2); parts.Add(part); } } start = index + boundary.Length; if (start + 1 < data.Length && data[start] == '\r' && data[start + 1] == '\n') { start += 2; } } return parts; }
上記メソッドで使われている IndexOf
は、バイト配列内で特定のパターンを検索するためのメソッドです。以下のように実装します。
private static int IndexOf(byte[] array, byte[] pattern, int startIndex) { for (int i = startIndex; i <= array.Length - pattern.Length; i++) { bool found = true; for (int j = 0; j < pattern.Length; j++) { if (array[i + j] != pattern[j]) { found = false; break; } } if (found) return i; } return -1; }
ParsePart
メソッドは、各パートのヘッダーとボディを解析するためのメソッドです。以下のように実装します。
private static (Dictionary<string, string> headers, byte[] content) ParsePart(byte[] part) { var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var headerEndIndex = IndexOf(part, Encoding.UTF8.GetBytes("\r\n\r\n"), 0); if (headerEndIndex == -1) { return (headers, part); } var headerBytes = new byte[headerEndIndex]; Array.Copy(part, 0, headerBytes, 0, headerEndIndex); var headerText = Encoding.UTF8.GetString(headerBytes); var headerLines = headerText.Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries); foreach (var line in headerLines) { var colonIndex = line.IndexOf(':'); if (colonIndex > 0) { var key = line[..colonIndex].Trim(); var value = line[(colonIndex + 1)..].Trim(); headers[key] = value; } } var contentStart = headerEndIndex + 4; // Skip \r\n\r\n var contentLength = part.Length - contentStart; var content = new byte[contentLength]; Array.Copy(part, contentStart, content, 0, contentLength); return (headers, content); }
ExtractQuotedValue
メソッドは、ヘッダーから特定のキーの値を抽出するためのメソッドです。以下のように実装します。
private static string ExtractQuotedValue(string input, string key) { var keyPattern = key + "=\""; var startIndex = input.IndexOf(keyPattern); if (startIndex == -1) return string.Empty; startIndex += keyPattern.Length; var endIndex = input.IndexOf('"', startIndex); if (endIndex == -1) return string.Empty; return input[startIndex..endIndex]; }
送られてくるファイル名は、RFC 2047 でエンコードされている場合があります(日本語の場合など)。これをデコードするためのメソッドとして DecodeRfc2047Filename
を用意します。
private static string DecodeRfc2047Filename(string filename) { // Check if the filename is RFC 2047 encoded // Format: =?charset?encoding?encoded-text?= if (!filename.StartsWith("=?") || !filename.EndsWith("?=")) { return filename; } try { // Remove the leading =? and trailing ?= var encodedPart = filename[2..^2]; // Split by ? to get charset, encoding, and encoded text var parts = encodedPart.Split('?'); if (parts.Length != 3) { return filename; // Invalid format, return original } var charset = parts[0].ToUpperInvariant(); var encoding = parts[1].ToUpperInvariant(); var encodedText = parts[2]; // Decode based on the encoding type byte[] decodedBytes; if (encoding == "B") // Base64 { decodedBytes = Convert.FromBase64String(encodedText); } else if (encoding == "Q") // Quoted-Printable { decodedBytes = DecodeQuotedPrintable(encodedText); } else { return filename; // Unsupported encoding } // Convert bytes to string using the specified charset var textEncoding = charset switch { "UTF-8" => Encoding.UTF8, "ISO-8859-1" => Encoding.Latin1, "US-ASCII" => Encoding.ASCII, _ => Encoding.UTF8 // Default to UTF-8 }; return textEncoding.GetString(decodedBytes); } catch { // If decoding fails, return the original filename return filename; } } private static byte[] DecodeQuotedPrintable(string input) { var result = new List<byte>(); for (int i = 0; i < input.Length; i++) { if (input[i] == '=') { if (i + 2 < input.Length) { // Try to parse hex digits if (byte.TryParse(input.Substring(i + 1, 2), System.Globalization.NumberStyles.HexNumber, null, out byte hexByte)) { result.Add(hexByte); i += 2; // Skip the two hex digits continue; } } } else if (input[i] == '_') { // In RFC 2047, underscore represents space result.Add((byte)' '); } else { result.Add((byte)input[i]); } } return result.ToArray(); }
処理結果をログに出力
ParseMultipartFormData
メソッドの結果を出力します。
// 全フィールドとファイルの情報をログに出力 formData.Fields.ToList().ForEach(f => context.Logger.LogInformation($"Field: {f.Key} = {f.Value}")); formData.Files.ToList().ForEach(f => context.Logger.LogInformation($"File: {f.Key} = {f.Value.FileName} ({f.Value.ContentType}, {f.Value.Content.Length} bytes)"));
以下はログのサンプルです。
2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info Field: server_composition = pro 2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info Field: filter = test@smtps.jp 2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info Field: subject = [Not Virus Scanned] メールテスト 2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info Field: envelope-from = admin@example.com 2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info Field: attachments = 1 2025-06-06T06:34:05.688Z f189c4e0-1c5a-42d6-bf6d-2c5a63b85cea info File: attachment1 = 添付ファイル.xlsx (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; name="=?UTF-8?B?44...?=", 60270 bytes)
全体のコード
今回のコードの全体図です。
using Amazon.Lambda.Core; using Amazon.Lambda.APIGatewayEvents; using System.Text; // Lambda関数のJSON入力を.NETクラスに変換するためのアセンブリ属性 [assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace ExampleCS; /// <summary> /// マルチパートフォームデータの解析結果を保存するクラス /// フォームの入力フィールドとアップロードされたファイルを分けて管理します /// </summary> public class MultipartFormData { /// <summary> /// テキスト形式の入力フィールドを保存(例:名前、メールアドレスなど) /// キー = フィールド名、値 = 入力された文字列 /// </summary> public Dictionary<string, string> Fields { get; set; } = []; /// <summary> /// アップロードされたファイルを保存 /// キー = フィールド名、値 = ファイルデータ /// </summary> public Dictionary<string, FileData> Files { get; set; } = []; } /// <summary> /// アップロードされたファイルの情報を保存するクラス /// ファイル名、形式、実際のデータを含みます /// </summary> public class FileData { /// <summary> /// アップロードされたファイルの名前(例:「報告書.xlsx」) /// </summary> public string FileName { get; set; } = string.Empty; /// <summary> /// ファイルの形式(例:「image/jpeg」「application/pdf」など) /// </summary> public string ContentType { get; set; } = string.Empty; /// <summary> /// ファイルの実際のデータ(バイト配列として保存) /// </summary> public byte[] Content { get; set; } = []; } /// <summary> /// AWS Lambda関数のメインクラス /// マルチパートフォームデータ(ファイルアップロードを含むフォーム)を処理します /// </summary> public class Function { /// <summary> /// Lambda関数のエントリーポイント(入り口) /// API Gatewayからのリクエストを受け取り、マルチパートデータを解析してレスポンスを返します /// </summary> /// <param name="request">API Gatewayからのリクエスト情報</param> /// <param name="context">Lambda実行時の情報(ログ出力などに使用)</param> /// <returns>処理結果のJSON文字列</returns> public static string FunctionHandler(APIGatewayProxyRequest request, ILambdaContext context) { try { // Base64エンコードされているかどうかをログ出力 context.Logger.LogInformation($"IsBase64Encoded: {request.IsBase64Encoded}"); // マルチパートデータを解析(文字列からフィールドとファイルに分離) var formData = ParseMultipartFormData(request.Body, contentType, request.IsBase64Encoded); // 全フィールドとファイルの情報をログに出力 formData.Fields.ToList().ForEach(f => context.Logger.LogInformation($"Field: {f.Key} = {f.Value}")); formData.Files.ToList().ForEach(f => context.Logger.LogInformation($"File: {f.Key} = {f.Value.FileName} ({f.Value.ContentType}, {f.Value.Content.Length} bytes)")); // レスポンスをJSON文字列に変換して返す return "ok"; } catch (Exception ex) { // エラーが発生した場合の処理 context.Logger.LogError($"Error processing multipart form data: {ex.Message}"); return System.Text.Json.JsonSerializer.Serialize(new { error = ex.Message }); } } /// <summary> /// マルチパートフォームデータの本体を解析するメソッド /// 受信したデータを個別のフィールドとファイルに分離します /// </summary> /// <param name="body">リクエストボディの文字列</param> /// <param name="contentType">Content-Typeヘッダーの値</param> /// <param name="isBase64Encoded">データがBase64でエンコードされているかどうか</param> /// <returns>解析されたフォームデータ</returns> private static MultipartFormData ParseMultipartFormData(string body, string contentType, bool isBase64Encoded) { var formData = new MultipartFormData(); // Content-Typeからboundaryパラメータを抽出 // boundaryは各フィールドを区切る文字列です var boundary = ExtractBoundary(contentType); if (string.IsNullOrEmpty(boundary)) { throw new ArgumentException("Content-TypeヘッダーにBoundaryが見つかりません"); } // リクエストボディをバイト配列に変換 byte[] bodyBytes; if (isBase64Encoded) { // Base64でエンコードされている場合はデコード bodyBytes = Convert.FromBase64String(body); } else { // 通常の文字列の場合はUTF-8でバイト配列に変換 bodyBytes = Encoding.UTF8.GetBytes(body); } // boundaryを使ってデータを個別の部分に分割 var boundaryBytes = Encoding.UTF8.GetBytes("--" + boundary); var parts = SplitByBoundary(bodyBytes, boundaryBytes); // 分割された各部分を処理 foreach (var part in parts) { if (part.Length == 0) continue; // 空の部分はスキップ // 各部分からヘッダーと内容を分離 var (headers, content) = ParsePart(part); // Content-Dispositionヘッダーを取得 var disposition = headers.TryGetValue("Content-Disposition", out var disp) ? disp : ""; // form-dataかどうかをチェック if (disposition.Contains("form-data")) { // フィールド名を取得 var name = ExtractQuotedValue(disposition, "name"); // ファイル名を取得(ファイルアップロードの場合のみ存在) var filename = ExtractQuotedValue(disposition, "filename"); if (!string.IsNullOrEmpty(filename)) { // ファイルフィールドの場合 var fileContentType = headers.TryGetValue("Content-Type", out var ct) ? ct : "application/octet-stream"; // RFC 2047エンコードされたファイル名をデコード var decodedFileName = DecodeRfc2047Filename(filename); formData.Files[name] = new FileData { FileName = decodedFileName, ContentType = fileContentType, Content = content }; } else { // テキストフィールドの場合 formData.Fields[name] = Encoding.UTF8.GetString(content); } } } return formData; } /// <summary> /// Content-Typeヘッダーからboundary値を取得します /// 例:「multipart/form-data; boundary=----WebKitFormBoundary123」から「----WebKitFormBoundary123」を抽出 /// </summary> /// <param name="contentType">Content-Typeヘッダーの値</param> /// <returns>boundary文字列</returns> private static string ExtractBoundary(string contentType) { // セミコロンで分割してパラメータ部分を取得 var parts = contentType.Split(';'); foreach (var part in parts) { var trimmed = part.Trim(); // boundary=で始まる部分を探す if (trimmed.StartsWith("boundary=")) { // "boundary="の部分(9文字)を除いた文字列を返す return trimmed[9..]; } } return string.Empty; } private static List<byte[]> SplitByBoundary(byte[] data, byte[] boundary) { var parts = new List<byte[]>(); var start = 0; while (start < data.Length) { var index = IndexOf(data, boundary, start); if (index == -1) break; if (start > 0) { var partLength = index - start; if (partLength > 2) // Skip \r\n { var part = new byte[partLength - 2]; Array.Copy(data, start, part, 0, partLength - 2); parts.Add(part); } } start = index + boundary.Length; if (start + 1 < data.Length && data[start] == '\r' && data[start + 1] == '\n') { start += 2; } } return parts; } private static int IndexOf(byte[] array, byte[] pattern, int startIndex) { for (int i = startIndex; i <= array.Length - pattern.Length; i++) { bool found = true; for (int j = 0; j < pattern.Length; j++) { if (array[i + j] != pattern[j]) { found = false; break; } } if (found) return i; } return -1; } private static (Dictionary<string, string> headers, byte[] content) ParsePart(byte[] part) { var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var headerEndIndex = IndexOf(part, Encoding.UTF8.GetBytes("\r\n\r\n"), 0); if (headerEndIndex == -1) { return (headers, part); } var headerBytes = new byte[headerEndIndex]; Array.Copy(part, 0, headerBytes, 0, headerEndIndex); var headerText = Encoding.UTF8.GetString(headerBytes); var headerLines = headerText.Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries); foreach (var line in headerLines) { var colonIndex = line.IndexOf(':'); if (colonIndex > 0) { var key = line[..colonIndex].Trim(); var value = line[(colonIndex + 1)..].Trim(); headers[key] = value; } } var contentStart = headerEndIndex + 4; // Skip \r\n\r\n var contentLength = part.Length - contentStart; var content = new byte[contentLength]; Array.Copy(part, contentStart, content, 0, contentLength); return (headers, content); } private static string ExtractQuotedValue(string input, string key) { var keyPattern = key + "=\""; var startIndex = input.IndexOf(keyPattern); if (startIndex == -1) return string.Empty; startIndex += keyPattern.Length; var endIndex = input.IndexOf('"', startIndex); if (endIndex == -1) return string.Empty; return input[startIndex..endIndex]; } private static string DecodeRfc2047Filename(string filename) { // Check if the filename is RFC 2047 encoded // Format: =?charset?encoding?encoded-text?= if (!filename.StartsWith("=?") || !filename.EndsWith("?=")) { return filename; } try { // Remove the leading =? and trailing ?= var encodedPart = filename[2..^2]; // Split by ? to get charset, encoding, and encoded text var parts = encodedPart.Split('?'); if (parts.Length != 3) { return filename; // Invalid format, return original } var charset = parts[0].ToUpperInvariant(); var encoding = parts[1].ToUpperInvariant(); var encodedText = parts[2]; // Decode based on the encoding type byte[] decodedBytes; if (encoding == "B") // Base64 { decodedBytes = Convert.FromBase64String(encodedText); } else if (encoding == "Q") // Quoted-Printable { decodedBytes = DecodeQuotedPrintable(encodedText); } else { return filename; // Unsupported encoding } // Convert bytes to string using the specified charset var textEncoding = charset switch { "UTF-8" => Encoding.UTF8, "ISO-8859-1" => Encoding.Latin1, "US-ASCII" => Encoding.ASCII, _ => Encoding.UTF8 // Default to UTF-8 }; return textEncoding.GetString(decodedBytes); } catch { // If decoding fails, return the original filename return filename; } } private static byte[] DecodeQuotedPrintable(string input) { var result = new List<byte>(); for (int i = 0; i < input.Length; i++) { if (input[i] == '=') { if (i + 2 < input.Length) { // Try to parse hex digits if (byte.TryParse(input.Substring(i + 1, 2), System.Globalization.NumberStyles.HexNumber, null, out byte hexByte)) { result.Add(hexByte); i += 2; // Skip the two hex digits continue; } } } else if (input[i] == '_') { // In RFC 2047, underscore represents space result.Add((byte)' '); } else { result.Add((byte)input[i]); } } return result.ToArray(); } }
Webhookの結果は管理画面で確認
Webhookでデータが送信されたログは管理画面で確認できます。送信時のAPIキー設定など、HTTPヘッダーを編集するといった機能も用意されていますので、運用に応じて細かなカスタマイズが可能です。
まとめ
メールと連携したシステムはよくあります。通常、メールサーバを立てて、その中で処理することが多いのですが、メールサーバが落ちてしまうとシステムが稼働しなくなったり、メール文面の解析が煩雑でした。Customers Mail Cloudを使えばそうした手間なくJSONで処理できて便利です。
添付ファイルまで処理対象にしたい時には、この方法を利用してください。