← Back to blog
·6 min read·
Vanessa LozzardoVanessa Lozzardo

How to send email with Elixir using an API or SMTP

Send email from Elixir apps using the Sendkit SDK. Covers setup, HTML email, error handling, SMTP, and production tips.

elixiremail-apitransactional-emailtutorial
How to send email with Elixir using an API or SMTP

Elixir has gen_smtp and :ssl for sending email, but wiring them together means writing MIME construction code, managing connection pools, and handling retry logic that has nothing to do with your actual app. All that just to send a password reset.

An email API skips the plumbing. This guide walks through sending email from Elixir with the Sendkit email API: basic sends, HTML messages, error handling, and SMTP as a fallback option.

Install the SDK

Add sendkit to your dependencies in mix.exs:

def deps do
  [
    {:sendkit, "~> 1.0"}
  ]
end

Then fetch it:

mix deps.get

Grab an API key from your Sendkit dashboard. Keys starting with sk_live_ send real email. Keys starting with sk_test_ run the same validation pipeline but don't deliver anything, which is useful for tests.

Configure the client

Create a client with your API key:

client = Sendkit.new("sk_live_your_api_key")

Don't hardcode the key. Read it from the environment instead:

client = Sendkit.new()

This pulls from the SENDKIT_API_KEY environment variable. In a Phoenix app, you'd typically set this in config/runtime.exs or your deployment's environment.

Send a plain text email

The simplest send:

{:ok, %{"id" => id}} =
  Sendkit.Emails.send(client, %{
    from: "[email protected]",
    to: ["[email protected]"],
    subject: "Your order has shipped",
    text: "Order #4821 shipped via FedEx. Tracking number: 7489201384."
  })

IO.puts("Sent: #{id}")

The from address must be on a domain you've verified in Sendkit. If you haven't set that up, the DMARC, DKIM, and SPF guide walks through the DNS records.

The to field takes a list of email address strings.

Send HTML email

Most transactional email needs formatting. Pass both text and html so clients that can't render HTML still display something useful:

defmodule MyApp.Email do
  def send_welcome(user_email, user_name) do
    client = Sendkit.new()

    Sendkit.Emails.send(client, %{
      from: "[email protected]",
      to: [user_email],
      subject: "Welcome, #{user_name}",
      text: "Hey #{user_name}, thanks for signing up. Your account is ready.",
      html: """
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h2>Welcome, #{user_name}</h2>
        <p>Your account is set up and ready to go.</p>
        <p>A few things to try first:</p>
        <ul>
          <li>Complete your profile</li>
          <li>Connect your first integration</li>
        </ul>
        <a href="https://app.yoursite.com/getting-started"
           style="background: #0066ff; color: white; padding: 12px 24px;
                  text-decoration: none; border-radius: 4px;
                  display: inline-block; margin-top: 16px;">
          Get started
        </a>
      </div>
      """
    })
  end
end

Use inline CSS. Email clients strip <style> blocks and ignore external stylesheets, so inline is the only reliable option across Gmail, Outlook, and Apple Mail.

Email notification on a laptop screen

Wrapping it in a GenServer

In a real Elixir app, you probably don't want to block your request process on an HTTP call to an email API. A simple GenServer gives you async sending with built-in retries:

defmodule MyApp.Mailer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def send_async(params) do
    GenServer.cast(__MODULE__, {:send, params})
  end

  @impl true
  def init(_opts) do
    {:ok, %{client: Sendkit.new()}}
  end

  @impl true
  def handle_cast({:send, params}, %{client: client} = state) do
    case Sendkit.Emails.send(client, params) do
      {:ok, %{"id" => id}} ->
        IO.puts("Email sent: #{id}")

      {:error, %Sendkit.Error{status_code: 429}} ->
        Process.send_after(self(), {:retry, params}, 60_000)

      {:error, %Sendkit.Error{name: name, message: message}} ->
        IO.puts("Failed to send: #{name} - #{message}")
    end

    {:noreply, state}
  end

  @impl true
  def handle_info({:retry, params}, state) do
    handle_cast({:send, params}, state)
    {:noreply, state}
  end
end

Add it to your supervision tree and call MyApp.Mailer.send_async/1 from your controllers or context modules. The email goes out in a separate process, so the user's request doesn't wait for the API round trip.

