[242] Bamboo Email

A library from Thoughtbot that makes it easy to send emails that is testable, composable, and adapter-based.

Subscribe now

Bamboo Email [05.13.2016]

Thoughtbot has released a new library, called Bamboo, for building emails in a functional manner. It's focused on being testable and composable, and it's adapter-based to make it easy to modify to suit your needs. Let's have a look.

Project

Setup (off-video)

mix new bamboo_playground
cd bamboo_playground
vim mix.exs
  def application do
    [applications: [:logger, :bamboo]]
  end

  defp deps do
    [
      {:bamboo, "~> 0.5.0"}
    ]
  end
mix deps.get

We'll build the first email in a test:

vim test/bamboo_playground_test.exs
defmodule BambooPlaygroundTest do
  use ExUnit.Case
  import Bamboo.Email

  test "creating an email" do
    email = new_email(
      to: "josh@dailydrip.com",
      from: "adam@dailydrip.com",
      subject: "Testing Bamboo",
      html_body: "<h1>Hey</h1><p>Check out this email or else.</p>",
      text_body: "# Hey\n\nCheck out this email or else."
    )
    assert email.to == "josh@dailydrip.com"
    assert email.from == "adam@dailydrip.com"
    assert email.subject == "Testing Bamboo"
    assert email.html_body =~ ~r/Check/
    assert email.text_body =~ ~r/Check/
  end
end

Getting Started

We've got a project with Bamboo installed. Let's look at the basics of building an email:

vim test/bamboo_playground_test.exs

So here's the basics of constructing an email. Bamboo separates the concept of building the email from delivering it, as you'd expect. As it stands, we've built the email using a keyword list. Let's use their composable API for building the same email:

  test "creating an email" do
    email =
      new_email
      |> to("josh@dailydrip.com")
      |> from("adam@dailydrip.com")
      |> subject("Testing Bamboo")
      |> html_body("<h1>Hey</h1><p>Check out this email or else.</p>")
      |> text_body("# Hey\n\nCheck out this email or else.")

    assert email.to == "josh@dailydrip.com"
    assert email.from == "adam@dailydrip.com"
    assert email.subject == "Testing Bamboo"
    assert email.html_body =~ ~r/Check/
    assert email.text_body =~ ~r/Check/
  end

As you can see, Bamboo's pipeable functions provide a nice means to gradually modify an email. This means you can have a function return a given base email and then modify it after the fact trivially:

defmodule BambooPlaygroundTest do
  use ExUnit.Case
  import Bamboo.Email

  test "creating an email" do
    email =
      base_email
      |> to("josh@dailydrip.com")

    assert email.to == "josh@dailydrip.com"
    assert email.from == "adam@dailydrip.com"
    assert email.subject == "Testing Bamboo"
    assert email.html_body =~ ~r/Check/
    assert email.text_body =~ ~r/Check/
  end

  def base_email do
    new_email
    |> from("adam@dailydrip.com")
    |> subject("Testing Bamboo")
    |> html_body("<h1>Hey</h1><p>Check out this email or else.</p>")
    |> text_body("# Hey\n\nCheck out this email or else.")
  end
end

Let's send an email. To do this, you need to build a Mailer:

mkdir lib/bamboo_playground
vim lib/bamboo_playground/mailer.ex
defmodule BambooPlayground.Mailer do
  # You use Bamboo.Mailer, and you need to tell it where to find its
  # configuration.  Using Bamboo.Mailer provides a host of functions
  # inside of this module.
  use Bamboo.Mailer, otp_app: :bamboo_playground
end

Let's configure it:

vim config/config.exs
import_config "#{Mix.env}.exs"
vim config/test.exs
use Mix.Config

# We'll use the Bamboo.TestAdapter for our tests.  There are other adapters for
# all manner of things, including Mandrill, Sendgrid, Mailgun, and SparkPost, as
# well as one for storing them in memory locally for development purposes.
config :bamboo_playground, BambooPlayground.Mailer,
  adapter: Bamboo.TestAdapter

Now let's add a test that delivers the email:

  use Bamboo.Test
  alias BambooPlayground.Mailer

  test "sending an email" do
    email =
      base_email
      |> to("josh@dailydrip.com")

    # The Mailer supports delivering now or later.  We'll use `now` for now
    email |> Mailer.deliver_now

    # We can use a nice test helper to assert it's been delivered
    assert_delivered_email email
  end

