Skip to content

About

MillionSend .NET SDK — NuGet: MillionSend (MIT)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

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. MillionSend Cloud works with just an API key; a self-hosted instance sets its origin. Targets net8.0.

Install

dotnet add package MillionSend

Quickstart

using MillionSend;

var client = new MillionSendClient("ms_123");                             // MillionSend Cloud
// var client = new MillionSendClient("ms_123", "https://mail.acme.dev");   // self-hosted

var res = await client.EmailSendAsync(new EmailMessage
{
    From = "Acme <onboarding@acme.dev>",
    To = "success@simulator.amazonses.com",
    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",
});
  • ApiToken falls back to the MILLIONSEND_API_KEY environment variable. Missing key → throws at construction.
  • ApiUrl falls back to MILLIONSEND_BASE_URL, then https://api.millionsend.com (MillionSend Cloud). Self-hosting? Set this to your deployment's origin.
  • Plain http:// is only accepted for loopback hosts (localhost, 127.0.0.1, ::1); any other http:// URL throws ArgumentException at construction, since the API key is sent as a bearer header. Set AllowInsecureHttp = true on the options to talk to a non-TLS instance elsewhere (e.g. inside a private network).

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 — true when the call succeeded.
  • Content — the deserialized response body on success.
  • Exception — a MillionSendException on failure, carrying StatusCode (int?), ErrorName (the stable snake_case discriminant, e.g. validation_error, not_found, forbidden, rate_limit_exceeded, sending_paused, all_recipients_suppressed), RetryAfter (int?: the response's retry-after header in seconds, null when it has none) and Message.

EmailSendAsync and EmailBatchAsync answer 422 all_recipients_suppressed when every To recipient is on the suppression list or opted out of the send's TopicId. When the suppression list alone empties To, EmailSendAsync also keeps a metadata-only suppressed email, which fires email.suppressed; each retry keeps another, even with the same idempotency key.

Inboxes add inbox_send_policy (403, a send the inbox's rules or the shared domain hold), inbox_send_cap_reached (429, the inbox's or the team's daily cap), approval_required (403), email_quarantined (403), email_blocked (410, a download of an email that carries malware), too_many_waits (429, too many concurrent long-polls) and name_review_unavailable (503, a name on the shared domain can't be reviewed yet). A quarantine release adds trust_not_allowed (403) and held_by_screening (409), see Inboxes; it answers forbidden (403) and screening_unavailable (503) only to MCP clients.

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")
{
    // ...
}

Retries

The SDK never retries on its own. Worth retrying after RetryAfter seconds: 503 name_review_unavailable, 429 too_many_waits, and 429 rate_limit_exceeded when RetryAfter is 60 or less (the per-minute limits). Not worth retrying in the same process: 429 inbox_send_cap_reached and the daily name-review budget (429 rate_limit_exceeded with a RetryAfter that runs to the next UTC midnight), and any 403, 404, 409 or 422. An unknown route answers 404 not_found (application_error from servers before v0.13.0, which answer it in plain text).

Resend a send with the same idempotency key: a batch the API already accepted gets its stored response back. A held draft (IsDraft) is not a failure; never resend it. InboxCreateAsync takes no idempotency key: after a 429 or 503 nothing was created, but after a timeout a resend can create a second inbox. With a chosen Username, a 409 on the resend usually means the first attempt succeeded: read it with InboxRetrieveAsync(address).

Clearing a field on update (Optional<T>)

PATCH bodies distinguish "leave unchanged" (key omitted) from "clear" (JSON null). Fields that the API lets you clear are typed Optional<T>?: assign a plain value as usual, leave it unset (or assign C# null) to keep the current value, or assign Optional<T>.Null to send an explicit null.

await client.ContactUpdateAsync(new ContactUpdateOptions
{
    Id = contactId,
    FirstName = "Ada",                  // sets
    LastName = Optional<string>.Null,   // clears
});
await client.BroadcastUpdateAsync(id, new BroadcastUpdateOptions { TopicId = Optional<Guid?>.Null });

Available on contact FirstName/LastName, broadcast TopicId, template Subject/Text/Alias, domain TrackingSubdomain, contact-property FallbackValue, inbox ExpiresIn, safety SendCap, draft To/Cc/Bcc/Subject/Html/Text and webhook InboxIds. A null value inside Properties clears that key.

Pagination

List methods take a ListOptions (Limit, and the keyset cursors After and Before) and return Data and HasMore. Lists run newest first (MillionSend v0.13.0 and later): After takes the last id of a page and pages toward older items, Before takes the first id and pages toward newer ones, and HasMore refers to that direction. Threads sort by last activity, drafts by last update, a thread's emails run oldest first, and labels and topics are not paginated.

// Incremental sync: page toward newer items, starting from the newest id you stored
Guid? before = newestStoredId;
while (true)
{
    var page = await client.EmailListAsync(new ListOptions { Before = before, Limit = 100 });
    if (!page.Success || page.Content!.Data.Count == 0) break;
    Save(page.Content.Data);                       // newest first within the page
    if (!page.Content.HasMore) break;
    before = page.Content.Data[0].Id;
}

A cursor whose item was deleted answers 422 validation_error (404 not_found on the inbox lists): start again without a cursor and stop at the ids you already have.

Resources

Emails

await client.EmailSendAsync(message, idempotencyKey: "unique-key"); // POST /emails
await client.EmailSendAsync("unique-key", message);                 // same, resend-dotnet argument order
await client.EmailRetrieveAsync(id);                                // GET /emails/{id}
await client.EmailListAsync(new ListOptions { Limit = 50 });        // GET /emails
await client.EmailRescheduleAsync(id, "2026-09-01T09:00:00Z");      // PATCH /emails/{id}
await client.EmailCancelAsync(id);                                  // POST /emails/{id}/cancel (scheduled only)
await client.EmailDeleteAsync(id);                                  // DELETE /emails/{id}
await client.EmailInsightsRetrieveAsync(id);                        // GET /emails/{id}/insights

EmailMessage carries every wire field: From, To, Subject, Html, Text, Cc, Bcc, ReplyTo, ScheduledAt, Tags, TopicId, Attachments (Filename, base64 Content, ContentType, ContentId, Path), Headers, and Template (passed through; the server answers 422 until template sending ships).

Subject takes up to 998 characters. A To, Cc or Bcc address on a domain that can never receive mail (example.com, .test, .invalid, .localhost, a TLD that doesn't exist, …) answers 422 validation_error; to test sending without emailing anyone, use success@simulator.amazonses.com. An inbox's address on the shared domain can be the From too, under the rules in Sending from the shared domain.

Batches take up to 100 messages. BatchValidationMode.Permissive sends the x-batch-validation header so valid items go out and invalid ones come back in Errors (the default, Strict, rejects the whole batch):

var res = await client.EmailBatchAsync(new[] { a, b }, BatchValidationMode.Permissive, idempotencyKey: "k");
foreach (var err in res.Content!.Errors ?? new())
    Console.WriteLine($"item {err.Index}: {err.Message}");

Batch items never send from the shared domain: a strict batch fails with 403 inbox_send_policy (the message starts with emails.<index>: ), a permissive one lists the item in Errors.

The sends take an Idempotency-Key: EmailSendAsync, EmailBatchAsync, replies, forwards and InboxDraftSendAsync.

EmailRetrieveAsync includes a Score (0–10 best-practice score, null when the email has no insights). EmailInsightsRetrieveAsync returns the full pre-send report — score, band, and per-check results — or a not_found error when insights are not available for the email.

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" },
    Segments = new() { new SegmentRef { Id = segmentId } },
    Topics = new() { new ContactTopicUpdate { Id = topicId, Subscription = TopicSubscription.OptIn } },
});
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" });   // their emails stay in the log
// Erase (MillionSend extension): also scrub the address from email history, event
// payloads and API logs — a GDPR/LGPD erasure                                   // ?erase=true
await client.ContactDeleteAsync(new ContactAddress { Email = "ada@acme.dev" }, new ContactDeleteOptions { Erase = true });
await client.ContactListAsync(new ListOptions { Limit = 50 });
// Bulk read (MillionSend extension): attach Properties and Topics to every row, so an
// audience reads in one request per 100 contacts instead of one per contact   // ?include=properties,topics
await client.ContactListAsync(new ContactListOptions { Limit = 100, Include = new() { ContactInclude.Properties, ContactInclude.Topics } });

// Topic subscriptions (granular unsubscribe)
await client.ContactTopicsUpdateAsync(new ContactTopicsUpdateOptions
{
    Email = "ada@acme.dev",
    Topics = new() { new ContactTopicUpdate { Id = topicId, Subscription = TopicSubscription.OptOut } },
});
// Every topic with the contact's effective subscription: their explicit choice,
// else the topic default (Explicit == false)                                 // GET /contacts/{id}/topics
var topics = await client.ContactListTopicsAsync(new ContactAddress { Email = "ada@acme.dev" });
foreach (var t in topics.Content!.Data)
    Console.WriteLine($"{t.Name}: {t.Subscription}{(t.Explicit ? "" : " (default)")}");

// Segment membership
await client.ContactAddToSegmentAsync(new ContactAddress { Id = contactId }, segmentId);      // POST /contacts/{id}/segments/{segmentId}
await client.ContactRemoveFromSegmentAsync(new ContactAddress { Id = contactId }, segmentId); // DELETE …

// Bulk import (MillionSend extension): up to 1000 items per call
var batch = await client.ContactBatchAsync(contacts, new ContactBatchOptions
{
    OnConflict = ContactBatchOnConflict.Upsert,       // ?on_conflict=error|skip|upsert
    Validation = BatchValidationMode.Permissive,      // x-batch-validation header
});
Console.WriteLine($"{batch.Content!.Counts.Created} created, {batch.Content.Counts.Failed} failed");
foreach (var err in batch.Content.Errors ?? new())   // permissive only: what strict mode would answer per item
    Console.WriteLine($"item {err.Index}: {err.StatusCode} {err.Name} {err.Message}");

// Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request,
// in request order; unknown entries are listed, not errors — one request against the rate limit
var found = await client.ContactBatchGetAsync(                                 // POST /contacts/batch/get
    new[] { new ContactAddress { Id = contactId }, new ContactAddress { Email = "b@acme.dev" } },
    new ContactBatchGetOptions { Include = new() { ContactInclude.Topics } });
found.Content!.Data;      // the contacts found: Id, Email, …, plus Properties/Topics per Include
found.Content.Missing;    // [{ Index, Id?, Email? }] — request entries that matched nobody

// Bulk delete (MillionSend extension): up to 1000 per call, by emails or by ids;
// the response lists only the rows actually deleted. Their emails stay in the log;
// Erase also scrubs each address, as on a single delete          // POST /contacts/batch/remove
await client.ContactBatchRemoveAsync(new[] { "a@acme.dev", "b@acme.dev" });
await client.ContactBatchRemoveAsync(new[] { id1, id2 }, new ContactDeleteOptions { Erase = true });

// Preference-center link (MillionSend extension): the contact's hosted preferences
// page, the same one their unsubscribe links open. No expiry — hand it only to the
// contact. 422 when the instance cannot build hosted links.   // POST /contacts/{id}/preferences-link
var link = await client.ContactPreferencesLinkAsync(new ContactAddress { Email = "ada@acme.dev" });
Console.WriteLine(link.Content!.Url);

A contact is addressable by id or email; when both are set, email wins. ContactListTopicsAsync items carry the topic's Visibility; the hosted preference page lists public topics only.

Contact properties

await client.ContactPropCreateAsync(new ContactPropertyCreateOptions
{
    Key = "plan", Type = ContactPropertyType.String, FallbackValue = "free",
});
await client.ContactPropCreateAsync(new ContactPropertyCreateOptions
{
    Key = "last_login", Type = ContactPropertyType.Date,   // MillionSend extension
});
await client.ContactPropListAsync();
await client.ContactPropRetrieveAsync(id);
await client.ContactPropUpdateAsync(id, new ContactPropertyUpdateOptions { FallbackValue = "trial" });
await client.ContactPropDeleteAsync(id);

Type is String or Number, as on Resend, or Date. A date property's values, and its FallbackValue, must read as a date, or the write answers 422 validation_error:

  • an ISO 8601 date such as 2026-01-08, read as midnight UTC;
  • a date-time such as 2026-01-08T10:30:00Z or 2026-01-08 10:30, read as UTC when it names no zone;
  • Unix time in seconds or milliseconds, such as 1767225600, between 2000 and 2100.

A contact's date comes back in Properties as { "type": "date", "value": "2026-01-08" }, the text as stored. A type the server adds after this SDK version reads as ContactPropertyType.Unknown.

Topics

await client.TopicAddAsync(new TopicCreateOptions
{
    Name = "Product updates",
    DefaultSubscription = TopicSubscription.OptIn,
    Visibility = TopicVisibility.Public,   // always shown on the unsubscribe page; default Private
});
await client.TopicRetrieveAsync(id);
await client.TopicListAsync();     // unpaginated: bare { data }
await client.TopicUpdateAsync(id, new TopicUpdateOptions { Name = "Product news" });
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>",
    PreviewText = "It's here",
    SegmentId = segmentId,               // optional
    Send = true,                          // send now instead of saving a draft
    ScheduledAt = "2026-09-01T09:00:00Z", // requires Send = true
});
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

