From 8ca1c1671bd9dbb5a09afe5059e1944edec3e126 Mon Sep 17 00:00:00 2001 From: smunuswamiac Date: Tue, 7 Jul 2026 09:47:26 -0500 Subject: [PATCH 1/2] PMK-2660 - Add missing webhook payload models (Click, SpamComplaint, SubscriptionChange) The library had webhook payload models for only 4 of the documented webhook record types. This adds type-safe models for three more: - PostmarkClickWebhookMessage : PostmarkClick (adds ClickLocation, ReceivedAt, Tag, Recipient, Metadata) - supersedes stalled PR #146 / issue #145. - PostmarkSpamComplaintWebhookMessage : PostmarkBounce (adds TypeCode, Content, Metadata) - mirrors the existing PostmarkBounceWebhookMessage pattern. - PostmarkSubscriptionChangeWebhookMessage - standalone model. Property casing was taken verbatim from the public Postmark webhook docs to match the case-sensitive System.Text.Json defaults consumers use. Scope note: the ticket also proposed a PostmarkSmtpApiErrorWebhookMessage and an SmtpApiError webhook-configuration trigger. Both were intentionally omitted: SMTP API Error is not a distinct webhook record type but a Bounce with Type = SMTPApiError (already present as PostmarkBounceType.SMTPApiError), and the webhook Triggers API supports only the six triggers already modeled. postmark.js models neither, confirming the gap does not exist. EnableSmtpApiErrorHooks on PostmarkServer is a separate server-level setting, not a config trigger. Adds WebhookMessageDeserializationTests covering the documented example payloads, including a subscription-change reactivation edge case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WebhookMessageDeserializationTests.cs | 160 ++++++++++++++++++ .../Model/PostmarkClickWebhookMessage.cs | 41 +++++ .../PostmarkSpamComplaintWebhookMessage.cs | 28 +++ ...ostmarkSubscriptionChangeWebhookMessage.cs | 69 ++++++++ 4 files changed, 298 insertions(+) create mode 100644 src/Postmark.Tests/WebhookMessageDeserializationTests.cs create mode 100644 src/Postmark/Model/PostmarkClickWebhookMessage.cs create mode 100644 src/Postmark/Model/PostmarkSpamComplaintWebhookMessage.cs create mode 100644 src/Postmark/Model/PostmarkSubscriptionChangeWebhookMessage.cs diff --git a/src/Postmark.Tests/WebhookMessageDeserializationTests.cs b/src/Postmark.Tests/WebhookMessageDeserializationTests.cs new file mode 100644 index 0000000..5bb08bc --- /dev/null +++ b/src/Postmark.Tests/WebhookMessageDeserializationTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Globalization; +using System.Text.Json; +using PostmarkDotNet; +using PostmarkDotNet.Webhooks; +using Xunit; + +namespace Postmark.Tests +{ + /// + /// Verifies that the webhook payload models deserialize the documented Postmark + /// webhook payloads. Payloads are copied from the public docs at + /// https://postmarkapp.com/developer/webhooks so the property casing matches the + /// case-sensitive System.Text.Json defaults consumers use. + /// + public class WebhookMessageDeserializationTests + { + [Fact] + public void CanDeserializeClickWebhook() + { + // https://postmarkapp.com/developer/webhooks/click-webhook + var json = @"{ + ""RecordType"": ""Click"", + ""MessageStream"": ""outbound"", + ""ClickLocation"": ""HTML"", + ""Client"": { ""Name"": ""Chrome 35.0.1916.153"", ""Company"": ""Google"", ""Family"": ""Chrome"" }, + ""OS"": { ""Name"": ""OS X 10.7 Lion"", ""Company"": ""Apple Computer, Inc."", ""Family"": ""OS X 10"" }, + ""Platform"": ""Desktop"", + ""UserAgent"": ""Mozilla/5.0"", + ""OriginalLink"": ""https://example.com"", + ""Geo"": { ""CountryISOCode"": ""RS"", ""Country"": ""Serbia"", ""City"": ""Novi Sad"", ""IP"": ""8.8.8.8"" }, + ""MessageID"": ""00000000-0000-0000-0000-000000000000"", + ""Metadata"": { ""a_key"": ""a_value"", ""b_key"": ""b_value"" }, + ""ReceivedAt"": ""2017-10-25T15:21:11.9065619Z"", + ""Tag"": ""welcome-email"", + ""Recipient"": ""john@example.com"" + }"; + + var message = JsonSerializer.Deserialize(json); + + Assert.NotNull(message); + Assert.Equal("HTML", message.ClickLocation); + Assert.Equal("Desktop", message.Platform); + Assert.Equal("https://example.com", message.OriginalLink); + Assert.Equal("00000000-0000-0000-0000-000000000000", message.MessageID); + Assert.Equal("welcome-email", message.Tag); + Assert.Equal("john@example.com", message.Recipient); + var expectedReceivedAt = DateTime.Parse( + "2017-10-25T15:21:11.9065619Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + Assert.Equal(expectedReceivedAt.ToUniversalTime(), message.ReceivedAt.ToUniversalTime()); + Assert.Equal("Chrome", message.Client.Family); + Assert.Equal("OS X 10", message.OS.Family); + Assert.Equal("Serbia", message.Geo.Country); + Assert.Equal("a_value", message.Metadata["a_key"]); + } + + [Fact] + public void CanDeserializeSpamComplaintWebhook() + { + // https://postmarkapp.com/developer/webhooks/spam-complaint-webhook + var json = @"{ + ""RecordType"": ""SpamComplaint"", + ""MessageStream"": ""outbound"", + ""ID"": 42, + ""Type"": ""SpamComplaint"", + ""TypeCode"": 100001, + ""Name"": ""Spam Complaint"", + ""Tag"": ""my-tag"", + ""MessageID"": ""00000000-0000-0000-0000-000000000000"", + ""ServerID"": 1234, + ""Description"": ""The subscriber explicitly marked this message as spam."", + ""Details"": ""Test spam complaint details"", + ""Email"": ""jim@example.com"", + ""From"": ""sender@example.com"", + ""BouncedAt"": ""2019-11-05T16:33:54.9070259Z"", + ""DumpAvailable"": true, + ""Inactive"": true, + ""CanActivate"": false, + ""Subject"": ""Test subject"", + ""Content"": ""Abuse report content"", + ""Metadata"": { ""a_key"": ""a_value"", ""b_key"": ""b_value"" } + }"; + + var message = JsonSerializer.Deserialize(json); + + Assert.NotNull(message); + Assert.Equal(42, message.ID); + Assert.Equal(PostmarkBounceType.SpamComplaint, message.Type); + Assert.Equal(100001, message.TypeCode); + Assert.Equal("jim@example.com", message.Email); + Assert.Equal("sender@example.com", message.From); + Assert.Equal(1234, message.ServerID); + Assert.True(message.Inactive); + Assert.False(message.CanActivate); + Assert.Equal("Test subject", message.Subject); + Assert.Equal("Abuse report content", message.Content); + Assert.Equal("b_value", message.Metadata["b_key"]); + } + + [Fact] + public void CanDeserializeSubscriptionChangeWebhook() + { + // https://postmarkapp.com/developer/webhooks/subscription-change-webhook + var json = @"{ + ""RecordType"": ""SubscriptionChange"", + ""MessageID"": ""883953f4-6105-42a2-a16a-77a8eac79483"", + ""ServerID"": 123456, + ""MessageStream"": ""outbound"", + ""ChangedAt"": ""2020-02-01T10:53:34.416071Z"", + ""Recipient"": ""bounced-address@wildbit.com"", + ""Origin"": ""Recipient"", + ""SuppressSending"": true, + ""SuppressionReason"": ""HardBounce"", + ""Tag"": ""my-tag"", + ""Metadata"": { ""example"": ""value"", ""example_2"": ""value"" } + }"; + + var message = JsonSerializer.Deserialize(json); + + Assert.NotNull(message); + Assert.Equal("SubscriptionChange", message.RecordType); + Assert.Equal("883953f4-6105-42a2-a16a-77a8eac79483", message.MessageID); + Assert.Equal(123456, message.ServerID); + Assert.Equal("outbound", message.MessageStream); + Assert.Equal("bounced-address@wildbit.com", message.Recipient); + Assert.Equal("Recipient", message.Origin); + Assert.True(message.SuppressSending); + Assert.Equal("HardBounce", message.SuppressionReason); + Assert.Equal("my-tag", message.Tag); + Assert.Equal("value", message.Metadata["example"]); + } + + [Fact] + public void SubscriptionChangeReactivationHasNullSuppressionReason() + { + // Reactivations (SuppressSending = false) omit SuppressionReason/Tag and carry empty Metadata. + var json = @"{ + ""RecordType"": ""SubscriptionChange"", + ""MessageID"": ""883953f4-6105-42a2-a16a-77a8eac79483"", + ""ServerID"": 123456, + ""MessageStream"": ""outbound"", + ""ChangedAt"": ""2020-02-01T10:53:34.416071Z"", + ""Recipient"": ""reactivated@example.com"", + ""Origin"": ""Recipient"", + ""SuppressSending"": false, + ""SuppressionReason"": null, + ""Tag"": null, + ""Metadata"": {} + }"; + + var message = JsonSerializer.Deserialize(json); + + Assert.NotNull(message); + Assert.False(message.SuppressSending); + Assert.Null(message.SuppressionReason); + Assert.Null(message.Tag); + Assert.Empty(message.Metadata); + } + } +} diff --git a/src/Postmark/Model/PostmarkClickWebhookMessage.cs b/src/Postmark/Model/PostmarkClickWebhookMessage.cs new file mode 100644 index 0000000..c7e89b2 --- /dev/null +++ b/src/Postmark/Model/PostmarkClickWebhookMessage.cs @@ -0,0 +1,41 @@ +using PostmarkDotNet.Model; +using System; +using System.Collections.Generic; + +namespace PostmarkDotNet.Webhooks +{ + /// + /// Representation of the payload of the click tracking webhook + /// - https://postmarkapp.com/developer/webhooks/click-webhook + /// + public class PostmarkClickWebhookMessage : PostmarkClick + { + /// + /// Where in the message the clicked link was located, e.g. "HTML" or "Text". + /// + public string ClickLocation { get; set; } + + /// + /// The time the click was received by the Postmark servers. + /// + /// The time the click was received + public DateTime ReceivedAt { get; set; } + + /// + /// The tags users add to emails + /// + /// The specific tag string + public string Tag { get; set; } + + /// + /// The email address of the recipient who clicked the link. + /// + /// Email address of the recipient + public string Recipient { get; set; } + + /// + /// The metadata for the clicked message. + /// + public Dictionary Metadata { get; set; } + } +} diff --git a/src/Postmark/Model/PostmarkSpamComplaintWebhookMessage.cs b/src/Postmark/Model/PostmarkSpamComplaintWebhookMessage.cs new file mode 100644 index 0000000..8b1d90a --- /dev/null +++ b/src/Postmark/Model/PostmarkSpamComplaintWebhookMessage.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace PostmarkDotNet.Webhooks +{ + /// + /// Representation of the payload of the spam complaint webhook + /// - https://postmarkapp.com/developer/webhooks/spam-complaint-webhook + /// + public class PostmarkSpamComplaintWebhookMessage : PostmarkBounce + { + /// + /// The int based type code for this spam complaint. + /// + /// The type code + public int TypeCode { get; set; } + + /// + /// The full content of the spam complaint. Only included when IncludeContent is + /// enabled on the SpamComplaint webhook trigger. + /// + public string Content { get; set; } + + /// + /// The metadata for the message that was complained about. + /// + public Dictionary Metadata { get; set; } + } +} diff --git a/src/Postmark/Model/PostmarkSubscriptionChangeWebhookMessage.cs b/src/Postmark/Model/PostmarkSubscriptionChangeWebhookMessage.cs new file mode 100644 index 0000000..b9a281a --- /dev/null +++ b/src/Postmark/Model/PostmarkSubscriptionChangeWebhookMessage.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace PostmarkDotNet.Webhooks +{ + /// + /// Representation of the payload of the subscription change webhook + /// - https://postmarkapp.com/developer/webhooks/subscription-change-webhook + /// + public class PostmarkSubscriptionChangeWebhookMessage + { + /// + /// The type of webhook record. Always "SubscriptionChange". + /// + public string RecordType { get; set; } + + /// + /// The ID of the message associated with the subscription change. + /// Can be null for manual suppressions and reactivations. + /// + public string MessageID { get; set; } + + /// + /// The ID of the Server that sent the original message. + /// + public int ServerID { get; set; } + + /// + /// The message stream on which the recipient's subscription changed. + /// + public string MessageStream { get; set; } + + /// + /// The time the subscription change occurred. + /// + public DateTime ChangedAt { get; set; } + + /// + /// The email address of the recipient whose subscription changed. + /// + public string Recipient { get; set; } + + /// + /// Where the subscription change originated, e.g. "Recipient", "Customer", or "Admin". + /// + public string Origin { get; set; } + + /// + /// Whether sending to this recipient is currently suppressed. + /// + public bool SuppressSending { get; set; } + + /// + /// The reason sending is suppressed, e.g. "HardBounce", "SpamComplaint", or + /// "ManualSuppression". Null during reactivations (when SuppressSending is false). + /// + public string SuppressionReason { get; set; } + + /// + /// The tag associated with the message, if any. + /// + public string Tag { get; set; } + + /// + /// The metadata for the message. Empty on reactivations. + /// + public Dictionary Metadata { get; set; } + } +} From 49887c4bd56eb2085075cc7b9fdaaf07b89a5814 Mon Sep 17 00:00:00 2001 From: smunuswamiac Date: Tue, 7 Jul 2026 15:15:44 -0500 Subject: [PATCH 2/2] PMK-2660 - Prep 5.4.1 release: bump version and add NuGet package README - Bump to 5.4.1. - Add a NuGet package README (PackageReadmeFile) so the published package no longer warns about a missing readme. The prior release (5.4.0) shipped with no element in its nuspec. Includes a "What's New" section covering the 5.4.1 webhook payload models and the 5.4.0 Bulk Email API. Note: CI derives the published version from the git tag, so tagging 5.4.1 on main is what triggers the NuGet publish; the csproj bump keeps local packs in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Postmark/Postmark.csproj | 6 +++- src/Postmark/README.md | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/Postmark/README.md diff --git a/src/Postmark/Postmark.csproj b/src/Postmark/Postmark.csproj index 7036e5e..b1b05fc 100644 --- a/src/Postmark/Postmark.csproj +++ b/src/Postmark/Postmark.csproj @@ -6,10 +6,11 @@ https://github.com/wildbit/postmark-dotnet/raw/master/postmark-logo.png https://github.com/wildbit/postmark-dotnet.git git - 5.4.0 + 5.4.1 Wildbit, LLC. The official .net client for Postmark. MIT + README.md latest @@ -17,4 +18,7 @@ + + + \ No newline at end of file diff --git a/src/Postmark/README.md b/src/Postmark/README.md new file mode 100644 index 0000000..83c1743 --- /dev/null +++ b/src/Postmark/README.md @@ -0,0 +1,66 @@ +# Postmark .NET + +The official .NET client for [Postmark](https://postmarkapp.com). Postmark helps +you deliver and track transactional and broadcast email for your applications, +replacing SMTP with a reliable, scalable HTTP API. This package wraps the +Postmark API so you can send email, manage templates, read bounces, configure +webhooks, and more without building your own HTTP calls. + +Get a free API token at https://postmarkapp.com. + +## Installation + +``` +dotnet add package PostmarkDotNet +``` + +## Quick start + +```csharp +using PostmarkDotNet; + +var client = new PostmarkClient("your-server-token"); + +var message = new PostmarkMessage +{ + To = "recipient@example.com", + From = "sender@example.com", + Subject = "Hello from Postmark", + TextBody = "This is a test message.", + HtmlBody = "This is a test message.", + MessageStream = "outbound" +}; + +var response = await client.SendMessageAsync(message); +``` + +See the [wiki](https://github.com/ActiveCampaign/postmark-dotnet/wiki) for guides on +sending email, using the bounce API, templates, and additional options. + +## What's New + +### 5.4.1 + +- Added missing webhook payload models for type-safe deserialization of incoming + webhook requests: + - `PostmarkClickWebhookMessage` — click tracking webhook. + - `PostmarkSpamComplaintWebhookMessage` — spam complaint webhook. + - `PostmarkSubscriptionChangeWebhookMessage` — subscription change webhook. + + These join the existing `PostmarkBounceWebhookMessage`, `PostmarkDeliveryWebhookMessage`, + `PostmarkOpenWebhookMessage`, and `PostmarkInboundWebhookMessage`. + +### 5.4.0 + +- Added Bulk Email API support: `SendBulkEmailAsync` (`POST /email/bulk`) and + `GetBulkEmailStatusAsync` (`GET /email/bulk/{id}`) for broadcast/marketing sends. + +## Links + +- [Source & issues](https://github.com/ActiveCampaign/postmark-dotnet) +- [Documentation wiki](https://github.com/ActiveCampaign/postmark-dotnet/wiki) +- [Postmark API reference](https://postmarkapp.com/developer) + +## License + +Licensed under the [MIT](https://github.com/ActiveCampaign/postmark-dotnet/blob/main/LICENSE) license.