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

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:
defp deps do
[
{:phoenix, "~> 1.7"},
{:req, "~> 0.5"},
# Req is all you need — direct API calls
]
enddefp deps do
[
{:phoenix, "~> 1.7"},
{:swoosh, "~> 1.16"},
# Swoosh includes a built-in Resend adapter
]
enddefp deps do
[
{:phoenix, "~> 1.7"},
{:swoosh, "~> 1.16"},
# Swoosh includes a built-in SendGrid adapter
]
endThen fetch dependencies:
mix deps.getAdd 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 :my_app, :sequenzy_api_key,
System.get_env("SEQUENZY_API_KEY") ||
raise "SEQUENZY_API_KEY not set"# 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 :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):
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
enddefmodule 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
enddefmodule 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
endSend Your First Email
The simplest possible email. No template, just getting something out the door.
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
enddefmodule 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
enddefmodule 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
endAdd the route in your router:
# lib/my_app_web/router.ex
scope "/api", MyAppWeb do
pipe_through :api
post "/send", EmailController, :send
endTest it:
curl -X POST http://localhost:4000/api/sendBuild 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>© <%= 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
endMove 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:
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
enddefmodule 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
enddefmodule 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
endCompile-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
endPhoenix 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:
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
enddefmodule 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
enddefmodule 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
endImportant: 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
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>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()
enddef 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()
endPayment Receipt
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>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()
enddef 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()
endStripe 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.
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
enddefmodule 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
enddefmodule 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
endYou 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
endError Handling
Elixir's pattern matching makes error handling elegant. Build a structured error system:
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
enddefmodule 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
enddefmodule 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
endBackground 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:
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()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()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)
endRun 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)
endScheduled 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
endWith 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
endTesting 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
endGoing 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"
end3. 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
end6. 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
endNow 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:
- Swoosh or Req — two approaches to sending email in Elixir
- EEx templates with layouts for maintainable HTML emails
- Phoenix controllers and LiveView for sending from different contexts
- Pattern matching for robust error handling
- Oban for reliable background sending with retries, scheduling, and uniqueness
- Stripe webhooks with HMAC signature verification
- Testing with Swoosh test adapter and Mox
- 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.