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
160 changes: 160 additions & 0 deletions src/Postmark.Tests/WebhookMessageDeserializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System;
using System.Globalization;
using System.Text.Json;
using PostmarkDotNet;
using PostmarkDotNet.Webhooks;
using Xunit;

namespace Postmark.Tests
{
/// <summary>
/// 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.
/// </summary>
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<PostmarkClickWebhookMessage>(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<PostmarkSpamComplaintWebhookMessage>(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<PostmarkSubscriptionChangeWebhookMessage>(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<PostmarkSubscriptionChangeWebhookMessage>(json);

Assert.NotNull(message);
Assert.False(message.SuppressSending);
Assert.Null(message.SuppressionReason);
Assert.Null(message.Tag);
Assert.Empty(message.Metadata);
}
}
}
41 changes: 41 additions & 0 deletions src/Postmark/Model/PostmarkClickWebhookMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using PostmarkDotNet.Model;
using System;
using System.Collections.Generic;

namespace PostmarkDotNet.Webhooks
{
/// <summary>
/// Representation of the payload of the click tracking webhook
/// - https://postmarkapp.com/developer/webhooks/click-webhook
/// </summary>
public class PostmarkClickWebhookMessage : PostmarkClick
{
/// <summary>
/// Where in the message the clicked link was located, e.g. "HTML" or "Text".
/// </summary>
public string ClickLocation { get; set; }

/// <summary>
/// The time the click was received by the Postmark servers.
/// </summary>
/// <value>The time the click was received</value>
public DateTime ReceivedAt { get; set; }

/// <summary>
/// The tags users add to emails
/// </summary>
/// <value>The specific tag string</value>
public string Tag { get; set; }

/// <summary>
/// The email address of the recipient who clicked the link.
/// </summary>
/// <value>Email address of the recipient</value>
public string Recipient { get; set; }

/// <summary>
/// The metadata for the clicked message.
/// </summary>
public Dictionary<string, string> Metadata { get; set; }
}
}
28 changes: 28 additions & 0 deletions src/Postmark/Model/PostmarkSpamComplaintWebhookMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Collections.Generic;

namespace PostmarkDotNet.Webhooks
{
/// <summary>
/// Representation of the payload of the spam complaint webhook
/// - https://postmarkapp.com/developer/webhooks/spam-complaint-webhook
/// </summary>
public class PostmarkSpamComplaintWebhookMessage : PostmarkBounce
{
/// <summary>
/// The int based type code for this spam complaint.
/// </summary>
/// <value>The type code</value>
public int TypeCode { get; set; }

/// <summary>
/// The full content of the spam complaint. Only included when IncludeContent is
/// enabled on the SpamComplaint webhook trigger.
/// </summary>
public string Content { get; set; }

/// <summary>
/// The metadata for the message that was complained about.
/// </summary>
public Dictionary<string, string> Metadata { get; set; }
}
}
69 changes: 69 additions & 0 deletions src/Postmark/Model/PostmarkSubscriptionChangeWebhookMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;

namespace PostmarkDotNet.Webhooks
{
/// <summary>
/// Representation of the payload of the subscription change webhook
/// - https://postmarkapp.com/developer/webhooks/subscription-change-webhook
/// </summary>
public class PostmarkSubscriptionChangeWebhookMessage
{
/// <summary>
/// The type of webhook record. Always "SubscriptionChange".
/// </summary>
public string RecordType { get; set; }

/// <summary>
/// The ID of the message associated with the subscription change.
/// Can be null for manual suppressions and reactivations.
/// </summary>
public string MessageID { get; set; }

/// <summary>
/// The ID of the Server that sent the original message.
/// </summary>
public int ServerID { get; set; }

/// <summary>
/// The message stream on which the recipient's subscription changed.
/// </summary>
public string MessageStream { get; set; }

/// <summary>
/// The time the subscription change occurred.
/// </summary>
public DateTime ChangedAt { get; set; }

/// <summary>
/// The email address of the recipient whose subscription changed.
/// </summary>
public string Recipient { get; set; }

/// <summary>
/// Where the subscription change originated, e.g. "Recipient", "Customer", or "Admin".
/// </summary>
public string Origin { get; set; }

/// <summary>
/// Whether sending to this recipient is currently suppressed.
/// </summary>
public bool SuppressSending { get; set; }

/// <summary>
/// The reason sending is suppressed, e.g. "HardBounce", "SpamComplaint", or
/// "ManualSuppression". Null during reactivations (when SuppressSending is false).
/// </summary>
public string SuppressionReason { get; set; }

/// <summary>
/// The tag associated with the message, if any.
/// </summary>
public string Tag { get; set; }

/// <summary>
/// The metadata for the message. Empty on reactivations.
/// </summary>
public Dictionary<string, string> Metadata { get; set; }
}
}
6 changes: 5 additions & 1 deletion src/Postmark/Postmark.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
<PackageIconUrl>https://github.com/wildbit/postmark-dotnet/raw/master/postmark-logo.png</PackageIconUrl>
<RepositoryUrl>https://github.com/wildbit/postmark-dotnet.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<Version>5.4.0</Version>
<Version>5.4.1</Version>
<Company>Wildbit, LLC.</Company>
<Description>The official .net client for Postmark.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.Json" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
66 changes: 66 additions & 0 deletions src/Postmark/README.md
Original file line number Diff line number Diff line change
@@ -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 = "<strong>This is a test message.</strong>",
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.