Subject takes up to 998 characters. A broadcast never sends from the shared domain: BroadcastAddAsync with Send = true and BroadcastSendAsync answer 403 inbox_send_policy.

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.SegmentContactListAsync(id, new ListOptions { Limit = 100 }); // GET /segments/{id}/contacts
await client.SegmentContactListAsync(id, new ContactListOptions { Include = new() { ContactInclude.Properties } }); // ?include=properties
await client.SegmentDeleteAsync(id);

created_at and every property:<key> also take date ops. before and after take an ISO 8601 date or date-time. in_last_days and not_in_last_days take a whole number of days, from 1 to 36500, as a string:

new SegmentCondition { Field = "property:last_login", Op = "not_in_last_days", Value = "90" }

Suppressions

await client.SuppressionAddAsync("bounced@acme.dev", SuppressionOrigin.Manual);
await client.SuppressionListAsync(new SuppressionListOptions { Limit = 50, Origin = SuppressionOrigin.Bounce });
await client.SuppressionRetrieveAsync("bounced@acme.dev");   // by id or email
await client.SuppressionRemoveAsync(id.ToString());
await client.SuppressionBatchAddAsync(new[] { "a@acme.dev", "b@acme.dev" }, SuppressionOrigin.Unsubscribe);
await client.SuppressionBatchRemoveAsync(new[] { "a@acme.dev" });   // by emails…
await client.SuppressionBatchRemoveAsync(new[] { id1, id2 });        // …or by ids

