Back to Blog

How to Send Emails in Elixir / Phoenix (2026 Guide)

18 min read

Most "how to send email in Elixir" tutorials stop at Swoosh.Email.new() |> Mailer.deliver(). That's fine for a contact form. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users.

This guide covers the full picture: picking a provider, building HTML email templates with EEx, sending from Phoenix controllers and LiveView, handling errors with pattern matching, background sending with Oban, and scaling to production. All code examples use Phoenix 1.7+ with the latest conventions.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences. Swoosh has a built-in adapter.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface. Swoosh has a built-in adapter.

Swoosh vs Direct API

Phoenix ships with Swoosh for email. It gives you a unified API with adapters for different providers. For providers without a Swoosh adapter (like Sequenzy), you can call the API directly with Req:

# Swoosh: compose email, adapter handles transport
import Swoosh.Email
 
new()
|> to("user@example.com")
|> from({"Your App", "noreply@yourdomain.com"})
|> subject("Hello")
|> html_body("<h1>Welcome</h1>")
|> MyApp.Mailer.deliver()
 
# Direct API: one HTTP call with Req
Req.post!("https://api.sequenzy.com/v1/transactional/send",
  headers: [{"authorization", "Bearer #{api_key}"}],
  json: %{to: "user@example.com", subject: "Hello", body: "<h1>Welcome</h1>"}
)

Both approaches work. Swoosh is nice because you can swap providers by changing config. Direct API is simpler when you only need one provider. This guide shows both.

Install Dependencies

Add to your mix.exs:

mix.exs
defp deps do
[
  {:phoenix, "~> 1.7"},
  {:req, "~> 0.5"},
  # Req is all you need — direct API calls
]
end
mix.exs
defp deps do
[
  {:phoenix, "~> 1.7"},
  {:swoosh, "~> 1.16"},
  # Swoosh includes a built-in Resend adapter
]
end
mix.exs
defp deps do
[
  {:phoenix, "~> 1.7"},
  {:swoosh, "~> 1.16"},
  # Swoosh includes a built-in SendGrid adapter
]
end

Then fetch dependencies:

mix deps.get

Add your API key to your environment. In Elixir, you configure secrets in config/runtime.exs which reads environment variables at startup:

config/runtime.exs
# config/runtime.exs
config :my_app, :sequenzy_api_key,
System.get_env("SEQUENZY_API_KEY") ||
  raise "SEQUENZY_API_KEY not set"
config/runtime.exs
# config/runtime.exs
config :my_app, MyApp.Mailer,
adapter: Swoosh.Adapters.Resend,
api_key:
  System.get_env("RESEND_API_KEY") ||
    raise "RESEND_API_KEY not set"
config/runtime.exs
# config/runtime.exs
config :my_app, MyApp.Mailer,
adapter: Swoosh.Adapters.Sendgrid,
api_key:
  System.get_env("SENDGRID_API_KEY") ||
    raise "SENDGRID_API_KEY not set"

Create the Email Client

Set up a module that handles all email sending. In Elixir, you'll use a context module (the "bounded context" pattern Phoenix encourages):

lib/my_app/emails.ex
defmodule MyApp.Emails do
@moduledoc "Sends transactional emails via Sequenzy API."

@base_url "https://api.sequenzy.com/v1"

def send_email(to, subject, body) do
  api_key = Application.fetch_env!(:my_app, :sequenzy_api_key)

  case Req.post("#{@base_url}/transactional/send",
         headers: [{"authorization", "Bearer #{api_key}"}],
         json: %{to: to, subject: subject, body: body}
       ) do
    {:ok, %Req.Response{status: 200, body: body}} ->
      {:ok, body}

    {:ok, %Req.Response{status: status, body: body}} ->
      {:error, %{status: status, body: body}}

    {:error, reason} ->
      {:error, reason}
  end
end
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
@moduledoc "Composes and sends emails via Swoosh + Resend."

import Swoosh.Email

alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def send_email(to, subject, html_body) do
  new()
  |> to(to)
  |> from(@from)
  |> subject(subject)
  |> html_body(html_body)
  |> Mailer.deliver()
end
end

# lib/my_app/mailer.ex (generated by Phoenix)
defmodule MyApp.Mailer do
use Swoosh.Mailer, otp_app: :my_app
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
@moduledoc "Composes and sends emails via Swoosh + SendGrid."

import Swoosh.Email

alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def send_email(to, subject, html_body) do
  new()
  |> to(to)
  |> from(@from)
  |> subject(subject)
  |> html_body(html_body)
  |> Mailer.deliver()
end
end

# lib/my_app/mailer.ex (generated by Phoenix)
defmodule MyApp.Mailer do
use Swoosh.Mailer, otp_app: :my_app
end

Send Your First Email

The simplest possible email. No template, just getting something out the door.

lib/my_app_web/controllers/email_controller.ex
defmodule MyAppWeb.EmailController do
use MyAppWeb, :controller

alias MyApp.Emails

def send(conn, _params) do
  case Emails.send_email(
         "user@example.com",
         "Hello from Phoenix",
         "<p>Your app is sending emails. Nice.</p>"
       ) do
    {:ok, body} ->
      json(conn, %{jobId: body["jobId"]})

    {:error, reason} ->
      conn
      |> put_status(500)
      |> json(%{error: "Failed to send", details: inspect(reason)})
  end
end
end
lib/my_app_web/controllers/email_controller.ex
defmodule MyAppWeb.EmailController do
use MyAppWeb, :controller

alias MyApp.Emails

def send(conn, _params) do
  case Emails.send_email(
         "user@example.com",
         "Hello from Phoenix",
         "<p>Your app is sending emails. Nice.</p>"
       ) do
    {:ok, _metadata} ->
      json(conn, %{sent: true})

    {:error, reason} ->
      conn
      |> put_status(500)
      |> json(%{error: "Failed to send", details: inspect(reason)})
  end
end
end
lib/my_app_web/controllers/email_controller.ex
defmodule MyAppWeb.EmailController do
use MyAppWeb, :controller

alias MyApp.Emails

def send(conn, _params) do
  case Emails.send_email(
         "user@example.com",
         "Hello from Phoenix",
         "<p>Your app is sending emails. Nice.</p>"
       ) do
    {:ok, _metadata} ->
      json(conn, %{sent: true})

    {:error, reason} ->
      conn
      |> put_status(500)
      |> json(%{error: "Failed to send", details: inspect(reason)})
  end
end
end

Add the route in your router:

