Envello
Tutorial

Sending transactional email from Elixir/Phoenix with Envello

Envello Team·2026-07-29·8 min read

Phoenix applications typically use Bamboo or Swoosh for email, both of which abstract over SMTP transports. For transactional email, a direct HTTP call to Envello's API gives you structured delivery events, queryable logs, and bounce handling without SMTP configuration. Elixir's concurrency model makes the async sending pattern natural.

Prerequisites

A Phoenix 1.7+ project, Req (HTTP client) in your dependencies, an Envello account with a verified domain, and your API key in runtime config.

  • Add {:req, "~> 0.5"} to mix.exs deps
  • Add envello_api_key and envello_from to your runtime config (config/runtime.exs)
  • Read from environment: System.get_env("ENVELLO_API_KEY") || raise "ENVELLO_API_KEY not set"

Create the email module

Create a module that wraps the API call with proper error handling.

  • defmodule YourApp.Email do
  • A send/1 function that accepts a map with :to, :subject, :html, and optional :text
  • Build the request: Req.post!("https://api.envello.dev/emails", json: body, headers: [{"authorization", "Bearer #{api_key}"}])
  • Pattern match on the response: %Req.Response{status: status} when status in 200..299 returns {:ok, body}
  • Status 400 returns {:error, :validation, body} with the error details
  • Status 429 returns {:error, :rate_limited}
  • Status >= 500 returns {:error, :server_error}

Async sending with Task.Supervisor

For sends triggered by a web request (password reset, signup confirmation), run the API call asynchronously so the Phoenix response returns immediately.

  • Add a Task.Supervisor to your application's supervision tree: {Task.Supervisor, name: YourApp.EmailSupervisor}
  • In your controller or LiveView: Task.Supervisor.start_child(YourApp.EmailSupervisor, fn -> YourApp.Email.send(params) end)
  • The supervised task runs in its own process; if it crashes, it's isolated from the request process
  • For fire-and-forget sends where you don't need the result, this is the simplest approach

Job queuing with Oban

For production applications that need guaranteed delivery, retry logic, and observability, use Oban to queue email sends as background jobs.

  • Add {:oban, "~> 2.18"} to mix.exs and configure the Oban instance in your application supervisor
  • Create an Oban worker: defmodule YourApp.Workers.SendEmail do use Oban.Worker, queue: :email, max_attempts: 3
  • Implement perform/1 to call YourApp.Email.send/1 with the job args
  • Return :ok on success, {:error, reason} on failure (Oban retries automatically with backoff)
  • Enqueue from your controller: %{to: email, subject: subject, html: html} |> YourApp.Workers.SendEmail.new() |> Oban.insert()
  • Oban persists jobs in Postgres, so sends survive application restarts

Structured telemetry

Elixir's telemetry library gives you structured observability for every email send without polluting your application logs.

  • Emit telemetry events from your email module: :telemetry.execute([:your_app, :email, :send], %{duration: duration}, %{to_domain: domain, status: status})
  • Log the recipient's domain, not the full address (PII)
  • Attach a telemetry handler that logs to your structured logging pipeline (Logger with JSON formatter)
  • Track metrics: sends per minute, error rate by type, p99 latency
  • If using Oban, it emits its own telemetry events for job execution, retry, and failure

Rendering email content

Phoenix's own template engine (HEEx) is designed for live HTML rendering, not email. For email HTML, use a separate approach.

EEx (Embedded Elixir) templates work well for email: create .eex files in a priv/email_templates/ directory and render with EEx.eval_file/2. Pass variables as a keyword list binding.

For complex responsive layouts, author templates in MJML and compile to HTML as a build step. Store the compiled HTML templates in priv/ and render with EEx variable injection at send time.

Keep it simple: most transactional emails (password reset, verification code, receipt) are short enough that a template string in the module works fine.

Webhook endpoint

Add a Phoenix controller to receive Envello delivery event webhooks.

  • Create a controller: YourAppWeb.WebhookController
  • Add a route: post "/webhooks/envello", WebhookController, :envello
  • Read the raw body (you'll need a custom Plug.Parsers setup or a body reader plug that caches the raw body for signature verification)
  • Verify the HMAC-SHA256 signature: :crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower)
  • Compare with the x-envello-signature header (use Plug.Crypto.secure_compare/2 to prevent timing attacks)
  • Handle event types: delivered, bounced (suppress address in your Ecto schema), complained (suppress)
  • Return conn |> put_status(200) |> json(%{received: true})

Suppression in Ecto

Track suppressed addresses in your database so your application doesn't attempt sends that will fail.

  • Create an email_suppressions table with columns: email (unique index), reason (bounced/complained), suppressed_at
  • Before sending, check: if Repo.exists?(from s in EmailSuppression, where: s.email == ^to), skip the send
  • In the webhook handler, insert a suppression record on bounce or complaint events
  • Surface suppressions in your admin dashboard so support staff can see why an email wasn't sent

Testing

Use Mox to define a mock for your email module's behavior. In tests, verify the send function receives the correct arguments without making real API calls.

For integration tests, use Envello's test mode. The API accepts and validates the request but doesn't deliver to real inboxes, so you can test the full Oban job flow end-to-end.

Oban provides Oban.Testing for asserting that jobs were enqueued with the right arguments, which is usually sufficient for testing the email sending path without involving the API at all.

Free tool

Check your domain's SPF, DKIM, and DMARC records

Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.

Check your domain →
Envello

EU-hosted transactional email, done right by default.