Receiving

Mail received at your receiving domains and inboxes. Everything in a received email was written by its sender, so show its HTML only in a sandbox.

var page = await client.ReceivedEmailListAsync(new ReceivedEmailListOptions
{
    InboxId = inboxId, From = "acme.dev", SubjectContains = "code", Limit = 20,   // newest first
});
var email = await client.ReceivedEmailRetrieveAsync(id);          // htmlFormat: "cid" keeps cid: references
Console.WriteLine(email.Content!.ExtractedText);                  // the reply without quotes, signature or hidden text
Console.WriteLine(email.Content.Screening?.SenderTrust);          // owner, allowlisted, known_thread, external, …
Console.WriteLine(email.Content.Extracted?.Code);                 // a verification code, when one was found

// Long-poll: returns as soon as matching mail arrives (or an empty page after 30 s)
var next = await client.ReceivedEmailListAsync(new ReceivedEmailListOptions { InboxId = inboxId, Wait = 30 });
var held = next.Content!.Held;                                    // matching mail held in quarantine, ids only

var reply = await client.ReceivedEmailReplyAsync(id, new EmailReplyOptions { Text = "Thanks, confirmed." },
    idempotencyKey: $"reply-{id}");
if (reply.Content!.IsDraft)
    Console.WriteLine(string.Join(", ", reply.Content.Draft!.ApprovalReasons!));  // held for a person (HTTP 202)
