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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,4 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
CLAUDE.md
7 changes: 7 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<Project>

<PropertyGroup>
<!-- Do not import the monorepo root Directory.Packages.props. This library owns its
own package versions and must also build correctly through local project references. -->
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
<!-- Opt out of Central Package Management — this library is a standalone
NuGet package with its own versioning, independent of the monorepo. -->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>

<!-- Multi-targeting: .NET 8 (LTS), .NET 9, .NET 10 -->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>

Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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:
Expand Down
5 changes: 5 additions & 0 deletions src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </para>
/// <para>
/// Deserialization is case-insensitive because live webhook captures showed mixed casing for
/// some callback keys (for example <c>Message-Id</c> and <c>Subject</c>).
/// </para>
/// </remarks>
internal static class Smtp2GoJsonDefaults
{
Expand All @@ -20,6 +24,7 @@ internal static class Smtp2GoJsonDefaults
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
33 changes: 2 additions & 31 deletions src/Smtp2Go.NET/Models/Webhooks/BounceType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,6 @@ public enum BounceType
/// </remarks>
public class BounceTypeJsonConverter : JsonConverter<BounceType>
{
#region Constants & Statics

/// <summary>
/// The SMTP2GO API string for hard bounces.
/// </summary>
private const string HardValue = "hard";

/// <summary>
/// The SMTP2GO API string for soft bounces.
/// </summary>
private const string SoftValue = "soft";

#endregion


#region Methods - Public

/// <summary>
Expand All @@ -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;
}

/// <summary>
Expand All @@ -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
Expand Down
54 changes: 2 additions & 52 deletions src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,32 +116,6 @@ public enum WebhookCallbackEvent
/// </remarks>
public class WebhookCallbackEventJsonConverter : JsonConverter<WebhookCallbackEvent>
{
#region Constants & Statics

/// <summary>SMTP2GO callback payload string for the "processed" event.</summary>
private const string ProcessedValue = "processed";

/// <summary>SMTP2GO callback payload string for the "delivered" event.</summary>
private const string DeliveredValue = "delivered";

/// <summary>SMTP2GO callback payload string for the "bounce" event.</summary>
private const string BounceValue = "bounce";

/// <summary>SMTP2GO callback payload string for the "opened" event.</summary>
private const string OpenedValue = "opened";

/// <summary>SMTP2GO callback payload string for the "clicked" event.</summary>
private const string ClickedValue = "clicked";

/// <summary>SMTP2GO callback payload string for the "unsubscribed" event.</summary>
private const string UnsubscribedValue = "unsubscribed";

/// <summary>SMTP2GO callback payload string for the "spam_complaint" event.</summary>
private const string SpamComplaintValue = "spam_complaint";

#endregion


#region Methods - Public

/// <summary>
Expand All @@ -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());
}

/// <summary>
Expand All @@ -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
Expand Down
95 changes: 92 additions & 3 deletions src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,46 @@ namespace Smtp2Go.NET.Models.Webhooks;
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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:
/// <list type="bullet">
/// <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>
/// <item>
/// Processed and delivered callbacks include additional provider metadata such as
/// <c>id</c>, <c>auth</c>, <c>message-id</c>/<c>Message-Id</c>, <c>subject</c>/<c>Subject</c>,
/// <c>from</c>, <c>from_address</c>, and <c>from_name</c>.
/// </item>
/// </list>
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // In an ASP.NET Core controller:
/// [HttpPost("webhooks/smtp2go")]
/// public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload)
/// public async Task&lt;IActionResult&gt; 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&lt;string, string?&gt;(pair.Key, value))));
/// }
/// else
/// {
/// payload = await Request.ReadFromJsonAsync&lt;WebhookCallbackPayload&gt;(cancellationToken: cancellationToken)
/// ?? new WebhookCallbackPayload();
/// }
///
/// switch (payload.Event)
/// {
/// case WebhookCallbackEvent.Delivered:
Expand Down Expand Up @@ -64,6 +85,22 @@ public class WebhookCallbackPayload
[JsonPropertyName("email_id")]
public string? EmailId { get; init; }

/// <summary>
/// Gets the SMTP message identifier observed in the webhook callback.
/// </summary>
/// <remarks>
/// <para>
/// This is distinct from <see cref="EmailId" />. <see cref="EmailId" /> 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.
/// </para>
/// <para>
/// Live callbacks emitted this field with inconsistent casing (<c>Message-Id</c> and <c>message-id</c>).
/// </para>
/// </remarks>
[JsonPropertyName("message-id")]
public string? MessageId { get; init; }

/// <summary>
/// Gets the type of event that triggered this webhook callback.
/// </summary>
Expand Down Expand Up @@ -97,6 +134,35 @@ public class WebhookCallbackPayload
[JsonPropertyName("sendtime")]
public DateTimeOffset? SendTime { get; init; }

/// <summary>
/// Gets the message subject observed in the webhook callback.
/// </summary>
/// <remarks>
/// Live callbacks emitted this field with inconsistent casing (<c>Subject</c> and <c>subject</c>).
/// </remarks>
[JsonPropertyName("subject")]
public string? Subject { get; init; }

/// <summary>
/// Gets the provider-specific callback event identifier.
/// </summary>
/// <remarks>
/// This maps to the raw <c>id</c> field observed in live callbacks. Different events for the same
/// <see cref="EmailId" /> can carry different values, so this is treated as an event-level identifier.
/// </remarks>
[JsonPropertyName("id")]
public string? EventId { get; init; }

/// <summary>
/// Gets the opaque provider auth marker observed in live callbacks.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[JsonPropertyName("auth")]
public string? Auth { get; init; }

/// <summary>
/// Gets the per-event recipient email address.
/// </summary>
Expand All @@ -116,6 +182,29 @@ public class WebhookCallbackPayload
[JsonPropertyName("sender")]
public string? Sender { get; init; }

/// <summary>
/// Gets the raw <c>from</c> field observed in live callbacks.
/// </summary>
/// <remarks>
/// Live callbacks included <c>sender</c>, <c>from</c>, and <c>from_address</c>. They carried the same
/// address in the captured delivered and processed payloads, so this property is preserved separately
/// to avoid losing transport detail.
/// </remarks>
[JsonPropertyName("from")]
public string? From { get; init; }

/// <summary>
/// Gets the raw <c>from_address</c> field observed in live callbacks.
/// </summary>
[JsonPropertyName("from_address")]
public string? FromAddress { get; init; }

/// <summary>
/// Gets the raw <c>from_name</c> field observed in live callbacks.
/// </summary>
[JsonPropertyName("from_name")]
public string? FromName { get; init; }

/// <summary>
/// Gets the list of all recipients of the original email.
/// </summary>
Expand Down
Loading
Loading