Mailgunner 0.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Mailgunner --version 0.1.0
                    
NuGet\Install-Package Mailgunner -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Mailgunner" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Mailgunner" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Mailgunner" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Mailgunner --version 0.1.0
                    
#r "nuget: Mailgunner, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Mailgunner@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Mailgunner&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Mailgunner&version=0.1.0
                    
Install as a Cake Tool

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.0 and netstandard2.0; minimal dependency footprint (System.Text.Json, Polly.Core, Microsoft.Extensions.Http).
  • Resilient HTTP — built around typed HttpClient via IHttpClientFactory with 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.Ushttps://api.mailgun.net, MailgunRegion.Euhttps://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 — MailgunnerException stays reserved for HTTP responses.
  • The existing unnamed AddMailgunner keeps working unchanged and can coexist with named clients. If you register only named clients, a bare IMailgunnerClient is intentionally not resolvable (there is no implicit default) — resolve through IMailgunnerClientFactory.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?) to Attachments (downloadable) or InlineFiles (embeddable, referenced from HTML by content id), or MailgunFile(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 to application/octet-stream.
  • TagsOptions.Tags may carry several values; all are sent (not de-duplicated).
  • Test modeOptions.TestMode = true exercises the pipeline without delivering.
  • TrackingOptions.TrackingOpens (on/off) and Options.TrackingClicks (ClickTracking.Yes/No/HtmlOnly).
  • Scheduled deliveryOptions.DeliveryTime (a DateTimeOffset) is sent as an RFC 2822 date-time with a numeric timezone offset (for example Thu, 25 Jun 2026 14:00:00 +0000), never a named zone.
  • Custom headers & variablesOptions.CustomHeaders (h: prefix; names are case-insensitive, like mail headers) and Options.CustomVariables (v: prefix, string values; Mailgun truncates variables above 4KB).
  • Reply-Tomessage.ReplyTo = "support@example.com" emits the Reply-To header.
  • Delivery controlsRequireTls, SkipVerification, Tracking (master toggle), TrackingPixelLocationTop, SendingIp, SendingIpPool, DeliverWithin, DeliveryTimeOptimizePeriod, TimeZoneLocalize, Dkim, SecondaryDkim/SecondaryDkimPublic, ArchiveTo, SuppressHeaders; MailgunMessage.AmpHtml for 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 a MailgunnerException carrying 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 specialPOST /messages is not idempotent, so by default a send is retried only on 429 (Retry.SendRetryMode = SendRetryMode.Safe). Set SendRetryMode.Full to 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 any 5xx, plus transport-level faults with no response (timeout, connection reset/refused, DNS failure).
  • Never retried — a non-429 4xx (for example 400/401/403/404) surfaces immediately as a MailgunnerException after 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 carries Retry-After (delta-seconds or an HTTP-date), that value takes precedence for the next wait.
  • Mandatory capevery single wait is clamped to MaxSingleWait, so a hostile or far-future Retry-After cannot 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 CancellationToken abandons 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
  • ListAsync is the ergonomic default: it returns an IAsyncEnumerable<T> and follows the service's next pointer across pages until the list is exhausted. ListPageAsync returns one SuppressionPage<T> (its Items plus an opaque NextCursor) 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.
  • AddAsync sends the address plus that list's optional fields (a bounce's Code/Error, an unsubscribe's Tags) as JSON. AddRangeAsync sends many entries as a JSON array (chunked by 1000 per request). RemoveAsync deletes a single address; ClearAsync deletes every entry on the list.
  • Any non-success response — including a not-found GetAsync/RemoveAsync — surfaces a MailgunnerException carrying 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 whitespace signingKey throws ArgumentException (a configuration error). Every malformed or missing webhook-supplied field — a null timestamp/token, or a null, empty, wrong-length, or non-hexadecimal signature — returns false rather 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-based System.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); with SendRetryMode.Full a lost response can lead to the same message being delivered twice.
  • Timeouts. Each attempt is bounded by Retry.AttemptTimeout up to the response headers; the typed HttpClient.Timeout bounds 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 a MailgunnerException. When no response is obtained, the underlying exception surfaces after the retry budget: HttpRequestException (connection/DNS), TimeoutException (an attempt exceeded AttemptTimeout), or TaskCanceledException (the overall HttpClient.Timeout elapsed).
  • Batch failures. SendBatchAsync is fail-fast; MailgunnerException.AcceptedResults / FailedChunkIndex tell 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

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 120 9/6/2026
0.1.1 98 9/4/2026
0.1.0 94 9/3/2026