# lib/my_app_web/router.ex
scope "/api", MyAppWeb do
  pipe_through :api
  post "/send", EmailController, :send
end

Test it:

curl -X POST http://localhost:4000/api/send

Build Email Templates with EEx

Raw HTML strings in your Elixir modules get messy fast. Phoenix uses EEx (Embedded Elixir) for templates. You can use the same system for emails.

Create a layout and template:

# lib/my_app/emails/templates/layout.html.eex
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
  <div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
    <%= @inner_content %>
  </div>
  <div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
    <p>&copy; <%= DateTime.utc_now().year %> Your App. All rights reserved.</p>
  </div>
</body>
</html>
# lib/my_app/emails/templates/welcome.html.eex
<h1 style="font-size:24px;margin-bottom:16px;">Welcome, <%= @name %></h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
  Your account is ready. You can log in and start exploring.
</p>
<a href="<%= @login_url %>"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
          border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
          margin-top:16px;">
  Go to Dashboard
</a>

Now create a renderer module that compiles EEx templates:

# lib/my_app/emails/renderer.ex
defmodule MyApp.Emails.Renderer do
  @moduledoc "Renders email templates using EEx with a shared layout."
 
  @template_dir Path.join(:code.priv_dir(:my_app), "emails/templates")
 
  def render(template, assigns \\ %{}) do
    # Render the inner template
    inner =
      @template_dir
      |> Path.join("#{template}.html.eex")
      |> EEx.eval_file(assigns: Map.to_list(assigns))
 
    # Wrap in layout
    @template_dir
    |> Path.join("layout.html.eex")
    |> EEx.eval_file(assigns: [inner_content: inner])
  end
end

Move the template files to priv/emails/templates/ so they're included in releases:

priv/
  emails/
    templates/
      layout.html.eex
      welcome.html.eex
      password_reset.html.eex
      receipt.html.eex

Then use it:

lib/my_app/emails.ex
defmodule MyApp.Emails do
alias MyApp.Emails.Renderer

@base_url "https://api.sequenzy.com/v1"

def welcome(email, name) do
  html = Renderer.render("welcome", %{
    name: name,
    login_url: "#{MyAppWeb.Endpoint.url()}/dashboard"
  })

  send_email(email, "Welcome, #{name}", html)
end

def send_email(to, subject, body) do
  api_key = Application.fetch_env!(:my_app, :sequenzy_api_key)

  case Req.post("#{@base_url}/transactional/send",
         headers: [{"authorization", "Bearer #{api_key}"}],
         json: %{to: to, subject: subject, body: body}
       ) do
    {:ok, %Req.Response{status: 200, body: body}} ->
      {:ok, body}

    {:ok, %Req.Response{status: status, body: body}} ->
      {:error, %{status: status, body: body}}

    {:error, reason} ->
      {:error, reason}
  end
end
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
import Swoosh.Email

alias MyApp.Emails.Renderer
alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def welcome(email, name) do
  html = Renderer.render("welcome", %{
    name: name,
    login_url: "#{MyAppWeb.Endpoint.url()}/dashboard"
  })

  new()
  |> to(email)
  |> from(@from)
  |> subject("Welcome, #{name}")
  |> html_body(html)
  |> Mailer.deliver()
end
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
import Swoosh.Email

alias MyApp.Emails.Renderer
alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def welcome(email, name) do
  html = Renderer.render("welcome", %{
    name: name,
    login_url: "#{MyAppWeb.Endpoint.url()}/dashboard"
  })

  new()
  |> to(email)
  |> from(@from)
  |> subject("Welcome, #{name}")
  |> html_body(html)
  |> Mailer.deliver()
end
end

Compile-Time Templates for Better Performance

For production, you can compile templates at build time instead of reading files at runtime. This is faster and catches template errors during compilation:

defmodule MyApp.Emails.Renderer do
  @moduledoc "Compile-time EEx template rendering."
 
  @template_dir "priv/emails/templates"
 
  # Compile templates at build time
  @welcome EEx.compile_file(Path.join(@template_dir, "welcome.html.eex"))
  @layout EEx.compile_file(Path.join(@template_dir, "layout.html.eex"))
 
  def render(:welcome, assigns) do
    inner = eval_compiled(@welcome, assigns)
    eval_compiled(@layout, %{inner_content: inner})
  end
 
  defp eval_compiled(compiled, assigns) do
    {result, _binding} = Code.eval_quoted(compiled, assigns: Map.to_list(assigns))
    result
  end
end

Phoenix Component Templates (Phoenix 1.7+)

If you prefer using Phoenix's HEEx components for emails (type-safe, compile-time checked), you can render them to static HTML:

defmodule MyApp.Emails.Components do
  use Phoenix.Component
 
  def welcome(assigns) do
    ~H"""
    <h1 style="font-size:24px;margin-bottom:16px;">Welcome, {@name}</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your account is ready.
    </p>
    <.button url={@login_url}>Go to Dashboard</.button>
    """
  end
 
  defp button(assigns) do
    ~H"""
    <a href={@url}
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
              border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
              margin-top:16px;">
      {render_slot(@inner_block)}
    </a>
    """
  end
end
 
# Render to HTML string
html = Phoenix.Template.render_to_string(
  MyApp.Emails.Components,
  :welcome,
  "html",
  %{name: "Jane", login_url: "https://app.yoursite.com/dashboard"}
)

Send from LiveView

Phoenix LiveView is the dominant way to build interactive UIs in Phoenix. Here's how to send emails from LiveView event handlers:

lib/my_app_web/live/contact_live.ex
defmodule MyAppWeb.ContactLive do
use MyAppWeb, :live_view

alias MyApp.Emails

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(%{}))}
end

def handle_event("send_message", %{"email" => email, "message" => message}, socket) do
  case Emails.send_email(
         "you@yourcompany.com",
         "Contact form: #{email}",
         "<p><strong>From:</strong> #{email}</p><p>#{message}</p>"
       ) do
    {:ok, _} ->
      {:noreply,
       socket
       |> put_flash(:info, "Message sent!")
       |> assign(form: to_form(%{}))}

    {:error, _reason} ->
      {:noreply, put_flash(socket, :error, "Failed to send. Try again.")}
  end
end

def render(assigns) do
  ~H"""
  <.form for={@form} phx-submit="send_message">
    <.input field={@form[:email]} type="email" label="Your email" required />
    <.input field={@form[:message]} type="textarea" label="Message" required />
    <.button type="submit">Send</.button>
  </.form>
  """
