Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) |

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
```

Expand Down
79 changes: 54 additions & 25 deletions src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ namespace Smtp2Go.NET.Models.Webhooks;
/// <para>
/// The fields populated depend on the event type:
/// <list type="bullet">
/// <item><see cref="BounceType"/>, <see cref="BounceContext"/>, and <see cref="Host"/> are only present for bounce events.</item>
/// <item><see cref="Recipient"/> (<c>rcpt</c>) is present for delivered and bounce events.</item>
/// <item><see cref="Recipients"/> is present for processed events (array of all recipients).</item>
/// <item><see cref="BounceType"/>, <see cref="BounceContext"/>, and <see cref="Host"/>
/// are present for bounce and delivered events.</item>
/// <item><see cref="ClickUrl"/> and <see cref="Link"/> are only present for click events.</item>
/// </list>
/// </para>
Expand All @@ -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
Expand All @@ -40,10 +43,13 @@ namespace Smtp2Go.NET.Models.Webhooks;
public class WebhookCallbackPayload
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("hostname")]
public string? Hostname { get; init; }
/// <remarks>
/// Maps to the <c>srchost</c> field in the SMTP2GO webhook JSON payload.
/// </remarks>
[JsonPropertyName("srchost")]
public string? SourceHost { get; init; }

/// <summary>
/// Gets the unique SMTP2GO identifier for the email associated with this event.
Expand Down Expand Up @@ -72,29 +78,37 @@ public class WebhookCallbackPayload
public WebhookCallbackEvent Event { get; init; }

/// <summary>
/// Gets the Unix timestamp (seconds since epoch) when the event occurred.
/// Gets the ISO 8601 timestamp when the event occurred.
/// </summary>
/// <remarks>
/// <para>
/// Convert to <see cref="DateTimeOffset"/> using
/// <see cref="DateTimeOffset.FromUnixTimeSeconds"/>.
/// </para>
/// Maps to the <c>time</c> field in the SMTP2GO webhook JSON payload.
/// Format example: <c>2026-02-07T18:05:02Z</c>.
/// </remarks>
[JsonPropertyName("time")]
public DateTimeOffset? Time { get; init; }

/// <summary>
/// Gets the ISO 8601 timestamp when the email was sent by SMTP2GO.
/// </summary>
/// <remarks>
/// Maps to the <c>sendtime</c> field in the SMTP2GO webhook JSON payload.
/// Format example: <c>2026-02-07T18:05:02.199324+00:00</c>.
/// </remarks>
[JsonPropertyName("timestamp")]
public int Timestamp { get; init; }
[JsonPropertyName("sendtime")]
public DateTimeOffset? SendTime { get; init; }

/// <summary>
/// Gets the recipient email address associated with this event.
/// Gets the per-event recipient email address.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>rcpt</c> field in the SMTP2GO webhook JSON payload.
/// Present for delivered and bounce events (one webhook per recipient).
/// Not present for processed events — use <see cref="Recipients"/> instead.
/// </para>
/// </remarks>
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("rcpt")]
public string? Recipient { get; init; }

/// <summary>
/// Gets the sender email address of the original email.
Expand All @@ -107,11 +121,13 @@ public class WebhookCallbackPayload
/// </summary>
/// <remarks>
/// <para>
/// Contains all To, CC, and BCC recipients from the original send request.
/// Maps to the <c>recipients</c> field in the SMTP2GO webhook JSON payload.
/// Present for processed events. For delivered/bounce events, use
/// <see cref="Recipient"/> (<c>rcpt</c>) which has the per-event recipient.
/// </para>
/// </remarks>
[JsonPropertyName("recipients_list")]
public string[]? RecipientsList { get; init; }
[JsonPropertyName("recipients")]
public string[]? Recipients { get; init; }

/// <summary>
/// Gets the bounce type when the event is a bounce.
Expand All @@ -132,12 +148,13 @@ public class WebhookCallbackPayload
public BounceType? BounceType { get; init; }

/// <summary>
/// Gets the bounce diagnostic context from the recipient's mail server.
/// Gets the diagnostic context from the recipient's mail server.
/// </summary>
/// <remarks>
/// <para>
/// Only populated for <see cref="WebhookCallbackEvent.Bounce"/> events. Contains
/// Present for bounce and delivered events. For bounce events, contains
/// the SMTP transaction context (e.g., <c>"RCPT TO:&lt;user@example.com&gt;"</c>).
/// For delivered events, may contain <c>"Unavailable"</c>.
/// </para>
/// </remarks>
[JsonPropertyName("context")]
Expand All @@ -148,13 +165,25 @@ public class WebhookCallbackPayload
/// </summary>
/// <remarks>
/// <para>
/// Only populated for <see cref="WebhookCallbackEvent.Bounce"/> events. Contains the
/// MX host and IP address (e.g., <c>"gmail-smtp-in.l.google.com [209.85.233.26]"</c>).
/// Present for bounce and delivered events. Contains the MX host and IP address
/// (e.g., <c>"mail.protonmail.ch [176.119.200.128]"</c>).
/// </para>
/// </remarks>
[JsonPropertyName("host")]
public string? Host { get; init; }

/// <summary>
/// Gets the SMTP response message from the receiving mail server.
/// </summary>
/// <remarks>
/// <para>
/// Present for delivered events. Contains the SMTP 250 response
/// (e.g., <c>"250 2.0.0 Ok: 2788 bytes queued as 4f7f4b3tWbzKy"</c>).
/// </para>
/// </remarks>
[JsonPropertyName("message")]
public string? SmtpResponse { get; init; }

/// <summary>
/// Gets the URL that was clicked by the recipient.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Smtp2Go.NET/Smtp2Go.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<!-- NuGet Package Properties (common properties inherited from Directory.Build.props) -->
<PackageId>Smtp2Go.NET</PackageId>
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Description>A .NET client library for the SMTP2GO email delivery API. Supports sending emails, webhook management, and email statistics with built-in resilience.</Description>
<PackageTags>smtp2go;email;smtp;api;webhook;dotnet</PackageTags>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@ private async Task<int> 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,
Expand All @@ -308,6 +312,35 @@ private async Task<int> SetupWebhookPipelineAsync(
}


/// <summary>
/// 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.
/// </summary>
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.
}
}
}
}


/// <summary>
/// Best-effort webhook cleanup. Silently ignores errors to prevent masking test failures.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,39 @@ public WebhookManagementIntegrationTests(Smtp2GoLiveFixture fixture)
#endregion


#region Methods - Helpers

/// <summary>
/// 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.
/// </summary>
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]
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading