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

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で新しい関数を作成します。その際、条件として以下を指定します。

  • ランタイム
    .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";
    }
}

全体の流れ

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

  1. データを扱うクラスの定義

  2. API Gateway から Base64 エンコードされた body を受け取る。Content-Type から boundary を取り出す

  3. MultipartStream を使って各パートを解析
  4. フィールドデータは変数に格納、添付ファイルは一時ファイルとして保存
  5. 最後に処理結果をログに出力

データを扱うクラスの定義

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

Webhookログ

まとめ

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

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

受信サーバ | Customers Mail Cloud