end
end
lib/my_app_web/live/contact_live.ex
defmodule MyAppWeb.ContactLive do
use MyAppWeb, :live_view

alias MyApp.Emails

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(%{}))}
end

def handle_event("send_message", %{"email" => email, "message" => message}, socket) do
  case Emails.send_email(
         "you@yourcompany.com",
         "Contact form: #{email}",
         "<p><strong>From:</strong> #{email}</p><p>#{message}</p>"
       ) do
    {:ok, _} ->
      {:noreply,
       socket
       |> put_flash(:info, "Message sent!")
       |> assign(form: to_form(%{}))}

    {:error, _reason} ->
      {:noreply, put_flash(socket, :error, "Failed to send. Try again.")}
  end
end

def render(assigns) do
  ~H"""
  <.form for={@form} phx-submit="send_message">
    <.input field={@form[:email]} type="email" label="Your email" required />
    <.input field={@form[:message]} type="textarea" label="Message" required />
    <.button type="submit">Send</.button>
  </.form>
  """
end
end
lib/my_app_web/live/contact_live.ex
defmodule MyAppWeb.ContactLive do
use MyAppWeb, :live_view

alias MyApp.Emails

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(%{}))}
end

def handle_event("send_message", %{"email" => email, "message" => message}, socket) do
  case Emails.send_email(
         "you@yourcompany.com",
         "Contact form: #{email}",
         "<p><strong>From:</strong> #{email}</p><p>#{message}</p>"
       ) do
    {:ok, _} ->
      {:noreply,
       socket
       |> put_flash(:info, "Message sent!")
       |> assign(form: to_form(%{}))}

    {:error, _reason} ->
      {:noreply, put_flash(socket, :error, "Failed to send. Try again.")}
  end
end

def render(assigns) do
  ~H"""
  <.form for={@form} phx-submit="send_message">
    <.input field={@form[:email]} type="email" label="Your email" required />
    <.input field={@form[:message]} type="textarea" label="Message" required />
    <.button type="submit">Send</.button>
  </.form>
  """
end
end

Important: Email sends are blocking I/O. For a better UX, use Task.async or Oban (shown later) to send emails in the background so the LiveView responds instantly.

Common Email Patterns for SaaS

Here are the emails almost every SaaS app needs, with production-ready implementations.

Password Reset

lib/my_app/emails.ex
def password_reset(email, reset_token) do
reset_url = "#{MyAppWeb.Endpoint.url()}/reset-password?token=#{reset_token}"

html = Renderer.render("password_reset", %{reset_url: reset_url})

send_email(email, "Reset your password", html)
end

# priv/emails/templates/password_reset.html.eex
# <h2 style="font-size:20px;">Password Reset</h2>
# <p style="font-size:16px;line-height:1.6;color:#374151;">
#   Click the link below to reset your password. This link expires in 1 hour.
# </p>
# <a href="<%= @reset_url %>"
#    style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
#           border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
#           margin-top:16px;">
#   Reset Password
# </a>
# <p style="color:#6b7280;font-size:14px;margin-top:24px;">
#   If you didn't request this, ignore this email.
# </p>
lib/my_app/emails.ex
def password_reset(email, reset_token) do
reset_url = "#{MyAppWeb.Endpoint.url()}/reset-password?token=#{reset_token}"

html = Renderer.render("password_reset", %{reset_url: reset_url})

new()
|> to(email)
|> from(@from)
|> subject("Reset your password")
|> html_body(html)
|> Mailer.deliver()
end
lib/my_app/emails.ex
def password_reset(email, reset_token) do
reset_url = "#{MyAppWeb.Endpoint.url()}/reset-password?token=#{reset_token}"

html = Renderer.render("password_reset", %{reset_url: reset_url})

new()
|> to(email)
|> from(@from)
|> subject("Reset your password")
|> html_body(html)
|> Mailer.deliver()
end

Payment Receipt

lib/my_app/emails.ex
def payment_receipt(email, %{amount: amount, plan: plan, invoice_url: invoice_url}) do
formatted = :erlang.float_to_binary(amount / 100, decimals: 2)

html = Renderer.render("receipt", %{
  formatted: "$#{formatted}",
  plan: plan,
  invoice_url: invoice_url
})

send_email(email, "Payment receipt - $#{formatted}", html)
end

# priv/emails/templates/receipt.html.eex
# <h2 style="font-size:20px;">Payment Received</h2>
# <p style="font-size:16px;color:#374151;">Thanks for your payment.</p>
# <table style="width:100%;border-collapse:collapse;margin:16px 0;">
#   <tr>
#     <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
#     <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">
#       <%= @plan %>
#     </td>
#   </tr>
#   <tr>
#     <td style="padding:8px;font-weight:600;">Total</td>
#     <td style="padding:8px;text-align:right;font-weight:600;"><%= @formatted %></td>
#   </tr>
# </table>
# <a href="<%= @invoice_url %>" style="color:#f97316;">View full invoice</a>
lib/my_app/emails.ex
def payment_receipt(email, %{amount: amount, plan: plan, invoice_url: invoice_url}) do
formatted = :erlang.float_to_binary(amount / 100, decimals: 2)

html = Renderer.render("receipt", %{
  formatted: "$#{formatted}",
  plan: plan,
  invoice_url: invoice_url
})

new()
|> to(email)
|> from({"Your App", "billing@yourdomain.com"})
|> subject("Payment receipt - $#{formatted}")
|> html_body(html)
|> Mailer.deliver()
end
lib/my_app/emails.ex
def payment_receipt(email, %{amount: amount, plan: plan, invoice_url: invoice_url}) do
formatted = :erlang.float_to_binary(amount / 100, decimals: 2)

html = Renderer.render("receipt", %{
  formatted: "$#{formatted}",
  plan: plan,
  invoice_url: invoice_url
})

new()
|> to(email)
|> from({"Your App", "billing@yourdomain.com"})
|> subject("Payment receipt - $#{formatted}")
|> html_body(html)
|> Mailer.deliver()
end

Stripe Webhook Handler

Phoenix uses Plugs for request handling. Here's a Stripe webhook that verifies signatures and sends emails. For a deeper dive into all Stripe lifecycle events, see our Stripe webhook email guide.

lib/my_app_web/controllers/stripe_webhook_controller.ex
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller

alias MyApp.Emails

@webhook_secret Application.compile_env(:my_app, :stripe_webhook_secret)