else
    Console.WriteLine(reply.Content.Sent!.ThreadId);

await client.ReceivedEmailForwardAsync(id, new EmailForwardOptions { To = "ops@acme.dev", Mode = "attachment" });
await client.ReceivedEmailDeleteAsync(id);                        // erase now
await client.ReceivedEmailAttachmentListAsync(id);                // signed download links, valid one hour
await client.ReceivedEmailAttachmentRetrieveAsync(id, attachmentId);

Held mail is invisible to inbox-scoped keys: a detail read answers 403 email_quarantined. IncludeQuarantined = true lists it for full-access keys, Quarantined = true lists only held mail. Wait holds the request for up to 50 seconds, which the default HttpClient timeout (100 s) covers. Unread needs InboxId with a team key; an inbox-scoped key reads its own inbox. An InboxId that doesn't exist answers 404 not_found at once, even with Wait.

Inboxes

An inbox is one address that receives mail, read by an agent (Kind = "agent", the default) or by people ("human"), with threads in both directions. The paths mirror Resend's Inboxes API; Kind, Shared, ExpiresAt, safety, quarantine and codes are MillionSend extensions. Folder, verdict, kind and status values are plain strings, so new server values never break decoding.

var inbox = await client.InboxCreateAsync(new InboxCreateOptions
{
    Name = "Signup bot",                             // no EmailAddress: an address on the shared domain
    Username = "signup-bot",                         // signup-bot@<team>.<shared domain>; omit it for a random one
    ExpiresIn = "24h",                               // "1h" to "365d"; omitted, it never expires
    Safety = new InboxSafetyOptions { AcceptFrom = "anyone" },
});
var inboxId = inbox.Content!.Id;
await client.InboxCreateAsync(new InboxCreateOptions { DomainId = domainId, Username = "orders" }); // orders@ a DomainListAsync row
await client.InboxListAsync(new InboxListOptions { Kind = "agent" });
await client.InboxRetrieveAsync("bot@acme.dev");                 // by id or address
await client.InboxUpdateAsync(inboxId, new InboxUpdateOptions { ExpiresIn = Optional<string>.Null }); // never expires
await client.InboxDeleteAsync(inboxId);

