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で処理できて便利です。
添付ファイルまで処理対象にしたい時には、この方法を利用してください。