def handle(conn, _params) do
  with {:ok, payload} <- read_raw_body(conn),
       {:ok, event} <- verify_signature(payload, conn) do
    process_event(event)
    json(conn, %{received: true})
  else
    {:error, reason} ->
      conn
      |> put_status(400)
      |> json(%{error: reason})
  end
end

defp read_raw_body(conn) do
  case Plug.Conn.read_body(conn) do
    {:ok, body, _conn} -> {:ok, body}
    _ -> {:error, "Failed to read body"}
  end
end

defp verify_signature(payload, conn) do
  signature = Plug.Conn.get_req_header(conn, "stripe-signature") |> List.first()

  # Extract timestamp and signature from header
  parts =
    signature
    |> String.split(",")
    |> Enum.map(&String.split(&1, "="))
    |> Enum.into(%{}, fn [k, v] -> {k, v} end)

  timestamp = parts["t"]
  expected = parts["v1"]

  signed_payload = "#{timestamp}.#{payload}"
  computed = :crypto.mac(:hmac, :sha256, @webhook_secret, signed_payload)
             |> Base.encode16(case: :lower)

  if Plug.Crypto.secure_compare(computed, expected) do
    {:ok, Jason.decode!(payload)}
  else
    {:error, "Invalid signature"}
  end
end

defp process_event(%{"type" => "checkout.session.completed", "data" => %{"object" => session}}) do
  email = session["customer_email"]

  Emails.send_email(
    email,
    "Payment confirmed",
    "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>"
  )
end

defp process_event(%{"type" => "invoice.payment_failed", "data" => %{"object" => invoice}}) do
  email = invoice["customer_email"]

  Emails.send_email(
    email,
    "Payment failed",
    "<h1>Payment issue</h1><p>We couldn't process your payment. Please update your card.</p>"
  )
end

defp process_event(_event), do: :ok
end
lib/my_app_web/controllers/stripe_webhook_controller.ex
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller

import Swoosh.Email
alias MyApp.Mailer

@webhook_secret Application.compile_env(:my_app, :stripe_webhook_secret)
@from {"Your App", "noreply@yourdomain.com"}

def handle(conn, _params) do
  with {:ok, payload} <- read_raw_body(conn),
       {:ok, event} <- verify_signature(payload, conn) do
    process_event(event)
    json(conn, %{received: true})
  else
    {:error, reason} ->
      conn
      |> put_status(400)
      |> json(%{error: reason})
  end
end

defp read_raw_body(conn) do
  case Plug.Conn.read_body(conn) do
    {:ok, body, _conn} -> {:ok, body}
    _ -> {:error, "Failed to read body"}
  end
end

defp verify_signature(payload, conn) do
  signature = Plug.Conn.get_req_header(conn, "stripe-signature") |> List.first()

  parts =
    signature
    |> String.split(",")
    |> Enum.map(&String.split(&1, "="))
    |> Enum.into(%{}, fn [k, v] -> {k, v} end)

  timestamp = parts["t"]
  expected = parts["v1"]

  signed_payload = "#{timestamp}.#{payload}"
  computed = :crypto.mac(:hmac, :sha256, @webhook_secret, signed_payload)
             |> Base.encode16(case: :lower)

  if Plug.Crypto.secure_compare(computed, expected) do
    {:ok, Jason.decode!(payload)}
  else
    {:error, "Invalid signature"}
  end
end

defp process_event(%{"type" => "checkout.session.completed", "data" => %{"object" => session}}) do
  new()
  |> to(session["customer_email"])
  |> from(@from)
  |> subject("Payment confirmed")
  |> html_body("<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>")
  |> Mailer.deliver()
end

defp process_event(%{"type" => "invoice.payment_failed", "data" => %{"object" => invoice}}) do
  new()
  |> to(invoice["customer_email"])
  |> from(@from)
  |> subject("Payment failed")
  |> html_body("<h1>Payment issue</h1><p>Please update your payment method.</p>")
  |> Mailer.deliver()
end

defp process_event(_event), do: :ok
end
lib/my_app_web/controllers/stripe_webhook_controller.ex
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller

import Swoosh.Email
alias MyApp.Mailer

@webhook_secret Application.compile_env(:my_app, :stripe_webhook_secret)
@from {"Your App", "noreply@yourdomain.com"}

def handle(conn, _params) do
  with {:ok, payload} <- read_raw_body(conn),
       {:ok, event} <- verify_signature(payload, conn) do
    process_event(event)
    json(conn, %{received: true})
  else
    {:error, reason} ->
      conn
      |> put_status(400)
      |> json(%{error: reason})
  end
end

defp read_raw_body(conn) do
  case Plug.Conn.read_body(conn) do
    {:ok, body, _conn} -> {:ok, body}
    _ -> {:error, "Failed to read body"}
  end
end

defp verify_signature(payload, conn) do
  signature = Plug.Conn.get_req_header(conn, "stripe-signature") |> List.first()

  parts =
    signature
    |> String.split(",")
    |> Enum.map(&String.split(&1, "="))
    |> Enum.into(%{}, fn [k, v] -> {k, v} end)

  timestamp = parts["t"]
  expected = parts["v1"]

  signed_payload = "#{timestamp}.#{payload}"
  computed = :crypto.mac(:hmac, :sha256, @webhook_secret, signed_payload)
             |> Base.encode16(case: :lower)

  if Plug.Crypto.secure_compare(computed, expected) do
    {:ok, Jason.decode!(payload)}
  else
    {:error, "Invalid signature"}
  end
end

defp process_event(%{"type" => "checkout.session.completed", "data" => %{"object" => session}}) do
  new()
  |> to(session["customer_email"])
  |> from(@from)
  |> subject("Payment confirmed")
  |> html_body("<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>")
  |> Mailer.deliver()
end

defp process_event(%{"type" => "invoice.payment_failed", "data" => %{"object" => invoice}}) do
  new()
  |> to(invoice["customer_email"])
  |> from(@from)
  |> subject("Payment failed")
  |> html_body("<h1>Payment issue</h1><p>Please update your payment method.</p>")
  |> Mailer.deliver()
end

defp process_event(_event), do: :ok
end

You need to read the raw body for signature verification. Add a custom body reader plug:

# lib/my_app_web/plugs/raw_body_reader.ex
defmodule MyAppWeb.Plugs.RawBodyReader do
  @moduledoc "Caches the raw request body for webhook signature verification."
 
  def init(opts), do: opts
 
  def read_body(conn, opts) do
    case Plug.Conn.read_body(conn, opts) do
      {:ok, body, conn} ->
        conn = Plug.Conn.put_private(conn, :raw_body, body)
        {:ok, body, conn}
 
      {:more, body, conn} ->
        conn = Plug.Conn.put_private(conn, :raw_body, body)
        {:more, body, conn}
 
      {:error, reason} ->
        {:error, reason}
    end
  end
