millionsend
Official Rust SDK for MillionSend — a self-hostable, Resend-compatible email API on AWS SES.
The HTTP API is wire-compatible with Resend, and this crate mirrors the shape of
resend-rs, so migrating is mostly a
find-and-replace: swap the crate, the client type, and point the base URL at
your instance.
Async (tokio + reqwest). Every fallible call returns Result<T, Error>.
Install
[]
= "0.4"
= { = "1", = ["macros", "rt-multi-thread"] }
Quickstart
use ;
async
to, cc, bcc, and reply_to accept a single address ("a@b.dev".into()) or
many (vec!["a@b.dev", "c@d.dev"].into()).
Configuration
use MillionSend;
// Explicit base URL.
let ms = with_base_url;
// Key only; base URL falls back to MILLIONSEND_BASE_URL, then http://localhost:3001.
let ms = new;
// Both from the environment (MILLIONSEND_API_KEY + optional MILLIONSEND_BASE_URL).
let ms = from_env?;
// Bring your own reqwest client (proxies, TLS, timeouts). The default has a
// 30s request timeout and a 10s connect timeout.
let ms = new.with_client;
// Accept a non-loopback http:// base URL (refused by default).
let ms = with_base_url.allow_insecure_http;
MillionSend is self-hosted, so there is no cloud default — set the base URL to
your deployment in production. Every request carries
Authorization: Bearer <api_key> and a millionsend-rust/<version> User-Agent.
Plain http:// is only accepted for loopback hosts (localhost, 127.0.0.1, ::1);
any other http:// URL makes every call return Error::Api named insecure_base_url,
since the API key is sent as a bearer header. Call allow_insecure_http() to talk to a
non-TLS instance elsewhere (e.g. inside a private network).
Error handling
Fallible calls return Result<T, millionsend::Error>:
Error::Api(ApiError { status_code, name, message })— a non-2xx response.nameis a stable snake_case code you can match on (validation_error,not_found,restricted_api_key,sending_paused, …).Error::Http(_)— a transport failure that never reached the API;err.status_code()isNone.Error::Parse(_)— a 2xx body that failed to deserialize.
match ms.emails.get.await
Resources
Emails
use ;
let email = SendEmailOptions ;
ms.emails.send.await?; // POST /emails
ms.emails.send.await?; // + Idempotency-Key (Resend shape)
ms.emails.send_with_idempotency_key.await?; // same, explicit
ms.emails.get.await?; // GET /emails/:id
ms.emails.list.await?; // GET /emails
ms.emails.update.await?;
ms.emails.get_insights.await?; // GET /emails/:id/insights
ms.emails.cancel.await?; // POST /emails/:id/cancel
ms.emails.delete.await?; // DELETE /emails/:id
Every field is put on the wire, including template, which the API currently
rejects with a 422 (send html/text instead). get includes a nullable
best-practice score (0–10); get_insights returns the full per-check report
behind it (404 not_found until insights exist).
Batch
use ;
let emails = vec!; // 1–100
ms.batch.send.await?; // POST /emails/batch
ms.batch.send.await?;
ms.batch.send_with_idempotency_key.await?;
// x-batch-validation: permissive — invalid items land in `errors` instead of
// failing the whole call (strict is the server default).
let res = ms.batch.send_with_batch_validation.await?;
for err in &res.errors
Contacts
Contacts are team-global — one record per email address (case-insensitive);
creating a duplicate is a 409 validation_error.
use ;
ms.contacts.create.await?;
// Address by id (a bare &str) or email; email wins if both are set.
ms.contacts.get.await?;
ms.contacts.get.await?;
// null clears a field, omitted leaves it unchanged.
ms.contacts.update.await?;
ms.contacts.delete.await?;
ms.contacts.list.await?;
Contact.properties values arrive as typed wrappers on the wire
({ "type": "string", "value": "pro" } / { "type": "number", "value": 3 }).
Batch create (MillionSend extension)
use ;
let res = ms.contacts.create_batch.await?; // POST /contacts/batch?on_conflict=upsert
println!;
for err in &res.errors
Up to 1000 contacts per call; each data entry carries the request index,
the contact id and a status (created | updated | skipped).
Topic subscriptions and segment membership
use ;
ms.contacts.topics.update.await?; // PATCH /contacts/:id/topics
ms.contacts.segments.add.await?; // POST /contacts/:id/segments/:segmentId
ms.contacts.segments.remove.await?; // DELETE /contacts/:id/segments/:segmentId
Contact properties
Typed definitions for the keys of contact.properties.
use ;
use ;
let plan = ms.contacts.properties.create.await?; // POST /contact-properties
ms.contacts.properties.get.await?;
ms.contacts.properties.list.await?;
ms.contacts.properties.update.await?;
ms.contacts.properties.delete.await?;
Topics
use ;
let mut topic = new;
topic.visibility = Some;
ms.topics.create.await?;
ms.topics.get.await?;
ms.topics.list.await?;
ms.topics.update.await?;
ms.topics.delete.await?;
Broadcasts
Target a segment (segment_id) and/or a topic's subscribers (topic_id);
with neither set, the broadcast goes to every contact.
use ;
let broadcast = ms.broadcasts.create.await?;
ms.broadcasts.list.await?;
ms.broadcasts.get.await?;
ms.broadcasts.update.await?; // draft only
ms.broadcasts.send.await?; // None = send now
ms.broadcasts.cancel.await?; // scheduled only
ms.broadcasts.delete.await?; // draft only
Segments (MillionSend extension)
A segment is either a saved filter over the team's contacts or, with no
filter, a manual list fed by contacts.segments.add. Segment.filter is
None for manual segments.
use ;
let segment = ms.segments.create.await?;
let vips = ms.segments.create.await?;
ms.segments.get.await?; // includes a live contact_count
ms.segments.list.await?;
ms.segments.list_contacts.await?; // GET /segments/:id/contacts
ms.segments.update.await?;
ms.segments.delete.await?;
Suppressions
Addresses the team never sends to. Entries are addressable by id or email.
use ;
ms.suppressions.add.await?; // POST /suppressions
ms.suppressions.get.await?; // GET /suppressions/:idOrEmail
ms.suppressions.list.await?;
ms.suppressions.remove.await?; // DELETE /suppressions/:idOrEmail
ms.suppressions.batch_add.await?;
ms.suppressions.batch_remove.await?;
ms.suppressions.batch_remove.await?;
Domains
use ;
let domain = ms.domains.create.await?;
for record in &domain.records
ms.domains.list.await?; // items carry no records
ms.domains.get.await?;
ms.domains.verify.await?; // POST /domains/:id/verify
ms.domains.update.await?;
ms.domains.delete.await?;
Webhooks
use ;
let hook = ms.webhooks.create.await?;
println!;
ms.webhooks.list.await?;
ms.webhooks.get.await?; // includes signing_secret
ms.webhooks.update.await?;
ms.webhooks.delete.await?;
API keys
use ;
let key = ms.api_keys.create.await?;
println!; // shown once
ms.api_keys.list.await?;
ms.api_keys.delete.await?;
Templates
Addressable by id or alias.
use ;
let mut welcome = new;
welcome.subject = Some;
welcome.alias = Some;
let created = ms.templates.create.await?;
ms.templates.get.await?;
ms.templates.list.await?;
ms.templates.update.await?;
ms.templates.publish.await?; // no-op kept for Resend compatibility
ms.templates.duplicate.await?;
ms.templates.delete.await?;
from, reply_to and variables are passed through; the API currently
answers 422 when they are set.
Deliverability (MillionSend extension)
Account-level score over the trailing 30 days; scores are None until there is
enough data.
let report = ms.deliverability.get.await?; // GET /deliverability
if let Some = report.score
Usage (MillionSend extension)
let usage = ms.usage.get.await?; // GET /usage
println!;
if let Some = usage.limits.emails_per_day
Migrating from Resend
- use resend_rs::{Resend, types::CreateEmailBaseOptions};
- let resend = Resend::new("re_123");
+ use millionsend::{MillionSend, SendEmailOptions};
+ let ms = MillionSend::with_base_url("ms_123", "https://mail.acme.dev");
Resource and method names match: emails, batch, contacts, topics,
broadcasts, segments, suppressions, domains, webhooks, api_keys,
templates. Notes:
-
Contacts nest their sub-resources —
contacts.topics.update,contacts.segments.add/remove,contacts.properties.*— whereresend-rskeeps them flat (update_contact_topics,add_contact_segment,create_property, …). -
No audiences. Contacts are team-global; the API's
/audiences/*routes are a compatibility shim and are not part of this SDK. Usesegments(saved filters or manual lists) to target a subset, or a broadcast with nosegment_id/topic_idto reach everyone. -
MillionSend extensions with no Resend counterpart:
segments,contacts.create_batch,deliverability,usage,emails.get_insights. -
Templates are always published;
publishis a no-op kept for compatibility, andfrom/reply_to/variablesare rejected with 422. -
Nullable clears:
Option<Option<T>>fields (Some(None)) send JSONnull;UpdateBroadcastOptions::clear_topic_iddoes the same fortopic_id.
License
MIT