This is your one-stop guide for structuring email-related logic in Phoenix projects. It should have most of the answers to your questions and provide peace of mind even better than a mint tea.

First, let’s agree on the founding principles:

  • Email is the second most used means of communications after the Web, so it deserves to reside on the same level  in the codebase structure
  • Email templates change pretty often, so they have to be readily accessible
  • Email notifications can be extracted into a separate service, and all the data necessary to send an email should be provided by a client code
  • Peace of mind of our users is equally important, and we want to be sure that emails are sent most of the time without paying any significant maintenance overhead for that
  • We shouldn’t be hammering Email Server Provider services with individual requests for automated notifications spanning over a significant portion of our users.

The only two libraries that we will plug into our Oban and Bamboo. Both are lightweight, well maintained and carefully engineered to be on top of the game in their fields.

Oban gives us hassle-free retries, and Bamboo allows us to interact with Email Service Providers without worrying about API details and gives us email previews. Kudos to everyone involved!

The flow

┌─────────────────────────┐
│        Use Case from the host app        │
└─────────────────────────┘
                     │                     
                     │                     
                 sync call                 
                     │                     
                     ▼                     
┌─────────────────────────┐
│ Email subsystem public API entry point   │
└─────────────────────────┘
                     │                     
                     │                     
         Schedule with Oban worker         
                     │                     
                     ▼                     
┌─────────────────────────┐
│               Oban worker                │
└─────────────────────────┘
                     │                     
                     │                     
           Render email contents           
                     │                     
                     ▼                     
┌─────────────────────────┐
│ Send with Bamboo or direct API request   │
└─────────────────────────┘

The Structure

lib
├── email                                  <- Email subsystem goes here 
│   ├── envelope.ex                        <- Mostly from/to fields 
│   ├── mailer.ex                          <- Host module for Bamboo logic
│   ├── newlsetter                         <- Mass comms logic
│   │   ├── fake.ex                        <- Dev adapter
│   │   ├── sendgrid.ex                    <- Prod adapter #1
│   │   └── sendinblue.ex                  <- Prod adapter #2
│   ├── newsletter.ex                      <- Mass comms @behaviour 
│   ├── senders                            <- Oban workers
│   │   └── withdrawal_request.ex
│   ├── src                                <- Email templates sources
│   │   └── withdrawal_request.mjml
│   ├── templates                          <- Email temples
│   │   ├── withdrawal_request.html.eex
│   │   └── withdrawal_request.text.eex
│   └── view.ex                            <- View logic
├── my_app
├── my_app.ex
├── my_app_email.ex                        <- Email subsystem entry point
├── my_app_web
├── my_app_web.ex
└── use_case

The dependencies

{:bamboo, "~> 2.0"},
{:bamboo_phoenix, "~> 1.0"},
{:oban, "~> 2.0"},

Sending a single email

Client code

First, you have to gather data used to send an email. Imagine that the email subsystem is in another completely standalone app and ask yourself what functionality it would be able to access? It might be some public API of the main app or a common code shared through a private hex package.

Definitely, it wouldn’t be reaching into your database and implementation details.

Be vigilant about decoupling email subsystem and host applications. Try to keep them as isolated as possible. Ideally, you should be able to extract the email app and deploy it as a standalone service within a day and not be inclined to swear even once during the whole process.

defp enqueue_withdrawal_email(betslip_id, withdrawal_request_id, withdrawable_amount) do
  betslip = Betting.get_bet!(betslip_id)
  punter = Punter.get!(betslip.punter_id).email
  token = TokenService.encrypt(withdrawal_request_id)
  withdrawal = Routes.withdrawal_request_url(Endpoint, :confirm, "en", betslip_id, token),
  assigns = %{
    bet_placed_at: betslip.inserted_at,
    punter_email = punter.email
    withdrawal_confirmation_url: withdrawal_confirmation_url,
    withdrawable_amount: withdrawable_amount
  }

  MyAppEmail.enqueue_withdrawal_request(assigns)
end
Client code sending a single email

Email subsystem public API

I decided to put the Email subsystem public API directly under the lib folder by analogy with the Web: we have lib/my_app_web.ex, so let there be lib/my_app_email.ex.

Sometimes that feels messy, so logic can be placed equally under lib/email/email.ex. The same thing goes for the module name - it can either be MyAppEmail or MyApp.Email.

It’s the only code that the client app interacts with, and its logic is kept to the bare minimum: insert new Oban job.

defmodule MyAppEmail do
  alias MyAppoEmail.Sender.WithdrawalRequest

  def enqueue_withdrawal_request(email_assigns) do
    email_assigns
    |> WithdrawalRequest.new()
    |> Oban.insert()
  end
end
Creating a retriable, unique job to send an email

Boom, done! Your client app is now free to go and mind its business. The rest will happen asynchronously.

Sending emails

lib/email/senders/withdrawal_request.ex

We do not need extra abstractions to send an email - we can do it straight from the Oban worker.

The other "free" benefit from using Oban is idempotency in sending emails. Here I added the unique keyword to instruct the worker that within a 60-second window, there should be one job with the same params, i.e. no other copy of the same email will be sent within the same minute.

Next, I use  Bamboo helpers to assign data to all the variables used in a template. This is where we are using data supplied by a client code.

