From d04cacf67c7d8b118d644633c2cd2cd72c5b7193 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:04:33 +0100 Subject: [PATCH 1/3] Fixed payload - smtp2go dev docs have errors --- README.md | 26 ++-- .../Models/Webhooks/WebhookCallbackPayload.cs | 79 ++++++++---- src/Smtp2Go.NET/Smtp2Go.NET.csproj | 2 +- .../WebhookPayloadDeserializationTests.cs | 115 ++++++++++++++---- 4 files changed, 160 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 4e65d3a..69c792c 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,16 @@ public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload) switch (payload.Event) { case WebhookCallbackEvent.Delivered: - logger.LogInformation("Delivered to {Email}", payload.Email); + logger.LogInformation("Delivered to {Recipient}", payload.Recipient); break; case WebhookCallbackEvent.Bounce: - logger.LogWarning("Bounce ({Type}) for {Email}: {Context}", - payload.BounceType, payload.Email, payload.BounceContext); + logger.LogWarning("Bounce ({Type}) for {Recipient}: {Context}", + payload.BounceType, payload.Recipient, payload.BounceContext); break; case WebhookCallbackEvent.SpamComplaint: - logger.LogWarning("Spam complaint from {Email}", payload.Email); + logger.LogWarning("Spam complaint from {Recipient}", payload.Recipient); break; } @@ -129,14 +129,16 @@ SMTP2GO uses different event names for **subscriptions** vs **callback payloads* |-------|------|-------------| | `Event` | `WebhookCallbackEvent` | The event type that triggered this callback | | `EmailId` | `string?` | SMTP2GO email identifier (correlates with send response) | -| `Email` | `string?` | Recipient email address for this event | +| `Recipient` | `string?` | Per-event recipient (`rcpt`); present for delivered/bounce events | +| `Recipients` | `string[]?` | All recipients from the original send; present for processed events | | `Sender` | `string?` | Sender email address | -| `Timestamp` | `int` | Unix timestamp (seconds since epoch) | -| `Hostname` | `string?` | SMTP2GO server that processed the email | -| `RecipientsList` | `string[]?` | All recipients from the original send | +| `Time` | `DateTimeOffset?` | ISO 8601 timestamp when the event occurred | +| `SendTime` | `DateTimeOffset?` | ISO 8601 timestamp when the email was sent by SMTP2GO | +| `SourceHost` | `string?` | Source host IP of the SMTP2GO server that processed the email | | `BounceType` | `BounceType?` | `Hard` or `Soft` (bounce events only) | -| `BounceContext` | `string?` | SMTP transaction context (bounce events only) | -| `Host` | `string?` | Target mail server host and IP (bounce events only) | +| `BounceContext` | `string?` | SMTP transaction context (bounce and delivered events) | +| `Host` | `string?` | Target mail server host and IP (bounce and delivered events) | +| `SmtpResponse` | `string?` | SMTP 250 response from receiving server (delivered events only) | | `ClickUrl` | `string?` | Original URL clicked (click events only) | | `Link` | `string?` | Tracked link URL (click events only) | @@ -209,7 +211,7 @@ dotnet build Smtp2Go.NET.slnx ### Testing ```bash -# Unit tests (73 tests, no network required) +# Unit tests (74 tests, no network required) tests/Smtp2Go.NET.UnitTests/bin/Debug/net10.0/Smtp2Go.NET.UnitTests # Integration tests (15 tests, requires API keys configured via user secrets) @@ -244,7 +246,7 @@ Smtp2Go.NET/ │ ├── Smtp2GoClient.cs # Main client implementation │ └── ServiceCollectionExtensions.cs # DI registration └── tests/ - ├── Smtp2Go.NET.UnitTests/ # 73 unit tests (Moq-based) + ├── Smtp2Go.NET.UnitTests/ # 77 unit tests (Moq-based) └── Smtp2Go.NET.IntegrationTests/ # 15 integration tests (live API) ``` diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs index c112bef..f84388f 100644 --- a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs @@ -13,7 +13,10 @@ namespace Smtp2Go.NET.Models.Webhooks; /// /// The fields populated depend on the event type: /// -/// , , and are only present for bounce events. +/// (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. /// /// @@ -27,7 +30,7 @@ namespace Smtp2Go.NET.Models.Webhooks; /// switch (payload.Event) /// { /// case WebhookCallbackEvent.Delivered: -/// // Handle delivery confirmation +/// // Handle delivery confirmation — payload.Recipient has the recipient /// break; /// case WebhookCallbackEvent.Bounce: /// // Handle bounce — check payload.BounceType for hard/soft @@ -40,10 +43,13 @@ namespace Smtp2Go.NET.Models.Webhooks; public class WebhookCallbackPayload { /// - /// Gets the hostname of the SMTP2GO sending server that processed the email. + /// Gets the source host IP address of the SMTP2GO server that processed the email. /// - [JsonPropertyName("hostname")] - public string? Hostname { get; init; } + /// + /// Maps to the srchost field in the SMTP2GO webhook JSON payload. + /// + [JsonPropertyName("srchost")] + public string? SourceHost { get; init; } /// /// Gets the unique SMTP2GO identifier for the email associated with this event. @@ -72,29 +78,37 @@ public class WebhookCallbackPayload public WebhookCallbackEvent Event { get; init; } /// - /// Gets the Unix timestamp (seconds since epoch) when the event occurred. + /// Gets the ISO 8601 timestamp when the event occurred. /// /// - /// - /// Convert to using - /// . - /// + /// Maps to the time field in the SMTP2GO webhook JSON payload. + /// Format example: 2026-02-07T18:05:02Z. + /// + [JsonPropertyName("time")] + public DateTimeOffset? Time { get; init; } + + /// + /// Gets the ISO 8601 timestamp when the email was sent by SMTP2GO. + /// + /// + /// Maps to the sendtime field in the SMTP2GO webhook JSON payload. + /// Format example: 2026-02-07T18:05:02.199324+00:00. /// - [JsonPropertyName("timestamp")] - public int Timestamp { get; init; } + [JsonPropertyName("sendtime")] + public DateTimeOffset? SendTime { get; init; } /// - /// Gets the recipient email address associated with this event. + /// Gets the per-event recipient email address. /// /// /// - /// The specific recipient that this event applies to. For example, - /// a delivered event for a multi-recipient email will generate one - /// webhook per recipient. + /// Maps to the rcpt field in the SMTP2GO webhook JSON payload. + /// Present for delivered and bounce events (one webhook per recipient). + /// Not present for processed events — use instead. /// /// - [JsonPropertyName("email")] - public string? Email { get; init; } + [JsonPropertyName("rcpt")] + public string? Recipient { get; init; } /// /// Gets the sender email address of the original email. @@ -107,11 +121,13 @@ public class WebhookCallbackPayload /// /// /// - /// Contains all To, CC, and BCC recipients from the original send request. + /// Maps to the recipients field in the SMTP2GO webhook JSON payload. + /// Present for processed events. For delivered/bounce events, use + /// (rcpt) which has the per-event recipient. /// /// - [JsonPropertyName("recipients_list")] - public string[]? RecipientsList { get; init; } + [JsonPropertyName("recipients")] + public string[]? Recipients { get; init; } /// /// Gets the bounce type when the event is a bounce. @@ -132,12 +148,13 @@ public class WebhookCallbackPayload public BounceType? BounceType { get; init; } /// - /// Gets the bounce diagnostic context from the recipient's mail server. + /// Gets the diagnostic context from the recipient's mail server. /// /// /// - /// Only populated for events. Contains + /// Present for bounce and delivered events. For bounce events, contains /// the SMTP transaction context (e.g., "RCPT TO:<user@example.com>"). + /// For delivered events, may contain "Unavailable". /// /// [JsonPropertyName("context")] @@ -148,13 +165,25 @@ public class WebhookCallbackPayload /// /// /// - /// Only populated for events. Contains the - /// MX host and IP address (e.g., "gmail-smtp-in.l.google.com [209.85.233.26]"). + /// Present for bounce and delivered events. Contains the MX host and IP address + /// (e.g., "mail.protonmail.ch [176.119.200.128]"). /// /// [JsonPropertyName("host")] public string? Host { get; init; } + /// + /// Gets the SMTP response message from the receiving mail server. + /// + /// + /// + /// Present for delivered events. Contains the SMTP 250 response + /// (e.g., "250 2.0.0 Ok: 2788 bytes queued as 4f7f4b3tWbzKy"). + /// + /// + [JsonPropertyName("message")] + public string? SmtpResponse { get; init; } + /// /// Gets the URL that was clicked by the recipient. /// diff --git a/src/Smtp2Go.NET/Smtp2Go.NET.csproj b/src/Smtp2Go.NET/Smtp2Go.NET.csproj index 20e8f78..df55b91 100644 --- a/src/Smtp2Go.NET/Smtp2Go.NET.csproj +++ b/src/Smtp2Go.NET/Smtp2Go.NET.csproj @@ -5,7 +5,7 @@ Smtp2Go.NET - 1.0.0 + 1.1.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 diff --git a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs index 4e2a439..e7ba467 100644 --- a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs +++ b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs @@ -9,24 +9,82 @@ namespace Smtp2Go.NET.UnitTests.Models; /// including the custom JSON converters for /// and . /// +/// +/// JSON fixtures in these tests use the actual SMTP2GO webhook format, +/// captured from live webhook callbacks (not documentation — the docs are inaccurate). +/// Key differences from SMTP2GO docs: +/// +/// Recipient field is "rcpt" (delivered/bounce), not "email". +/// Recipients array is "recipients" (processed), not "recipients_list". +/// Timestamp is "time" (ISO 8601 string), not "timestamp" (int). +/// Source host is "srchost", not "hostname". +/// +/// [Trait("Category", "Unit")] public sealed class WebhookPayloadDeserializationTests { + #region Processed Event + + [Fact] + public void Deserialize_ProcessedEvent_ParsesCorrectly() + { + // Arrange — Actual SMTP2GO processed event format. Note: processed events have "recipients" + // array but NOT "rcpt". + const string json = """ + { + "srchost": "146.70.170.30", + "email_id": "1vomg2-abc123", + "event": "processed", + "time": "2026-02-07T18:05:02Z", + "sender": "noreply@example.com", + "recipients": ["user@example.com", "user2@example.com"], + "sendtime": "2026-02-07T18:05:02.199324+00:00" + } + """; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + 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"); + + // Processed events have "recipients" array, not "rcpt". + payload.Recipient.Should().BeNull(); + payload.Recipients.Should().HaveCount(2); + payload.Recipients![0].Should().Be("user@example.com"); + + payload.BounceType.Should().BeNull(); + payload.BounceContext.Should().BeNull(); + } + + #endregion + + #region Delivered Event [Fact] public void Deserialize_DeliveredEvent_ParsesCorrectly() { - // Arrange + // Arrange — Actual SMTP2GO delivered event format. Note: delivered events have "rcpt" + // (single recipient) but NOT "recipients" array. const string json = """ { - "hostname": "mail01.smtp2go.com", - "email_id": "abc-123", + "srchost": "146.70.170.30", + "email_id": "1vomg2-abc123", "event": "delivered", - "timestamp": 1700000000, - "email": "user@example.com", + "time": "2026-02-07T18:05:06Z", + "rcpt": "user@example.com", "sender": "noreply@alos.app", - "recipients_list": ["user@example.com", "user2@example.com"] + "host": "mail.protonmail.ch [176.119.200.128]", + "context": "Unavailable", + "message": "250 2.0.0 Ok: 2788 bytes queued as 4f7f4b3tWbzKy", + "sendtime": "2026-02-07T18:05:06.215451+00:00" } """; @@ -35,15 +93,21 @@ public void Deserialize_DeliveredEvent_ParsesCorrectly() // Assert payload.Should().NotBeNull(); - payload!.Hostname.Should().Be("mail01.smtp2go.com"); - payload.EmailId.Should().Be("abc-123"); + payload!.SourceHost.Should().Be("146.70.170.30"); + payload.EmailId.Should().Be("1vomg2-abc123"); payload.Event.Should().Be(WebhookCallbackEvent.Delivered); - payload.Timestamp.Should().Be(1700000000); - payload.Email.Should().Be("user@example.com"); + payload.Time.Should().Be(new DateTimeOffset(2026, 2, 7, 18, 5, 6, TimeSpan.Zero)); + payload.SendTime.Should().NotBeNull(); + + // Delivered events have "rcpt" (per-event recipient), not "recipients" array. + payload.Recipient.Should().Be("user@example.com"); + payload.Recipients.Should().BeNull(); + payload.Sender.Should().Be("noreply@alos.app"); - payload.RecipientsList.Should().HaveCount(2); + 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"); payload.BounceType.Should().BeNull(); - payload.BounceContext.Should().BeNull(); } #endregion @@ -61,9 +125,9 @@ public void Deserialize_BounceEvent_HardBounce_ParsesBounceFields() { "email_id": "bounce-456", "event": "bounce", - "timestamp": 1700000100, - "email": "invalid@nonexistent.com", - "from": "noreply@alos.app", + "time": "2026-02-07T18:15:00Z", + "rcpt": "invalid@nonexistent.com", + "sender": "noreply@alos.app", "bounce": "hard", "context": "RCPT TO:", "host": "gmail-smtp-in.l.google.com [209.85.233.26]" @@ -79,6 +143,7 @@ public void Deserialize_BounceEvent_HardBounce_ParsesBounceFields() payload.BounceType.Should().Be(BounceType.Hard); payload.BounceContext.Should().Be("RCPT TO:"); payload.Host.Should().Be("gmail-smtp-in.l.google.com [209.85.233.26]"); + payload.Recipient.Should().Be("invalid@nonexistent.com"); } @@ -89,8 +154,8 @@ public void Deserialize_BounceEvent_SoftBounce_ParsesBounceFields() const string json = """ { "event": "bounce", - "timestamp": 1700000200, - "email": "user@example.com", + "time": "2026-02-07T18:18:00Z", + "rcpt": "user@example.com", "bounce": "soft", "context": "DATA: 452 Mailbox full" } @@ -104,6 +169,7 @@ public void Deserialize_BounceEvent_SoftBounce_ParsesBounceFields() payload!.Event.Should().Be(WebhookCallbackEvent.Bounce); payload.BounceType.Should().Be(BounceType.Soft); payload.BounceContext.Should().Be("DATA: 452 Mailbox full"); + payload.Recipient.Should().Be("user@example.com"); } #endregion @@ -118,8 +184,8 @@ public void Deserialize_ClickedEvent_ParsesClickFields() const string json = """ { "event": "clicked", - "timestamp": 1700000300, - "email": "user@example.com", + "time": "2026-02-07T18:20:00Z", + "rcpt": "user@example.com", "click_url": "https://alos.app/dashboard", "link": "https://track.smtp2go.com/abc123" } @@ -133,6 +199,7 @@ public void Deserialize_ClickedEvent_ParsesClickFields() payload!.Event.Should().Be(WebhookCallbackEvent.Clicked); payload.ClickUrl.Should().Be("https://alos.app/dashboard"); payload.Link.Should().Be("https://track.smtp2go.com/abc123"); + payload.Recipient.Should().Be("user@example.com"); } #endregion @@ -151,7 +218,7 @@ public void Deserialize_ClickedEvent_ParsesClickFields() public void CallbackEventConverter_DeserializesKnownEvents(string jsonValue, WebhookCallbackEvent expected) { // Arrange - var json = $$"""{"event": "{{jsonValue}}", "timestamp": 0}"""; + var json = $$"""{"event": "{{jsonValue}}"}"""; // Act var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); @@ -171,7 +238,7 @@ public void CallbackEventConverter_DeserializesUnknownEvent_AsUnknown(string jso // Arrange — The API may introduce new event types in the future. // Also verifies that the removed legacy values ("hard_bounced", "soft_bounced") // now correctly fall through to Unknown instead of being mapped to dead enum values. - var json = $$"""{"event": "{{jsonValue}}", "timestamp": 0}"""; + var json = $$"""{"event": "{{jsonValue}}"}"""; // Act var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); @@ -214,7 +281,7 @@ public void CallbackEventConverter_SerializesToSnakeCase(WebhookCallbackEvent va public void BounceTypeConverter_DeserializesKnownTypes(string jsonValue, BounceType expected) { // Arrange — The "bounce" field contains the bounce classification (hard/soft). - var json = $$"""{"event": "bounce", "timestamp": 0, "bounce": "{{jsonValue}}"}"""; + var json = $$"""{"event": "bounce", "bounce": "{{jsonValue}}"}"""; // Act var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); @@ -229,7 +296,7 @@ public void BounceTypeConverter_DeserializesKnownTypes(string jsonValue, BounceT public void BounceTypeConverter_DeserializesUnknownType_AsUnknown() { // Arrange - const string json = """{"event": "bounce", "timestamp": 0, "bounce": "future_type"}"""; + const string json = """{"event": "bounce", "bounce": "future_type"}"""; // Act var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); @@ -244,7 +311,7 @@ public void BounceTypeConverter_DeserializesUnknownType_AsUnknown() public void BounceTypeConverter_DeserializesNull_AsNull() { // Arrange — Non-bounce events have no "bounce" field. - const string json = """{"event": "delivered", "timestamp": 0}"""; + const string json = """{"event": "delivered"}"""; // Act var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); From 9173a4839fe415209a0f1ad447dd775cc0c033e7 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:12:25 +0100 Subject: [PATCH 2/3] Fixed integration test issue --- .../WebhookDeliveryIntegrationTests.cs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs index 3413791..cfa66e8 100644 --- a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs +++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs @@ -290,7 +290,11 @@ private async Task SetupWebhookPipelineAsync( }; var webhookUrl = webhookUri.Uri.AbsoluteUri; - // Step 3: Register the webhook with SMTP2GO. + // Step 3: Delete any stale webhooks from previous runs. + // SMTP2GO free tier allows only 1 webhook — a stale webhook from a failed run blocks creation. + await DeleteAllExistingWebhooksAsync(ct); + + // Step 4: Register the webhook with SMTP2GO. var createRequest = new WebhookCreateRequest { WebhookUrl = webhookUrl, @@ -308,6 +312,35 @@ private async Task SetupWebhookPipelineAsync( } + /// + /// Deletes all existing webhooks on the SMTP2GO account. + /// SMTP2GO free tier limits accounts to 1 webhook — stale webhooks from + /// previous failed runs block creation of new ones. + /// + private async Task DeleteAllExistingWebhooksAsync(CancellationToken ct) + { + var listResponse = await _fixture.Client.Webhooks.ListAsync(ct); + + if (listResponse.Data is not { Length: > 0 }) + return; + + foreach (var webhook in listResponse.Data) + { + if (webhook.WebhookId is { } id) + { + try + { + await _fixture.Client.Webhooks.DeleteAsync(id, ct); + } + catch + { + // Best-effort cleanup — continue with remaining webhooks. + } + } + } + } + + /// /// Best-effort webhook cleanup. Silently ignores errors to prevent masking test failures. /// From c558458d2cea874c2e87797ec144aadd56074447 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:12:32 +0100 Subject: [PATCH 3/3] Added missing file --- .../WebhookManagementIntegrationTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs index 9345483..a459a52 100644 --- a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs +++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs @@ -45,6 +45,39 @@ public WebhookManagementIntegrationTests(Smtp2GoLiveFixture fixture) #endregion + #region Methods - Helpers + + /// + /// Deletes all existing webhooks on the SMTP2GO account. + /// SMTP2GO free tier limits accounts to 1 webhook — stale webhooks from + /// previous failed runs or E2E tests block creation of new ones. + /// + private async Task DeleteAllExistingWebhooksAsync(CancellationToken ct) + { + var listResponse = await _fixture.Client.Webhooks.ListAsync(ct); + + if (listResponse.Data is not { Length: > 0 }) + return; + + foreach (var webhook in listResponse.Data) + { + if (webhook.WebhookId is { } id) + { + try + { + await _fixture.Client.Webhooks.DeleteAsync(id, ct); + } + catch + { + // Best-effort cleanup — continue with remaining webhooks. + } + } + } + } + + #endregion + + #region Webhook Lifecycle [Fact] @@ -56,6 +89,9 @@ public async Task WebhookLifecycle_CreateListDelete_Succeeds() var ct = TestContext.Current.CancellationToken; int? webhookId = null; + // SMTP2GO free tier allows only 1 webhook — clear stale webhooks from previous runs. + await DeleteAllExistingWebhooksAsync(ct); + try { // Step 1: Create a webhook. @@ -133,6 +169,9 @@ public async Task WebhookCreate_WithSpecificEvents_ConfiguresCorrectly() var ct = TestContext.Current.CancellationToken; int? webhookId = null; + // SMTP2GO free tier allows only 1 webhook — clear stale webhooks from previous runs. + await DeleteAllExistingWebhooksAsync(ct); + try { // Arrange — Create a webhook with a specific set of event types.