Skip to content

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#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. 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
      ]
    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
    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()

Good to know

  • 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

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.

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.

Get started free

Next steps

Was this page helpful?