// Verification codes and sign-in links (links are never fetched)
var code = await client.InboxCodeLatestAsync(inboxId, new InboxCodeLatestOptions { From = "example.com", Wait = 30 });
if (code.Content!.Status == "found")
    Console.WriteLine(code.Content.Code ?? code.Content.Url);
// The next sign-in: only a code received after the one already used
await client.InboxCodeLatestAsync(inboxId, new InboxCodeLatestOptions { Since = code.Content.ReceivedAt, Wait = 30 });

// Safety rules (inbox-scoped keys can read them, never change them)
await client.InboxSafetyRetrieveAsync(inboxId);
await client.InboxSafetyUpdateAsync(inboxId, new InboxSafetyOptions
{
    AcceptFrom = "allowlist", Allowlist = new() { "*@partner.dev" }, SendPolicy = "reply_only", SendCap = 100,
});

// Quarantine: held mail is kept and listed until a person releases it
await client.InboxQuarantineListAsync(inboxId);
await client.InboxQuarantineReleaseAsync(inboxId, emailId);

// Threads and their emails
await client.InboxThreadListAsync(inboxId, new InboxThreadListOptions { Folders = new() { "inbox", "archive" }, Read = false });
await client.InboxThreadListAsync(inboxId, new InboxThreadListOptions { IncludeQuarantined = true }); // owner views: a Quarantined count per thread
await client.InboxThreadSearchAsync(inboxId, new InboxThreadSearchOptions { Query = "invoice -draft", From = new() { "acme" } });
await client.InboxThreadRetrieveAsync(inboxId, threadId);
await client.InboxThreadUpdateAsync(inboxId, threadId, new InboxThreadUpdateOptions { Read = true, Folder = "archive" });
await client.InboxThreadDeleteAsync(inboxId, threadId);            // to trash, purged after 30 days
await client.InboxThreadEmailListAsync(inboxId, threadId);         // oldest first
await client.InboxThreadEmailRetrieveAsync(inboxId, threadId, emailId);   // includeQuarantined: true reads held mail (owner views)
await client.InboxThreadEmailReplyAsync(inboxId, threadId, emailId, new EmailReplyOptions { Text = "On it" });
await client.InboxThreadEmailForwardAsync(inboxId, threadId, emailId, new EmailForwardOptions { To = "ops@acme.dev" });

// Labels
var label = await client.InboxLabelCreateAsync(inboxId, new InboxLabelOptions { Name = "Billing" });
await client.InboxLabelListAsync(inboxId);
await client.InboxLabelUpdateAsync(inboxId, label.Content!.Id, new InboxLabelOptions { Color = "#FF8800" });
await client.InboxLabelDeleteAsync(inboxId, label.Content.Id);