end
 
# In your endpoint.ex, for the webhook path:
plug Plug.Parsers,
  parsers: [:json],
  pass: ["application/json"],
  body_reader: {MyAppWeb.Plugs.RawBodyReader, :read_body, []},
  json_decoder: Jason
 
# Router
scope "/webhooks", MyAppWeb do
  pipe_through :api
  post "/stripe", StripeWebhookController, :handle
end

Error Handling

Elixir's pattern matching makes error handling elegant. Build a structured error system:

lib/my_app/emails.ex
defmodule MyApp.Emails do
require Logger

alias MyApp.Emails.Renderer

@base_url "https://api.sequenzy.com/v1"

defmodule SendError do
  @moduledoc "Structured email send error."
  defexception [:message, :status, :retryable?]

  def new(status, body) do
    %__MODULE__{
      message: "Email send failed (#{status}): #{inspect(body)}",
      status: status,
      retryable?: status in [429, 500, 502, 503, 504]
    }
  end
end

def send_email(to, subject, body) do
  api_key = Application.fetch_env!(:my_app, :sequenzy_api_key)

  case Req.post("#{@base_url}/transactional/send",
         headers: [{"authorization", "Bearer #{api_key}"}],
         json: %{to: to, subject: subject, body: body}
       ) do
    {:ok, %Req.Response{status: 200, body: resp_body}} ->
      Logger.info("Email sent", to: to, subject: subject)
      {:ok, resp_body}

    {:ok, %Req.Response{status: 429, body: resp_body}} ->
      Logger.warning("Rate limited sending email", to: to)
      {:error, SendError.new(429, resp_body)}

    {:ok, %Req.Response{status: 401}} ->
      Logger.error("Invalid API key for email send")
      {:error, SendError.new(401, "Invalid API key")}

    {:ok, %Req.Response{status: status, body: resp_body}} ->
      Logger.error("Email send failed", to: to, status: status)
      {:error, SendError.new(status, resp_body)}

    {:error, %Req.TransportError{reason: reason}} ->
      Logger.error("Network error sending email", to: to, reason: inspect(reason))
      {:error, SendError.new(0, reason)}
  end
end

def send_email_with_retry(to, subject, body, max_retries \\ 3) do
  do_send_with_retry(to, subject, body, 0, max_retries)
end

defp do_send_with_retry(to, subject, body, attempt, max_retries) do
  case send_email(to, subject, body) do
    {:ok, result} ->
      {:ok, result}

    {:error, %SendError{retryable?: true}} when attempt < max_retries ->
      delay = :math.pow(2, attempt) |> round() |> :timer.seconds()
      Process.sleep(delay)
      do_send_with_retry(to, subject, body, attempt + 1, max_retries)

    {:error, error} ->
      {:error, error}
  end
end
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
require Logger

import Swoosh.Email

alias MyApp.Emails.Renderer
alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def send_email(to, subject, html_body) do
  email =
    new()
    |> to(to)
    |> from(@from)
    |> subject(subject)
    |> html_body(html_body)

  case Mailer.deliver(email) do
    {:ok, metadata} ->
      Logger.info("Email sent", to: to, subject: subject)
      {:ok, metadata}

    {:error, {_code, %{"message" => message}}} ->
      Logger.error("Email send failed", to: to, error: message)
      {:error, message}

    {:error, reason} ->
      Logger.error("Email send failed", to: to, reason: inspect(reason))
      {:error, reason}
  end
end

def send_email_with_retry(to, subject, html_body, max_retries \\ 3) do
  do_send_with_retry(to, subject, html_body, 0, max_retries)
end

defp do_send_with_retry(to, subject, html_body, attempt, max_retries)
     when attempt < max_retries do
  case send_email(to, subject, html_body) do
    {:ok, result} ->
      {:ok, result}

    {:error, _reason} ->
      delay = :math.pow(2, attempt) |> round() |> :timer.seconds()
      Process.sleep(delay)
      do_send_with_retry(to, subject, html_body, attempt + 1, max_retries)
  end
end

defp do_send_with_retry(to, subject, html_body, _attempt, _max_retries) do
  send_email(to, subject, html_body)
end
end
lib/my_app/emails.ex
defmodule MyApp.Emails do
require Logger

import Swoosh.Email

alias MyApp.Emails.Renderer
alias MyApp.Mailer

@from {"Your App", "noreply@yourdomain.com"}

def send_email(to, subject, html_body) do
  email =
    new()
    |> to(to)
    |> from(@from)
    |> subject(subject)
    |> html_body(html_body)

  case Mailer.deliver(email) do
    {:ok, metadata} ->
      Logger.info("Email sent", to: to, subject: subject)
      {:ok, metadata}

    {:error, {_code, %{"errors" => errors}}} ->
      message = errors |> Enum.map(& &1["message"]) |> Enum.join(", ")
      Logger.error("Email send failed", to: to, error: message)
      {:error, message}

    {:error, reason} ->
      Logger.error("Email send failed", to: to, reason: inspect(reason))
      {:error, reason}
  end
end

def send_email_with_retry(to, subject, html_body, max_retries \\ 3) do
  do_send_with_retry(to, subject, html_body, 0, max_retries)
end

defp do_send_with_retry(to, subject, html_body, attempt, max_retries)
     when attempt < max_retries do
  case send_email(to, subject, html_body) do
    {:ok, result} ->
      {:ok, result}

    {:error, _reason} ->
      delay = :math.pow(2, attempt) |> round() |> :timer.seconds()
      Process.sleep(delay)
      do_send_with_retry(to, subject, html_body, attempt + 1, max_retries)
  end
end

defp do_send_with_retry(to, subject, html_body, _attempt, _max_retries) do
  send_email(to, subject, html_body)
end
end

Background Sending with Oban

Email sends are I/O-bound and should not block your request/response cycle. Oban is the standard background job library for Elixir. Jobs are persisted in PostgreSQL, survive restarts, and retry automatically on failure.

# Add to mix.exs
{:oban, "~> 2.17"}

Set up the worker:

lib/my_app/workers/email_worker.ex
defmodule MyApp.Workers.EmailWorker do
use Oban.Worker,
  queue: :emails,
  max_attempts: 5,
  priority: 1

alias MyApp.Emails

