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

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:

  1. Accepts a signup
  2. Sends the welcome email in the background via MailAnvil's POST /v1/send
  3. Returns the user immediately — the caller never waits on email delivery

Prerequisites

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:

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

Next steps