Error handling

The SDK returns {:ok, result} or {:error, %Sendkit.Error{}} tuples, so you can pattern match on them like anything else in Elixir. Here's how to handle the common failure modes:

def send_email_safely(client, params) do
  case Sendkit.Emails.send(client, params) do
    {:ok, %{"id" => id}} ->
      {:ok, id}

    {:error, %Sendkit.Error{status_code: 422, message: message}} ->
      # Bad input: invalid address, missing required field
      IO.puts("Validation error: #{message}")
      {:error, :invalid_params}

    {:error, %Sendkit.Error{status_code: 429, message: message}} ->
      # Rate limited - back off and retry
      IO.puts("Rate limited: #{message}")
      {:error, :rate_limited}

    {:error, %Sendkit.Error{status_code: code}} when code >= 500 ->
      # Server-side failure - safe to retry
      IO.puts("Server error: #{code}")
      {:error, :server_error}

    {:error, %Sendkit.Error{name: name, message: message}} ->
      IO.puts("API error (#{name}): #{message}")
      {:error, :api_error}
  end
end

For production systems, add retry logic with exponential backoff:

def send_with_retry(client, params, attempts \\ 3)

def send_with_retry(_client, _params, 0) do
  {:error, :max_retries_exceeded}
end

def send_with_retry(client, params, attempts) do
  case send_email_safely(client, params) do
    {:ok, id} ->
      {:ok, id}

    {:error, reason} when reason in [:rate_limited, :server_error] ->
      delay = :math.pow(2, 4 - attempts) |> round() |> :timer.seconds()
      Process.sleep(delay)
      send_with_retry(client, params, attempts - 1)

    {:error, reason} ->
      {:error, reason}
  end
end

Only retry on transient failures. A 422 means the request itself is wrong, and sending it again won't help.

Code on a terminal screen

Sending raw MIME messages

If you already have a MIME-encoded message (from Swoosh, Bamboo, or another library), you can send it directly:

{:ok, %{"id" => id}} =
  Sendkit.Emails.send_mime(client, %{
    envelope_from: "[email protected]",
    envelope_to: "[email protected]",
    raw_message: mime_string
  })

This is handy when migrating from another provider. You keep your existing email construction logic and just swap the transport layer.

Using SMTP instead of the API

If you're using Swoosh or Bamboo and don't want to write a custom adapter, Sendkit also provides an SMTP relay. Point your existing SMTP config at Sendkit's servers:

# config/config.exs (Swoosh example)
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.SMTP,
  relay: "smtp.sendkit.com",
  port: 587,
  username: "sendkit",
  password: "sk_live_your_api_key",
  tls: :if_available,
  auth: :always

Your API key works as the SMTP password. No separate credentials.

The API is still the better choice for new projects. There's no SMTP handshake overhead, you get a message ID back immediately, and errors come as structured data instead of SMTP reply codes. But SMTP works well when you need to plug into existing infrastructure.

Deliverability matters more than the send call

Getting the send/2 call working is the quick part. Whether the email lands in the inbox is a different problem entirely.

First, authenticate your domain. DKIM, SPF, and DMARC are non-negotiable at this point. Gmail and Outlook will either spam-folder or outright reject unauthenticated messages. The DMARC/DKIM/SPF guide covers the DNS records you need.

Second, pay attention to bounces. If you keep sending to addresses that don't exist, your sender reputation takes a hit, and that drags down delivery for every email you send. Sendkit auto-suppresses bounced addresses, but you should monitor your bounce rate in your own logs too.

Third, validate addresses before sending. If you accept user input for email addresses, run a validation call first. It catches typos like gmial.com and disposable addresses that will never engage. Sendkit has built-in validation for this, and there's a longer guide on validating email addresses before sending.

The Sendkit docs have more on webhooks, templates, and batch sending.

That's it

Add sendkit to your mix.exs, call Sendkit.Emails.send/2, handle the error tuples. Working email.

The code is the easy part. Deliverability is the ongoing work: authenticate your domain, don't ignore bounces, validate addresses. A few lines of Elixir get the email out. Keeping your sender reputation intact is where the real effort goes.

Share this article