← Back to all posts
2026-09-03 · MailAnvil Team

Send Transactional Email from Elixir/Phoenix — Req + Oban, No SMTP

Elixir runs on the BEAM, built for millions of concurrent, fault-isolated processes. That makes Phoenix a great home for transactional email: you can fire the send off to a background job and return to the user instantly, while the BEAM supervisor tree keeps the queue alive through restarts.

This tutorial sends a welcome email on signup using Req (the standard Elixir HTTP client) and Oban (the standard background job library). No SMTP server, no vendor SDK.

Why Req + Oban?

Prerequisites

Step 1 — Add the dependencies

# mix.exs
defp deps do
  [
    {:req, "~> 0.5"},
    {:oban, "~> 2.18"}
  ]
end
mix deps.get
mix ecto.migrate   # Oban.install runs the oban_jobs migration

Step 2 — The send function

defmodule MyApp.Mailer do
  @api_key "re_your_api_key"
  @endpoint "https://api.mailanvil.com/v1/send"

  def send_welcome_email(to, name) do
    body = %{
      "from" => "MailAnvil <[email protected]>",
      "to" => [to],
      "subject" => "Welcome to MailAnvil, #{name}",
      "html" => "<h1>Welcome, #{name}!</h1><p>Your account is ready to send.</p>"
    }

    resp =
      Req.post!(@endpoint,
        json: body,
        headers: [
          {"authorization", "Bearer #{@api_key}"},
          {"idempotency-key", idempotency_key(to)}
        ]
      )

    resp.status
  end

  defp idempotency_key(to) do
    :crypto.hash(:sha256, "welcome:" <> to) |> Base.encode16(case: :lower)
  end
end

Step 3 — Why the idempotency key is deterministic

The Idempotency-Key header is what makes retries safe. It must be deterministic — same recipient + same intent = same key. If a network timeout makes Oban retry the job, MailAnvil sees the same key and returns the original result instead of sending a duplicate.

defp idempotency_key(to) do
  :crypto.hash(:sha256, "welcome:" <> to) |> Base.encode16(case: :lower)
end

Never put a timestamp or a random UUID here — every retry would look like a brand-new email.

Step 4 — The Oban worker

defmodule MyApp.Workers.WelcomeEmail do
  use Oban.Worker,
    queue: :mailer,
    max_attempts: 5,
    backoff: &Oban.Worker.backoff/1

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"to" => to, "name" => name}}) do
    case MyApp.Mailer.send_welcome_email(to, name) do
      202 -> :ok
      other -> {:error, "MailAnvil returned HTTP #{other}"}
    end
  end
end

Oban retries with exponential backoff for free, and the idempotency key means a retry after a timeout is harmless.

Step 5 — Enqueue from the controller

def create(conn, %{"email" => to, "name" => name}) do
  %{to: to, name: name}
  |> MyApp.Workers.WelcomeEmail.new()
  |> Oban.insert()

  conn |> put_status(:accepted) |> json(%{ok: true})
end

The controller returns 202 Accepted immediately; the email is sent in the background. That's the correct contract for transactional email — your API responds in milliseconds, the queue absorbs delivery.

Why MailAnvil

Next steps

Swap the hardcoded to/name for real signup data, add a :mailer queue in config/config.exs, and mount the Oban web dashboard to watch retries in dev. Full API reference at docs.mailanvil.com.

No SMTP server, no vendor SDK — just Req, Oban, and the BEAM you already run.