Send email from Phoenix
A Phoenix app generated in the last few years already has Swoosh and a MyApp.Mailer module. The clean integration is a Swoosh adapter, so every existing Swoosh.Email goes out over the emails.sh HTTP API without touching the emails themselves.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Read it in runtime.exs
System.fetch_env!("EMAILSSH_API_KEY") in config/runtime.exs. config.exs is evaluated at compile time, so a key read there gets baked into the release image.
- 03
Add the adapter
lib/my_app/emailssh_adapter.ex below implements the Swoosh.Adapter behaviour, which is one deliver/2 callback.
- 04
Point your Mailer at it
config :my_app, MyApp.Mailer, adapter: MyApp.EmailsshAdapter. Your existing MyApp.Mailer.deliver/1 calls need no change.
{:swoosh, "~> 1.16"}, {:req, "~> 0.5"}import Config
if config_env() == :prod do
config :my_app, MyApp.Mailer,
adapter: MyApp.EmailsshAdapter,
api_key: System.fetch_env!("EMAILSSH_API_KEY")
end
# In dev, keep Swoosh's local adapter so mail lands in the preview at
# http://localhost:4000/dev/mailbox instead of a real inbox.
if config_env() == :dev do
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Local
endThe Swoosh adapter
lib/my_app/emailssh_adapter.ex (Swoosh 1.16)
defmodule MyApp.EmailsshAdapter do
@moduledoc """
Swoosh adapter for the emails.sh HTTP API.
Implementing the behaviour rather than calling the API from a context means
every existing Swoosh.Email in the app, including whatever a generator wrote,
goes out over emails.sh with no other change.
"""
use Swoosh.Adapter, required_config: [:api_key]
@endpoint "https://emails.sh/v1/emails"
@impl Swoosh.Adapter
def deliver(%Swoosh.Email{} = email, config) do
body =
%{
from: address(email.from),
to: Enum.map(email.to, &address/1),
subject: email.subject,
html: email.html_body,
text: email.text_body
}
|> put_unless_empty(:cc, Enum.map(email.cc || [], &address/1))
|> put_unless_empty(:bcc, Enum.map(email.bcc || [], &address/1))
|> put_unless_empty(:reply_to, email.reply_to && address(email.reply_to))
|> Map.reject(fn {_key, value} -> is_nil(value) end)
case Req.post(@endpoint,
json: body,
auth: {:bearer, Keyword.fetch!(config, :api_key)},
receive_timeout: 15_000
) do
{:ok, %{status: status, body: %{"id" => id}}} when status in 200..299 ->
{:ok, %{id: id}}
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
defp address({nil, email}), do: email
defp address({name, email}), do: "#{name} <#{email}>"
defp address(email) when is_binary(email), do: email
defp put_unless_empty(map, _key, nil), do: map
defp put_unless_empty(map, _key, []), do: map
defp put_unless_empty(map, key, value), do: Map.put(map, key, value)
endThe email and how you send it
lib/my_app/accounts/user_notifier.ex
defmodule MyApp.Accounts.UserNotifier do
import Swoosh.Email
alias MyApp.Mailer
def deliver_welcome(user) do
new()
|> to({user.name, user.email})
|> from({"Acme", "hello@acme.com"})
|> subject("Welcome to Acme")
|> html_body("<p>Hi #{Phoenix.HTML.html_escape(user.name) |> Phoenix.HTML.safe_to_string()}, your Acme account is ready.</p>")
|> text_body("Hi #{user.name}, your Acme account is ready.")
|> Mailer.deliver()
end
@doc """
Sends without holding up the caller. Task.Supervisor is already in the
supervision tree of a generated Phoenix app as MyApp.TaskSupervisor.
"""
def deliver_welcome_async(user) do
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> deliver_welcome(user) end)
end
endWorth knowing
Read the key in runtime.exs, not config.exs
config.exs is evaluated when you compile, so a value read there is frozen into the release and the same for every environment. runtime.exs runs on boot, which is what you want for a secret.
Keep the Local adapter in dev
Swoosh.Adapters.Local plus the mailbox preview at /dev/mailbox lets you see rendered mail without sending. Only prod needs the real adapter.
Mailer.deliver blocks the caller
In a LiveView or controller that is a request the user is waiting on. Wrap it in Task.Supervisor.start_child, or use Oban if the send must survive a node restart.
Swoosh addresses are tuples
{"Acme", "hello@acme.com"}, not a formatted string, which is why the adapter has an address/1 that turns both shapes into the "Name <addr>" the API expects.
Questions
How do I send email from Phoenix without SMTP?
Write a Swoosh adapter over the HTTP API, as above. No SMTP configuration, no connection pool, and it works on hosts that block outbound mail ports.
Do I have to use Swoosh?
No. Req.post to https://emails.sh/v1/emails from a context module works. Swoosh is worth it because a generated Phoenix app already builds Swoosh.Email structs.
How do I test emails in Phoenix?
Keep Swoosh.Adapters.Test in config/test.exs and use assert_email_sent/1 from Swoosh.TestAssertions. The adapter is never called in tests.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.