millionsend 0.4.0

Official Rust SDK for MillionSend — a self-hostable, Resend-compatible email API.
Documentation

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

[dependencies]
millionsend = "0.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Quickstart

use millionsend::{MillionSend, SendEmailOptions};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ms = MillionSend::with_base_url("ms_123", "https://mail.acme.dev");

    let sent = ms
        .emails
        .send(&SendEmailOptions {
            from: "Acme <onboarding@acme.dev>".into(),
            to: "delivered@resend.dev".into(),
            subject: "Hello from MillionSend".into(),
            html: Some("<strong>It works!</strong>".into()),
            ..Default::default()
        })
        .await?;

    println!("sent {}", sent.id);
    Ok(())
}

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::MillionSend;

// Explicit base URL.
let ms = MillionSend::with_base_url("ms_123", "https://mail.acme.dev");

// Key only; base URL falls back to MILLIONSEND_BASE_URL, then http://localhost:3001.
let ms = MillionSend::new("ms_123");

// Both from the environment (MILLIONSEND_API_KEY + optional MILLIONSEND_BASE_URL).
let ms = MillionSend::from_env()?;

// Bring your own reqwest client (proxies, TLS, timeouts). The default has a
// 30s request timeout and a 10s connect timeout.
let ms = MillionSend::new("ms_123").with_client(reqwest::Client::new());

// Accept a non-loopback http:// base URL (refused by default).
let ms = MillionSend::with_base_url("ms_123", "http://10.0.0.5:3001").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. name is 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() is None.
  • Error::Parse(_) — a 2xx body that failed to deserialize.
match ms.emails.get(&id).await {
    Ok(email) => println!("{}", email.last_event),
    Err(err) if err.name() == Some("not_found") => { /**/ }
    Err(err) => eprintln!("{err}"),
}

Resources

Emails

use millionsend::{Attachment, IdempotentTrait, SendEmailOptions, Tag, UpdateEmailOptions};

let email = SendEmailOptions {
    from: "Acme <onboarding@acme.dev>".into(),
    to: vec!["ada@acme.dev", "bob@acme.dev"].into(),
    subject: "Invoice".into(),
    html: Some("<p>Attached.</p>".into()),
    reply_to: Some("billing@acme.dev".into()),
    tags: Some(vec![Tag { name: "kind".into(), value: "invoice".into() }]),
    topic_id: Some(topic.id.clone()),                      // skips opted-out recipients
    attachments: Some(vec![Attachment {
        filename: "invoice.pdf".into(),
        content: Some(base64_pdf),                          // base64
        content_type: Some("application/pdf".into()),
        ..Default::default()
    }]),
    headers: Some([("X-Entity-Ref-ID".to_string(), "42".to_string())].into()),
    ..Default::default()
};

ms.emails.send(&email).await?;                                    // POST /emails
ms.emails.send(email.with_idempotency_key("inv-42")).await?;      // + Idempotency-Key (Resend shape)
ms.emails.send_with_idempotency_key(&email, "inv-42").await?;     // same, explicit
ms.emails.get(&id).await?;                                        // GET /emails/:id
ms.emails.list(None).await?;                                      // GET /emails
ms.emails.update(&id, &UpdateEmailOptions {                       // PATCH /emails/:id
    scheduled_at: "2026-09-01T09:00:00Z".into(),
}).await?;
ms.emails.get_insights(&id).await?;                               // GET /emails/:id/insights
ms.emails.cancel(&id).await?;                                     // POST /emails/:id/cancel
ms.emails.delete(&id).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 millionsend::{BatchValidation, IdempotentTrait};

let emails = vec![email_a, email_b];                       // 1–100
ms.batch.send(&emails).await?;                             // POST /emails/batch
ms.batch.send(emails.with_idempotency_key("batch-1")).await?;
ms.batch.send_with_idempotency_key(&emails, "batch-1").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(&emails, BatchValidation::Permissive).await?;
for err in &res.errors {
    eprintln!("email #{} rejected: {}", err.index, err.message);
}

