# millionsend

Official Elixir SDK for [MillionSend](https://github.com/MillionSend/millionsend) — a self-hostable, Resend-compatible email API on AWS SES.

The API is wire-compatible with Resend, and this SDK mirrors the shape of
[`resend-elixir`](https://hex.pm/packages/resend), so migrating is mostly a
find-and-replace: swap the module prefix and point `base_url` at your instance.

## Install

```elixir
# mix.exs
def deps do
  [{:millionsend, "~> 0.1"}]
end
```

Requires Elixir 1.15+. HTTP is handled by [Req](https://hex.pm/packages/req).

## Quickstart

```elixir
config :millionsend, MillionSend.Client,
  api_key: System.get_env("MILLIONSEND_API_KEY"),
  base_url: "https://mail.acme.dev"
```

```elixir
case MillionSend.Emails.send(%{
       from: "Acme <onboarding@acme.dev>",
       to: "delivered@resend.dev",
       subject: "Hello from MillionSend",
       html: "<strong>It works!</strong>"
     }) do
  {:ok, email} -> IO.puts("sent #{email.id}")
  {:error, error} -> IO.puts("#{error.name}: #{error.message}")
end
```

## Configuration

Two interchangeable styles, use whichever fits:

```elixir
# 1. Application env — every call works without an explicit client.
config :millionsend, MillionSend.Client,
  api_key: "ms_123",
  base_url: "https://mail.acme.dev"

MillionSend.Emails.get("email-id")

# 2. An explicit client passed as the first argument.
client = MillionSend.client(api_key: "ms_123", base_url: "https://mail.acme.dev")
MillionSend.Emails.get(client, "email-id")
```

Resolution precedence for each option: explicit `MillionSend.client/1` opts →
`config :millionsend, MillionSend.Client` → OS environment
(`MILLIONSEND_API_KEY`, `MILLIONSEND_BASE_URL`) → defaults.

- `api_key` is required; missing everywhere raises `ArgumentError`.
- `base_url` defaults to `http://localhost:3001`. MillionSend is self-hosted, so
  **set this to your deployment in production.**
- `user_agent` (optional) appends a suffix after the SDK's own User-Agent token.
- `http_client` (optional) swaps the HTTP layer — any module implementing the
  `MillionSend.HTTP` behaviour (used to stub requests in tests).

Every function accepts an optional leading `client` argument; omit it to use the
configured default.

## Errors

No function raises for an API error — each returns `{:ok, struct}` or
`{:error, %MillionSend.Error{}}`. The error's `name` is a stable snake_case code
you can match on (`"validation_error"`, `"not_found"`, `"restricted_api_key"`,
`"sending_paused"`, …). Client-side and transport failures carry
`status_code: nil`.

```elixir
case MillionSend.Emails.get(id) do
  {:ok, email} -> email
  {:error, %MillionSend.Error{name: "not_found"}} -> :gone
  {:error, error} -> {:error, error.status_code, error.message}
end
```

`MillionSend.Error` is also an exception, so you can `raise`/`Exception.message/1`
it if you prefer to bubble failures up.

## Resources

### Emails

```elixir
MillionSend.Emails.send(payload)                             # POST /emails
MillionSend.Emails.send(payload, idempotency_key: key)       # with idempotency
MillionSend.Emails.get(id)                                   # GET /emails/:id
MillionSend.Emails.cancel(id)                                # POST /emails/:id/cancel (scheduled only)
MillionSend.Emails.send_batch([a, b], idempotency_key: key)  # POST /emails/batch (up to 100)
```

Input maps are snake_case (`:reply_to`, `:scheduled_at`); `:to`/`:cc`/`:bcc`/
`:reply_to` accept a string or a list of strings.

### Audiences & contacts

```elixir
{:ok, audience} = MillionSend.Audiences.create(%{name: "Registered users"})
MillionSend.Audiences.list(limit: 20, after: cursor)
MillionSend.Audiences.get(id)
MillionSend.Audiences.remove(id)

MillionSend.Contacts.create(%{audience_id: aud, email: "ada@acme.dev",
                              first_name: "Ada", properties: %{plan: "pro"}})
MillionSend.Contacts.get(%{audience_id: aud, email: "ada@acme.dev"})  # id or email (email wins)
MillionSend.Contacts.get("contact-uuid")                              # bare id works too
MillionSend.Contacts.update(%{id: id, unsubscribed: true, first_name: nil})  # nil clears
MillionSend.Contacts.remove(%{email: "ada@acme.dev"})
MillionSend.Contacts.list(audience_id: aud, limit: 50)

# Topic subscriptions (granular unsubscribe)
MillionSend.Contacts.update_topics(%{email: "ada@acme.dev",
                                     topics: [%{id: topic_id, subscription: :opt_out}]})
```

### Topics

```elixir
MillionSend.Topics.create(%{name: "Product updates", default_subscription: :opt_in})
MillionSend.Topics.get(id)
MillionSend.Topics.list()      # a plain list — topics are unpaginated
MillionSend.Topics.remove(id)
```

### Broadcasts

```elixir
{:ok, broadcast} = MillionSend.Broadcasts.create(%{
  audience_id: aud, from: "Acme <news@acme.dev>", subject: "Launch",
  html: "<p>Hi {{{FIRST_NAME|there}}}</p>"
})
MillionSend.Broadcasts.list()
MillionSend.Broadcasts.get(id)
MillionSend.Broadcasts.update(id, %{subject: "Launch 🚀"})              # draft only
MillionSend.Broadcasts.send(id, scheduled_at: "2026-09-01T09:00:00Z")  # omit to send now
MillionSend.Broadcasts.cancel(id)                                      # scheduled only
MillionSend.Broadcasts.remove(id)                                      # draft only
```

### Segments (MillionSend extension)

Dynamic segments are a saved filter over an audience's contacts — a MillionSend
superset with no Resend equivalent (wire path `/segments2`).

```elixir
MillionSend.Segments.create(%{
  name: "Pro plan",
  audience_id: aud,
  filter: %{match: :all, conditions: [%{field: "property:plan", op: "equals", value: "pro"}]}
})
MillionSend.Segments.get(id)   # includes a live contact_count
MillionSend.Segments.list()
MillionSend.Segments.update(id, %{name: "Pro tier"})
MillionSend.Segments.remove(id)
```

## Migrating from Resend

```diff
- {:ok, email} = Resend.Emails.send(%{from: ..., to: ..., subject: ..., html: ...})
+ {:ok, email} = MillionSend.Emails.send(%{from: ..., to: ..., subject: ..., html: ...})
```

```diff
- config :resend, api_key: "re_123"
+ config :millionsend, MillionSend.Client, api_key: "ms_123", base_url: "https://mail.acme.dev"
```

Module names, function names and payloads match. Notes:

- **Domains and API keys** are managed in the MillionSend dashboard, not via the
  API, so there are no `Domains`/`ApiKeys` modules here.
- Resend's segments are an alias of audiences; MillionSend's `Segments` are the
  distinct dynamic-filter feature. Use `Audiences` for a straight port.

## Testing against a real instance

The suite is fully mocked. An opt-in end-to-end test runs only when
`MILLIONSEND_API_KEY` is set (and `MILLIONSEND_BASE_URL` if not localhost):

```bash
MILLIONSEND_API_KEY=ms_... MILLIONSEND_BASE_URL=http://localhost:3001 mix test
```

## License

MIT
