Serilog.Sinks.PostmarkEmail
1.1.0
dotnet add package Serilog.Sinks.PostmarkEmail --version 1.1.0
NuGet\Install-Package Serilog.Sinks.PostmarkEmail -Version 1.1.0
<PackageReference Include="Serilog.Sinks.PostmarkEmail" Version="1.1.0" />
<PackageVersion Include="Serilog.Sinks.PostmarkEmail" Version="1.1.0" />
<PackageReference Include="Serilog.Sinks.PostmarkEmail" />
paket add Serilog.Sinks.PostmarkEmail --version 1.1.0
#r "nuget: Serilog.Sinks.PostmarkEmail, 1.1.0"
#:package Serilog.Sinks.PostmarkEmail@1.1.0
#addin nuget:?package=Serilog.Sinks.PostmarkEmail&version=1.1.0
#tool nuget:?package=Serilog.Sinks.PostmarkEmail&version=1.1.0
Serilog.Sinks.PostmarkEmail
A Serilog sink that batches log events and delivers them as email through the Postmark HTTP API.
No dependencies beyond Serilog itself. Targets net10.0, net8.0 and netstandard2.0.
dotnet add package Serilog.Sinks.PostmarkEmail
Quick start
using Serilog;
using Serilog.Events;
using Serilog.Sinks.PostmarkEmail;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.PostmarkEmail(
serverToken: "your-postmark-server-token",
from: "Application Logs <logs@yourdomain.com>",
to: "ops@yourdomain.com",
subject: "[{Level}] {Message}",
restrictedToMinimumLevel: LogEventLevel.Error)
.CreateLogger();
Call Log.CloseAndFlush() (or dispose the logger) on shutdown so buffered events are sent rather
than dropped.
Configuring from appsettings.json
Every parameter on the flat overload is a string or primitive, so Serilog.Settings.Configuration can bind it directly:
{
"Serilog": {
"Using": [ "Serilog.Sinks.PostmarkEmail" ],
"WriteTo": [
{
"Name": "PostmarkEmail",
"Args": {
"serverToken": "your-postmark-server-token",
"from": "Application Logs <logs@yourdomain.com>",
"to": "ops@yourdomain.com; oncall@yourdomain.com",
"subject": "[{Level}] {Message}",
"tag": "app-errors",
"batchSizeLimit": 50,
"bufferingTimeLimit": "00:01:00",
"retryTimeLimit": "00:02:00",
"httpTimeout": "00:00:30",
"restrictedToMinimumLevel": "Error"
}
}
]
}
}
Keep the token out of source control — supply it through user secrets, an environment variable, or your key vault of choice and let configuration substitution fill it in.
Configuring in code
For anything the flat overload doesn't cover — a shared HttpClient, a custom IFormatProvider —
use the options overload:
using var httpClient = httpClientFactory.CreateClient("postmark");
Log.Logger = new LoggerConfiguration()
.WriteTo.PostmarkEmail(new PostmarkEmailSinkOptions
{
ServerToken = configuration["Postmark:ServerToken"],
From = "logs@yourdomain.com",
To = "ops@yourdomain.com",
Cc = "audit@yourdomain.com",
Subject = "[{Level}] {Message}",
Tag = "app-errors",
MessageStream = "outbound",
BatchSizeLimit = 50,
BufferingTimeLimit = TimeSpan.FromMinutes(1),
HttpClient = httpClient
}, restrictedToMinimumLevel: LogEventLevel.Error)
.CreateLogger();
Options
| Option | Default | Notes |
|---|---|---|
ServerToken |
required | Postmark server token. See the gotcha below. |
From |
required | Must be a verified sender signature or an address on a confirmed domain. Display Name <addr> is accepted. |
To |
required | Comma- or semicolon-separated. Postmark accepts at most 50. |
Cc, Bcc, ReplyTo |
null |
Omitted from the request when unset. |
Subject |
"Log Email" |
An output template, rendered against the most significant event in the batch. |
OutputTemplate |
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}" |
Applied to every event to build the body. |
IsBodyHtml |
false |
Sends the body as HtmlBody instead of TextBody. Nothing is escaped or wrapped, so pair it with an HTML-emitting template. |
Tag |
null |
Groups these messages in Postmark's statistics. |
MessageStream |
null |
Postmark defaults to outbound. |
TrackOpens |
null |
Inherits the server setting when unset. |
BatchSizeLimit |
100 |
Events per email. |
BufferingTimeLimit |
30s |
How long to wait for a batch to fill. |
QueueLimit |
10000 |
Events buffered before new ones are dropped. null for unbounded. |
EagerlyEmitFirstEvent |
false |
Serilog's own default is true; sending one email for the very first event defeats the point of batching log mail. |
RetryTimeLimit |
null |
Serilog's default of ten minutes. |
HttpClient / MessageHandler |
null |
Mutually exclusive, code-only. See below. |
ServerUrl |
https://api.postmarkapp.com/ |
Override for proxies and tests. Must be an absolute URI. |
HttpTimeout |
30s |
Applies only to a sink-owned client. |
Everything above except HttpClient and MessageHandler is reachable from appsettings.json.
Behavior worth knowing
The subject is a template. It is rendered against the highest-level event in the batch (earliest
one, on a tie), so "[{Level}] {Message}" produces [Error] Order 4021 failed to settle. Because a
mail header cannot span lines, runs of whitespace in the rendered subject collapse to single spaces
and the result is truncated at 250 characters.
Errors are split by whether a retry could help. Serilog's batching infrastructure retries a batch
whose send threw, for up to RetryTimeLimit. That is right for a network blip or a 500, so those
throw. A rejected token or a malformed sender address would fail identically for the full ten
minutes, so those are written once to
SelfLog and the batch is
dropped. Postmark also returns a non-zero ErrorCode inside some 200-level responses; those are
reported to SelfLog too.
Turn SelfLog on while setting the sink up — it is where every delivery problem surfaces:
Serilog.Debugging.SelfLog.Enable(Console.Error);
HttpClient ownership. Supply MessageHandler and the sink builds a client over it, owns that
client, and leaves the handler for you to dispose. Supply HttpClient and the sink borrows it: it
never disposes it and never mutates its BaseAddress, Timeout, or default headers, so it is safe
to hand over a client from IHttpClientFactory. Requests always use an absolute URI derived from
ServerUrl, so a borrowed client's own BaseAddress is ignored. Supply neither and the sink creates
and disposes its own.
Postmark gotchas
- Server token, not account token. The sink sends
X-Postmark-Server-Token. An account token manages servers and domains and cannot send email; using one yields a 401. - The sender must be verified. Postmark rejects a
Fromthat isn't a confirmed sender signature or on a verified domain, withErrorCode400. This is a configuration error, so the sink reports it toSelfLogand drops the batch rather than retrying. - Message streams. Log email is transactional and belongs on the default
outboundstream. Sending it on a broadcast stream adds unsubscribe handling you don't want on an alert. - Recipient cap. Fifty addresses per field. Beyond that the sink drops the excess and warns
through
SelfLog, because Postmark would otherwise reject the whole message.
Building
dotnet build
dotnet test
dotnet pack -c Release
dotnet test runs on Microsoft.Testing.Platform, which global.json opts into. Note that MTP mode
does not accept --nologo.
Releasing
The git tag is the source of truth for the published version; it overrides VersionPrefix in the
csproj. Publishing is therefore:
git tag v1.0.0
git push origin v1.0.0
That runs .github/workflows/release.yml, which builds, runs the tests, packs with
ContinuousIntegrationBuild=true, pushes the .nupkg and .snupkg to nuget.org, and opens a
GitHub release with both attached.
Authentication uses trusted publishing:
no long-lived API key is stored anywhere. The job mints a GitHub OIDC token, NuGet/login@v1 trades
it with nuget.org for a temporary key valid for one hour and redeemable once, and that key is used
for the push. Setup is a one-time trusted publishing policy on nuget.org:
| Policy field | Value |
|---|---|
| Repository Owner | jfgreco |
| Repository | Serilog.Sinks.PostmarkEmail |
| Workflow File | release.yml (filename only, no path) |
| Environment | nuget |
The only repository secret is NUGET_USER, the nuget.org account name. That is not a credential —
it is a secret purely to keep the account name out of public build logs.
Publishing is not reversible — a version number on nuget.org is permanent and can only be unlisted,
never replaced. The workflow runs in a nuget GitHub environment, so adding required reviewers to
that environment in repository settings puts a manual approval in front of the push.
Release checklist
masteris green anddotnet testpasses locally.- Decide the version. Breaking changes to
PostmarkEmailSinkOptionsor theWriteTo.PostmarkEmailsignatures need a major bump; a published version cannot be re-cut. - Tag and push. The tag drives the version — there is nothing to edit in the csproj.
- Watch the run. Everything before
Push to NuGetis a gate: build, the full test suite, pack, and a check that the packed filename matches the tag. A failure in any of them publishes nothing, so the tag can safely be moved and re-pushed. - Once
Push to NuGethas succeeded the version is spent. From that point a mistake means publishing a new version, not re-tagging. - nuget.org takes roughly five minutes to index a new version. A 404 immediately after a successful push is normal.
One-time setup
Already done for this repository; recorded here in case it needs rebuilding.
- A trusted publishing policy on nuget.org with the fields in the table above, and a glob pattern
of
Serilog.Sinks.PostmarkEmailunder package scoping. A package that does not exist yet cannot be picked from the list, so the glob field is the only way to scope the first release. - A
NUGET_USERrepository secret holding the nuget.org profile name, not the email address. - The
nugetGitHub environment. The name is load-bearing twice over: it gates the push behind optional required reviewers, and it travels as a claim in the OIDC token that nuget.org validates against the policy. Renaming it means updating the policy too.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Serilog (>= 4.4.0)
- System.Text.Json (>= 8.0.5)
-
net10.0
- Serilog (>= 4.4.0)
-
net8.0
- Serilog (>= 4.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.