Contacts

Contacts are team-global — one record per email address (case-insensitive); creating a duplicate is a 409 validation_error.

use millionsend::{
    ContactAddress, ContactTopicUpdate, CreateContactOptions, ListOptions, SegmentRef,
    TopicSubscription, UpdateContactOptions,
};

ms.contacts.create(&CreateContactOptions {
    email: "ada@acme.dev".into(),
    first_name: Some("Ada".into()),
    properties: Some([("plan".to_string(), "pro".into())].into()),
    segments: Some(vec![SegmentRef { id: segment.id.clone() }]),
    topics: Some(vec![ContactTopicUpdate {
        id: topic.id.clone(),
        subscription: TopicSubscription::OptIn,
    }]),
    ..Default::default()
}).await?;

// Address by id (a bare &str) or email; email wins if both are set.
ms.contacts.get("contact-id").await?;
ms.contacts.get(ContactAddress::email("ada@acme.dev")).await?;

// null clears a field, omitted leaves it unchanged.
ms.contacts.update("contact-id", &UpdateContactOptions {
    first_name: Some(None),        // clear
    unsubscribed: Some(true),      // set
    ..Default::default()
}).await?;

ms.contacts.delete(ContactAddress::email("ada@acme.dev")).await?;
ms.contacts.list(Some(&ListOptions { limit: Some(20), ..Default::default() })).await?;

Contact.properties values arrive as typed wrappers on the wire ({ "type": "string", "value": "pro" } / { "type": "number", "value": 3 }).

Batch create (MillionSend extension)

use millionsend::{BatchContactsOptions, BatchValidation, OnConflict};

let res = ms.contacts.create_batch(&contacts, Some(&BatchContactsOptions {
    on_conflict: Some(OnConflict::Upsert),                 // error (default) | skip | upsert
    batch_validation: Some(BatchValidation::Permissive),
})).await?;                                                // POST /contacts/batch?on_conflict=upsert
println!("{} created, {} failed", res.counts.created, res.counts.failed);
for err in &res.errors { eprintln!("contacts.{}: {}", err.index, err.message); }

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 millionsend::{ContactTopicUpdate, TopicSubscription};

ms.contacts.topics.update("contact-id", &[ContactTopicUpdate {
    id: "topic-id".into(),
    subscription: TopicSubscription::OptOut,
}]).await?;                                                // PATCH /contacts/:id/topics

ms.contacts.segments.add("contact-id", &segment.id).await?;     // POST   /contacts/:id/segments/:segmentId
ms.contacts.segments.remove("contact-id", &segment.id).await?;  // DELETE /contacts/:id/segments/:segmentId

Contact properties

Typed definitions for the keys of contact.properties.

use millionsend::{ContactPropertyType, CreateContactPropertyOptions, UpdateContactPropertyOptions};
use serde_json::{json, Value};

let plan = ms.contacts.properties.create(&CreateContactPropertyOptions {
    key: "plan".into(),
    r#type: ContactPropertyType::String,
    fallback_value: Some(json!("free")),
}).await?;                                                 // POST /contact-properties
ms.contacts.properties.get(&plan.id).await?;
ms.contacts.properties.list(None).await?;
ms.contacts.properties.update(&plan.id, &UpdateContactPropertyOptions {
    fallback_value: Some(Value::Null),                     // null clears the fallback
}).await?;
ms.contacts.properties.delete(&plan.id).await?;

Topics

use millionsend::{CreateTopicOptions, TopicSubscription, TopicVisibility, UpdateTopicOptions};

let mut topic = CreateTopicOptions::new("Product updates", TopicSubscription::OptIn);
topic.visibility = Some(TopicVisibility::Public);
ms.topics.create(&topic).await?;
ms.topics.get(&id).await?;
ms.topics.list().await?;
ms.topics.update(&id, &UpdateTopicOptions { name: Some("News".into()), ..Default::default() }).await?;
ms.topics.delete(&id).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 millionsend::{CreateBroadcastOptions, UpdateBroadcastOptions};

