Skip to content

Send emails with Elixir

Learn how to send transactional emails using PostStack and Elixir.

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.

1. Add dependencies

bash
# 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
  ]
end

2. Configure 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")
end

3. Send an email with Req

elixir
defmodule 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_..."}

4. Handle errors

Elixir idioms for error handling, retries, and structured logging when sending through PostStack.

elixir
defmodule 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.

elixir
# 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
end

Build 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.

elixir
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.

elixir
# 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
end

Framework 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.

Notes

  • Phoenix apps already use Swoosh — point `Swoosh.Adapters.SMTP` at `smtp.poststack.dev` and keep your mailer modules
  • For the REST API, `Req` is all you need; `retry: :transient` is required for POST retries (Req only retries GET/HEAD by default)
  • Read the key in `config/runtime.exs` so releases pick it up at boot

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.

Related guides

Ready to send emails with Elixir?

Create a free account and get your API key in under a minute.