At the last step, I'm asking Bamboo to deliver email through a provider of choice. The return value of deliver_now, an error tuple, is perfectly recognisable by Oban, and it will know to retry the job should we encounter any error.

defmodule MyAppEmail.Sender.WithdrawalRequest do
  use Oban.Worker, queue: :default, max_attempts: 20, unique: [period: 60]
  use Bamboo.Phoenix, view: MyAppEmail.View
  alias MyAppEmail.View

  @impl Oban.Worker
  def perform(%Oban.Job{args: args) do
    Envelope.base_email_from_support()
    |> to(args["punter_email"])
    |> subject("...")
    |> assign(:bet_created_at, args["bet_placed_at"])
    |> assign(:withdrawable_amount, args["withdrawable_amount"])
    |> assign(:withdrawal_confirmation_url, args["withdrawal_confirmation_url"])
    |> assign(:twitter_profile, View.twitter_profile())
    |> render(:withdrawal_request)
    |> MyAppEmail.Mailer.deliver_now()

  end
end
Oban worker sending email using Bamboo

Envelope

lib/email/envelope.ex

This module contains the shared “envelope” logic. new_email() and from() are brought into scope by Bamboo.Phoenix.

defmodule MyAppEmail.Envelope do
  use Bamboo.Phoenix, view: MyAppEmail.View
  alias MyAppEmail.View


  defp base_email_from_support do
    new_email()
    |> from({"MyApp", View.support_email()})
  end

  defp base_email_from_no_reply do
    new_email()
    |> from({"MyApp", View.no_reply_email_address()})
  end
end
Shared "envelope" logic

View  / Template

lib/email/view.ex

In my case, that's the only place where I reach for a code located in the host app. It's is a side-effect free, presentation-related logic that is by coincidence also shared among other View modules of the host app.

Feel free to use defdelegate to shorten the contents. It's just that my editor can't follow them then :)

defmodule MyAppEmail.View do
  import MyAppWeb.Gettext
  use Phoenix.View, root: "lib/email/templates", namespace: MyAppEmail
  alias MyAppWeb.SharedView

  def twitter_profile() do
    SharedView.twitter_profile()
  end

  def support_email() do
    SharedView.support_email()
  end
  
  def no_reply_email_address() do
    SharedView.no_reply_email_address()
  end
end
Email View module delegates to host app's shared View

Sending mass comms

It wouldn't be polite to leverage the concurrency properties of OTP to hammer Email Service Provider services when you need to send the same type of email to multiple recipients.
The standard practice, in that case, is to create a template on the provider side, create a subscription list, populate that list with subscribers and then instruct the provider to send a tailored copy of that template to every subscriber in a list.

There is no library for that, but still, it's advisable to have a standard interface for all the providers you might want to use. For example, in the file layout above, you can see adapters for two different services - SendInBlue and SendGrid.

Client code

Again, gather all the necessary data so that email-related code won't be coupled with the host app.


def activate(conn, %{"round_id" => id}) do
  round = Rounds.get!(id)
  MyAppEmail.announce_new_round(%{
    round_name: RoundService.name(round),
  })
end
Host app invoking mass comms logic

Email subsystem public API

There is no difference from sending a single email here - create a new Oban job and return the control.

def announce_new_round(params) do
  params
  |> NewRoundAnnouncement.new()
  |> Oban.insert()
end
Creating a retriable, unique job to send a newsletter

Sending emails

email/senders/new_round_announcement.ex

Here we will call our own adapter instead of relying on Bamboo. Notice that we are using environment configs for dependency injection: in the prod, you'd like to use an actual adapter, probably a fake in dev and mock it the tests.

defmodule MyApp.Sender.NewRoundAnnouncement do
  use Oban.Worker, queue: :default, max_attempts: 50, unique: [period: 60]
  alias MyAppEmail.View

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"round_name" => round_name}}) do
    args = %{
      round_name: round_name,
      twitter_profile: View.twitter_profile(),
    }

    newsletter().announce_new_round(args)
  end

  defp newsletter() do
    Application.get_env(:my_app_email, :newsletter)
  end
end

Oban worker calling our Email Service Provider adapter

The Newsletter behaviour

lib/email/newsletter.ex

defmodule MyAppEmail.Newsletter do
  @type email :: String.t()
  @type new_round_email_params :: %{round_id: pos_integer()}
  
  @callback announce_new_round(new_round_email_params) :: :ok | {:error, String.t()}
end

Fake adapter

lib/email/newlsetter/fake.ex

Here goes anything that would help you during development.

defmodule MyAppEmail.Newsletter.Fake do
  @behaviour SportlottoEmail.Newsletter

  @impl true
  def announce_new_round(new_round_email_params) do
    Logger.info("Sending Newsletter: New Round Announcement")

    :ok
  end
end

Wrapping up

That's about it. The only improvement that I'd like to suggest is to use MJML NIF to automatically transpile MJML templates into EEX templates. Unfortunately, that would make it impossible to use the assign helper to inject variables into templates, and you won't be able to delegate their interpolation to a Phoenix View mechanism. Not a big deal, but it also means that Elixir won't compile them for you, and you will need to copy them into the priv folder before packaging the release tarball.

The feedback is always welcome, and happy sending!