Send email with Elixir
Send transactional email from Elixir through PostStack.
There are two good ways to send email from Elixir with PostStack. Phoenix generates a Swoosh mailer for every new app, so the least-change path is Swoosh’s SMTP adapter pointed at smtp.poststack.dev — your Swoosh.Email builders, previews and tests stay the same. When you want the email id back (to match delivery and bounce webhooks), stored templates, scheduling or batch sends, call the REST API with Req, ideally from an Oban job so retries survive restarts. Both paths are below, plus the TLS options that trip up gen_smtp on recent OTP versions.
Quickstart#
Add dependencies
elixir# mix.exs — pick the path you need defp deps do [ {:req, "~> 0.5"}, # REST API {:swoosh, "~> 1.16"}, # Phoenix's mailer (already present in new Phoenix apps) {:gen_smtp, "~> 1.2"} # required by Swoosh.Adapters.SMTP ] endConfigure the API key at runtime
elixir# config/runtime.exs — read secrets at boot, not at compile time import Config if config_env() == :prod do config :my_app, :poststack_api_key, System.fetch_env!("POSTSTACK_API_KEY") endSend an email with Req
elixirdefmodule MyApp.PostStack do @moduledoc "Minimal client for the PostStack REST API." def send_email(payload) do [ base_url: "https://api.poststack.dev", auth: {:bearer, Application.fetch_env!(:my_app, :poststack_api_key)}, retry: :transient, # retries 408/429/5xx and network errors, honouring Retry-After max_retries: 3 ] |> Req.new() # One idempotency key for all retries, so a retry never delivers twice. |> Req.post(url: "/emails", json: payload, headers: [{"idempotency-key", Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)}]) |> case do {:ok, %Req.Response{status: 202, body: %{"id" => id}}} -> {:ok, id} {:ok, %Req.Response{} = resp} -> {:error, resp} {:error, exception} -> {:error, exception} end end end # usage MyApp.PostStack.send_email(%{ from: "Acme <hello@yourdomain.com>", # must be on a verified domain to: ["user@example.com"], # always a list subject: "Hello from Elixir!", html: "<h1>Welcome!</h1>", text: "Welcome!" }) #=> {:ok, "em_..."}Handle errors
elixirdefmodule MyApp.Workers.SendEmail do use Oban.Worker, queue: :mailers, max_attempts: 5 @impl Oban.Worker def perform(%Oban.Job{args: payload}) do case MyApp.PostStack.send_email(payload) do {:ok, _email_id} -> :ok # 400 invalid payload, 401/403 bad key, 422 unverified domain or # suppressed recipient — retrying cannot fix these {:error, %Req.Response{status: status, body: body} = resp} when status in 400..499 and status != 429 -> request_id = Req.Response.get_header(resp, "x-request-id") |> List.first() {:cancel, "PostStack #{status}: #{inspect(body)} (request #{request_id})"} # 429/5xx after Req's retries, or a network error: let Oban back off and retry {:error, reason} -> {:error, reason} end end end # enqueue from a controller or LiveView without blocking it %{from: "hello@yourdomain.com", to: [user.email], subject: "Welcome", html: "<h1>Hi</h1>"} |> MyApp.Workers.SendEmail.new() |> Oban.insert()
Phoenix: Swoosh with the SMTP adapter#
Configure the mailer Phoenix generated (MyApp.Mailer). Port 587 uses STARTTLS (tls: :always); for implicit TLS on 465 use ssl: true instead. The explicit tls_options make certificate verification work on OTP 26+, where gen_smtp otherwise fails with a hostname or unknown_ca TLS error.
# config/runtime.exs
if config_env() == :prod do
config :my_app, MyApp.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: "smtp.poststack.dev",
port: 587,
username: "poststack", # any non-empty value
password: System.fetch_env!("POSTSTACK_API_KEY"),
auth: :always,
tls: :always,
ssl: false,
retries: 2,
tls_options: [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
server_name_indication: ~c"smtp.poststack.dev",
depth: 3,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
end
# lib/my_app/mailer.ex (generated by Phoenix)
defmodule MyApp.Mailer do
use Swoosh.Mailer, otp_app: :my_app
endBuild and deliver an email with Swoosh (HTML, attachments, headers)#
Swoosh.Email builds the message; MyApp.Mailer.deliver/1 sends it. PostStack accepts up to 10 attachments, 10 MB per file and 25 MB in total, and reads X-PostStack-Tags and X-PostStack-Idempotency-Key headers on relayed mail.
defmodule MyApp.UserEmail do
import Swoosh.Email
def welcome(user) do
new()
|> to({user.name, user.email})
|> from({"Acme", "hello@yourdomain.com"})
|> reply_to("support@yourdomain.com")
|> subject("Welcome to Acme")
|> html_body("<h1>Welcome, #{user.name}!</h1>")
|> text_body("Welcome, #{user.name}!")
|> header("X-PostStack-Tags", "welcome,transactional")
end
def receipt(order, pdf_binary) do
new()
|> to(order.email)
|> from({"Acme Billing", "billing@yourdomain.com"})
|> subject("Receipt for order #{order.number}")
|> text_body("Your receipt is attached.")
|> attachment(
Swoosh.Attachment.new({:data, pdf_binary},
filename: "receipt-#{order.number}.pdf",
content_type: "application/pdf"
)
)
|> header("X-PostStack-Idempotency-Key", "receipt-#{order.id}")
end
end
# deliver
{:ok, _} = user |> MyApp.UserEmail.welcome() |> MyApp.Mailer.deliver()Development and tests#
Phoenix defaults to Swoosh.Adapters.Local in dev (view mail at /dev/mailbox) and Swoosh.Adapters.Test in tests, so only prod talks to PostStack. For staging, use the SMTP config with an sk_test_ key: mail is accepted and logged but never delivered.
# config/test.exs (Phoenix default)
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Test
# test/my_app/user_email_test.exs
defmodule MyApp.UserEmailTest do
use ExUnit.Case, async: true
import Swoosh.TestAssertions
test "welcome email" do
user = %{name: "Ada", email: "ada@example.com"}
user |> MyApp.UserEmail.welcome() |> MyApp.Mailer.deliver()
assert_email_sent(subject: "Welcome to Acme", to: {"Ada", "ada@example.com"})
end
endFramework integrations#
Phoenix + Swoosh (SMTP)
Swoosh’s SMTP adapter sends every existing mailer through PostStack. Configure it in config/runtime.exs so the API key is read when the release boots.
Oban for durable sends
Wrap REST sends in an Oban worker: jobs survive restarts, retry with backoff, and {:cancel, reason} stops retries for permanent 4xx errors. Swoosh deliveries can be wrapped the same way.
Phoenix LiveView
Never call the API or SMTP synchronously in handle_event — enqueue an Oban job or use Task.Supervisor.start_child/2 so the LiveView process stays responsive.
Broadway / GenStage fan-out
For large batches, prefer POST /emails/batch (up to 100 emails per request) from a Broadway processor; backpressure keeps you inside the API rate limit.
Common pitfalls#
TLS errors from gen_smtp on OTP 26+
Delivery errors mentioning tls_failed, hostname_check_failed or unknown_ca usually mean tls_options are missing. Pass verify: :verify_peer, cacerts: :public_key.cacerts_get(), server_name_indication and the HTTPS hostname match fun as shown above.
Authentication failures (auth_failed, 535)
The SMTP password must be a PostStack API key (sk_live_… or sk_test_…). Check that POSTSTACK_API_KEY is set in the environment the release runs in.
"Domain '…' not found for this team"
The From address must be on a domain verified in PostStack — check the from/2 call in every email builder.
Reading the key at compile time
System.get_env in config/config.exs or Application.compile_env runs when the release is built, not when it starts. Use config/runtime.exs and Application.fetch_env!/2.
POST requests are not retried
Req’s default retry: :safe_transient retries only GET and HEAD. Set retry: :transient for sends — safe here because the idempotency key makes a repeated POST a replay, not a second email.
FAQ#
How do I send email from Phoenix with PostStack?
Keep the Swoosh mailer Phoenix generated, add {:gen_smtp, "~> 1.2"}, and configure Swoosh.Adapters.SMTP with relay smtp.poststack.dev, port 587, tls: :always, username poststack and your API key as the password in config/runtime.exs.
Swoosh SMTP or the REST API?
SMTP through Swoosh keeps existing mailers unchanged. The REST API (with Req) returns the email id, supports stored templates, scheduled sends and batches of up to 100, and works where outbound SMTP ports are blocked.
Does Bamboo work too?
Yes, through bamboo_smtp with the same host, port, username and password. New projects generally use Swoosh, the Phoenix default.
How do I retry failed sends?
Run the send in an Oban worker with max_attempts. Return {:error, reason} for 429/5xx so Oban backs off and retries, and {:cancel, reason} for other 4xx errors that a retry cannot fix.
Ready to send from Elixir?
Create a free account and an API key — no card needed.