Post

Reliable e-mail delivery in Elixir with Bamboo + Oban

In the dynamic landscape of web applications, effective communication with users is paramount. Sending bulk emails, such as newsletters or promotional messages, is a common practice for keeping users engaged. Elixir offers a powerful combination of tools for handling such tasks efficiently. In this blog post, we’ll explore how to leverage Bamboo and Oban libraries to send bulk emails seamlessly from your Elixir application.

Some considerations

  • We should have a mailer module will be the entry-point for our mail delivery API. A user should be able to pass a list of emails and call a function like MyApp.Mailer.deliver_many(list_of_emails) to send the e-mails.
  • The user shouldn’t be worried about the app being choked when delivering a large number of e-mails, i.e. mail delivery should be a background activity, it shouldn’t increase the load on our app significantly, and the app should still be able to serve its main purpose while still delivering e-mails reliably.
  • In the real world, lots of things can happen - our app can crash due to some bugs, the machine/VM/datacenter running our app could go offline due to reasons like software bugs, hardware failures or for maintenance reasons. In such cases, we should be able to restart the e-mail delivery jobs once our app comes back up and it should be done in a reliable manner - only those e-mails which haven’t been delivered yet should be delivered.

About the libraries we’ll use

  • Bamboo: A testable, composable, and adapter based Elixir email library for devs that love piping. Ships with adapters for several popular mail delivery services. It’s also quite easy to write your own delivery adapter if your platform isn’t yet supported.
  • Oban: A robust job processing library which uses PostgreSQL or SQLite3 for storage and coordination, with primary goals of reliability, consistency and observability.
  • Ecto: The Elixir toolkit for interacting with databases.

Setting up dependencies in your project

To install Bamboo and Oban, add them to your list of dependencies in mix.exs.

1
2
3
4
5
6
def deps do
  [
    {:bamboo, "~> 2.3.0"},
    {:oban, "~> 2.16"}
  ]
end

Run mix deps.get to fetch and install the dependencies.

Initial setup of the Mailer module

We’ll setup MyApp.Mailer module as the mailer module for our application. For setting up a mailer powered by Bamboo, all we have to do is the following:

1
2
3
defmodule MyApp.Mailer do
  use Bamboo.Mailer, otp_app: :my_app
end

Here’s the configuration we’ll add for our mailer module:

1
2
config :my_app, MyApp.Mailer,
  adapter: Bamboo.LocalAdapter

The above 3 lines of code and 2 lines of configuration are enough to create a mailer that can deliver e-mails (in development only; in production you’ll have to configure one of the Adapters for an e-mail delivery service or write your own adapter). However, since we need reliable e-mail delivery, our strategy will be to create an e-mail delivery job per e-mail and insert it into Oban’s job queue. Oban should take care of the rest.

Setting up Oban

Behind the scenes, Oban uses a database to store and retrieve the jobs that it has to perform. So, we’ll do the following:

  • Setup the required database tables for Oban.
  • Setup Oban to start in our application supervisor.
  • Create an Oban job that will take an e-mail as a parameter and deliver it using the Mailer module we’ve written above.
  • Insert jobs into Oban and watch it deliver e-mails.

Setting up the database for Oban

For Oban, I’ve created a job queue named events where mail delivery jobs will be put. It is powered by a PostgreSQL Ecto Repo called MyApp.Repo. Here’s the configuration:

1
2
3
4
5
# For Oban
config :my_app, Oban,
  repo: MyApp.Repo,
  plugins: [Oban.Plugins.Pruner],
  queues: [events: [limit: 200, dispatch_cooldown: 10]]

Here’s the configuration for the Ecto Repo that will power Oban:

1
2
config :my_app,
  ecto_repos: [MyApp.Repo]
1
2
3
4
5
6
7
8
9
# Configure your database
config :my_app, MyApp.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "my_app_dev",
  stacktrace: true,
  show_sensitive_data_on_connection_error: true,
  pool_size: 10

