MillionSend 0.2.0
See the version list below for details.
dotnet add package MillionSend --version 0.2.0
NuGet\Install-Package MillionSend -Version 0.2.0
<PackageReference Include="MillionSend" Version="0.2.0" />
<PackageVersion Include="MillionSend" Version="0.2.0" />
<PackageReference Include="MillionSend" />
paket add MillionSend --version 0.2.0
#r "nuget: MillionSend, 0.2.0"
#:package MillionSend@0.2.0
#addin nuget:?package=MillionSend&version=0.2.0
#tool nuget:?package=MillionSend&version=0.2.0
MillionSend .NET SDK
Official .NET SDK for MillionSend — a self-hostable, Resend-wire-compatible email API.
The API is wire-compatible with Resend, so migrating is mostly swapping the package and pointing the client at your instance. Targets net8.0.
Install
dotnet add package MillionSend
Quickstart
using MillionSend;
var client = new MillionSendClient("ms_123", "https://mail.acme.dev");
var res = await client.EmailSendAsync(new EmailMessage
{
From = "Acme <onboarding@acme.dev>",
To = "delivered@resend.dev",
Subject = "Hello from MillionSend",
Html = "<strong>It works!</strong>",
});
if (res.Success)
Console.WriteLine($"sent {res.Content!.Id}");
else
Console.Error.WriteLine($"{res.Exception!.ErrorName}: {res.Exception.Message}");
To, Cc, Bcc, and ReplyTo accept a single address or an array — assign a
string or a string[] directly.
Configuration
Construct with the convenience constructor, or with MillionSendClientOptions:
var client = new MillionSendClient(new MillionSendClientOptions
{
ApiToken = "ms_123",
ApiUrl = "https://mail.acme.dev",
});
ApiTokenfalls back to theMILLIONSEND_API_KEYenvironment variable. Missing key → throws at construction.ApiUrlfalls back toMILLIONSEND_BASE_URL, thenhttp://localhost:3001. MillionSend is self-hosted, so set this to your deployment in production.
You may pass your own HttpClient as the second argument (for proxies, custom
handlers, or tests): new MillionSendClient(options, httpClient).
Error handling
No method throws for an API or transport error. Every call returns a
MillionSendResponse<T>:
Success—truewhen the call succeeded.Content— the deserialized response body on success.Exception— aMillionSendExceptionon failure, carryingStatusCode(int?),ErrorName(the stable snake_case discriminant, e.g.validation_error,not_found,sending_paused), andMessage.
Transport and client-side failures (the request never reached the API) carry
StatusCode == null and ErrorName == "application_error".
var res = await client.EmailRetrieveAsync(id);
if (!res.Success && res.Exception!.ErrorName == "not_found")
{
// ...
}
Resources
Emails
await client.EmailSendAsync(message, idempotencyKey: "unique-key"); // POST /emails
await client.EmailRetrieveAsync(id); // GET /emails/{id}
await client.EmailCancelAsync(id); // POST /emails/{id}/cancel (scheduled only)
await client.EmailBatchAsync(new[] { messageA, messageB }, idempotencyKey: "k"); // up to 100
EmailSendAsync and EmailBatchAsync accept an optional idempotencyKey — the
only two endpoints that support the Idempotency-Key header.
Contacts
Contacts are team-global: one record per email address (case-insensitive), no
audiences. Creating a duplicate returns a 409 validation_error.
await client.ContactAddAsync(new ContactCreateOptions
{
Email = "ada@acme.dev",
FirstName = "Ada",
Properties = new() { ["plan"] = "pro" },
});
await client.ContactRetrieveAsync(new ContactAddress { Email = "ada@acme.dev" });
await client.ContactRetrieveAsync(new ContactAddress { Id = contactId }); // by id or email (email wins)
await client.ContactUpdateAsync(new ContactUpdateOptions { Id = contactId, Unsubscribed = true });
await client.ContactDeleteAsync(new ContactAddress { Email = "ada@acme.dev" });
await client.ContactListAsync(new ListOptions { Limit = 50 });
// Topic subscriptions (granular unsubscribe)
await client.ContactTopicsUpdateAsync(new ContactTopicsUpdateOptions
{
Email = "ada@acme.dev",
Topics = new() { new ContactTopicUpdate { Id = topicId, Subscription = TopicSubscription.OptOut } },
});
A contact is addressable by id or email; when both are set, email wins.
Topics
await client.TopicAddAsync(new TopicCreateOptions { Name = "Product updates", DefaultSubscription = TopicSubscription.OptIn });
await client.TopicRetrieveAsync(id);
await client.TopicListAsync(); // unpaginated: bare { data }
await client.TopicDeleteAsync(id);
Broadcasts
Targeting is an optional SegmentId and/or TopicId — set neither to send to
every contact of the team.
var broadcast = await client.BroadcastAddAsync(new BroadcastCreateOptions
{
From = "Acme <news@acme.dev>",
Subject = "Launch",
Html = "<p>Hi {{{FIRST_NAME|there}}}</p>",
SegmentId = segmentId, // optional
});
await client.BroadcastListAsync();
await client.BroadcastRetrieveAsync(id);
await client.BroadcastUpdateAsync(id, new BroadcastUpdateOptions { Subject = "Launch 🚀" }); // draft only
await client.BroadcastSendAsync(id, scheduledAt: "2026-09-01T09:00:00Z"); // omit to send now
await client.BroadcastCancelAsync(id); // scheduled only
await client.BroadcastDeleteAsync(id); // draft only
Segments (MillionSend extension)
Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with no Resend equivalent.
await client.SegmentAddAsync(new SegmentCreateOptions
{
Name = "Pro plan",
Filter = new SegmentFilter
{
Match = SegmentMatch.All,
Conditions = new() { new SegmentCondition { Field = "property:plan", Op = "equals", Value = "pro" } },
},
});
await client.SegmentRetrieveAsync(id); // includes a live contact_count
await client.SegmentListAsync();
await client.SegmentUpdateAsync(id, new SegmentUpdateOptions { Name = "Pro tier" });
await client.SegmentDeleteAsync(id);
Migrating from Resend
The resend-dotnet SDK exposes resources as client.Emails.SendAsync(...) and
throws ResendException. MillionSend flattens the surface to
client.EmailSendAsync(...) and returns a MillionSendResponse<T> (no throw).
Method names and payload shapes otherwise line up. Notes:
- Domains and API keys are managed in the MillionSend dashboard, not via the API, so there are no domain/api-key methods here.
- No audiences: contacts are team-global, one record per email address. Resend's audience/segment grouping maps to MillionSend's dynamic segments (saved filters) above.
Development
dotnet test # unit tests (the e2e tests stay inert without MILLIONSEND_E2E)
The e2e tests run only when MILLIONSEND_E2E=1 opts in (plus the API key):
MILLIONSEND_E2E=1 MILLIONSEND_API_KEY=ms_... dotnet test --filter Category=e2e
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.