@impl Oban.Worker
def perform(%Oban.Job{args: %{"type" => "welcome", "email" => email, "name" => name}}) do
  case Emails.welcome(email, name) do
    {:ok, _} -> :ok
    {:error, %Emails.SendError{retryable?: true} = error} -> {:error, error.message}
    {:error, %Emails.SendError{retryable?: false}} -> :discard
    {:error, reason} -> {:error, inspect(reason)}
  end
end

def perform(%Oban.Job{args: %{"type" => "password_reset", "email" => email, "token" => token}}) do
  case Emails.password_reset(email, token) do
    {:ok, _} -> :ok
    {:error, _} = error -> error
  end
end

def perform(%Oban.Job{args: %{"type" => "receipt"} = args}) do
  case Emails.payment_receipt(args["email"], %{
         amount: args["amount"],
         plan: args["plan"],
         invoice_url: args["invoice_url"]
       }) do
    {:ok, _} -> :ok
    {:error, _} = error -> error
  end
end

# Custom backoff: 1s, 10s, 60s, 5m, 30m
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
  [1, 10, 60, 300, 1800]
  |> Enum.at(attempt - 1, 1800)
end
end

# Queue an email from anywhere:
%{type: "welcome", email: "user@example.com", name: "Jane"}
|> MyApp.Workers.EmailWorker.new()
|> Oban.insert()
lib/my_app/workers/email_worker.ex
defmodule MyApp.Workers.EmailWorker do
use Oban.Worker,
  queue: :emails,
  max_attempts: 5,
  priority: 1

alias MyApp.Emails

@impl Oban.Worker
def perform(%Oban.Job{args: %{"type" => "welcome", "email" => email, "name" => name}}) do
  case Emails.welcome(email, name) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

def perform(%Oban.Job{args: %{"type" => "password_reset", "email" => email, "token" => token}}) do
  case Emails.password_reset(email, token) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

def perform(%Oban.Job{args: %{"type" => "receipt"} = args}) do
  case Emails.payment_receipt(args["email"], %{
         amount: args["amount"],
         plan: args["plan"],
         invoice_url: args["invoice_url"]
       }) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
  [1, 10, 60, 300, 1800]
  |> Enum.at(attempt - 1, 1800)
end
end

# Queue from anywhere:
%{type: "welcome", email: "user@example.com", name: "Jane"}
|> MyApp.Workers.EmailWorker.new()
|> Oban.insert()
lib/my_app/workers/email_worker.ex
defmodule MyApp.Workers.EmailWorker do
use Oban.Worker,
  queue: :emails,
  max_attempts: 5,
  priority: 1

alias MyApp.Emails

@impl Oban.Worker
def perform(%Oban.Job{args: %{"type" => "welcome", "email" => email, "name" => name}}) do
  case Emails.welcome(email, name) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

def perform(%Oban.Job{args: %{"type" => "password_reset", "email" => email, "token" => token}}) do
  case Emails.password_reset(email, token) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

def perform(%Oban.Job{args: %{"type" => "receipt"} = args}) do
  case Emails.payment_receipt(args["email"], %{
         amount: args["amount"],
         plan: args["plan"],
         invoice_url: args["invoice_url"]
       }) do
    {:ok, _} -> :ok
    {:error, reason} -> {:error, inspect(reason)}
  end
end

@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
  [1, 10, 60, 300, 1800]
  |> Enum.at(attempt - 1, 1800)
end
end

# Queue from anywhere:
%{type: "welcome", email: "user@example.com", name: "Jane"}
|> MyApp.Workers.EmailWorker.new()
|> Oban.insert()

Configure Oban in your application:

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [
    emails: 10,        # 10 concurrent email workers
    default: 5
  ]
 
# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    {Oban, Application.fetch_env!(:my_app, Oban)},
    MyAppWeb.Endpoint
  ]
 
  Supervisor.start_link(children, strategy: :one_for_one)
end

Run the Oban migration:

mix ecto.gen.migration add_oban_jobs_table
# In the generated migration
defmodule MyApp.Repo.Migrations.AddObanJobsTable do
  use Ecto.Migration
 
  def up, do: Oban.Migration.up(version: 12)
  def down, do: Oban.Migration.down(version: 1)
end

Scheduled Emails

Oban supports scheduling jobs for future delivery:

# Send a follow-up email 3 days after signup
%{type: "onboarding_followup", email: email, name: name}
|> MyApp.Workers.EmailWorker.new(schedule_in: {3, :days})
|> Oban.insert()
 
# Send at a specific time
%{type: "digest", email: email}
|> MyApp.Workers.EmailWorker.new(scheduled_at: ~U[2026-03-01 09:00:00Z])
|> Oban.insert()
 
# Unique jobs — prevent duplicate emails
%{type: "welcome", email: email, name: name}
|> MyApp.Workers.EmailWorker.new(
  unique: [period: 3600, keys: [:email, :type]]
)
|> Oban.insert()

Testing Emails

Elixir's testing story is excellent. Here's how to test email sending without hitting real APIs.

With Swoosh Test Adapter

Swoosh has a built-in test adapter that captures sent emails:

# config/test.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Test
 
# test/my_app/emails_test.exs
defmodule MyApp.EmailsTest do
  use MyApp.DataCase
 
  import Swoosh.TestAssertions
 
  alias MyApp.Emails
 
  describe "welcome/2" do
    test "sends welcome email with correct content" do
      {:ok, _} = Emails.welcome("user@example.com", "Jane")
 
      assert_email_sent(
        to: [{"", "user@example.com"}],
        subject: "Welcome, Jane"
      )
    end
 
    test "includes the login URL in the body" do
      {:ok, _} = Emails.welcome("user@example.com", "Jane")
 
      assert_email_sent(fn email ->
        assert email.html_body =~ "Go to Dashboard"
        assert email.html_body =~ "/dashboard"
      end)
    end
  end
end

With Mox for Direct API Calls

For Sequenzy (direct API calls), use Mox to mock the HTTP client:

# test/support/mocks.ex
Mox.defmock(MyApp.MockHTTP, for: MyApp.HTTPBehaviour)
 
