A sandbox adapter for email delivery in tests, analogous to
Ecto.Adapters.SQL.Sandbox.
Each test registers as an owner via checkout/0. All emails delivered from
that test process or its $callers chain are routed to the test's private
inbox. This makes email assertions safe with async: true.
Compatible with Swoosh.TestAssertions — the owner process receives
{:email, email} messages so assert_email_sent/1 continues to work.
Setup
In config/test.exs:
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Sandbox
# Disable the HTTP API client — it is not needed for test adapters.
config :swoosh, :api_client, falseIn test/test_helper.exs, start the storage process before ExUnit.start():
{:ok, _} = Swoosh.Adapters.Sandbox.Storage.start_link([])Unit and integration tests
Call checkout/0 in setup and checkin/0 on exit. Tests can be
async: true — each test process has its own isolated inbox.
setup do
:ok = Swoosh.Adapters.Sandbox.checkout()
on_exit(&Swoosh.Adapters.Sandbox.checkin/0)
endPhoenix integration (async-safe browser tests)
In browser/E2E tests the web server spawns request-handling processes
independently, so they have no $callers ancestry back to the test process.
A plug and a LiveView hook solve this: each process is explicitly allowed into
the test's sandbox via a token embedded in the user-agent header.
With PhoenixTest.Playwright
PhoenixTest.Playwright 0.13+
unconditionally sets the browser user-agent to
BeamMetadata (...) Ecto metadata. Derive the Swoosh sandbox owner from
that metadata rather than embedding a separate token.
1. Add a plug to your endpoint (e.g. lib/my_app_web/plug/swoosh_sandbox.ex):
defmodule MyAppWeb.Plug.SwooshSandbox do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
conn
|> get_req_header("user-agent")
|> List.first()
|> allow()
conn
end
defp allow(nil), do: :ok
defp allow(user_agent) do
case Phoenix.Ecto.SQL.Sandbox.decode_metadata(user_agent) do
%{owner: owner_pid} -> Swoosh.Adapters.Sandbox.allow(owner_pid, self())
_ -> :ok
end
end
endRegister it in endpoint.ex, guarded by the test environment:
if Application.compile_env(:my_app, :swoosh_sandbox, false) do
plug MyAppWeb.Plug.SwooshSandbox
end2. Add a LiveView hook (e.g. lib/my_app_web/live/live_allow_swoosh_sandbox.ex)
and mount it on any live session that delivers email:
defmodule MyAppWeb.LiveAllowSwooshSandbox do
import Phoenix.LiveView
def on_mount(:default, _params, _session, socket) do
if connected?(socket) do
case Phoenix.Ecto.SQL.Sandbox.decode_metadata(
get_connect_info(socket, :user_agent)
) do
%{owner: owner_pid} -> Swoosh.Adapters.Sandbox.allow(owner_pid, self())
_ -> :ok
end
end
{:cont, socket}
end
endThen mount it:
live_session :default,
on_mount: [MyAppWeb.LiveAllowSwooshSandbox] do
...
end3. Configure the endpoint to pass the user-agent to LiveView connect_info:
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [:user_agent, ...]]4. Enable the plug in config/test.exs:
config :my_app, :swoosh_sandbox, true5. In your test setup, check out the sandbox. No encode_owner() or
custom user_agent key is needed — PTP supplies the Ecto metadata automatically:
setup do
:ok = Swoosh.Adapters.Sandbox.checkout()
on_exit(&Swoosh.Adapters.Sandbox.checkin/0)
endWith Wallaby
Wallaby lets you set the user-agent freely.
Use a plug that decodes the SwooshSandbox (...) token instead of Ecto metadata.
1. Add a plug to your endpoint (e.g. lib/my_app_web/plug/swoosh_sandbox.ex):
defmodule MyAppWeb.Plug.SwooshSandbox do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
conn
|> get_req_header("user-agent")
|> List.first()
|> allow()
conn
end
defp allow(nil), do: :ok
defp allow(user_agent) do
with [_, encoded] <- Regex.run(~r/SwooshSandbox \((\S+)\)/, user_agent),
{:ok, binary} <- Base.url_decode64(encoded),
pid when is_pid(pid) <- :erlang.binary_to_term(binary, [:safe]) do
Swoosh.Adapters.Sandbox.allow(pid, self())
end
:ok
end
endRegister it in endpoint.ex with the same guard as above.
2. Add a LiveView hook if any live sessions deliver email, following the
same pattern as the PhoenixTest.Playwright section but decoding with the
SwooshSandbox regex instead of Phoenix.Ecto.SQL.Sandbox.decode_metadata/1.
3-4. Same connect_info and config/test.exs steps as above.
5. In your test setup, pass encode_owner() as the --user-agent flag:
setup do
:ok = Swoosh.Adapters.Sandbox.checkout()
token = Swoosh.Adapters.Sandbox.encode_owner()
on_exit(&Swoosh.Adapters.Sandbox.checkin/0)
{:ok, session} =
Wallaby.start_session(
capabilities: %{"goog:chromeOptions": %{args: ["--user-agent=#{token}"]}}
)
on_exit(fn -> Wallaby.end_session(session) end)
{:ok, session: session}
endShared mode (simple alternative for non-async E2E tests)
If adding the plug is not practical, shared mode routes all unregistered
deliveries to a single owner. Note that shared mode requires async: false
since there is only one global shared owner at a time.
setup do
:ok = Swoosh.Adapters.Sandbox.set_shared(self())
on_exit(fn -> Swoosh.Adapters.Sandbox.set_shared(nil) end)
endAllowing other processes
If email is sent from a process not in the $callers chain of the test
(e.g. a background worker), allow it explicitly:
Swoosh.Adapters.Sandbox.allow(self(), worker_pid)Configuration options
:on_unregisteredwhat to do when email is delivered from a process with no registered owner. Defaults to:raise. Set to:ignoreto silently succeed.config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Sandbox, on_unregistered: :ignore
Summary
Functions
Returns all emails captured for the given owner.
Allows allowed_pid to deliver emails into owner_pid's sandbox inbox.
Unregisters the current process as a sandbox owner and clears its inbox.
Registers the current process as a sandbox owner.
Encodes the given owner pid into a user-agent token for browser tests.
Returns and removes all emails captured for the given owner.
Sets or clears the shared sandbox owner for non-async tests.
Callback implementation for Swoosh.Adapter.validate_config/1.
Callback implementation for Swoosh.Adapter.validate_dependency/0.
Functions
Returns all emails captured for the given owner.
Allows allowed_pid to deliver emails into owner_pid's sandbox inbox.
This is useful when a process that is not in the test process' $callers
chain sends email, such as a background worker or a browser-driven request.
Unregisters the current process as a sandbox owner and clears its inbox.
Registers the current process as a sandbox owner.
Emails delivered by this process, its $callers, or processes explicitly
allowed with allow/2 are stored in this owner's inbox.
Encodes the given owner pid into a user-agent token for browser tests.
Returns and removes all emails captured for the given owner.
Callback implementation for Swoosh.Adapter.validate_config/1.
Callback implementation for Swoosh.Adapter.validate_dependency/0.