Run that...and it passes. We can also deliver it later:

    email |> Mailer.deliver_later

And the test still passes, even though that's running in a Task now from a different process, using the TaskSupervisorStrategy in Bamboo. If you want to, you can define your own strategies for this, to take advantage of reliable work queues rather than just leaving it to a Task that could conceivably fail without telling you. It really depends on your business use case for the email, whether or not that matters to you.

You can also specify a given email address as a 2-tuple, where the first element is the name and the second is the email address. Let's check that out.

First, we'll introduce a struct to represent users in our system:

vim lib/bamboo_playground/user.ex
defmodule User do
  defstruct [:name, :email]
end

Then we'll use this to produce an email address and name combination, which is called a normalized email address. Here, we'll also see sending emails to multiple people:

#...
  test "normalized addresses" do
    josh = %User{name: "Josh Adams", email: "josh@dailydrip.com"}
    adam = %User{name: "Adam Dill", email: "adam@dailydrip.com"}

    email =
      base_email
      |> to([{josh.name, josh.email}, {adam.name, adam.email}])

    assert email.to == [{"Josh Adams", "josh@dailydrip.com"}, {"Adam Dill", "adam@dailydrip.com"}]
  end
#...

However, this could become tiresome - manually deconstructing the user into this 2-tuple. We could create a function to do it, but what module should it really live in? Should the User module really care about this cross-cutting concern with the Mailer module? Probably not. So Bamboo provides a Bamboo.Formatter protocol:

defimpl Bamboo.Formatter, for: User do
  def format_email_address(user, _opts) do
    {user.name, user.email}
  end
end

Now we can just pass the users as a list:

  test "normalized addresses" do
    josh = %User{name: "Josh Adams", email: "josh@dailydrip.com"}
    adam = %User{name: "Adam Dill", email: "adam@dailydrip.com"}

    email =
      base_email
      |> to([josh, adam])

    assert email.to == [{"Josh Adams", "josh@dailydrip.com"}, {"Adam Dill", "adam@dailydrip.com"}]
  end

But that doesn't pass. That's because formatting doesn't occur until delivery. Let's deliver it and look in the console:

  test "normalized addresses" do
    josh = %User{name: "Josh Adams", email: "josh@dailydrip.com"}
    adam = %User{name: "Adam Dill", email: "adam@dailydrip.com"}

    email =
      base_email
      |> to([josh, adam])

    assert email.to == [josh, adam]

    email |> Mailer.deliver_now

    assert_delivered_email email
  end

If we look, we can see the to addresses are as we'd expect. But we can actually normalize the addresses to verify this from our tests:

  test "normalized addresses" do
    josh = %User{name: "Josh Adams", email: "josh@dailydrip.com"}
    adam = %User{name: "Adam Dill", email: "adam@dailydrip.com"}

    email =
      base_email
      |> to([josh, adam])

    assert email.to == [josh, adam]
    email = Bamboo.Mailer.normalize_addresses(email)
    assert email.to == [{"Josh Adams", "josh@dailydrip.com"}, {"Adam Dill", "adam@dailydrip.com"}]

    email |> Mailer.deliver_now

    assert_delivered_email email
  end

One thing to note here: you shouldn't ever actually have to use normalize_addresses. I just wanted to show you that they were in fact being properly formatted via that protocol.

Also, you should probably only do assertions on delivered mails in an integration test. A really nice thing with the way Bamboo is built is that you can just verify that the email is built like you expected in your unit tests, without bothering with delivery.

Sometimes you'll accidentally pass nil around. What happens if you do that to an outbound email with Bamboo?

  test "emailing someone without an email address" do
    email = base_email
      |> to(nil)

    email |> Mailer.deliver_now

    assert_delivered_email email
  end

Cool, you get a useful error message.

Summary

This has been a whirlwind tour of Bamboo. One of the things I've liked the most in playing with it is how easy it is to write tests for. There's a lot of other stuff I haven't touched on. Perhaps one of the coolest-looking features is its Phoenix integration, which allows you to use Phoenix templates and pass in assigns to them in the email. You can also use the options in the Formatter protocol to allow you to customize outbound email addresses nicely, for instance by specifying that when using a user in a from address, it should add that the email came from your app. There's a great resource on this in the resources section. Anyway, check Bamboo out and use it to send some emails. See you soon!

Resources