# lib/my_app/http_behaviour.ex
defmodule MyApp.HTTPBehaviour do
  @callback post(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
end
 
# test/my_app/emails_test.exs
defmodule MyApp.EmailsTest do
  use MyApp.DataCase
 
  import Mox
 
  setup :verify_on_exit!
 
  describe "send_email/3" do
    test "returns ok on successful send" do
      MyApp.MockHTTP
      |> expect(:post, fn _url, opts ->
        assert opts[:json][:to] == "user@example.com"
        assert opts[:json][:subject] == "Test"
        {:ok, %Req.Response{status: 200, body: %{"jobId" => "job-123"}}}
      end)
 
      assert {:ok, %{"jobId" => "job-123"}} =
               MyApp.Emails.send_email("user@example.com", "Test", "<p>Hello</p>")
    end
 
    test "returns error on rate limit" do
      MyApp.MockHTTP
      |> expect(:post, fn _url, _opts ->
        {:ok, %Req.Response{status: 429, body: %{"error" => "rate limited"}}}
      end)
 
      assert {:error, %MyApp.Emails.SendError{status: 429, retryable?: true}} =
               MyApp.Emails.send_email("user@example.com", "Test", "<p>Hello</p>")
    end
  end
end

Testing Oban Workers

Oban has first-class testing support:

# config/test.exs
config :my_app, Oban, testing: :inline
 
# test/my_app/workers/email_worker_test.exs
defmodule MyApp.Workers.EmailWorkerTest do
  use MyApp.DataCase
  use Oban.Testing, repo: MyApp.Repo
 
  alias MyApp.Workers.EmailWorker
 
  test "welcome email job is enqueued" do
    assert :ok =
             perform_job(EmailWorker, %{
               "type" => "welcome",
               "email" => "user@example.com",
               "name" => "Jane"
             })
  end
 
  test "enqueues the correct job" do
    %{type: "welcome", email: "user@example.com", name: "Jane"}
    |> EmailWorker.new()
    |> Oban.insert()
 
    assert_enqueued(worker: EmailWorker, args: %{"type" => "welcome"})
  end
end

Going to Production

Before you start sending real emails, handle these things.

1. Verify Your Domain

Every email provider requires domain verification. Add SPF, DKIM, and DMARC DNS records. Without this, your emails go straight to spam. Each provider has a dashboard to get the DNS records.

2. Use Runtime Config

Never compile API keys into your release. Use config/runtime.exs:

# config/runtime.exs — reads env vars at startup, not compile time
import Config
 
if config_env() == :prod do
  config :my_app, :sequenzy_api_key,
    System.get_env("SEQUENZY_API_KEY") ||
      raise "SEQUENZY_API_KEY is not set"
 
  config :my_app, :stripe_webhook_secret,
    System.get_env("STRIPE_WEBHOOK_SECRET") ||
      raise "STRIPE_WEBHOOK_SECRET is not set"
end

3. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.

4. Telemetry for Observability

Add telemetry events to track email metrics:

defmodule MyApp.Emails do
  def send_email(to, subject, body) do
    start_time = System.monotonic_time()
 
    result = do_send_email(to, subject, body)
 
    duration = System.monotonic_time() - start_time
 
    :telemetry.execute(
      [:my_app, :email, :send],
      %{duration: duration},
      %{to: to, subject: subject, result: elem(result, 0)}
    )
 
    result
  end
end
 
# In your application.ex or a separate module
:telemetry.attach(
  "email-logger",
  [:my_app, :email, :send],
  fn _event, measurements, metadata, _config ->
    Logger.info(
      "Email sent",
      to: metadata.to,
      result: metadata.result,
      duration_ms: System.convert_time_unit(measurements.duration, :native, :millisecond)
    )
  end,
  nil
)

5. Rate Limit Email Sends

Use a GenServer or ETS table for in-memory rate limiting:

defmodule MyApp.Emails.RateLimiter do
  use GenServer
 
  @max_per_hour 100
 
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
 
  def check(email) do
    GenServer.call(__MODULE__, {:check, email})
  end
 
  @impl true
  def init(_opts), do: {:ok, %{}}
 
  @impl true
  def handle_call({:check, email}, _from, state) do
    now = System.monotonic_time(:second)
    hour_ago = now - 3600
 
    timestamps =
      state
      |> Map.get(email, [])
      |> Enum.filter(&(&1 > hour_ago))
 
    if length(timestamps) >= @max_per_hour do
      {:reply, {:error, :rate_limited}, state}
    else
      {:reply, :ok, Map.put(state, email, [now | timestamps])}
    end
  end
end

6. Swoosh Local Development

Swoosh ships with a local mailbox viewer. Add it to your dev config:

# config/dev.exs
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Local
 
# lib/my_app_web/router.ex (inside scope)
if Mix.env() == :dev do
  forward "/dev/mailbox", Plug.Swoosh.MailboxPreview
end

Now visit http://localhost:4000/dev/mailbox to see all sent emails during development. No real emails sent.

Production Checklist

- [ ] Domain verified ([SPF, DKIM, DMARC](/blog/how-to-set-up-email-authentication-spf-dkim-dmarc))
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] API keys in runtime.exs, not compiled
- [ ] Oban for background sending (jobs survive restarts)
- [ ] Oban unique jobs to prevent duplicate sends
- [ ] Custom backoff strategy for retries
- [ ] Telemetry events for email metrics
- [ ] Rate limiting on public-facing endpoints
- [ ] Swoosh local adapter for development
- [ ] Swoosh test adapter for tests
- [ ] Logger metadata on all send attempts
- [ ] Error handling with pattern matching

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do (or don't do) in your app
  • Track engagement to see which emails get opened and clicked

Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.

Here's what subscriber management looks like:

defmodule MyApp.Subscribers do
  @base_url "https://api.sequenzy.com/v1"
 
  defp api_key, do: Application.fetch_env!(:my_app, :sequenzy_api_key)
 
  def create(email, attrs \\ %{}) do
    Req.post!("#{@base_url}/subscribers",
      headers: [{"authorization", "Bearer #{api_key()}"}],
      json: Map.merge(%{email: email}, attrs)
    )
  end
 
  def add_tag(email, tag) do
    Req.post!("#{@base_url}/subscribers/tags",
      headers: [{"authorization", "Bearer #{api_key()}"}],
      json: %{email: email, tag: tag}
    )
  end
 
  def track_event(email, event, properties \\ %{}) do
    Req.post!("#{@base_url}/subscribers/events",
      headers: [{"authorization", "Bearer #{api_key()}"}],
      json: %{email: email, event: event, properties: properties}
    )
  end
end
 
# When a user signs up
MyApp.Subscribers.create("user@example.com", %{
  firstName: "Jane",
  tags: ["signed-up"],
  customAttributes: %{plan: "free", source: "organic"}
})
 
# When they upgrade
MyApp.Subscribers.add_tag("user@example.com", "customer")
 
# Track events to trigger automated sequences
MyApp.Subscribers.track_event("user@example.com", "onboarding.completed", %{
  completed_steps: 5
})

You set up sequences in the Sequenzy dashboard (onboarding drip, trial conversion, churn prevention), and the API triggers them based on what happens in your app. No webhook glue code needed.

FAQ

Should I use Swoosh or direct API calls?

Use Swoosh if you want provider-agnostic code and might switch providers later. Swoosh has built-in adapters for Resend, SendGrid, Mailgun, Postmark, and more. Use direct API calls with Req if your provider doesn't have a Swoosh adapter, or if you prefer simplicity. Both work well.

How do I send emails from a GenServer or background process?

Just call your Emails module directly. Elixir processes are lightweight, so you don't need special handling. For reliability, use Oban instead of plain Task.async — Oban persists jobs to PostgreSQL so they survive restarts and retry automatically.

Why Oban over Task.async for email sending?

Task.async runs in memory. If your BEAM node crashes or restarts, the task is gone and the email is never sent. Oban persists jobs in PostgreSQL, retries on failure, provides backoff strategies, supports scheduled delivery, and has built-in uniqueness constraints to prevent duplicate sends.

How do I handle email bounces?

Configure a webhook in your email provider's dashboard. The provider sends a POST request to your Phoenix app when an email bounces. Parse the webhook payload and update your subscriber record. Most providers use similar bounce notification formats.

Can I use Phoenix.Swoosh.TestAssertions with async tests?

Yes, but be careful with async: true. Swoosh test assertions check a global mailbox. If multiple async tests send emails, assertions can interfere with each other. Either use async: false for email tests, or use unique recipient addresses to differentiate.

How do I preview email templates during development?

If you use Swoosh, add Plug.Swoosh.MailboxPreview to your router in dev mode. All emails sent with the Local adapter appear at /dev/mailbox. For EEx templates, you can also render them in IEx: MyApp.Emails.Renderer.render("welcome", %{name: "Test", login_url: "#"}).

What about Bamboo instead of Swoosh?

Bamboo was popular before Phoenix adopted Swoosh as the default. As of Phoenix 1.6+, Swoosh is the standard — mix phx.new generates Swoosh config out of the box. Both work, but Swoosh has more active maintenance and better integration with the Phoenix ecosystem.

How do I send attachments?

With Swoosh, use the attachment/2 function:

import Swoosh.Email
 
new()
|> to("user@example.com")
|> from({"Your App", "noreply@yourdomain.com"})
|> subject("Your invoice")
|> html_body("<p>Invoice attached.</p>")
|> attachment(Swoosh.Attachment.new("/path/to/invoice.pdf"))

For direct API calls, you'd need to Base64-encode the file and include it in the request body. Check your provider's API docs for the exact format.

How do I avoid sending test emails to real users?

In your test config, use Swoosh.Adapters.Test. In development, use Swoosh.Adapters.Local with the mailbox viewer. For staging, set up a catch-all that redirects all emails to your team's inbox. You can do this with a Swoosh middleware or by overriding the to field in non-production environments.

What's the difference between compile_env and runtime config?

Application.compile_env/3 reads config at compile time and bakes it into the release. Application.fetch_env!/2 with config/runtime.exs reads environment variables when the app starts. For secrets like API keys, always use runtime config so you can change them without rebuilding.

Wrapping Up

Here's what we covered:

  1. Swoosh or Req — two approaches to sending email in Elixir
  2. EEx templates with layouts for maintainable HTML emails
  3. Phoenix controllers and LiveView for sending from different contexts
  4. Pattern matching for robust error handling
  5. Oban for reliable background sending with retries, scheduling, and uniqueness
  6. Stripe webhooks with HMAC signature verification
  7. Testing with Swoosh test adapter and Mox
  8. Production checklist: domain verification, runtime config, telemetry, rate limiting

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.

Frequently Asked Questions

Should I use Swoosh or Bamboo for email in Elixir?

Swoosh is the modern standard and is included in new Phoenix projects by default. It has a cleaner API, better maintainability, and first-class support from the Phoenix team. Bamboo is older and less actively maintained. Use Swoosh for new projects.

How do I send emails asynchronously in Phoenix?

Use Oban for background job processing. Create an Oban worker that sends the email and enqueue the job from your context or controller. Oban handles retries, scheduling, and concurrency. For simpler cases, Task.Supervisor.async_nolink/2 works but lacks retry logic.

How do I configure different email adapters for dev, test, and production?

Set the adapter in each environment's config file. Use Swoosh.Adapters.Local for development (viewable at /dev/mailbox), Swoosh.Adapters.Test for tests, and your provider's adapter (e.g., Swoosh.Adapters.Sendgrid) for production. This is idiomatic Elixir config management.

Can I preview emails in development with Phoenix?

Yes. Swoosh includes a local mailbox viewer. Add forward "/mailbox", Plug.Swoosh.MailboxPreview to your router (dev only) and configure the Local adapter. All emails sent in development appear in a web UI at /mailbox.

How do I test email sending in Elixir?

Use Swoosh's test adapter and assert_email_sent/1 macro. In your test, call the function that sends the email, then assert on the recipient, subject, and body. Swoosh captures all emails in test mode for assertion without any mocking needed.

How do I use Phoenix templates for email content?

Create email templates as .heex files in a dedicated email template directory. Render them with Phoenix.Template.render_to_string/3 in your email module. This gives you full HEEx syntax including components and assigns for dynamic content.

How do I handle email delivery failures in Elixir?

Pattern match on the result of Mailer.deliver/1 to handle {:ok, _} and {:error, reason} cases. For retries, use Oban's built-in retry mechanism with configurable backoff. Log errors with Logger and set up telemetry events for monitoring.

How do I send emails with attachments in Swoosh?

Use Swoosh.Email.attachment/2 to add files: email |> attachment(%Swoosh.Attachment{path: "/path/to/file.pdf"}). You can also pass binary content directly. Swoosh handles encoding and MIME type detection automatically.

How do I rate limit email sending in Phoenix?

Use Hammer for rate limiting at the application level. Create a plug that checks the rate limit before allowing the email endpoint to process. For provider-level limits, Oban's rate limiting features control how many email jobs run per time window.

Can I use LiveView for email forms that trigger sends?

Yes. Handle the form submission in your LiveView's handle_event/3 callback and call your email-sending context function. LiveView gives you real-time form validation and immediate feedback without full page reloads. Keep the email send itself in a background job via Oban.