let broadcast = ms.broadcasts.create(&CreateBroadcastOptions {
    name: Some("Launch".into()),
    segment_id: Some(segment.id.clone()),
    from: "Acme <news@acme.dev>".into(),
    subject: "Launch".into(),
    html: Some("<p>Hi {{{FIRST_NAME|there}}}</p>".into()),
    preview_text: Some("It's here".into()),
    topic_id: Some(topic.id.clone()),
    send: Some(true),                                      // send now instead of saving a draft
    scheduled_at: Some("in 1 hour".into()),                // with send: true — schedule instead
    ..Default::default()
}).await?;

ms.broadcasts.list(None).await?;
ms.broadcasts.get(&broadcast.id).await?;
ms.broadcasts.update(&broadcast.id, &UpdateBroadcastOptions {
    subject: Some("Launch 🚀".into()),
    clear_topic_id: true,                                  // sends "topic_id": null (detach)
    ..Default::default()
}).await?;                                                 // draft only
ms.broadcasts.send(&broadcast.id, Some("2026-09-01T09:00:00Z")).await?;  // None = send now
ms.broadcasts.cancel(&broadcast.id).await?;                // scheduled only
ms.broadcasts.delete(&broadcast.id).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 millionsend::{
    CreateSegmentOptions, SegmentCondition, SegmentFilter, SegmentMatch, UpdateSegmentOptions,
};

let segment = ms.segments.create(&CreateSegmentOptions {
    name: "Pro plan".into(),
    filter: Some(SegmentFilter {
        match_: SegmentMatch::All,
        conditions: vec![SegmentCondition {
            field: "property:plan".into(),
            op: "equals".into(),
            value: Some("pro".into()),
        }],
    }),
}).await?;
let vips = ms.segments.create(&CreateSegmentOptions {
    name: "VIPs".into(),
    filter: None,                            // manual segment: members come from contacts.segments.add
}).await?;

ms.segments.get(&id).await?;                 // includes a live contact_count
ms.segments.list(None).await?;
ms.segments.list_contacts(&id, None).await?; // GET /segments/:id/contacts
ms.segments.update(&id, &UpdateSegmentOptions {
    filter: Some(None),                      // null drops the filter, keeping the members added by hand
    ..Default::default()
}).await?;
ms.segments.delete(&id).await?;

Suppressions

Addresses the team never sends to. Entries are addressable by id or email.

use millionsend::{
    AddSuppressionOptions, BatchAddSuppressionOptions, BatchRemoveSuppressionOptions,
    ListSuppressionsOptions, SuppressionOrigin,
};

ms.suppressions.add(&AddSuppressionOptions::new("bounced@example.com")).await?;   // POST /suppressions
ms.suppressions.get("bounced@example.com").await?;                                // GET /suppressions/:idOrEmail
ms.suppressions.list(Some(&ListSuppressionsOptions {
    origin: Some(SuppressionOrigin::Complaint),                                    // bounce | complaint | manual | unsubscribe
    ..Default::default()
})).await?;
ms.suppressions.remove("bounced@example.com").await?;                             // DELETE /suppressions/:idOrEmail

ms.suppressions.batch_add(&BatchAddSuppressionOptions {
    emails: vec!["a@example.com".into(), "b@example.com".into()],                 // up to 1000
    origin: Some(SuppressionOrigin::Manual),
}).await?;
ms.suppressions.batch_remove(&BatchRemoveSuppressionOptions::Emails(vec!["a@example.com".into()])).await?;
ms.suppressions.batch_remove(&BatchRemoveSuppressionOptions::Ids(vec![id])).await?;

Domains

use millionsend::{CreateDomainOptions, UpdateDomainOptions};

