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
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
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
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
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
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
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
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
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!