Sending Emails with Swoosh and Oban

- 7 min read

Welcome back to another article! Let’s start with the mandatory dad joke:

Apparently you can’t use “beef stew” as a password.
It’s not stroganoff.

Alright, now let’s talk about a small but important topic: How to build emails with Swoosh and send them through Oban background workers.

The Problem

Almost all applications have to send emails. Whenever somebody registers or buys something, you send out emails. The problem with sending emails is that either you send them synchronously as part of a transaction or send them asynchronously after an action completes. For example, when a user clicks the “Buy now” button and your system has to handle the purchase action, you can either create the order and send the email in the same transaction or you only create the order and schedule the email for later.

The synchronous option has the benefit that you will never have an order for which you haven’t sent the confirmation email, but it also means that users can’t purchase anything if you can’t send the email. This is a significant risk. Mailing services don’t offer 100% availability and might refuse to send your email at random times (usually late at night on weekends so you won’t notice until Monday morning and probably lose your annual revenue in orders until then). Also, you might deploy a bug to the email sending code which now also breaks your purchase code. Fun!

The asynchronous option has the benefit that sending the email will never block the user from purchasing. It also makes the whole interaction faster because you don’t wait until the email is sent before letting the user know that they successfully bought their 10th CO2 monitor in a week! The downside of this option is your email might never get sent. For example, if you spin up a new process for sending the email and your server decides to shut down at exactly that moment, the process goes bye-bye, and the email is lost. It’s not great, but not as bad as blocking users from giving you money.

The Solution

So, we see that asynchronous email sending is preferable to synchronous sending because we like having money to pay rent and buy CO2 monitors. Let’s discuss how we can make the asynchronous sending as robust as possible. Whenever you think “robust” and “asynchronous”, the answer is often the Oban background processing library. Oban is amazing for working with background jobs. We can leverage them for scheduling and sending our emails. So, let’s dive into the code next.

Like what you read? Sign up for more!

The Code

As an example, we will refactor the email-sending code of an auto-generated Phoenix application. When you create a new Phoenix app with authentication, it comes with the UserNotfier module for sending authentication-related emails using the Swoosh library by default. It sends the emails synchronously for now, so let’s refactor it to use an Oban background worker instead.

The Mailer

Here’s the final code of the mailer. We’ll discuss it below:

# lib/accounts/user_notifier.ex
defmodule MyApp.Accounts.UserNotifier do
  defp deliver(recipient, subject, body) do
    email =
      new()
      |> to(recipient)
      |> from({"Your Name", "your-email.com"})
      |> subject(subject)
      |> text_body(body)

    with email_map <- MyApp.Mailer.to_map(email),
         {:ok, _job} <- enqueue_worker(email_map) do
      {:ok, email}
    end
  end

  defp enqueue_worker(email) do
    %{email: email}
    |> MyApp.Workers.SendEmail.new()
    |> Oban.insert()
  end

  # Public functions for sending emails

end

If you compare this version with the auto-generated one, you’ll see that only a few lines changed. Instead of calling MyApp.Mailer.deliver(email) directly, we call enqueue_worker(email_map) instead which inserts a new SendEmail worker into the Oban queue. Nothing you haven’t seen yet if you’ve ever worked with Oban.The unusual part is the MyApp.Mailer.to_map(email) function. The problem that this code solves is that our email is a Swoosh.Email-struct when we insert it as an argument for the Oban worker. However, Oban serializes all arguments to JSON before inserting them into the database. In Elixir, we can’t simply convert a struct to JSON. Also, the email struct uses tuples for some fields like the to-field which often looks like this: [{"recipient name", "recipient@email.com"}]. There’s no JSON equivalent for tuples, so we need to convert them first.

That’s where the Mailer.to_map(email) helper comes in. Let’s have a look at the implementation next:

The Converter

This is the full implementation of the Mailer module. It converts Swoosh.Email-structs to simple maps and simple maps back to Swoosh.Email-structs. It also “sanitizes” the tuples used in the contact fields so that we can serialize them to JSON.

# lib/mailer.ex
defmodule MyApp.Mailer do
  use Swoosh.Mailer, otp_app: :my_app

  def to_map(%Swoosh.Email{} = email) do
    %{
      "to" => contact_to_map(email.to),
      "from" => contact_to_map(email.from),
      "subject" => email.subject,
      "text_body" => email.text_body
    }
  end

  def from_map(args) do
    %{
      "to" => to,
      "from" => from,
      "subject" => subject,
      "text_body" => text_body
    } = args

    opts = [
      to: map_to_contact(to),
      from: map_to_contact(from),
      subject: subject,
      text_body: text_body
      # Add a text_html if needed.
    ]

    Swoosh.Email.new(opts)
  end

  defp contact_to_map(info) when is_list(info) do
    Enum.map(info, &contact_to_map/1)
  end

  defp contact_to_map({name, email}) do
    %{"name" => name, "email" => email}
  end

  defp map_to_contact(info) when is_list(info) do
    Enum.map(info, &map_to_contact/1)
  end

  defp map_to_contact(%{"name" => name, "email" => email}) do
    {name, email}
  end
end

These little helper functions allow us to create a Swoosh.Email in our UserNotifier and convert it to a map so that Oban can use it as an argument when inserting an Oban worker. Once the Oban worker is executed it needs to convert the map back to a Swoosh.Email-struct so that Swoosh can send it.

You might argue that this is not needed because we can insert the Oban worker with “raw” arguments like e.g. recipient email, redirect URL, any IDs to records we need to present in the email and let the worker build the email. There are a few problems with this approach which is why I prefer to build the email beforehand and only use the worker for sending the email.

The first problem is that our arguments might be incorrect and the worker can never build the email with them. For example, what if you need the user’s physical address in the email, but the user hasn’t provided it yet? Normally, you’d block sending the email and show an error to the user but if you build the email inside a worker, there’s no way to notify the user about this error.

The second problem is that the underlying data might change from when you insert the worker to when you send the email. Imagine a user completes a purchase and you insert a worker to send out the “order confirmed”-email. By the time you send the email, the user might have canceled the purchase and the order was deleted!

So, that’s why I prefer to build the email contents beforehand and only use the worker for sending the email, not building it.

Alright, enough armchair doomspelling. Let’s look at the worker’s code next:

The Worker

This module contains a super lightweight email-sending worker. It sends the mail synchronously through Swoosh, so if the sending fails, the worker fails and Oban will retry it again at a later time. Exceptions will be logged and will notify us when emails aren’t being sent. This makes our email-sending functionality asynchronous and robust because we will never miss sending an email.

# lib/workers/send_email.ex
defmodule MyApp.Workers.SendEmail do
  use Oban.Worker, queue: :mailer

  alias MyApp.Mailer

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"email" => email_args}}) do
    with email <- Mailer.from_map(email_args),
         {:ok, _metadata} <- Mailer.deliver(email) do
      :ok
    end
  end
end

Conclusion

And that’s it! I hope you enjoyed this article! If you want to support me, you can buy my book or video course. Follow me on BlueSky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until the next time! Cheerio 👋

Liked this article? Sign up for more