We’ll also create a migration using the following command from the terminal: mix ecto.gen.migration add_oban_jobs_table, which will create an empty ecto migration script in priv/repo/migrations. Change it to the following:

1
2
3
4
5
6
7
8
9
10
11
defmodule MyApp.Repo.Migrations.AddObanJobsTable do
  use Ecto.Migration

  def up do
    Oban.Migration.up(version: 11)
  end

  def down do
    Oban.Migration.down(version: 1)
  end
end

Once you’re done, make sure to create the necessary tables in your DB using the following command: mix ecto.create && mix ecto.migrate. Now let’s proceed to the next step.

Configuring Oban to start in our Application

Head over to application.ex file in your project and add the Repo and Oban items to the list of children in the application supervisor:

1
2
3
4
5
6
7
8
9
10
11
12
13
defmodule MyApp.Application do
  @impl true
  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      MyApp.Repo,
      # Start Oban
      {Oban, Application.fetch_env!(:my_app, Oban)}
    ]
    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Creating the Oban Job for e-mail delivery

Creating an Oban Job is pretty simple.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
defmodule MyApp.EmailJob do
  @moduledoc """
  Defines an email delivery job responsible for delivering a mail to a single recipient.
  The job is set to attempt 3 times.

  Powered by `Oban`
  """

  alias MyApp.Mailer
  import Bamboo.Email

  use Oban.Worker, queue: :events, max_attempts: 3, tags: ["user", "email"], unique: [period: 60]

  @impl true
  def perform(%Oban.Job{args: %{"to" => to, "subject" => subject, "body" => body}}) do
    email = new_email(
          to: to,
          from: "support@myapp.com",
          subject: subject,
          text_body: body
        )
    Mailer.deliver_now(email)
  end
end

Delivering e-mails with Oban Job

Suppose you have a list of emails to send. You can enqueue them as jobs in Oban:

1
2
3
4
5
6
7
8
9
10
11
emails_to_send = [
  %{"to" => "user1@example.com", "subject" => "Greetings", "body" => "Hello, User 1!"},
  %{"to" => "user2@example.com", "subject" => "Special Offer", "body" => "Check out our latest offers, User 2!"}
  # Add more emails as needed
]

Enum.each(emails_to_send, fn email ->
  email
  |> MyApp.EmailJob.new()
  |> Oban.insert()
end)

You can use Bamboo.SentEmail.all() function to view all the sent e-mails in development, if you are using Bamboo.LocalAdapter.

Adding bulk-delivery functionality to Mailer

Now that our proof-of-concept code works, lets add a function to our Mailer module to deliver a list of e-mails. We’ll make some optimizations along the way which are useful if you are inserting a large number of jobs into Oban.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
defmodule MyApp.Mailer do
  @moduledoc """
  Defines additional functions for mail delivery and checking status of mail delivery requests.
  Powered by `Bamboo.Mailer`.
  """

  use Bamboo.Mailer, otp_app: :mk_be_elixir

  def deliver_many(emails, _opts \\ []) when is_list(emails) do
    for email <- emails do
      %{"to" => email.to, "subject" => email.subject, "body" => email.body}
      |> MyApp.EmailJob.new()
    end
    |> Stream.chunk_every(5000)
    |> Task.async_stream(fn jobs ->
      # Insert jobs in batches of 5k to avoid parameter limitations
      Oban.insert_all(fn _ ->
        jobs
      end)
    end)
    |> Stream.run()
  end
end

Conclusion

Congratulations! You now have a mailer that delivers bulk e-mails in the background.

By integrating Bamboo and Oban into your Elixir application, you can effortlessly send bulk emails without compromising performance. This combination provides a robust and scalable solution for managing background jobs and handling email delivery seamlessly. Experiment with different adapters, explore additional features, and tailor the implementation to suit your specific use case for an even more powerful and flexible solution.

This post is licensed under CC BY 4.0 by the author.