Mailgunner 0.1.0
See the version list below for details.
dotnet add package Mailgunner --version 0.1.0
NuGet\Install-Package Mailgunner -Version 0.1.0
<PackageReference Include="Mailgunner" Version="0.1.0" />
<PackageVersion Include="Mailgunner" Version="0.1.0" />
<PackageReference Include="Mailgunner" />
paket add Mailgunner --version 0.1.0
#r "nuget: Mailgunner, 0.1.0"
#:package Mailgunner@0.1.0
#addin nuget:?package=Mailgunner&version=0.1.0
#tool nuget:?package=Mailgunner&version=0.1.0
Mailgunner
Lightweight, modern, unofficial .NET client for the Mailgun (Sinch) REST API, focused on bulk personalized email delivery.
Status:
0.1.0— first release. Sending (single, templated, personalized batches), suppression lists, domain webhook management, webhook signature verification, named clients, one-click List-Unsubscribe, stream attachments and a safe-by-default send retry mode. See the changelog.
Highlights
- Modern & slim — multi-targets
net8.0andnetstandard2.0; minimal dependency footprint (System.Text.Json,Polly.Core,Microsoft.Extensions.Http). - Resilient HTTP — built around typed
HttpClientviaIHttpClientFactorywith Polly transient-fault handling (automatic retry with backoff, on by default). - Documented & strict — nullable reference types, XML docs, and warnings-as-errors.
- Debuggable packages — deterministic builds with SourceLink and symbol packages.
Installation
dotnet add package Mailgunner
Releases are published on
v*tags; see docs/RELEASING.md.
Quickstart
Register the client, then send a personalized conference-invitation batch — each recipient gets their own name, ticket, and personal link from one stored Handlebars template. Adapt only the domain, key, region, and recipients; everything else is the scenario the sample runs verbatim.
using Mailgunner;
using Microsoft.Extensions.DependencyInjection;
// 1. Register the client (adapt domain / key / region; supply the key from configuration).
var services = new ServiceCollection();
services.AddMailgunner(
domain: "sandbox123.mailgun.org",
sendingKey: configuration["Mailgun:SendingKey"]!,
region: MailgunRegion.Us);
IMailgunnerClient client = services.BuildServiceProvider().GetRequiredService<IMailgunnerClient>();
// 2. Build the batch from a stored Handlebars template that references {{name}} / {{ticket}} / {{link}}.
var batch = new MailgunBatchMessage
{
From = "postmaster@sandbox123.mailgun.org",
Subject = "You're invited!",
Template = "conference-invitation",
GenerateTextFromTemplate = true,
};
// 3. The bridge: each template variable reads its per-recipient value from recipient-variables.
batch.TemplateVariables["name"] = "%recipient.name%";
batch.TemplateVariables["ticket"] = "%recipient.ticket%";
batch.TemplateVariables["link"] = "%recipient.link%";
// 4. Per-recipient values — each attendee gets their own name / ticket / link.
var ada = new BatchRecipient("dev1@example.com");
ada.Variables["name"] = "Ada Lovelace";
ada.Variables["ticket"] = "A-1024";
ada.Variables["link"] = "https://conf.example/t/A-1024";
batch.Recipients.Add(ada);
var alan = new BatchRecipient("dev2@example.com");
alan.Variables["name"] = "Alan Turing";
alan.Variables["ticket"] = "A-2048";
alan.Variables["link"] = "https://conf.example/t/A-2048";
batch.Recipients.Add(alan);
// 5. Send — automatically chunked; Mailgun delivers one personalized message per recipient.
IReadOnlyList<SendResult> results = await client.SendBatchAsync(batch);
foreach (SendResult result in results)
Console.WriteLine($"sent: id={result.Id} status={result.Message}");
Why the bridge (step 3)? A batch can use a stored template (this example) or inline Text/Html
with %recipient.var% placeholders. This example's stored-template path emits the global
t:variables (which a Handlebars template reads as {{var}}) and a per-recipient
recipient-variables object (addressed as %recipient.var%). Mapping each {{var}} to its
%recipient.var% token in TemplateVariables is what makes Mailgun render a distinct value per
recipient — no library change required.
Run the sample
A runnable version of the exact scenario above lives in
samples/Mailgunner.Sample. It is also the project's single
environment-gated live check: it sends only when credentials are present and is skipped — not
failed — when they are absent.
One-time setup (live run only): in the Mailgun dashboard, add your test addresses as
authorized recipients of your sandbox domain, and create a stored Handlebars template
named conference-invitation whose body references the per-recipient fields, for example:
<p>Hi {{name}}, your ticket is <strong>{{ticket}}</strong>.</p>
<p>Your personal link: <a href="{{link}}">{{link}}</a></p>
Supply credentials via environment variables (note the __ section separator) or user-secrets —
never edit source or commit a secret:
export Mailgun__Domain="sandbox123.mailgun.org"
export Mailgun__SendingKey="key-…" # prefer a Domain Sending Key
export Mailgun__Region="Us" # Us or Eu (must match the domain)
export Mailgun__Recipients__0__Address="you@example.com"
export Mailgun__Recipients__1__Address="teammate@example.com"
dotnet run --project samples/Mailgunner.Sample
With credentials present, the sample sends one personalized batch and prints a success line (id +
status) per chunk. With any required setting absent, it makes no request, prints exactly which
settings are missing and where to supply them, and exits 0.
Getting started
Register the client into your dependency-injection container with a single call, supplying your
Mailgun domain, a sending key, and a region. Resolving IMailgunnerClient then yields a ready
instance whose requests target the correct regional host and carry HTTP Basic authentication.
using Microsoft.Extensions.DependencyInjection;
using Mailgunner;
// Explicit settings:
services.AddMailgunner(
domain: "mg.example.com",
sendingKey: configuration["Mailgun:SendingKey"]!,
region: MailgunRegion.Eu);
// …or configure via a delegate (e.g. bound from configuration):
services.AddMailgunner(options =>
{
options.Domain = configuration["Mailgun:Domain"]!;
options.SendingKey = configuration["Mailgun:SendingKey"]!;
options.Region = MailgunRegion.Us;
});
// Later, anywhere DI is available:
var client = serviceProvider.GetRequiredService<IMailgunnerClient>();
Prefer a Domain Sending Key over your primary account key, and supply it from configuration or environment — never hard-code it.
Invalid configuration (a missing/blank domain or sending key, or an unspecified/unrecognized region) is rejected when the host starts, with a clear error that names the offending setting.
Regions
The region selects the API host: MailgunRegion.Us → https://api.mailgun.net,
MailgunRegion.Eu → https://api.eu.mailgun.net. The region and the sending domain are
independent: if you configure a region that does not match where your domain is hosted, the
client still builds, but requests go to a host where the domain is not found and Mailgun
responds with HTTP 404. Make sure the region matches your domain's region.
Multiple named clients
Need to talk to more than one Mailgun identity from one application — several domains, or a transactional/marketing split across subdomains? Register each under a distinct name. Every named client keeps its own domain, sending key, region, and retry settings, fully isolated from the others and from the unnamed registration.
// Register as many names as you need (explicit, delegate, or bound from configuration):
services.AddMailgunner("transactional", "tx.example.com", txKey, MailgunRegion.Us);
services.AddMailgunner("marketing", options =>
{
options.Domain = "news.example.com";
options.SendingKey = mktKey;
options.Region = MailgunRegion.Eu;
options.Retry.MaxRetryAttempts = 5;
});
services.AddMailgunner("audit", configuration.GetSection("Mailgun:Audit")); // IConfiguration binding
// Resolve a specific one at the point of sending:
var factory = serviceProvider.GetRequiredService<IMailgunnerClientFactory>();
IMailgunnerClient tx = factory.Get("transactional");
await tx.SendAsync(message, cancellationToken);
Notes:
- Names are case-sensitive (ordinal):
"transactional"and"Transactional"are different names. - A blank or duplicate name is rejected when you register it; resolving an unknown name
throws a clear
ArgumentException(it never returns a default client). These are standard .NET errors and never expose a sending key —MailgunnerExceptionstays reserved for HTTP responses. - The existing unnamed
AddMailgunnerkeeps working unchanged and can coexist with named clients. If you register only named clients, a bareIMailgunnerClientis intentionally not resolvable (there is no implicit default) — resolve throughIMailgunnerClientFactory.Get(name)instead. - The per-name region/domain match matters exactly as above: a mismatch yields HTTP 404.
Send options & limits
Any send — single, templated, or a personalized batch — can be enriched with optional production
"knobs" via MailgunMessage.Options / MailgunBatchMessage.Options (a MailgunSendOptions), plus
the Attachments and InlineFiles collections. Every knob is optional; omitting one leaves your
Mailgun account default in effect.
- Attachments & inline files — add
MailgunFile(fileName, content, contentType?)toAttachments(downloadable) orInlineFiles(embeddable, referenced from HTML by content id), orMailgunFile(fileName, () => File.OpenRead(path), contentType)to stream large files without buffering; the factory is called once per request. When the content type is omitted it defaults toapplication/octet-stream. - Tags —
Options.Tagsmay carry several values; all are sent (not de-duplicated). - Test mode —
Options.TestMode = trueexercises the pipeline without delivering. - Tracking —
Options.TrackingOpens(on/off) andOptions.TrackingClicks(ClickTracking.Yes/No/HtmlOnly). - Scheduled delivery —
Options.DeliveryTime(aDateTimeOffset) is sent as an RFC 2822 date-time with a numeric timezone offset (for exampleThu, 25 Jun 2026 14:00:00 +0000), never a named zone. - Custom headers & variables —
Options.CustomHeaders(h:prefix; names are case-insensitive, like mail headers) andOptions.CustomVariables(v:prefix, string values; Mailgun truncates variables above 4KB). - Reply-To —
message.ReplyTo = "support@example.com"emits theReply-Toheader. - Delivery controls —
RequireTls,SkipVerification,Tracking(master toggle),TrackingPixelLocationTop,SendingIp,SendingIpPool,DeliverWithin,DeliveryTimeOptimizePeriod,TimeZoneLocalize,Dkim,SecondaryDkim/SecondaryDkimPublic,ArchiveTo,SuppressHeaders;MailgunMessage.AmpHtmlfor an AMP part.
16KB limit. Mailgun caps the combined size of the option (
o:), custom-header (h:), custom-variable (v:), and template (t:) parameters at 16KB per request. Mailgunner does not enforce this client-side; exceeding it causes the service to reject the request, surfaced as aMailgunnerExceptioncarrying the HTTP status code and response body.
Automatic retry & backoff
Resilience is on by default — every outbound request (sends and suppressions, which share the
typed HttpClient) is retried automatically on transient failures, so you don't write retry loops:
services.AddMailgunner("mg.example.com", sendingKey, MailgunRegion.Us);
// 429 / 408 / 5xx and transient transport failures are now retried automatically;
// Retry-After is honored; a non-429 4xx still surfaces immediately.
- Sends are special —
POST /messagesis not idempotent, so by default a send is retried only on 429 (Retry.SendRetryMode = SendRetryMode.Safe). SetSendRetryMode.Fullto retry sends on 408/5xx and transport faults too, accepting the risk of duplicate delivery. Suppression and webhook requests always use the full policy. - Retried — HTTP
429,408, and any5xx, plus transport-level faults with no response (timeout, connection reset/refused, DNS failure). - Never retried — a non-
4294xx(for example400/401/403/404) surfaces immediately as aMailgunnerExceptionafter exactly one attempt, with no wait. - Backoff — each computed wait grows exponentially with bounded additive jitter, so successive waits are strictly increasing and desynchronized across callers.
Retry-After— when a retryable response carriesRetry-After(delta-seconds or an HTTP-date), that value takes precedence for the next wait.- Mandatory cap — every single wait is clamped to
MaxSingleWait, so a hostile or far-futureRetry-Aftercannot stall a send. - Bounded & observable — the retry budget is finite; when it is exhausted the final failure
surfaces unchanged as a single
MailgunnerException(last status + body) and a Warning record is logged (status and attempt count only — never the sending key or request body). - Cancelable — the caller's
CancellationTokenabandons a pending wait promptly.
A first-attempt success makes exactly one attempt with zero waiting, and an eventual success is indistinguishable from one.
Tuning is optional (the defaults are production-ready):
services.AddMailgunner(o =>
{
o.Domain = "mg.example.com";
o.SendingKey = sendingKey;
o.Region = MailgunRegion.Us;
o.Retry.MaxRetryAttempts = 3; // retries after the first attempt (>= 0; 0 disables)
o.Retry.BaseDelay = TimeSpan.FromMilliseconds(500); // starting backoff (> 0)
o.Retry.MaxSingleWait = TimeSpan.FromSeconds(30); // mandatory cap on any single wait (>= BaseDelay)
o.Retry.UseJitter = true; // bounded additive jitter
o.Retry.SendRetryMode = SendRetryMode.Safe; // Safe (429 only) or Full
o.Retry.AttemptTimeout = TimeSpan.FromSeconds(100); // cap on a single attempt, up to the response headers
});
The typed HttpClient.Timeout is set to the worst case over every attempt and wait,
(MaxRetryAttempts + 1) × AttemptTimeout + MaxRetryAttempts × MaxSingleWait (490 s with the defaults), so a
stalled response body, which HttpClient reads outside the per-attempt timeout, can never hang a caller
that passed no CancellationToken.
Suppression lists
Mailgun maintains three suppression lists per domain — bounces, unsubscribes, and
complaints — and Mailgunner exposes them through client.Suppressions. Unlike sending, these are
JSON endpoints, and they are completely independent of the send pipeline. Each list
(Suppressions.Bounces, Suppressions.Unsubscribes, Suppressions.Complaints) offers the same set of
operations over its own typed entry (Bounce, Unsubscribe, Complaint):
// List every entry — pagination is followed transparently (streams large lists).
await foreach (Bounce b in client.Suppressions.Bounces.ListAsync(cancellationToken: ct))
{
Console.WriteLine($"{b.Address} {b.Code} {b.CreatedAt:u}");
}
// Optional page size cuts round-trips on big lists (applied to the first request only).
await foreach (Unsubscribe u in client.Suppressions.Unsubscribes.ListAsync(pageSize: 1000, cancellationToken: ct)) { }
// Caller-driven paging via the single-page primitive and its opaque cursor.
SuppressionPage<Complaint> page = await client.Suppressions.Complaints.ListPageAsync(ct);
while (page.HasMore)
{
page = await client.Suppressions.Complaints.ListPageAsync(page.NextCursor!, ct);
}
await client.Suppressions.Unsubscribes.AddAsync(
new Unsubscribe { Address = "user@example.com", Tags = new[] { "newsletter" } }, ct);
Bounce one = await client.Suppressions.Bounces.GetAsync("user@example.com", ct); // 404 → MailgunnerException
await client.Suppressions.Bounces.RemoveAsync("user@example.com", ct); // remove one address
await client.Suppressions.Complaints.ClearAsync(ct); // clear the whole list
ListAsyncis the ergonomic default: it returns anIAsyncEnumerable<T>and follows the service's next pointer across pages until the list is exhausted.ListPageAsyncreturns oneSuppressionPage<T>(itsItemsplus an opaqueNextCursor) for callers that drive paging themselves.- An optional page size is applied only to the first request; subsequent pages follow the service's next pointer verbatim.
AddAsyncsends the address plus that list's optional fields (a bounce'sCode/Error, an unsubscribe'sTags) as JSON.AddRangeAsyncsends many entries as a JSON array (chunked by 1000 per request).RemoveAsyncdeletes a single address;ClearAsyncdeletes every entry on the list.- Any non-success response — including a not-found
GetAsync/RemoveAsync— surfaces aMailgunnerExceptioncarrying the HTTP status code and raw response body.
Domain webhooks
client.Webhooks manages the callback URLs Mailgun invokes for each delivery event of the domain
(Mailgun's v3 domain-webhook endpoints). A registration is keyed by one WebhookEventType (Accepted,
Delivered, Opened, Clicked, Unsubscribed, Complained, PermanentFail, TemporaryFail) and
carries up to three absolute http(s) URLs:
await client.Webhooks.CreateAsync(WebhookEventType.Delivered, new[] { "https://app.example.com/hooks/mailgun" }, ct);
await client.Webhooks.CreateAsync(new[] { WebhookEventType.Complained, WebhookEventType.Unsubscribed }, "https://app.example.com/hooks/mailgun", ct);
IReadOnlyList<WebhookRegistration> all = await client.Webhooks.ListAsync(ct);
WebhookRegistration one = await client.Webhooks.GetAsync(WebhookEventType.Delivered, ct); // 404 → MailgunnerException
await client.Webhooks.UpdateAsync(WebhookEventType.Delivered, new[] { "https://app.example.com/hooks/v2" }, ct);
await client.Webhooks.DeleteAsync(WebhookEventType.Delivered, ct);
The multi-event overload issues one create per distinct event type, in order, and is fail-fast with no
rollback. A URL that is not an absolute http/https URL is rejected with ArgumentException before any
request.
Webhook signature verification
Mailgun signs each event webhook (bounces, complaints, unsubscribes) so consumers can confirm it
genuinely came from Mailgun before acting on it. Acting on a forged event would corrupt your
suppression state and reputation handling, so verify first. MailgunWebhookSignature.Verify is a
pure, network-free primitive — no client, no dependency injection, no state:
using Mailgunner;
// Extract the three signed fields from the incoming webhook request (you own the parsing),
// and supply YOUR webhook signing key from configuration — the webhook signing key, not the
// sending key, and never hard-coded.
bool authentic = MailgunWebhookSignature.Verify(
signingKey: configuration["Mailgun:WebhookSigningKey"]!,
timestamp: timestamp,
token: token,
signature: signature);
if (!authentic)
return Results.Unauthorized(); // forged or tampered — do not touch suppression state
- The signature is validated as the HMAC-SHA256 of
timestamp + token, keyed by your signing key and rendered as lowercase hexadecimal. The comparison is constant-time — it never short-circuits on the first differing character, so timing reveals nothing about how many leading characters matched. - Only the signing key is a precondition: a
null, empty, or whitespacesigningKeythrowsArgumentException(a configuration error). Every malformed or missing webhook-supplied field — anulltimestamp/token, or anull, empty, wrong-length, or non-hexadecimal signature — returnsfalserather than throwing. - Verification answers only "was this signed with the signing key?". Pass
maxAge(e.g.TimeSpan.FromMinutes(5)) to the second overload to also reject stale or future timestamps; token-reuse tracking remains yours:
bool authentic = MailgunWebhookSignature.Verify(
signingKey: configuration["Mailgun:WebhookSigningKey"]!,
timestamp: timestamp,
token: token,
signature: signature,
maxAge: TimeSpan.FromMinutes(5));
Limitations & notes
- No trimming/AOT guarantee. Template and recipient variables (
t:variables,recipient-variables) are serialized with reflection-basedSystem.Text.Json; in a Native AOT app that path throws at runtime. The suppression and webhook DTOs use source generation and are unaffected. - Duplicate delivery vs. retries. A send is retried only on HTTP 429 by default (
SendRetryMode.Safe); withSendRetryMode.Fulla lost response can lead to the same message being delivered twice. - Timeouts. Each attempt is bounded by
Retry.AttemptTimeoutup to the response headers; the typedHttpClient.Timeoutbounds the whole call, body reads included, at(MaxRetryAttempts + 1) × AttemptTimeout + MaxRetryAttempts × MaxSingleWait. - Transport failures are not
MailgunnerException. A response, success or failure, always maps to a result or aMailgunnerException. When no response is obtained, the underlying exception surfaces after the retry budget:HttpRequestException(connection/DNS),TimeoutException(an attempt exceededAttemptTimeout), orTaskCanceledException(the overallHttpClient.Timeoutelapsed). - Batch failures.
SendBatchAsyncis fail-fast;MailgunnerException.AcceptedResults/FailedChunkIndextell you which chunks were already accepted so you can resume from the failed one. - 16KB parameter cap on
o:/h:/v:/t:fields is not enforced client-side (see Send options & limits).
Building from source
Requires a .NET SDK matching global.json
(a slnx-capable SDK; .NET 10 recommended).
dotnet restore
dotnet build Mailgunner.slnx -c Release
dotnet test Mailgunner.slnx -c Release
Tests run fully offline — no network access or Mailgun credentials are required.
Live integration tests (tests/Mailgunner.IntegrationTests) run only when the Mailgun__*
variables from the sample section are set; without them every test reports
Skipped and the suite stays green. They are not part of Mailgunner.slnx's CI/release runs —
CI and the release workflow invoke dotnet test scoped to the offline projects only — so opting
in is a manual, local dotnet test tests/Mailgunner.IntegrationTests with the environment
variables exported. Sends use MailgunSendOptions.TestMode, so nothing is actually delivered,
and every test removes whatever suppression entry or webhook it created, even when it fails
partway; the webhook test restores (rather than deletes) any registration that already existed
for the event type it exercises, since a webhook is a single whole-domain registration per event
type with no way to namespace it — run these against a sandbox/test domain, not one serving real
traffic on that event type.
Project layout
| Path | Purpose |
|---|---|
src/Mailgunner/ |
The publishable library. |
tests/Mailgunner.Tests/ |
Offline xUnit test suite. |
tests/Mailgunner.NetFxTests/ |
net48 tests exercising the netstandard2.0 build (Windows CI leg). |
tests/Mailgunner.IntegrationTests/ |
Opt-in live tests against a real Mailgun account (see above). |
Directory.Build.props |
Shared build/quality/package settings. |
Directory.Packages.props |
Central Package Management (pinned versions). |
.editorconfig |
Build-enforced style & analyzer rules. |
Documentation & history
- Changes are recorded in CHANGELOG.md (Keep a Changelog format).
- Licensed under the MIT License.
Disclaimer
Mailgunner is a community-maintained, unofficial library. It is not affiliated with, authorized by, or endorsed by Mailgun or Sinch. "Mailgun" and "Sinch" are trademarks of their respective owners.
| 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 was computed. 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
- Microsoft.Bcl.TimeProvider (>= 8.0.0)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Polly.Core (>= 8.7.0)
- System.Text.Json (>= 8.0.5)
-
net8.0
- Microsoft.Extensions.Configuration (>= 8.0.0)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Polly.Core (>= 8.7.0)
- System.Text.Json (>= 8.0.5)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
See the changelog: https://github.com/gberikov/Mailgunner/blob/master/CHANGELOG.md