// Drafts, including the ones the send rules hold for approval
var draft = await client.InboxDraftCreateAsync(inboxId, new InboxDraftCreateOptions
{
    To = "ada@acme.dev", Subject = "Hello", Text = "Hi Ada",
});
var draftId = draft.Content!.Id;
await client.InboxDraftListAsync(inboxId);
await client.InboxDraftRetrieveAsync(inboxId, draftId);
await client.InboxDraftUpdateAsync(inboxId, draftId, new InboxDraftUpdateOptions { Subject = Optional<string>.Null });
var sent = await client.InboxDraftSendAsync(inboxId, draftId);      // InboxSendResult<InboxDraft>
await client.InboxDraftRejectAsync(inboxId, draftId);               // approvers only
await client.InboxDraftDeleteAsync(inboxId, draftId);

Reply, forward and draft send answer an InboxSendResult<T>: IsDraft (and Kind == "draft") when the inbox's send rules held the message for a person to approve (the API's 202), else Sent. An agent's reply to a Reply-To at another organization is held that way too, with reply_to_redirect among its ApprovalReasons. An inbox-scoped key sending a pending draft gets 403 approval_required. Hosted agent settings (/inboxes/{id}/agent) are not exposed: MillionSend runs no agent of its own.

On an agent inbox with Screening on, mail held before screening ran (not_allowlisted, unauthenticated_sender, failed_auth) is screened at release when screening can run: if it finds prompt injection or phishing, the email stays held as injection or phishing (409 held_by_screening). trustSender: true also adds the sender to the allowlist, only for mail held as not_allowlisted from a sender that authenticated (SenderTrust "external"); anything else answers 403 trust_not_allowed.

An inbox never expires unless ExpiresIn says so, and its Name takes up to 320 characters. DomainId takes a DomainListAsync row instead of EmailAddress: the Shared row works like leaving both out, and one of your own domains needs a Username (and receiving on, or Forwarding = true).

On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on it) is deleted, which fires inbox.expired, then inbox.deleted. A chosen Username or FromName there is reviewed first: reserved names (postmaster, admin, support, noreply, …), a fwd- prefix and look-alikes of brands answer 422, a username in use 409, and 429 means today's review budget is spent. 503 name_review_unavailable means try again after RetryAfter seconds: the review is down, or the team's new subdomain still waits for its own, and then every shared or forwarding create gets it. Safety rules differ there too: SendPolicy = "allowlist" answers 422, and so does "open" unless the team's SendingMode is open; OwnerAddresses don't make senders trusted; ForwardTo takes team members only.

Sending from the shared domain

What the team's shared addresses may send is the SendingMode of the Shared row of DomainListAsync: open starts conversations, reply_only only answers inside a thread to its own correspondents, and receive_only sends nothing. The same answers come back from EmailSendAsync, replies, forwards and drafts:

  • 403 inbox_send_policy: the mode doesn't allow this send (or the inbox's name looks like a brand, which only replies even under open);
  • 429 inbox_send_cap_reached: the team's daily cap for the shared domain, with a RetryAfter that runs to the next UTC midnight;
  • 422 validation_error: more than 10 distinct recipients across To, Cc and Bcc.

EmailSendAsync takes a shared address in open mode only: the inbox's exact EmailAddress (no +tag), a display name left out or equal to its FromName, and no ReplyTo, Attachments, Headers or ScheduledAt. A key restricted to a domain gets 403 restricted_api_key. Batch items and broadcasts never send from the shared domain. Switch on ErrorName and StatusCode; the messages may change.

Domains

var domain = await client.DomainAddAsync(new DomainCreateOptions
{
    Name = "acme.dev",
    Region = "us-east-1",        // optional
    CustomReturnPath = "send",   // optional
    OpenTracking = true, ClickTracking = true, TrackingSubdomain = "track",
});
foreach (var r in domain.Content!.Records!)
    Console.WriteLine($"{r.Type} {r.Name} {r.Value}");   // DNS records to publish

