Skip to main content

guides/multi-tenancy.md

# Multi-Tenancy

Mailglass stores `tenant_id` on deliveries, events, and suppressions from day one. Phase 26 adds runtime per-tenant outbound adapter resolution without changing the zero-config single-tenant path.

## Single-tenant default

If you only have one transport, keep the default path boring:

```elixir
config :mailglass,
  adapter:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.Postmark, api_key: System.fetch_env!("POSTMARK_API_KEY")}}
```

You do not need `config :mailglass, adapters:` or a custom tenancy callback for this case. `Mailglass.Tenancy.SingleTenant` keeps returning `:default`, and queued deliveries persist the reserved default `adapter_ref` internally so retries stay deterministic.

## Named adapter refs

Add `config :mailglass, adapters:` only when you need reusable runtime route targets:

```elixir
config :mailglass,
  adapter:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.Postmark, api_key: System.fetch_env!("POSTMARK_DEFAULT_API_KEY")}},
  adapters: [
    postmark_acme:
      {Mailglass.Adapters.Swoosh,
       swoosh_adapter:
         {Swoosh.Adapters.Postmark, api_key: System.fetch_env!("POSTMARK_ACME_API_KEY")}},
    sendgrid_globex:
      {Mailglass.Adapters.Swoosh,
       swoosh_adapter:
         {Swoosh.Adapters.Sendgrid, api_key: System.fetch_env!("SENDGRID_GLOBEX_API_KEY")}},
    ses_ops:
      {Mailglass.Adapters.Swoosh,
       swoosh_adapter:
         {Swoosh.Adapters.AmazonSES, region: "us-east-1", access_key: System.fetch_env!("SES_ACCESS_KEY"), secret: System.fetch_env!("SES_SECRET")}}
  ]
```

Each registry entry is just the same adapter shape Mailglass already understands: `AdapterModule` or `{AdapterModule, opts}`.

## Tenancy callback

Put routing policy on your existing `Mailglass.Tenancy` module with `resolve_outbound_adapter_ref/1`:

```elixir
defmodule MyApp.Tenancy do
  @behaviour Mailglass.Tenancy

  @impl Mailglass.Tenancy
  def scope(query, %{tenant_id: tenant_id}) do
    Mailglass.Tenancy.scope(query, %{tenant_id: tenant_id})
  end

  @impl Mailglass.Tenancy
  def resolve_webhook_tenant(%{path_params: %{"tenant_id" => tenant_id}}), do: {:ok, tenant_id}
  def resolve_webhook_tenant(_ctx), do: {:error, :missing_tenant_id}

  @impl Mailglass.Tenancy
  def resolve_outbound_adapter_ref(%{tenant_id: tenant_id, message: message, mode: mode}) do
    case {tenant_id, message.stream, mode} do
      {"acme", :transactional, _mode} -> {:ok, :postmark_acme}
      {"globex", :transactional, _mode} -> {:ok, :sendgrid_globex}
      {"initech", :operational, :async} -> {:ok, :ses_ops}
      _ -> :default
    end
  end
end
```

The callback contract stays narrow on purpose:

- `{:ok, adapter_ref}` selects a named route from `config :mailglass, adapters:`
- `:default` keeps the global `config :mailglass, adapter` path
- missing callbacks behave the same as `:default`

Broken callback output or unknown refs fail loudly. Mailglass does not silently fall back to the default adapter when tenant-specific routing is misconfigured.

## Common routing patterns

### Different ESP per tenant

Route one tenant through Postmark and another through SendGrid by returning different named refs from `resolve_outbound_adapter_ref/1`.

### Same ESP family, different credentials

Point multiple refs at the same adapter module with different API keys or subaccount options:

```elixir
config :mailglass, adapters: [
  acme_postmark:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.Postmark, api_key: System.fetch_env!("POSTMARK_ACME_API_KEY")}},
  globex_postmark:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.Postmark, api_key: System.fetch_env!("POSTMARK_GLOBEX_API_KEY")}}
]
```

### Same provider family, different stream or domain routes

Use route refs to separate transactional and operational traffic even when the provider family is the same:

```elixir
config :mailglass, adapters: [
  ses_transactional:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.AmazonSES, region: "us-east-1", configuration_set_name: "transactional"}},
  ses_bulk:
    {Mailglass.Adapters.Swoosh,
     swoosh_adapter:
       {Swoosh.Adapters.AmazonSES, region: "us-east-1", configuration_set_name: "bulk"}}
]
```

## Sync vs async semantics

- `Mailglass.deliver/2` resolves the effective adapter at send time.
- `Mailglass.deliver_later/2` and `Mailglass.deliver_many/2` persist `delivery.adapter_ref` before the job is enqueued.
- Worker dispatch resolves credentials from runtime config at execution time, but it does not rerun tenant routing. That keeps retries on the same named route even if your tenancy callback would choose something different later.

Queued paths should use `adapter_ref` overrides, not raw `adapter` tuples. Mailglass will reject queued raw adapter overrides that cannot be persisted safely without storing secrets.