Skip to content
Open
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
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Build
dotnet build src/postmark-dotnet.sln

# Run all tests (requires API tokens - see Testing section)
dotnet test src/postmark-dotnet.sln

# Run a single test class
dotnet test src/Postmark.Tests/Postmark.Tests.csproj --filter "ClassName=ClientSendingTests"

# Run a single test method
dotnet test src/Postmark.Tests/Postmark.Tests.csproj --filter "FullyQualifiedName~ClientCanSendMessage"

# Build NuGet package
dotnet pack src/Postmark/Postmark.csproj
```

## Architecture

This is a .NET Standard 2.0 HTTP client library for the [Postmark](https://postmarkapp.com) transactional email API. There are no web servers, databases, or startup code — only the library and its integration tests.

**Client hierarchy:**

- `PostmarkClientBase` — abstract base handling all HTTP communication. Contains the two core methods (`ProcessRequestAsync` for POST/PUT with body, `ProcessNoBodyRequestAsync` for GET/DELETE with query params). Uses a static `ClientFactory` property returning `ISimpleHttpClient` — replace this to mock HTTP in tests.
- `PostmarkClient` — server-level API using `X-Postmark-Server-Token` header. Handles email sending, bounces, templates, message streams, suppressions, statistics, webhooks, and inbound messages.
- `PostmarkAdminClient` — account-level API using `X-Postmark-Account-Token` header. Handles servers, sender signatures, and domains.
- `PostmarkClientExtensions` — convenience overloads for `SendMessageAsync` (accepts primitives instead of a `PostmarkMessage` object).
- `LegacyClientExtensions` — deprecated IAsyncResult-style wrappers from the 1.x API. Do not use; all new code should use the `Async` task-based methods.

**Serialization:**

Uses `System.Text.Json` exclusively with two custom converters registered in `JsonExtensions.TryDeserializeObject`:
- `DateTimeConverter` — handles the Postmark API's `" (GMT)"` date format suffix.
- `UnicodeJsonStringConverter` — handles Unicode string encoding quirks.

All models live in `src/Postmark/Model/` with subdirectories for `MessageStreams/`, `Suppressions/`, and `Webhooks/`.

## Testing

All tests are **integration tests** that make real HTTP calls to the Postmark API. They require API tokens configured either via a `testing_keys.json` file (searched up the directory tree from the test assembly) or environment variables.

Required keys:
```json
{
"READ_SELENIUM_TEST_SERVER_TOKEN": "...",
"READ_LINK_TRACKING_TEST_SERVER_TOKEN": "...",
"READ_SELENIUM_OPEN_TRACKING_TOKEN": "...",
"READ_BOUNCE_TEST_SERVER_TOKEN": "...",
"WRITE_ACCOUNT_TOKEN": "...",
"WRITE_TEST_SERVER_TOKEN": "...",
"WRITE_TEST_SENDER_EMAIL_ADDRESS": "...",
"WRITE_TEST_EMAIL_RECIPIENT_ADDRESS": "...",
"WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE": "...",
"BASE_URL": "https://api.postmarkapp.com"
}
```

The config file location can be overridden with the `POSTMARK_SDK_CONFIG_FILE_NAME` environment variable.

Tests that create or modify data (servers, signatures, domains) require `WRITE_*` tokens. Read-only tests use `READ_*` tokens pointing to pre-configured test servers. `ClientBaseFixture` is the abstract base class all test classes inherit from.
4 changes: 2 additions & 2 deletions src/Postmark.Tests/AdminClientDomainsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ namespace Postmark.Tests
{
public class AdminClientDomainsTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private string _domainName;
private string _returnPath;

public Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
_domainName = "dotnet-lib-test.com";
_returnPath = $"return-path.{_domainName}";

Expand Down
4 changes: 2 additions & 2 deletions src/Postmark.Tests/AdminClientSenderSignatureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Postmark.Tests
{
public class AdminClientSenderSignatureTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private string _senderEmail;
private string _replyToAddress;
private string _senderName;
Expand All @@ -20,7 +20,7 @@ public class AdminClientSenderSignatureTests : ClientBaseFixture, IAsyncLifetime

public Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
var id = Guid.NewGuid();
_senderprefix = "test-sender-";
_returnPath = "test." + WriteTestSenderSignaturePrototype.Split('@')[1];
Expand Down
4 changes: 2 additions & 2 deletions src/Postmark.Tests/AdminClientServersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Postmark.Tests
{
public class AdminClientServersTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private string _serverPrefix;
private bool? _smtpActivated;
private string _inboundHookUrl;
Expand All @@ -29,7 +29,7 @@ public class AdminClientServersTests : ClientBaseFixture, IAsyncLifetime

public Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
var id = Guid.NewGuid().ToString("n");
_serverPrefix = "admin-client-integration-test-server-";

Expand Down
17 changes: 16 additions & 1 deletion src/Postmark.Tests/ClientBaseFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Reflection;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;

namespace Postmark.Tests
{
Expand Down Expand Up @@ -83,7 +84,21 @@ private static string ConfigVariable(string variableName)
protected static readonly string WriteTestSenderSignaturePrototype = ConfigVariable("WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE");
protected static readonly string BaseUrl = ConfigVariable("BASE_URL");

protected PostmarkClient Client;
protected IPostmarkClient Client;

protected static IPostmarkClient BuildClient(string token)
{
var services = new ServiceCollection();
services.AddPostmarkClient(token);
return services.BuildServiceProvider().GetRequiredService<IPostmarkClient>();
}

protected static IPostmarkAdminClient BuildAdminClient(string token)
{
var services = new ServiceCollection();
services.AddPostmarkAdminClient(token);
return services.BuildServiceProvider().GetRequiredService<IPostmarkAdminClient>();
}

protected ClientBaseFixture()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientBounceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public ClientBounceTests()
var bounceToken = !string.IsNullOrWhiteSpace(ReadBounceTestServerToken)
? ReadBounceTestServerToken
: WriteTestServerToken;
Client = new PostmarkClient(bounceToken, BaseUrl);
Client = BuildClient(bounceToken);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientMessageClickQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class ClientMessageClickQueryTests : ClientBaseFixture
{
public ClientMessageClickQueryTests()
{
Client = new PostmarkClient(ReadLinkTrackingTestServerToken, BaseUrl);
Client = BuildClient(ReadLinkTrackingTestServerToken);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientMessageOpenQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class ClientMessageOpenQueryTests : ClientBaseFixture
{
public ClientMessageOpenQueryTests()
{
Client = new PostmarkClient(ReadSeleniumOpenTrackingToken, BaseUrl);
Client = BuildClient(ReadSeleniumOpenTrackingToken);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientMessageSearchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class ClientMessageSearchingTests : ClientBaseFixture
{
public ClientMessageSearchingTests()
{
Client = new PostmarkClient(ReadSeleniumTestServerToken, BaseUrl);
Client = BuildClient(ReadSeleniumTestServerToken);
}

[Fact]
Expand Down
6 changes: 3 additions & 3 deletions src/Postmark.Tests/ClientMessageStreamTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ namespace Postmark.Tests
{
public class ClientMessageStreamTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private PostmarkServer _server;

public async Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
_server = await _adminClient.CreateServerAsync($"integration-test-message-stream-{Guid.NewGuid()}");
Client = new PostmarkClient(_server.ApiTokens.First(), BaseUrl);
Client = BuildClient(_server.ApiTokens.First());
}

public async Task DisposeAsync()
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientSendingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class ClientSendingTests : ClientBaseFixture
{
public ClientSendingTests()
{
Client = new PostmarkClient(WriteTestServerToken, BaseUrl);
Client = BuildClient(WriteTestServerToken);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientServerInformationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class ClientServerInformationTests : ClientBaseFixture, IAsyncLifetime

public Task InitializeAsync()
{
Client = new PostmarkClient(WriteTestServerToken, BaseUrl);
Client = BuildClient(WriteTestServerToken);
var id = Guid.NewGuid().ToString("n");
_serverBaseName = "dotnet-integration-test-server";

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientStatisticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class ClientStatisticsTests : ClientBaseFixture

public ClientStatisticsTests()
{
Client = new PostmarkClient(ReadSeleniumTestServerToken, BaseUrl);
Client = BuildClient(ReadSeleniumTestServerToken);
_lastMonth = TestingDate - TimeSpan.FromDays(30);
WindowStartDate = TestingDate - TimeSpan.FromDays(35);
}
Expand Down
6 changes: 3 additions & 3 deletions src/Postmark.Tests/ClientSuppressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ namespace Postmark.Tests
{
public class ClientSuppressionTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private PostmarkServer _server;

public async Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
_server = await _adminClient.CreateServerAsync($"integration-test-suppressions-{Guid.NewGuid()}");
Client = new PostmarkClient(_server.ApiTokens.First(), BaseUrl);
Client = BuildClient(_server.ApiTokens.First());
}

public async Task DisposeAsync()
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class ClientTemplateTests : ClientBaseFixture, IAsyncLifetime

public Task InitializeAsync()
{
Client = new PostmarkClient(WriteTestServerToken, BaseUrl);
Client = BuildClient(WriteTestServerToken);
return Task.CompletedTask;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/ClientTriggersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class ClientTriggersTests : ClientBaseFixture, IAsyncLifetime

public Task InitializeAsync()
{
Client = new PostmarkClient(WriteTestServerToken, BaseUrl);
Client = BuildClient(WriteTestServerToken);
return Task.CompletedTask;
}

Expand Down
6 changes: 3 additions & 3 deletions src/Postmark.Tests/ClientWebhookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ namespace Postmark.Tests
{
public class ClientWebhookTests : ClientBaseFixture, IAsyncLifetime
{
private PostmarkAdminClient _adminClient;
private IPostmarkAdminClient _adminClient;
private PostmarkServer _server;

public async Task InitializeAsync()
{
_adminClient = new PostmarkAdminClient(WriteAccountToken, BaseUrl);
_adminClient = BuildAdminClient(WriteAccountToken);
_server = await _adminClient.CreateServerAsync($"integration-test-webhooks-{Guid.NewGuid()}");
Client = new PostmarkClient(_server.ApiTokens.First(), BaseUrl);
Client = BuildClient(_server.ApiTokens.First());
}

public async Task DisposeAsync()
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/Postmark.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ApplicationIcon/>
<OutputTypeEx>library</OutputTypeEx>
<StartupObject/>
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark.Tests/PostmarkClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class PostmarkClientTests : ClientBaseFixture
{
public PostmarkClientTests()
{
Client = new PostmarkClient(WriteTestServerToken, BaseUrl);
Client = BuildClient(WriteTestServerToken);
}

[Fact]
Expand Down
32 changes: 32 additions & 0 deletions src/Postmark/IPostmarkAdminClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Threading.Tasks;
using PostmarkDotNet.Model;

namespace PostmarkDotNet
{
public interface IPostmarkAdminClient
{
Task<PostmarkServer> GetServerAsync(int serverId);
Task<PostmarkResponse> DeleteServerAsync(int serverId);
Task<PostmarkServer> CreateServerAsync(string name, string color = null, bool? rawEmailEnabled = null, bool? smtpApiActivated = null, string inboundHookUrl = null, string bounceHookUrl = null, string openHookUrl = null, bool? postFirstOpenOnly = null, bool? trackOpens = null, string inboundDomain = null, int? inboundSpamThreshold = null, LinkTrackingOptions? trackLinks = null, string clickHookUrl = null, string deliveryHookUrl = null, bool? enableSmtpApiErrorHooks = null, string deliveryType = null);
Task<PostmarkServer> EditServerAsync(int serverId, string name = null, string color = null, bool? rawEmailEnabled = null, bool? smtpApiActivated = null, string inboundHookUrl = null, string bounceHookUrl = null, string openHookUrl = null, bool? postFirstOpenOnly = null, bool? trackOpens = null, string inboundDomain = null, int? inboundSpamThreshold = null, LinkTrackingOptions? trackLinks = null, string clickHookUrl = null, string deliveryHookUrl = null, bool? enableSmtpApiErrorHooks = null);
Task<PostmarkServerList> GetServersAsync(int offset = 0, int count = 100, string name = null);
Task<PostmarkSenderSignatureList> GetSenderSignaturesAsync(int offset = 0, int count = 100);
Task<PostmarkCompleteSenderSignature> GetSenderSignatureAsync(int signatureId);
Task<PostmarkResponse> DeleteSignatureAsync(int signatureId);
Task<PostmarkResponse> ResendSignatureVerificationEmailAsync(int signatureId);
Task<PostmarkResponse> RequestNewSignatureDKIMAsync(int signatureId);
Task<PostmarkCompleteSenderSignature> CreateSignatureAsync(string fromEmail, string name, string replyToEmail = null, string returnPathDomain = null, string confirmationPersonalNote = null);
Task<PostmarkCompleteSenderSignature> UpdateSignatureAsync(int signatureId, string name = null, string replyToEmail = null, string returnPathDomain = null, string confirmationPersonalNote = null);
Task<PostmarkDomainList> GetDomainsAsync(int offset = 0, int count = 100);
Task<PostmarkCompleteDomain> GetDomainAsync(int domainId);
Task<PostmarkResponse> DeleteDomainAsync(int domainId);
Task<PostmarkResponse> RequestNewDomainDKIMAsync(int domainId);
Task<PostmarkCompleteDomain> CreateDomainAsync(string name, string returnPathDomain = null);
Task<PostmarkCompleteDomain> UpdateDomainAsync(int domainId, string returnPathDomain);
Task<PostmarkCompleteDomain> VerifyDomainDkim(int domainId);
Task<PostmarkCompleteDomain> VerifyDomainReturnPath(int domainId);
Task<PostmarkDataRemoval> RequestDataRemoval(string requestedBy, string requestedFor, bool notifyWhenCompleted);
Task<PostmarkDataRemoval> GetDataRemovalStatus(long id);
Task<PostmarkCompleteSenderSignature> VerifySignatureSPF(int signatureId);
}
}
Loading