millionsend 0.3.0

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

Official Rust SDK for [MillionSend](https://github.com/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`](https://crates.io/crates/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

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

## Quickstart

```rust
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".to_string(), "c@d.dev".to_string()].into()`).

## Configuration

```rust
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.

```rust
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

```rust
use millionsend::SendEmailOptions;

ms.emails.send(&email).await?;                                    // POST /emails
ms.emails.send_with_idempotency_key(&email, "key-123").await?;   // + Idempotency-Key
ms.emails.get(&id).await?;                                        // GET /emails/:id
ms.emails.get_insights(&id).await?;                               // GET /emails/:id/insights
ms.emails.cancel(&id).await?;                                     // POST /emails/:id/cancel

// Batch: 1–100 in one call.
ms.batch.send(&[email_a, email_b]).await?;                        // POST /emails/batch
ms.batch.send_with_idempotency_key(&emails, "batch-1").await?;
```

`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).

### Contacts

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

```rust
use millionsend::{ContactAddress, CreateContactOptions, ListOptions, UpdateContactOptions};

ms.contacts.create(&CreateContactOptions {
    email: "ada@acme.dev".into(),
    first_name: Some("Ada".into()),
    ..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?;
```

Topic subscriptions (granular unsubscribe):

```rust
use millionsend::{ContactTopicUpdate, TopicSubscription};

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

### Topics

```rust
use millionsend::{CreateTopicOptions, TopicSubscription};

ms.topics.create(&CreateTopicOptions::new("Product updates", TopicSubscription::OptIn)).await?;
ms.topics.get(&id).await?;
ms.topics.list().await?;    // bare { data } — topics are unpaginated
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.

```rust
use millionsend::{CreateBroadcastOptions, UpdateBroadcastOptions};

let broadcast = ms.broadcasts.create(&CreateBroadcastOptions {
    segment_id: Some(segment.id.clone()),
    from: "Acme <news@acme.dev>".into(),
    subject: "Launch".into(),
    html: Some("<p>Hi {{{FIRST_NAME|there}}}</p>".into()),
    ..Default::default()
}).await?;

ms.broadcasts.list(None).await?;
ms.broadcasts.get(&broadcast.id).await?;
ms.broadcasts.update(&broadcast.id, &UpdateBroadcastOptions {
    subject: Some("Launch 🚀".into()),
    ..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)

Dynamic segments are a saved filter over the team's contacts — a MillionSend
superset with no Resend equivalent.

```rust
use millionsend::{CreateSegmentOptions, SegmentCondition, SegmentFilter, SegmentMatch};

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

ms.segments.get(&id).await?;   // includes a live contact_count
ms.segments.list(None).await?;
ms.segments.update(&id, &Default::default()).await?;
ms.segments.delete(&id).await?;
```

### Deliverability

Account-level score over the trailing 30 days; scores are `None` until there is
enough data.

```rust
let report = ms.deliverability.get().await?;   // GET /deliverability
if let Some(score) = report.score {
    println!("{score} ({})", report.band.as_deref().unwrap_or("-"));
}
```

## Migrating from Resend

```diff
- 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");
```

Method names and nesting match. Notes:

- **Domains and API keys** are managed in the MillionSend dashboard, not via the
  API — there are no `domains`/`api_keys` resources here.
- **No audiences.** Contacts are team-global; use `segments` (saved filters) to
  target a subset, or a broadcast with no `segment_id`/`topic_id` to reach
  everyone.

## License

MIT