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?
- Req is the de-facto HTTP client for Elixir — built on Finch, maintained by the Elixir core team. MailAnvil is a plain HTTPS API, so sending is one
Req.post!/2. - Oban gives you a Postgres-backed job queue with retries and exponential backoff. Transactional email is exactly the kind of side effect that should never block the request and should never be silently dropped — Oban handles both.
Prerequisites
- Elixir 1.15+ with a Phoenix app (
mix phx.new my_app) - PostgreSQL (Oban needs it for the job table)
- A MailAnvil API key (starts with
re_) — get one at mailanvil.com
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
- IDR pricing + QRIS/GoPay — bill in Rupiah, not USD markup
- Bahasa docs + support — no translation layer for your team
- Cloudflare Workers native — the same API runs at the edge
- MCP-native — the API is discoverable by AI agents, not just your Phoenix app
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.