Send Transactional Email with Elixir + Phoenix — Req Tutorial
Phoenix apps don't need an SMTP library to send transactional email. MailAnvil is a plain HTTPS API, so one HTTP client covers it. Elixir's ecosystem makes this unusually clean: Req for the request, Task.Supervisor for fire-and-forget sends, and pattern matching on the response for error handling.
This tutorial builds a Phoenix context that sends a welcome email when a user signs up.
What you'll build
A Accounts.create_user/1 flow that:
- Accepts a signup
- Sends the welcome email in the background via MailAnvil's
POST /v1/send - Returns the user immediately — the caller never waits on email delivery
Prerequisites
- Elixir 1.16+ (
elixir --version) - Phoenix 1.7+ app (
mix phx.new acme) - A MailAnvil API key (starts with
re_) and a verified sending domain
Step 1 — Add Req
# mix.exs
defp deps do
[
{:req, "~> 0.5"}
]
end
mix deps.get
Req is the de facto standard Elixir HTTP client — Req.post with a JSON body, sensible defaults, and built-in retry support.
Step 2 — A MailAnvil client module
Create lib/acme/mailer/mailanvil.ex:
defmodule Acme.Mailer.MailAnvil do
@base_url "https://api.mailanvil.com/v1/send"
@from "[email protected]"
def send_welcome(email, name) do
idempotency_key = "welcome-" <> email
case Req.post(@base_url,
headers: [
{"authorization", "Bearer " <> api_key()},
{"idempotency-key", idempotency_key}
],
json: %{
from: @from,
to: [email],
subject: "Welcome to Acme, #{name}!",
text: "Hi #{name}, your account is ready.",
html: "<h1>Welcome, #{name}!</h1><p>Your account is ready.</p>"
},
retry: :transient
) do
{:ok, %{status: 202}} -> :ok
{:ok, %{status: status, body: body}} ->
{:error, {:mailanvil, status, body}}
{:error, reason} ->
{:error, reason}
end
end
defp api_key, do: System.fetch_env!("MAILANVIL_API_KEY")
end
Three details worth noticing:
Idempotency-Key— a unique value per send. If the network drops after MailAnvil accepts the request but before you read the response, your retry reuses the same key and MailAnvil deduplicates instead of double-sending.- Pattern match on status — a 202 means queued; anything else returns the actual MailAnvil error (
domain_not_verified,invalid_api_key, etc.) instead of a generic crash. frommust be a verified domain — MailAnvil refuses to send from unverified domains to protect your sender reputation.
Step 3 — Fire-and-forget from the context
Create lib/acme/mailer.ex:
defmodule Acme.Mailer do
# ponytail: swap for Task.Supervisor + telemetry when you need retries/observability
def send_background(fun) do
Task.Supervisor.start_child(Acme.TaskSupervisor, fun)
end
end
Ensure the supervisor exists in lib/acme/application.ex:
children = [
Acme.Repo,
{Task.Supervisor, name: Acme.TaskSupervisor},
AcmeWeb.Endpoint
]
Then call it from your signup context:
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, user} ->
Acme.Mailer.send_background(fn ->
Acme.Mailer.MailAnvil.send_welcome(user.email, user.name)
end)
{:ok, user}
error -> error
end
end
The user is returned the moment the row commits. The email goes out in a supervised task — if the process crashes, the supervisor logs it without taking down the request.
Step 4 — Configuration
Put the key in runtime.exs (never in source control):
config :acme, :mailanvil_api_key, System.fetch_env!("MAILANVIL_API_KEY")
And locally, .env:
MAILANVIL_API_KEY=re_your_key_here
Step 5 — Test it
iex -S mix
iex> Acme.Mailer.MailAnvil.send_welcome("[email protected]", "Budi")
:ok
Budi gets the welcome email. Check the response of a bad key to see the error shape:
{:error, {:mailanvil, 401, %{"error" => "invalid_api_key"}}}
Why not Bamboo / Swoosh?
Bamboo and Swoosh are fine libraries, but they're built around SMTP and adapter layers designed for provider SDKs. For a single HTTPS endpoint, one Req.post with an idempotency key is less code, fewer deps, and you see exactly what goes over the wire.
Indonesia notes
- MailAnvil pricing is in IDR with QRIS/GoPay payment — no USD invoice friction, no international credit card needed.
- Documentation available in Bahasa Indonesia.
- Runs on Cloudflare Workers with SES underneath: consistent latency from Jakarta to the API edge.
Next steps
- Add an OTP
GenServerqueue with backoff if you need guaranteed delivery for critical email (password resets, OTP codes). - Set up webhooks to track delivered/bounced events.
- Read the idempotency guide to understand exactly how retries deduplicate.