await client.DomainListAsync();
await client.DomainRetrieveAsync(id);
await client.DomainVerifyAsync(id);
await client.DomainUpdateAsync(id, new DomainUpdateOptions { ClickTracking = false });
await client.DomainUpdateAsync(id, new DomainUpdateOptions { ReceivingCatchAll = true }); // paid plans on Cloud
await client.DomainDeleteAsync(id);

Domain.ReceivingCatchAll: on, mail to addresses without an inbox lands in the received list; off, those addresses are refused at SMTP time.

On an instance with a shared inbox domain, the team's subdomain of it is the last row of DomainListAsync (Shared == true; MillionSend v0.14.0 and later). It is verified, has no DNS records, and its id is the DomainId of the team's shared inboxes. Its SendingMode (open, reply_only or receive_only) says what those inboxes may send, and SendingModeReason (plan or instance, null under open) why it is held below open. It is read-only: DomainUpdateAsync, DomainVerifyAsync and DomainDeleteAsync answer 403 forbidden, and an API key can't be restricted to it. It comes after every own domain whatever its CreatedAt, alone on the next page when they exactly fill one. A team whose new subdomain still waits for its name review has no such row yet. Older servers send no shared, which reads as false.

It sends only from its inboxes' own addresses, so leave it out when picking a domain to send from:

var domains = await client.DomainListAsync(new ListOptions { Limit = 100 });
var sender = domains.Content?.Data.FirstOrDefault(d => d.Status == "verified" && !d.Shared);

Webhooks

var hook = await client.WebhookCreateAsync(new WebhookCreateOptions
{
    Endpoint = "https://acme.dev/hooks/millionsend",
    Events = new() { "email.delivered", "email.bounced", "email.complained" },
});
Console.WriteLine(hook.Content!.SigningSecret);   // also returned by WebhookRetrieveAsync

await client.WebhookListAsync();
await client.WebhookRetrieveAsync(id);
await client.WebhookUpdateAsync(id, new WebhookUpdateOptions { Status = WebhookStatus.Disabled });
await client.WebhookDeleteAsync(id);

// Rotate the signing secret (MillionSend extension). For OverlapHours (0–72) every
// delivery is signed with both secrets, so the receiver switches without a gap.
var rotated = await client.WebhookRotateAsync(id, new WebhookRotateOptions { OverlapHours = 24 }); // POST /webhooks/{id}/rotate
Console.WriteLine($"{rotated.Content!.SigningSecret} (old one signs until {rotated.Content.PreviousSecretExpiresAt})");
await client.WebhookRotateAsync(id);   // mint a secret with the server's default overlap

WebhookRetrieveAsync also reports PreviousSecretExpiresAt while a rotation's overlap window is open. Subscribable events are the email.* set plus deliverability.*, quota.*, contact.created|updated|deleted|unsubscribed|resubscribed|topic_opt_in|topic_opt_out and suppression.added|removed.

email.scheduled, email.failed and email.suppressed need MillionSend v0.15.1 or later; an older server refuses a webhook naming them with 422 validation_error. email.failed says why in data.failed.reason, and email.suppressed in data.suppressed (type, message, reason); treat their values as open sets. suppression.added and suppression.removed carry data.source_id, the email whose bounce or complaint created the entry (null otherwise).

email.prefetched and the inbox events (inbox.created|updated|deleted|expired, inbox.thread.created|folder.updated|labels.updated, inbox.email.received|sent|quarantined, inbox.draft.created|updated|sent|deleted) reach only endpoints that name them; an endpoint subscribed to all events skips them but still gets email.received. InboxIds narrows the inbox events and email.received to some inboxes; Optional<List<Guid>>.Null on update removes the filter. Inbox events from sends made in the dashboard carry data.source: "dashboard".

API keys

var key = await client.ApiKeyCreateAsync("ci", ApiKeyPermission.SendingAccess, domainId);
Console.WriteLine(key.Content!.Token);   // shown once
await client.ApiKeyListAsync();
await client.ApiKeyDeleteAsync(key.Content.Id);

// A key restricted to one inbox: it reads that inbox and sends from its address only
await client.ApiKeyCreateAsync(new ApiKeyCreateOptions { Name = "support-agent", InboxId = inboxId });

DomainId takes one of your own domains: the Shared row of DomainListAsync answers 422 validation_error.

Templates

