Skip to content

Commit a7bcf65

Browse files
authored
Merge pull request #3 from Alos-no/dev
Dev
2 parents 2642f07 + 3857836 commit a7bcf65

14 files changed

Lines changed: 863 additions & 102 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,4 @@ FodyWeavers.xsd
416416
*.msix
417417
*.msm
418418
*.msp
419+
CLAUDE.md

Directory.Build.props

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<Project>
22

33
<PropertyGroup>
4+
<!-- Do not import the monorepo root Directory.Packages.props. This library owns its
5+
own package versions and must also build correctly through local project references. -->
6+
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
7+
<!-- Opt out of Central Package Management — this library is a standalone
8+
NuGet package with its own versioning, independent of the monorepo. -->
9+
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
10+
411
<!-- Multi-targeting: .NET 8 (LTS), .NET 9, .NET 10 -->
512
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
613

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,27 @@ await smtp2Go.Webhooks.DeleteAsync(webhookId);
8181

8282
### Receiving Webhook Callbacks
8383

84-
SMTP2GO sends HTTP POST requests to your registered webhook URL when email events occur. The `WebhookCallbackPayload` model deserializes the inbound payload:
84+
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:
8585

8686
```csharp
8787
[HttpPost("webhooks/smtp2go")]
88-
public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload)
88+
public async Task<IActionResult> HandleWebhook(CancellationToken cancellationToken)
8989
{
90+
WebhookCallbackPayload payload;
91+
92+
if (Request.HasFormContentType)
93+
{
94+
var form = await Request.ReadFormAsync(cancellationToken);
95+
payload = WebhookCallbackPayloadParser.ParseFormValues(
96+
form.SelectMany(pair => pair.Value.Select(value =>
97+
new KeyValuePair<string, string?>(pair.Key, value))));
98+
}
99+
else
100+
{
101+
payload = await Request.ReadFromJsonAsync<WebhookCallbackPayload>(cancellationToken: cancellationToken)
102+
?? new WebhookCallbackPayload();
103+
}
104+
90105
switch (payload.Event)
91106
{
92107
case WebhookCallbackEvent.Delivered:

src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ namespace Smtp2Go.NET.Internal;
1111
/// The SMTP2GO API uses snake_case naming convention for all JSON properties.
1212
/// Null values are omitted from serialization to keep requests minimal.
1313
/// </para>
14+
/// <para>
15+
/// Deserialization is case-insensitive because live webhook captures showed mixed casing for
16+
/// some callback keys (for example <c>Message-Id</c> and <c>Subject</c>).
17+
/// </para>
1418
/// </remarks>
1519
internal static class Smtp2GoJsonDefaults
1620
{
@@ -20,6 +24,7 @@ internal static class Smtp2GoJsonDefaults
2024
public static readonly JsonSerializerOptions Options = new()
2125
{
2226
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
27+
PropertyNameCaseInsensitive = true,
2328
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
2429
};
2530
}

src/Smtp2Go.NET/Models/Webhooks/BounceType.cs

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -73,21 +73,6 @@ public enum BounceType
7373
/// </remarks>
7474
public class BounceTypeJsonConverter : JsonConverter<BounceType>
7575
{
76-
#region Constants & Statics
77-
78-
/// <summary>
79-
/// The SMTP2GO API string for hard bounces.
80-
/// </summary>
81-
private const string HardValue = "hard";
82-
83-
/// <summary>
84-
/// The SMTP2GO API string for soft bounces.
85-
/// </summary>
86-
private const string SoftValue = "soft";
87-
88-
#endregion
89-
90-
9176
#region Methods - Public
9277

9378
/// <summary>
@@ -102,14 +87,7 @@ public override BounceType Read(
10287
Type typeToConvert,
10388
JsonSerializerOptions options)
10489
{
105-
var value = reader.GetString();
106-
107-
return value switch
108-
{
109-
HardValue => BounceType.Hard,
110-
SoftValue => BounceType.Soft,
111-
_ => BounceType.Unknown
112-
};
90+
return WebhookCallbackValueParser.ParseBounceType(reader.GetString()) ?? BounceType.Unknown;
11391
}
11492

11593
/// <summary>
@@ -123,14 +101,7 @@ public override void Write(
123101
BounceType value,
124102
JsonSerializerOptions options)
125103
{
126-
var stringValue = value switch
127-
{
128-
BounceType.Hard => HardValue,
129-
BounceType.Soft => SoftValue,
130-
_ => "unknown"
131-
};
132-
133-
writer.WriteStringValue(stringValue);
104+
writer.WriteStringValue(WebhookCallbackValueParser.FormatBounceType(value));
134105
}
135106

136107
#endregion

src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -116,32 +116,6 @@ public enum WebhookCallbackEvent
116116
/// </remarks>
117117
public class WebhookCallbackEventJsonConverter : JsonConverter<WebhookCallbackEvent>
118118
{
119-
#region Constants & Statics
120-
121-
/// <summary>SMTP2GO callback payload string for the "processed" event.</summary>
122-
private const string ProcessedValue = "processed";
123-
124-
/// <summary>SMTP2GO callback payload string for the "delivered" event.</summary>
125-
private const string DeliveredValue = "delivered";
126-
127-
/// <summary>SMTP2GO callback payload string for the "bounce" event.</summary>
128-
private const string BounceValue = "bounce";
129-
130-
/// <summary>SMTP2GO callback payload string for the "opened" event.</summary>
131-
private const string OpenedValue = "opened";
132-
133-
/// <summary>SMTP2GO callback payload string for the "clicked" event.</summary>
134-
private const string ClickedValue = "clicked";
135-
136-
/// <summary>SMTP2GO callback payload string for the "unsubscribed" event.</summary>
137-
private const string UnsubscribedValue = "unsubscribed";
138-
139-
/// <summary>SMTP2GO callback payload string for the "spam_complaint" event.</summary>
140-
private const string SpamComplaintValue = "spam_complaint";
141-
142-
#endregion
143-
144-
145119
#region Methods - Public
146120

147121
/// <summary>
@@ -156,19 +130,7 @@ public override WebhookCallbackEvent Read(
156130
Type typeToConvert,
157131
JsonSerializerOptions options)
158132
{
159-
var value = reader.GetString();
160-
161-
return value switch
162-
{
163-
ProcessedValue => WebhookCallbackEvent.Processed,
164-
DeliveredValue => WebhookCallbackEvent.Delivered,
165-
BounceValue => WebhookCallbackEvent.Bounce,
166-
OpenedValue => WebhookCallbackEvent.Opened,
167-
ClickedValue => WebhookCallbackEvent.Clicked,
168-
UnsubscribedValue => WebhookCallbackEvent.Unsubscribed,
169-
SpamComplaintValue => WebhookCallbackEvent.SpamComplaint,
170-
_ => WebhookCallbackEvent.Unknown
171-
};
133+
return WebhookCallbackValueParser.ParseCallbackEvent(reader.GetString());
172134
}
173135

174136
/// <summary>
@@ -182,19 +144,7 @@ public override void Write(
182144
WebhookCallbackEvent value,
183145
JsonSerializerOptions options)
184146
{
185-
var stringValue = value switch
186-
{
187-
WebhookCallbackEvent.Processed => ProcessedValue,
188-
WebhookCallbackEvent.Delivered => DeliveredValue,
189-
WebhookCallbackEvent.Bounce => BounceValue,
190-
WebhookCallbackEvent.Opened => OpenedValue,
191-
WebhookCallbackEvent.Clicked => ClickedValue,
192-
WebhookCallbackEvent.Unsubscribed => UnsubscribedValue,
193-
WebhookCallbackEvent.SpamComplaint => SpamComplaintValue,
194-
_ => "unknown"
195-
};
196-
197-
writer.WriteStringValue(stringValue);
147+
writer.WriteStringValue(WebhookCallbackValueParser.FormatCallbackEvent(value));
198148
}
199149

200150
#endregion

src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,46 @@ namespace Smtp2Go.NET.Models.Webhooks;
88
/// <remarks>
99
/// <para>
1010
/// SMTP2GO sends HTTP POST requests to registered webhook URLs when email
11-
/// events occur. This model deserializes the inbound webhook payload.
11+
/// events occur. This model is the canonical in-memory representation of
12+
/// JSON and form-encoded webhook callbacks.
1213
/// </para>
1314
/// <para>
14-
/// The fields populated depend on the event type:
15+
/// Live callbacks captured in the integration test suite showed that SMTP2GO does not emit one
16+
/// stable callback shape. The fields populated depend on the event type:
1517
/// <list type="bullet">
1618
/// <item><see cref="Recipient"/> (<c>rcpt</c>) is present for delivered and bounce events.</item>
1719
/// <item><see cref="Recipients"/> is present for processed events (array of all recipients).</item>
1820
/// <item><see cref="BounceType"/>, <see cref="BounceContext"/>, and <see cref="Host"/>
1921
/// are present for bounce and delivered events.</item>
2022
/// <item><see cref="ClickUrl"/> and <see cref="Link"/> are only present for click events.</item>
23+
/// <item>
24+
/// Processed and delivered callbacks include additional provider metadata such as
25+
/// <c>id</c>, <c>auth</c>, <c>message-id</c>/<c>Message-Id</c>, <c>subject</c>/<c>Subject</c>,
26+
/// <c>from</c>, <c>from_address</c>, and <c>from_name</c>.
27+
/// </item>
2128
/// </list>
2229
/// </para>
2330
/// </remarks>
2431
/// <example>
2532
/// <code>
2633
/// // In an ASP.NET Core controller:
2734
/// [HttpPost("webhooks/smtp2go")]
28-
/// public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload)
35+
/// public async Task&lt;IActionResult&gt; HandleWebhook(CancellationToken cancellationToken)
2936
/// {
37+
/// WebhookCallbackPayload payload;
38+
///
39+
/// if (Request.HasFormContentType)
40+
/// {
41+
/// var form = await Request.ReadFormAsync(cancellationToken);
42+
/// payload = WebhookCallbackPayloadParser.ParseFormValues(
43+
/// form.SelectMany(pair => pair.Value.Select(value => new KeyValuePair&lt;string, string?&gt;(pair.Key, value))));
44+
/// }
45+
/// else
46+
/// {
47+
/// payload = await Request.ReadFromJsonAsync&lt;WebhookCallbackPayload&gt;(cancellationToken: cancellationToken)
48+
/// ?? new WebhookCallbackPayload();
49+
/// }
50+
///
3051
/// switch (payload.Event)
3152
/// {
3253
/// case WebhookCallbackEvent.Delivered:
@@ -64,6 +85,22 @@ public class WebhookCallbackPayload
6485
[JsonPropertyName("email_id")]
6586
public string? EmailId { get; init; }
6687

88+
/// <summary>
89+
/// Gets the SMTP message identifier observed in the webhook callback.
90+
/// </summary>
91+
/// <remarks>
92+
/// <para>
93+
/// This is distinct from <see cref="EmailId" />. <see cref="EmailId" /> is SMTP2GO's provider-side
94+
/// correlation ID returned by the send API, while this property carries the RFC 5322 Message-ID style
95+
/// value observed in webhook callbacks.
96+
/// </para>
97+
/// <para>
98+
/// Live callbacks emitted this field with inconsistent casing (<c>Message-Id</c> and <c>message-id</c>).
99+
/// </para>
100+
/// </remarks>
101+
[JsonPropertyName("message-id")]
102+
public string? MessageId { get; init; }
103+
67104
/// <summary>
68105
/// Gets the type of event that triggered this webhook callback.
69106
/// </summary>
@@ -97,6 +134,35 @@ public class WebhookCallbackPayload
97134
[JsonPropertyName("sendtime")]
98135
public DateTimeOffset? SendTime { get; init; }
99136

137+
/// <summary>
138+
/// Gets the message subject observed in the webhook callback.
139+
/// </summary>
140+
/// <remarks>
141+
/// Live callbacks emitted this field with inconsistent casing (<c>Subject</c> and <c>subject</c>).
142+
/// </remarks>
143+
[JsonPropertyName("subject")]
144+
public string? Subject { get; init; }
145+
146+
/// <summary>
147+
/// Gets the provider-specific callback event identifier.
148+
/// </summary>
149+
/// <remarks>
150+
/// This maps to the raw <c>id</c> field observed in live callbacks. Different events for the same
151+
/// <see cref="EmailId" /> can carry different values, so this is treated as an event-level identifier.
152+
/// </remarks>
153+
[JsonPropertyName("id")]
154+
public string? EventId { get; init; }
155+
156+
/// <summary>
157+
/// Gets the opaque provider auth marker observed in live callbacks.
158+
/// </summary>
159+
/// <remarks>
160+
/// The exact semantics are undocumented. Live callbacks included values such as a truncated API key
161+
/// prefix, so the library preserves the field as opaque diagnostic metadata.
162+
/// </remarks>
163+
[JsonPropertyName("auth")]
164+
public string? Auth { get; init; }
165+
100166
/// <summary>
101167
/// Gets the per-event recipient email address.
102168
/// </summary>
@@ -116,6 +182,29 @@ public class WebhookCallbackPayload
116182
[JsonPropertyName("sender")]
117183
public string? Sender { get; init; }
118184

185+
/// <summary>
186+
/// Gets the raw <c>from</c> field observed in live callbacks.
187+
/// </summary>
188+
/// <remarks>
189+
/// Live callbacks included <c>sender</c>, <c>from</c>, and <c>from_address</c>. They carried the same
190+
/// address in the captured delivered and processed payloads, so this property is preserved separately
191+
/// to avoid losing transport detail.
192+
/// </remarks>
193+
[JsonPropertyName("from")]
194+
public string? From { get; init; }
195+
196+
/// <summary>
197+
/// Gets the raw <c>from_address</c> field observed in live callbacks.
198+
/// </summary>
199+
[JsonPropertyName("from_address")]
200+
public string? FromAddress { get; init; }
201+
202+
/// <summary>
203+
/// Gets the raw <c>from_name</c> field observed in live callbacks.
204+
/// </summary>
205+
[JsonPropertyName("from_name")]
206+
public string? FromName { get; init; }
207+
119208
/// <summary>
120209
/// Gets the list of all recipients of the original email.
121210
/// </summary>

0 commit comments

Comments
 (0)