let domain = ms.domains.create(&CreateDomainOptions {
    name: "acme.dev".into(),
    open_tracking: Some(true),
    click_tracking: Some(true),
    tracking_subdomain: Some("links".into()),              // links.acme.dev
    ..Default::default()                                   // region/custom_return_path: deployment defaults
}).await?;
for record in &domain.records {
    println!("{} {} {}", record.r#type, record.name, record.value);
}

ms.domains.list(None).await?;                              // items carry no records
ms.domains.get(&domain.id).await?;
ms.domains.verify(&domain.id).await?;                      // POST /domains/:id/verify
ms.domains.update(&domain.id, &UpdateDomainOptions {
    click_tracking: Some(false),
    tracking_subdomain: Some(None),                        // null clears the branded host
    ..Default::default()
}).await?;
ms.domains.delete(&domain.id).await?;

Webhooks

use millionsend::{CreateWebhookOptions, UpdateWebhookOptions, WebhookStatus};

let hook = ms.webhooks.create(&CreateWebhookOptions {
    endpoint: "https://acme.dev/hooks/millionsend".into(),
    events: vec!["email.delivered".into(), "email.bounced".into(), "deliverability.paused".into()],
    signing_secret: None,                                  // minted; or pass your own whsec_…
}).await?;
println!("verify payloads with {}", hook.signing_secret);

ms.webhooks.list(None).await?;
ms.webhooks.get(&hook.id).await?;                          // includes signing_secret
ms.webhooks.update(&hook.id, &UpdateWebhookOptions {
    status: Some(WebhookStatus::Disabled),
    ..Default::default()
}).await?;
ms.webhooks.delete(&hook.id).await?;

API keys

use millionsend::{ApiKeyPermission, CreateApiKeyOptions};

let key = ms.api_keys.create(&CreateApiKeyOptions {
    name: "ci".into(),
    permission: Some(ApiKeyPermission::SendingAccess),     // default full_access
    domain_id: Some(domain.id.clone()),                    // restrict sending to one domain
}).await?;
println!("{}", key.token);                                 // shown once
ms.api_keys.list(None).await?;
ms.api_keys.delete(&key.id).await?;

Templates

Addressable by id or alias.

use millionsend::{CreateTemplateOptions, UpdateTemplateOptions};

let mut welcome = CreateTemplateOptions::new("Welcome", "<p>Hi {{{FIRST_NAME|there}}}</p>");
welcome.subject = Some("Welcome!".into());
welcome.alias = Some("welcome".into());
let created = ms.templates.create(&welcome).await?;

ms.templates.get("welcome").await?;
ms.templates.list(None).await?;
ms.templates.update("welcome", &UpdateTemplateOptions {
    subject: Some(Some("Hello".into())),                   // set
    alias: Some(None),                                     // null clears
    ..Default::default()
}).await?;
ms.templates.publish(&created.id).await?;                  // no-op kept for Resend compatibility
ms.templates.duplicate(&created.id).await?;
ms.templates.delete(&created.id).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(score) = report.score {
    println!("{score} ({})", report.band.as_deref().unwrap_or("-"));
}

Usage (MillionSend extension)

let usage = ms.usage.get().await?;             // GET /usage
println!("{} sent today, resets {}", usage.today.emails_sent, usage.today.resets_at);
if let Some(cap) = usage.limits.emails_per_day {
    println!("plan {:?} caps at {cap}/day", usage.plan);
}

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.* — where resend-rs keeps 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. Use segments (saved filters or manual lists) to target a subset, or a broadcast with no segment_id/topic_id to reach everyone.

  • MillionSend extensions with no Resend counterpart: segments, contacts.create_batch, deliverability, usage, emails.get_insights.

  • Templates are always published; publish is a no-op kept for compatibility, and from/reply_to/variables are rejected with 422.

  • Nullable clears: Option<Option<T>> fields (Some(None)) send JSON null; UpdateBroadcastOptions::clear_topic_id does the same for topic_id.

License

MIT