Addressed by id or alias. MillionSend templates have no draft/publish cycle: TemplatePublishAsync is a no-op kept for resend-dotnet compatibility.

await client.TemplateCreateAsync(new TemplateCreateOptions
{
    Name = "Welcome", Alias = "welcome", Subject = "Hi {{{FIRST_NAME}}}", Html = "<p>…</p>",
});
await client.TemplateListAsync();
await client.TemplateRetrieveAsync("welcome");
await client.TemplateUpdateAsync("welcome", new TemplateUpdateOptions { Subject = "Welcome!" });
await client.TemplateDuplicateAsync("welcome");
await client.TemplatePublishAsync("welcome");
await client.TemplateDeleteAsync("welcome");

Usage (MillionSend extension)

var usage = await client.UsageRetrieveAsync();   // GET /usage
Console.WriteLine($"{usage.Content!.Today.EmailsSent}/{usage.Content.Limits.EmailsPerDay} today");

Deliverability (MillionSend extension)

The account-level deliverability score over the trailing window. Scores are 0–10; Score/Band are null when there is not enough data yet.

var res = await client.DeliverabilityRetrieveAsync(); // GET /deliverability
if (res.Success)
    Console.WriteLine($"{res.Content!.Score} ({res.Content.Band}), guardrail: {res.Content.GuardrailStatus}");

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:

  • Idempotency: both argument orders work — EmailSendAsync(message, idempotencyKey: k) and resend-dotnet's EmailSendAsync(k, message); same for EmailBatchAsync.
  • Batch validation: BatchValidationMode is resend-dotnet's EmailBatchValidationMode; it also drives ContactBatchAsync.
  • Contact addressing: methods that take a contact (ContactRetrieveAsync, ContactListTopicsAsync, …) accept a ContactAddress with an id or an email where resend-dotnet takes a Guid.
  • No audiences: contacts are team-global, one record per email address. The /audiences/* routes on the API are a compatibility shim and are not part of this SDK; Resend's audience grouping maps to MillionSend's dynamic segments (saved filters) above.
  • Templates exist but template-based sending does not yet: EmailMessage.Template is put on the wire and the server answers 422.
  • MillionSend extensions (no Resend counterpart): segments, the Date contact property type, contact batch import, batch get and batch remove, include= on contact lists, erase on contact deletes, contact preference links, webhook secret rotation, usage, email insights, deliverability, and on received mail and inboxes the long-poll Wait, received-mail reply/forward/delete, inbox safety, codes, quarantine, draft reject, Username and DomainId on InboxCreateAsync, and the extension fields; also MillionSendException.RetryAfter.

Server versions

On a self-hosted instance, each feature needs at least this MillionSend version:

MillionSend Features
v0.10.0 Received mail and inboxes; InboxIds on webhooks, InboxId on API keys, ReceivingCatchAll on domains
v0.11.0 422 for recipients that can never receive mail; a batch idempotency key replayed before validation
v0.12.0 Username; inboxes that never expire by default; sends from the shared domain under its mode; name_review_unavailable; StatusCode and Name on contacts batch errors
v0.13.0 Newest-first lists; Since on InboxCodeLatestAsync; Quarantined on thread items; inbox names up to 320 characters; Unread with an inbox-scoped key; 404 at once for an unknown InboxId; a JSON 404 for unknown routes
v0.14.0 Shared, SendingMode and SendingModeReason on domains; DomainId on InboxCreateAsync; Subject up to 998 characters
v0.15.1 email.scheduled, email.failed and email.suppressed webhook events; source_id on suppression.added and suppression.removed; the suppressed email kept for an all-suppressed EmailSendAsync
v0.16.0 Release rules: trustSender only for a not_allowlisted hold from an authenticated sender (trust_not_allowed); screening at release (held_by_screening)
v0.22.0 ContactPropertyType.Date; the before, after, in_last_days and not_in_last_days segment ops on created_at and properties

Older servers ignore options they don't know: before v0.12.0 Username is dropped and the address is random, before v0.14.0 DomainId is dropped, and before v0.13.0 Since is dropped, so a code you already used can come back. If you must support them, check the created inbox's EmailAddress, and treat a code whose ReceivedAt isn't after Since as none.

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

About

MillionSend .NET SDK — NuGet: MillionSend (MIT)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages