From 5133d6a5c7e881c31348324922faa3a6361f84a4 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:47:42 +0100 Subject: [PATCH 1/2] feat(webhooks): parse live SMTP2GO form callbacks and model observed payload fields --- .gitignore | 1 + Directory.Build.props | 7 + README.md | 19 +- .../Internal/Smtp2GoJsonDefaults.cs | 5 + src/Smtp2Go.NET/Models/Webhooks/BounceType.cs | 33 +-- .../Models/Webhooks/WebhookCallbackEvent.cs | 54 +--- .../Models/Webhooks/WebhookCallbackPayload.cs | 95 ++++++- .../Webhooks/WebhookCallbackPayloadParser.cs | 245 ++++++++++++++++++ .../Webhooks/WebhookCallbackValueParser.cs | 157 +++++++++++ .../Fixtures/WebhookReceiverFixture.cs | 35 ++- .../WebhookDeliveryIntegrationTests.cs | 18 +- .../WebhookPayloadDeserializationTests.cs | 50 +++- .../Models/WebhookPayloadFormParsingTests.cs | 244 +++++++++++++++++ 13 files changed, 862 insertions(+), 101 deletions(-) create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayloadParser.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackValueParser.cs create mode 100644 tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadFormParsingTests.cs diff --git a/.gitignore b/.gitignore index ce89292..b0f1f01 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,4 @@ FodyWeavers.xsd *.msix *.msm *.msp +CLAUDE.md diff --git a/Directory.Build.props b/Directory.Build.props index abb41cc..c13e3df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,13 @@ + + false + + false + net8.0;net9.0;net10.0 diff --git a/README.md b/README.md index 69c792c..1317ccc 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,27 @@ await smtp2Go.Webhooks.DeleteAsync(webhookId); ### Receiving Webhook Callbacks -SMTP2GO sends HTTP POST requests to your registered webhook URL when email events occur. The `WebhookCallbackPayload` model deserializes the inbound payload: +SMTP2GO sends HTTP POST requests to your registered webhook URL when email events occur. Callbacks may arrive as JSON or as form-encoded payloads, and both should be normalized into the same `WebhookCallbackPayload` model: ```csharp [HttpPost("webhooks/smtp2go")] -public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload) +public async Task HandleWebhook(CancellationToken cancellationToken) { + WebhookCallbackPayload payload; + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(cancellationToken); + payload = WebhookCallbackPayloadParser.ParseFormValues( + form.SelectMany(pair => pair.Value.Select(value => + new KeyValuePair(pair.Key, value)))); + } + else + { + payload = await Request.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? new WebhookCallbackPayload(); + } + switch (payload.Event) { case WebhookCallbackEvent.Delivered: diff --git a/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs b/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs index 82f8c33..10d033e 100644 --- a/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs +++ b/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs @@ -11,6 +11,10 @@ namespace Smtp2Go.NET.Internal; /// The SMTP2GO API uses snake_case naming convention for all JSON properties. /// Null values are omitted from serialization to keep requests minimal. /// +/// +/// Deserialization is case-insensitive because live webhook captures showed mixed casing for +/// some callback keys (for example Message-Id and Subject). +/// /// internal static class Smtp2GoJsonDefaults { @@ -20,6 +24,7 @@ internal static class Smtp2GoJsonDefaults public static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; } diff --git a/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs b/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs index 5ef734c..5cd990d 100644 --- a/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs +++ b/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs @@ -73,21 +73,6 @@ public enum BounceType /// public class BounceTypeJsonConverter : JsonConverter { - #region Constants & Statics - - /// - /// The SMTP2GO API string for hard bounces. - /// - private const string HardValue = "hard"; - - /// - /// The SMTP2GO API string for soft bounces. - /// - private const string SoftValue = "soft"; - - #endregion - - #region Methods - Public /// @@ -102,14 +87,7 @@ public override BounceType Read( Type typeToConvert, JsonSerializerOptions options) { - var value = reader.GetString(); - - return value switch - { - HardValue => BounceType.Hard, - SoftValue => BounceType.Soft, - _ => BounceType.Unknown - }; + return WebhookCallbackValueParser.ParseBounceType(reader.GetString()) ?? BounceType.Unknown; } /// @@ -123,14 +101,7 @@ public override void Write( BounceType value, JsonSerializerOptions options) { - var stringValue = value switch - { - BounceType.Hard => HardValue, - BounceType.Soft => SoftValue, - _ => "unknown" - }; - - writer.WriteStringValue(stringValue); + writer.WriteStringValue(WebhookCallbackValueParser.FormatBounceType(value)); } #endregion diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs index 8599ce0..825cae0 100644 --- a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs @@ -116,32 +116,6 @@ public enum WebhookCallbackEvent /// public class WebhookCallbackEventJsonConverter : JsonConverter { - #region Constants & Statics - - /// SMTP2GO callback payload string for the "processed" event. - private const string ProcessedValue = "processed"; - - /// SMTP2GO callback payload string for the "delivered" event. - private const string DeliveredValue = "delivered"; - - /// SMTP2GO callback payload string for the "bounce" event. - private const string BounceValue = "bounce"; - - /// SMTP2GO callback payload string for the "opened" event. - private const string OpenedValue = "opened"; - - /// SMTP2GO callback payload string for the "clicked" event. - private const string ClickedValue = "clicked"; - - /// SMTP2GO callback payload string for the "unsubscribed" event. - private const string UnsubscribedValue = "unsubscribed"; - - /// SMTP2GO callback payload string for the "spam_complaint" event. - private const string SpamComplaintValue = "spam_complaint"; - - #endregion - - #region Methods - Public /// @@ -156,19 +130,7 @@ public override WebhookCallbackEvent Read( Type typeToConvert, JsonSerializerOptions options) { - var value = reader.GetString(); - - return value switch - { - ProcessedValue => WebhookCallbackEvent.Processed, - DeliveredValue => WebhookCallbackEvent.Delivered, - BounceValue => WebhookCallbackEvent.Bounce, - OpenedValue => WebhookCallbackEvent.Opened, - ClickedValue => WebhookCallbackEvent.Clicked, - UnsubscribedValue => WebhookCallbackEvent.Unsubscribed, - SpamComplaintValue => WebhookCallbackEvent.SpamComplaint, - _ => WebhookCallbackEvent.Unknown - }; + return WebhookCallbackValueParser.ParseCallbackEvent(reader.GetString()); } /// @@ -182,19 +144,7 @@ public override void Write( WebhookCallbackEvent value, JsonSerializerOptions options) { - var stringValue = value switch - { - WebhookCallbackEvent.Processed => ProcessedValue, - WebhookCallbackEvent.Delivered => DeliveredValue, - WebhookCallbackEvent.Bounce => BounceValue, - WebhookCallbackEvent.Opened => OpenedValue, - WebhookCallbackEvent.Clicked => ClickedValue, - WebhookCallbackEvent.Unsubscribed => UnsubscribedValue, - WebhookCallbackEvent.SpamComplaint => SpamComplaintValue, - _ => "unknown" - }; - - writer.WriteStringValue(stringValue); + writer.WriteStringValue(WebhookCallbackValueParser.FormatCallbackEvent(value)); } #endregion diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs index f84388f..966e01a 100644 --- a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs @@ -8,16 +8,23 @@ namespace Smtp2Go.NET.Models.Webhooks; /// /// /// SMTP2GO sends HTTP POST requests to registered webhook URLs when email -/// events occur. This model deserializes the inbound webhook payload. +/// events occur. This model is the canonical in-memory representation of +/// JSON and form-encoded webhook callbacks. /// /// -/// The fields populated depend on the event type: +/// Live callbacks captured in the integration test suite showed that SMTP2GO does not emit one +/// stable callback shape. The fields populated depend on the event type: /// /// (rcpt) is present for delivered and bounce events. /// is present for processed events (array of all recipients). /// , , and /// are present for bounce and delivered events. /// and are only present for click events. +/// +/// Processed and delivered callbacks include additional provider metadata such as +/// id, auth, message-id/Message-Id, subject/Subject, +/// from, from_address, and from_name. +/// /// /// /// @@ -25,8 +32,22 @@ namespace Smtp2Go.NET.Models.Webhooks; /// /// // In an ASP.NET Core controller: /// [HttpPost("webhooks/smtp2go")] -/// public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload) +/// public async Task<IActionResult> HandleWebhook(CancellationToken cancellationToken) /// { +/// WebhookCallbackPayload payload; +/// +/// if (Request.HasFormContentType) +/// { +/// var form = await Request.ReadFormAsync(cancellationToken); +/// payload = WebhookCallbackPayloadParser.ParseFormValues( +/// form.SelectMany(pair => pair.Value.Select(value => new KeyValuePair<string, string?>(pair.Key, value)))); +/// } +/// else +/// { +/// payload = await Request.ReadFromJsonAsync<WebhookCallbackPayload>(cancellationToken: cancellationToken) +/// ?? new WebhookCallbackPayload(); +/// } +/// /// switch (payload.Event) /// { /// case WebhookCallbackEvent.Delivered: @@ -64,6 +85,22 @@ public class WebhookCallbackPayload [JsonPropertyName("email_id")] public string? EmailId { get; init; } + /// + /// Gets the SMTP message identifier observed in the webhook callback. + /// + /// + /// + /// This is distinct from . is SMTP2GO's provider-side + /// correlation ID returned by the send API, while this property carries the RFC 5322 Message-ID style + /// value observed in webhook callbacks. + /// + /// + /// Live callbacks emitted this field with inconsistent casing (Message-Id and message-id). + /// + /// + [JsonPropertyName("message-id")] + public string? MessageId { get; init; } + /// /// Gets the type of event that triggered this webhook callback. /// @@ -97,6 +134,35 @@ public class WebhookCallbackPayload [JsonPropertyName("sendtime")] public DateTimeOffset? SendTime { get; init; } + /// + /// Gets the message subject observed in the webhook callback. + /// + /// + /// Live callbacks emitted this field with inconsistent casing (Subject and subject). + /// + [JsonPropertyName("subject")] + public string? Subject { get; init; } + + /// + /// Gets the provider-specific callback event identifier. + /// + /// + /// This maps to the raw id field observed in live callbacks. Different events for the same + /// can carry different values, so this is treated as an event-level identifier. + /// + [JsonPropertyName("id")] + public string? EventId { get; init; } + + /// + /// Gets the opaque provider auth marker observed in live callbacks. + /// + /// + /// The exact semantics are undocumented. Live callbacks included values such as a truncated API key + /// prefix, so the library preserves the field as opaque diagnostic metadata. + /// + [JsonPropertyName("auth")] + public string? Auth { get; init; } + /// /// Gets the per-event recipient email address. /// @@ -116,6 +182,29 @@ public class WebhookCallbackPayload [JsonPropertyName("sender")] public string? Sender { get; init; } + /// + /// Gets the raw from field observed in live callbacks. + /// + /// + /// Live callbacks included sender, from, and from_address. They carried the same + /// address in the captured delivered and processed payloads, so this property is preserved separately + /// to avoid losing transport detail. + /// + [JsonPropertyName("from")] + public string? From { get; init; } + + /// + /// Gets the raw from_address field observed in live callbacks. + /// + [JsonPropertyName("from_address")] + public string? FromAddress { get; init; } + + /// + /// Gets the raw from_name field observed in live callbacks. + /// + [JsonPropertyName("from_name")] + public string? FromName { get; init; } + /// /// Gets the list of all recipients of the original email. /// diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayloadParser.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayloadParser.cs new file mode 100644 index 0000000..56ca9d0 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayloadParser.cs @@ -0,0 +1,245 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +/// +/// Converts SMTP2GO form-encoded webhook fields into . +/// +/// +/// +/// SMTP2GO webhook callbacks are not guaranteed to arrive as JSON. When a callback is delivered +/// as application/x-www-form-urlencoded or multipart/form-data, callers can flatten +/// the inbound form values into key/value pairs and pass them to this parser. +/// +/// +/// Live captures showed that form callbacks also vary by event type. For example, processed events +/// carried recipients and srchost, while delivered events carried rcpt, +/// context, host, and message. +/// +/// +/// The resulting payload uses the same canonical model as JSON callbacks so downstream application +/// code does not need separate handling for the transport format. +/// +/// +public static class WebhookCallbackPayloadParser +{ + + #region Methods - Public + + /// + /// Parses flattened SMTP2GO form values into a . + /// + /// + /// The flattened form values. Duplicate keys are allowed and are expected for repeated + /// fields such as recipients. + /// + /// The parsed . + public static WebhookCallbackPayload ParseFormValues(IEnumerable> formValues) + { + ArgumentNullException.ThrowIfNull(formValues); + + string? sourceHost = null; + string? emailId = null; + string? messageId = null; + string? eventValue = null; + string? time = null; + string? sendTime = null; + string? subject = null; + string? eventId = null; + string? auth = null; + string? recipient = null; + string? sender = null; + string? from = null; + string? fromAddress = null; + string? fromName = null; + string? bounceType = null; + string? bounceContext = null; + string? host = null; + string? smtpResponse = null; + string? clickUrl = null; + string? link = null; + List? recipients = null; + + foreach (var pair in formValues) + { + var normalizedKey = NormalizeKey(pair.Key); + + if (normalizedKey is null) + { + continue; + } + + switch (normalizedKey) + { + case "srchost": + sourceHost = pair.Value; + break; + + case "email_id": + emailId = pair.Value; + break; + + case "message-id": + case "message_id": + messageId = pair.Value; + break; + + case "event": + eventValue = pair.Value; + break; + + case "time": + time = pair.Value; + break; + + case "sendtime": + sendTime = pair.Value; + break; + + case "subject": + subject = pair.Value; + break; + + case "id": + eventId = pair.Value; + break; + + case "auth": + auth = pair.Value; + break; + + case "rcpt": + recipient = pair.Value; + break; + + case "sender": + sender = pair.Value; + break; + + case "from": + from = pair.Value; + break; + + case "from_address": + fromAddress = pair.Value; + break; + + case "from_name": + fromName = pair.Value; + break; + + case "recipients": + recipients ??= []; + recipients.Add(pair.Value); + break; + + case "bounce": + bounceType = pair.Value; + break; + + case "context": + bounceContext = pair.Value; + break; + + case "host": + host = pair.Value; + break; + + case "message": + smtpResponse = pair.Value; + break; + + case "click_url": + clickUrl = pair.Value; + break; + + case "link": + link = pair.Value; + break; + } + } + + return new WebhookCallbackPayload + { + SourceHost = sourceHost, + EmailId = emailId, + MessageId = messageId, + Event = WebhookCallbackValueParser.ParseCallbackEvent(eventValue), + Time = WebhookCallbackValueParser.ParseDateTimeOffset(time), + SendTime = WebhookCallbackValueParser.ParseDateTimeOffset(sendTime), + Subject = subject, + EventId = eventId, + Auth = auth, + Recipient = recipient, + Sender = sender, + From = from, + FromAddress = fromAddress, + FromName = fromName, + Recipients = recipients is null + ? null + : WebhookCallbackValueParser.ParseRecipients(recipients), + BounceType = WebhookCallbackValueParser.ParseBounceType(bounceType), + BounceContext = bounceContext, + Host = host, + SmtpResponse = smtpResponse, + ClickUrl = clickUrl, + Link = link + }; + } + + + /// + /// Parses grouped SMTP2GO form values into a . + /// + /// + /// The grouped form values, where each key maps to zero or more submitted values. + /// + /// The parsed . + public static WebhookCallbackPayload ParseFormValues(IReadOnlyDictionary formValues) + { + ArgumentNullException.ThrowIfNull(formValues); + + return ParseFormValues(Flatten(formValues)); + } + + #endregion + + + #region Methods - Private + + /// + /// Flattens grouped form values into duplicate-friendly key/value pairs. + /// + /// The grouped form values. + /// The flattened values. + private static IEnumerable> Flatten(IReadOnlyDictionary formValues) + { + foreach (var pair in formValues) + { + if (pair.Value is null || pair.Value.Length == 0) + { + yield return new KeyValuePair(pair.Key, null); + + continue; + } + + foreach (var value in pair.Value) + { + yield return new KeyValuePair(pair.Key, value); + } + } + } + + + /// + /// Normalizes a submitted form key for switch-based parsing. + /// + /// The raw form key. + /// The normalized key, or null when empty. + private static string? NormalizeKey(string key) + { + return string.IsNullOrWhiteSpace(key) + ? null + : key.Trim().ToLowerInvariant(); + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackValueParser.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackValueParser.cs new file mode 100644 index 0000000..7c059e4 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackValueParser.cs @@ -0,0 +1,157 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Globalization; + +/// +/// Centralizes normalization and parsing for SMTP2GO webhook callback values. +/// +/// +/// +/// This helper is intentionally internal so the library can keep one source of truth for +/// callback value parsing across JSON converters and form payload conversion. +/// +/// +/// The accepted values reflect the currently supported library behavior, including the +/// compatibility aliases already handled by AlosNotify's SMTP2GO form workaround. +/// +/// +internal static class WebhookCallbackValueParser +{ + + #region Methods - Internal + + /// + /// Parses a callback event string into a value. + /// + /// The raw callback event string. + /// The parsed value. + internal static WebhookCallbackEvent ParseCallbackEvent(string? value) + { + return Normalize(value) switch + { + "processed" => WebhookCallbackEvent.Processed, + "delivered" => WebhookCallbackEvent.Delivered, + "bounce" => WebhookCallbackEvent.Bounce, + "open" or "opened" => WebhookCallbackEvent.Opened, + "click" or "clicked" => WebhookCallbackEvent.Clicked, + "unsubscribe" or "unsubscribed" => WebhookCallbackEvent.Unsubscribed, + "spam" or "spam_complaint" => WebhookCallbackEvent.SpamComplaint, + _ => WebhookCallbackEvent.Unknown + }; + } + + + /// + /// Formats a callback event enum as the canonical SMTP2GO wire string. + /// + /// The callback event value. + /// The SMTP2GO wire string. + internal static string FormatCallbackEvent(WebhookCallbackEvent value) + { + return value switch + { + WebhookCallbackEvent.Processed => "processed", + WebhookCallbackEvent.Delivered => "delivered", + WebhookCallbackEvent.Bounce => "bounce", + WebhookCallbackEvent.Opened => "opened", + WebhookCallbackEvent.Clicked => "clicked", + WebhookCallbackEvent.Unsubscribed => "unsubscribed", + WebhookCallbackEvent.SpamComplaint => "spam_complaint", + _ => "unknown" + }; + } + + + /// + /// Parses a bounce type string into a nullable value. + /// + /// The raw bounce type string. + /// The parsed bounce type, or null when the value is absent. + internal static BounceType? ParseBounceType(string? value) + { + return Normalize(value) switch + { + null => null, + "hard" => BounceType.Hard, + "soft" => BounceType.Soft, + _ => BounceType.Unknown + }; + } + + + /// + /// Formats a bounce type enum as the canonical SMTP2GO wire string. + /// + /// The bounce type value. + /// The SMTP2GO wire string. + internal static string FormatBounceType(BounceType value) + { + return value switch + { + BounceType.Hard => "hard", + BounceType.Soft => "soft", + _ => "unknown" + }; + } + + + /// + /// Parses an ISO 8601 timestamp string into a . + /// + /// The raw timestamp value. + /// The parsed timestamp, or null when absent or invalid. + internal static DateTimeOffset? ParseDateTimeOffset(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed) + ? parsed + : null; + } + + + /// + /// Normalizes recipient list values from repeated or delimiter-separated fields. + /// + /// The raw recipient values. + /// The normalized recipients, or null when none are present. + internal static string[]? ParseRecipients(IEnumerable values) + { + ArgumentNullException.ThrowIfNull(values); + + var recipients = values.Where(static value => !string.IsNullOrWhiteSpace(value)) + .SelectMany(static value => value!.Split( + [',', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .ToArray(); + + return recipients.Length > 0 ? recipients : null; + } + + #endregion + + + #region Methods - Private + + /// + /// Normalizes a raw SMTP2GO callback string for switch-based parsing. + /// + /// The raw string value. + /// The normalized string, or null when empty. + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs index 654e1c0..cb66ccc 100644 --- a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs @@ -3,6 +3,7 @@ namespace Smtp2Go.NET.IntegrationTests.Fixtures; using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using Internal; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -55,7 +56,7 @@ internal sealed class WebhookReceiverFixture : IAsyncDisposable /// Thread-safe collection of received webhook payloads. private readonly ConcurrentBag _receivedPayloads = new(); - /// Thread-safe collection of raw JSON bodies received (for debugging). + /// Thread-safe collection of raw request bodies received (for debugging). private readonly ConcurrentBag _rawBodies = new(); /// Registered waiters notified via when a matching payload arrives. @@ -72,7 +73,7 @@ internal sealed class WebhookReceiverFixture : IAsyncDisposable /// Gets all received webhook payloads. public IReadOnlyCollection ReceivedPayloads => _receivedPayloads.ToArray(); - /// Gets all raw JSON bodies received (useful for debugging deserialization issues). + /// Gets all raw request bodies received (useful for debugging deserialization issues). public IReadOnlyCollection RawBodies => _rawBodies.ToArray(); #endregion @@ -142,9 +143,14 @@ public async Task StartAsync(string username, string password) Console.Error.WriteLine($"[WebhookReceiver] Auth OK"); + // Buffer the request so we can capture the raw body for diagnostics and still let + // the form reader parse the payload when the callback is form-encoded. + ctx.Request.EnableBuffering(); + // Read and store the raw body. using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); + ctx.Request.Body.Position = 0; _rawBodies.Add(body); Console.Error.WriteLine($"[WebhookReceiver] Body length: {body.Length} chars"); @@ -152,10 +158,7 @@ public async Task StartAsync(string username, string password) // Attempt to deserialize the webhook payload. try { - var payload = JsonSerializer.Deserialize(body, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var payload = await ParsePayloadAsync(ctx, body); if (payload != null) { @@ -254,6 +257,26 @@ private void NotifyWaiters(WebhookCallbackPayload payload) } } + + /// + /// Parses the inbound webhook payload based on the request content type. + /// + /// The current HTTP context. + /// The raw request body captured for diagnostics. + /// The parsed webhook payload, or null when parsing fails. + private static async Task ParsePayloadAsync(HttpContext ctx, string body) + { + if (ctx.Request.HasFormContentType) + { + var form = await ctx.Request.ReadFormAsync(); + + return WebhookCallbackPayloadParser.ParseFormValues( + form.SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value)))); + } + + return JsonSerializer.Deserialize(body, Smtp2GoJsonDefaults.Options); + } + #endregion diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs index cfa66e8..a7ba415 100644 --- a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs +++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs @@ -119,19 +119,25 @@ public async Task SendEmail_ReceivesDeliveredWebhook() // SMTP2GO sends one payload per event per recipient (WebhookCallbackPayload.Event is singular). // We accept any event type — 'processed' arrives first, 'delivered' later. // 180-second timeout accounts for email delivery delay and SMTP2GO processing time. - var payload = await receiver.WaitForPayloadAsync( - _ => true, + var processedPayload = await receiver.WaitForPayloadAsync( + p => p.Event == WebhookCallbackEvent.Processed, + timeout: TimeSpan.FromSeconds(180)); + + var deliveredPayload = await receiver.WaitForPayloadAsync( + p => p.Event == WebhookCallbackEvent.Delivered, timeout: TimeSpan.FromSeconds(180)); // Diagnostic: Log all received payloads and raw bodies for debugging. LogReceivedPayloads("WebhookDeliveryTest", receiver); // Assert: At minimum, we should receive a 'processed' or 'delivered' event. - payload.Should().NotBeNull("a webhook event (processed or delivered) should be received within 180 seconds"); + processedPayload.Should().NotBeNull("a processed webhook event should be received within 180 seconds"); + deliveredPayload.Should().NotBeNull("a delivered webhook event should be received within 180 seconds after SMTP2GO accepts the email"); // Log which event we received. - Console.Error.WriteLine($"[WebhookDeliveryTest] Received webhook event: {payload!.Event}"); - payload.Event.Should().BeOneOf(WebhookCallbackEvent.Processed, WebhookCallbackEvent.Delivered); + Console.Error.WriteLine($"[WebhookDeliveryTest] Received processed webhook event: {processedPayload!.Event}"); + Console.Error.WriteLine($"[WebhookDeliveryTest] Received delivered webhook event: {deliveredPayload!.Event}"); + deliveredPayload.Event.Should().Be(WebhookCallbackEvent.Delivered); } finally { @@ -372,7 +378,7 @@ private static void LogReceivedPayloads(string testName, WebhookReceiverFixture Console.Error.WriteLine($"[{testName}] Received {receiver.ReceivedPayloads.Count} payload(s), {receiver.RawBodies.Count} raw body(ies)."); foreach (var raw in receiver.RawBodies) - Console.Error.WriteLine($"[{testName}] Raw body: {raw[..Math.Min(raw.Length, 500)]}"); + Console.Error.WriteLine($"[{testName}] Raw body: {raw}"); } diff --git a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs index e7ba467..e691e7e 100644 --- a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs +++ b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs @@ -32,10 +32,17 @@ public void Deserialize_ProcessedEvent_ParsesCorrectly() // array but NOT "rcpt". const string json = """ { + "message-id": "", + "subject": "Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7", + "id": "e57b42854a69ee377c4221c22e08e5e7", + "auth": "api-597435AE4E55", "srchost": "146.70.170.30", "email_id": "1vomg2-abc123", "event": "processed", "time": "2026-02-07T18:05:02Z", + "from": "noreply@example.com", + "from_address": "noreply@example.com", + "from_name": "", "sender": "noreply@example.com", "recipients": ["user@example.com", "user2@example.com"], "sendtime": "2026-02-07T18:05:02.199324+00:00" @@ -47,12 +54,19 @@ public void Deserialize_ProcessedEvent_ParsesCorrectly() // Assert payload.Should().NotBeNull(); + payload!.MessageId.Should().Be(""); + payload.Subject.Should().Be("Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"); + payload.EventId.Should().Be("e57b42854a69ee377c4221c22e08e5e7"); + payload.Auth.Should().Be("api-597435AE4E55"); payload!.SourceHost.Should().Be("146.70.170.30"); payload.EmailId.Should().Be("1vomg2-abc123"); payload.Event.Should().Be(WebhookCallbackEvent.Processed); payload.Time.Should().Be(new DateTimeOffset(2026, 2, 7, 18, 5, 2, TimeSpan.Zero)); payload.SendTime.Should().NotBeNull(); payload.Sender.Should().Be("noreply@example.com"); + payload.From.Should().Be("noreply@example.com"); + payload.FromAddress.Should().Be("noreply@example.com"); + payload.FromName.Should().BeEmpty(); // Processed events have "recipients" array, not "rcpt". payload.Recipient.Should().BeNull(); @@ -75,11 +89,18 @@ public void Deserialize_DeliveredEvent_ParsesCorrectly() // (single recipient) but NOT "recipients" array. const string json = """ { + "Message-Id": "", + "Subject": "Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7", + "id": "6dfa7d3b4514c1f5f0e916bc0cc0395c", + "auth": "api-597435AE4E55", "srchost": "146.70.170.30", "email_id": "1vomg2-abc123", "event": "delivered", "time": "2026-02-07T18:05:06Z", "rcpt": "user@example.com", + "from": "noreply@alos.app", + "from_address": "noreply@alos.app", + "from_name": "", "sender": "noreply@alos.app", "host": "mail.protonmail.ch [176.119.200.128]", "context": "Unavailable", @@ -93,7 +114,11 @@ public void Deserialize_DeliveredEvent_ParsesCorrectly() // Assert payload.Should().NotBeNull(); - payload!.SourceHost.Should().Be("146.70.170.30"); + payload!.MessageId.Should().Be(""); + payload.Subject.Should().Be("Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"); + payload.EventId.Should().Be("6dfa7d3b4514c1f5f0e916bc0cc0395c"); + payload.Auth.Should().Be("api-597435AE4E55"); + payload.SourceHost.Should().Be("146.70.170.30"); payload.EmailId.Should().Be("1vomg2-abc123"); payload.Event.Should().Be(WebhookCallbackEvent.Delivered); payload.Time.Should().Be(new DateTimeOffset(2026, 2, 7, 18, 5, 6, TimeSpan.Zero)); @@ -104,6 +129,9 @@ public void Deserialize_DeliveredEvent_ParsesCorrectly() payload.Recipients.Should().BeNull(); payload.Sender.Should().Be("noreply@alos.app"); + payload.From.Should().Be("noreply@alos.app"); + payload.FromAddress.Should().Be("noreply@alos.app"); + payload.FromName.Should().BeEmpty(); payload.Host.Should().Be("mail.protonmail.ch [176.119.200.128]"); payload.BounceContext.Should().Be("Unavailable"); payload.SmtpResponse.Should().Be("250 2.0.0 Ok: 2788 bytes queued as 4f7f4b3tWbzKy"); @@ -229,6 +257,26 @@ public void CallbackEventConverter_DeserializesKnownEvents(string jsonValue, Web } + [Theory] + [InlineData("open", WebhookCallbackEvent.Opened)] + [InlineData("click", WebhookCallbackEvent.Clicked)] + [InlineData("spam", WebhookCallbackEvent.SpamComplaint)] + [InlineData("unsubscribe", WebhookCallbackEvent.Unsubscribed)] + public void CallbackEventConverter_DeserializesCompatibilityAliases(string jsonValue, WebhookCallbackEvent expected) + { + // Arrange — Preserve the compatibility aliases already accepted by the form workaround + // so JSON and form callback parsing cannot drift again. + var json = $$"""{"event": "{{jsonValue}}"}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(expected); + } + + [Theory] [InlineData("some_future_event")] [InlineData("hard_bounced")] diff --git a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadFormParsingTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadFormParsingTests.cs new file mode 100644 index 0000000..bf09d60 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadFormParsingTests.cs @@ -0,0 +1,244 @@ +namespace Smtp2Go.NET.UnitTests.Models; + +using Smtp2Go.NET.Models.Webhooks; + +/// +/// Verifies conversion of SMTP2GO form-encoded webhook callbacks into +/// . +/// +[Trait("Category", "Unit")] +public sealed class WebhookPayloadFormParsingTests +{ + #region Delivered Event + + [Fact] + public void ParseFormValues_LiveDeliveredPayload_ParsesObservedFields() + { + // Arrange + // This payload was captured from a real SMTP2GO delivered callback on 2026-03-24. + var formValues = new Dictionary + { + ["Message-Id"] = [""], + ["Subject"] = ["Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"], + ["auth"] = ["api-597435AE4E55"], + ["email_id"] = ["1w4x2g-FnQW0hPru7M-NRRC"], + ["event"] = ["delivered"], + ["from"] = ["testing@dev.mjosdrone.no"], + ["from_address"] = ["testing@dev.mjosdrone.no"], + ["from_name"] = [""], + ["host"] = ["mail.protonmail.ch [185.205.70.128]"], + ["id"] = ["6dfa7d3b4514c1f5f0e916bc0cc0395c"], + ["context"] = ["Unavailable"], + ["message"] = ["250 2.0.0 Ok: 2780 bytes queued as 4fg32Y2xRcz3T"], + ["message-id"] = [""], + ["rcpt"] = ["alexis.pujo@pm.me"], + ["sender"] = ["testing@dev.mjosdrone.no"], + ["sendtime"] = ["2026-03-24T08:23:19.052765+00:00"], + ["subject"] = ["Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"], + ["time"] = ["2026-03-24T08:23:19Z"] + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Delivered); + payload.EmailId.Should().Be("1w4x2g-FnQW0hPru7M-NRRC"); + payload.MessageId.Should().Be(""); + payload.Subject.Should().Be("Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"); + payload.EventId.Should().Be("6dfa7d3b4514c1f5f0e916bc0cc0395c"); + payload.Auth.Should().Be("api-597435AE4E55"); + payload.Recipient.Should().Be("alexis.pujo@pm.me"); + payload.Sender.Should().Be("testing@dev.mjosdrone.no"); + payload.From.Should().Be("testing@dev.mjosdrone.no"); + payload.FromAddress.Should().Be("testing@dev.mjosdrone.no"); + payload.FromName.Should().BeEmpty(); + payload.Time.Should().Be(new DateTimeOffset(2026, 3, 24, 8, 23, 19, TimeSpan.Zero)); + payload.SendTime.Should().Be(new DateTimeOffset(2026, 3, 24, 8, 23, 19, 52, TimeSpan.Zero).AddTicks(7650)); + payload.Host.Should().Be("mail.protonmail.ch [185.205.70.128]"); + payload.BounceContext.Should().Be("Unavailable"); + payload.SmtpResponse.Should().Be("250 2.0.0 Ok: 2780 bytes queued as 4fg32Y2xRcz3T"); + payload.Recipients.Should().BeNull(); + } + + + [Fact] + public void ParseFormValues_LiveProcessedPayload_ParsesObservedFields() + { + // Arrange + // This payload was captured from a real SMTP2GO processed callback on 2026-03-24. + var formValues = new Dictionary + { + ["Message-Id"] = [""], + ["Subject"] = ["Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"], + ["auth"] = ["api-597435AE4E55"], + ["email_id"] = ["1w4x2g-FnQW0hPru7M-NRRC"], + ["event"] = ["processed"], + ["from"] = ["testing@dev.mjosdrone.no"], + ["from_address"] = ["testing@dev.mjosdrone.no"], + ["from_name"] = [""], + ["id"] = ["e57b42854a69ee377c4221c22e08e5e7"], + ["message-id"] = [""], + ["recipients"] = ["alexis.pujo@pm.me"], + ["sender"] = ["testing@dev.mjosdrone.no"], + ["sendtime"] = ["2026-03-24T08:23:14.828376+00:00"], + ["srchost"] = ["146.70.170.19"], + ["subject"] = ["Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"], + ["time"] = ["2026-03-24T08:23:14Z"] + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Processed); + payload.EmailId.Should().Be("1w4x2g-FnQW0hPru7M-NRRC"); + payload.MessageId.Should().Be(""); + payload.Subject.Should().Be("Webhook Delivery Test - b83d60289ef94e028a45a905198ad9b7"); + payload.EventId.Should().Be("e57b42854a69ee377c4221c22e08e5e7"); + payload.Auth.Should().Be("api-597435AE4E55"); + payload.SourceHost.Should().Be("146.70.170.19"); + payload.Sender.Should().Be("testing@dev.mjosdrone.no"); + payload.From.Should().Be("testing@dev.mjosdrone.no"); + payload.FromAddress.Should().Be("testing@dev.mjosdrone.no"); + payload.FromName.Should().BeEmpty(); + payload.Time.Should().Be(new DateTimeOffset(2026, 3, 24, 8, 23, 14, TimeSpan.Zero)); + payload.SendTime.Should().Be(new DateTimeOffset(2026, 3, 24, 8, 23, 14, 828, TimeSpan.Zero).AddTicks(3760)); + payload.Recipient.Should().BeNull(); + payload.Recipients.Should().BeEquivalentTo("alexis.pujo@pm.me"); + payload.BounceContext.Should().BeNull(); + payload.SmtpResponse.Should().BeNull(); + } + + #endregion + + + #region Bounce Event + + [Fact] + public void ParseFormValues_BouncePayload_ParsesCorrectly() + { + // Arrange + var formValues = new[] + { + new KeyValuePair("event", "bounce"), + new KeyValuePair("email_id", "provider-bounce-123"), + new KeyValuePair("rcpt", "recipient@example.com"), + new KeyValuePair("bounce", "soft"), + new KeyValuePair("context", "DATA: 452 Mailbox full") + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Bounce); + payload.EmailId.Should().Be("provider-bounce-123"); + payload.Recipient.Should().Be("recipient@example.com"); + payload.BounceType.Should().Be(BounceType.Soft); + payload.BounceContext.Should().Be("DATA: 452 Mailbox full"); + } + + #endregion + + + #region Recipients + + [Fact] + public void ParseFormValues_RecipientsRepeatedAndDelimited_NormalizesRecipients() + { + // Arrange + var formValues = new[] + { + new KeyValuePair("event", "processed"), + new KeyValuePair("recipients", "one@example.com"), + new KeyValuePair("recipients", "two@example.com; three@example.com"), + new KeyValuePair("recipients", "four@example.com,five@example.com") + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Processed); + payload.Recipients.Should().BeEquivalentTo( + "one@example.com", + "two@example.com", + "three@example.com", + "four@example.com", + "five@example.com"); + } + + #endregion + + + #region Compatibility Aliases + + [Theory] + [InlineData("open", WebhookCallbackEvent.Opened)] + [InlineData("opened", WebhookCallbackEvent.Opened)] + [InlineData("click", WebhookCallbackEvent.Clicked)] + [InlineData("clicked", WebhookCallbackEvent.Clicked)] + [InlineData("spam", WebhookCallbackEvent.SpamComplaint)] + [InlineData("spam_complaint", WebhookCallbackEvent.SpamComplaint)] + [InlineData("unsubscribe", WebhookCallbackEvent.Unsubscribed)] + [InlineData("unsubscribed", WebhookCallbackEvent.Unsubscribed)] + public void ParseFormValues_CompatibilityEventAliases_NormalizesToCanonicalEnum( + string rawValue, + WebhookCallbackEvent expected) + { + // Arrange + var formValues = new[] + { + new KeyValuePair("event", rawValue) + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(expected); + } + + #endregion + + + #region Invalid Inputs + + [Fact] + public void ParseFormValues_InvalidTimestamp_ReturnsNullTimestamp() + { + // Arrange + var formValues = new[] + { + new KeyValuePair("event", "delivered"), + new KeyValuePair("time", "not-a-timestamp") + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Delivered); + payload.Time.Should().BeNull(); + } + + + [Fact] + public void ParseFormValues_UnknownEvent_ReturnsUnknown() + { + // Arrange + var formValues = new[] + { + new KeyValuePair("event", "totally_new_event") + }; + + // Act + var payload = WebhookCallbackPayloadParser.ParseFormValues(formValues); + + // Assert + payload.Event.Should().Be(WebhookCallbackEvent.Unknown); + } + + #endregion +} From 385783643848176896ebb0cbb9273c0777692e5f Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:48:32 +0100 Subject: [PATCH 2/2] Version bump --- src/Smtp2Go.NET/Smtp2Go.NET.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Smtp2Go.NET/Smtp2Go.NET.csproj b/src/Smtp2Go.NET/Smtp2Go.NET.csproj index df55b91..9d7f187 100644 --- a/src/Smtp2Go.NET/Smtp2Go.NET.csproj +++ b/src/Smtp2Go.NET/Smtp2Go.NET.csproj @@ -5,7 +5,7 @@ Smtp2Go.NET - 1.1.0 + 1.2.0 A .NET client library for the SMTP2GO email delivery API. Supports sending emails, webhook management, and email statistics with built-in resilience. smtp2go;email;smtp;api;webhook;dotnet