In order to facilitate conversations in a forum, we need a way to notify interested parties when a thread they're involved in has a new post. We might want to send these notifications via e-mail, SMS, or an internal messaging system. Let's build a quick GenServer to handle these notifications - we might replace it with something else in the future, but this works fine for now.
Project
We're starting with the dailydrip/firestorm repo tagged before this episode .
Normally I'd start with a test, but we don't really want to expose anything to the outside world that we could use to test this process, so we'll just start coding.
First, we want to build a
GenServer
. We've already covered
GenServer
in
003.2
so I'll just provide a nice starting point for us:
defmodule FirestormWeb.Notifications do
@moduledoc """
This is the entry point for our Notifications system. It will grow to support
various notification mechanisms, but for now it will send an email to anyone
involved in a thread everytime there's a new post.
"""
use GenServer
alias FirestormWeb.Forums.Post
### Client API
@moduledoc """
Start the notifications system and link it.
iex> {:ok, pid} = FirestormWeb.Notifications.start_link()
"""
def start_link() do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
@doc """
Send a notification that a new post has been made.
iex> :ok = FirestormWeb.Notifications.post_created(%Post{})
"""
def post_created(%Post{} = post) do
GenServer.cast(__MODULE__, {:post_created, post})
end
### Server API
def init(_) do
{:ok, :nostate}
end
def handle_cast({:post_created, %Post{} = post}, :nostate) do
IO.puts "We should really send an email here"
{:noreply, :nostate}
end
end
Next, let's start this server up when our app boots:
vim lib/firestorm_web/application.ex
defmodule FirestormWeb.Application do
# ...
def start(_type, _args) do
# ...
children = [
# ...
# Start the notifications server
worker(FirestormWeb.Notifications, [])
]
# ...
end
end
Next, we'll update our
Forums
context to send a notification each time a
Post
is created. We only want to send a notification if a
Post
is created
successfully, so we'll introduce a
with
:
defmodule FirestormWeb.Forums do
# ...
alias FirestormWeb.{Repo, Notifications}
# ...
def create_post(%Thread{} = thread, %User{} = user, attrs) do
attrs =
attrs
|> Map.put(:thread_id, thread.id)
|> Map.put(:user_id, user.id)
with changeset <- post_changeset(%Post{}, attrs),
{:ok, post} <- Repo.insert(changeset),
:ok <- Notifications.post_created(post) do
{:ok, post}
end
end
# ...
end
Now when a post is inserted in the repo, we'll send a notification to our server. Let's write a test that starts our notifications server and creates a post, then fails because we still need to actually send the email and add that to our test:
vim test/notifications_test.exs
defmodule FirestormWeb.NotificationsTest do
use FirestormWeb.DataCase
alias FirestormWeb.{Forums, Notifications}
test "creating a post in a thread notifies everyone involved in the thread" do
# TODO: This isn't actually what we want to do, so fix this later.
{:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@smoothterminal.com", name: "Josh Adams"})
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, user, %{title: "OTP is cool", body: "Don't you think?"})
{:ok, yup} = Forums.create_post(otp_is_cool, user, %{body: "yup"})
assert false
end
end
Our test fails, but if we look we can see that the
IO.puts
call ran, which
means our notifications server is getting the information it needs. Let's now
implement emails. We'll use
Bamboo
for
this, handling actually sending the emails with
SendGrid
.
We'll bring in Bamboo. We're going to use the
master
branch of it since 1.0
isn't quite out yet (but it's close!)
vim mix.exs
defmodule FirestormWeb.Mixfile do
# ...
defp deps do
[
# ...
{:bamboo, github: "thoughtbot/bamboo"},
# ...
]
end
# ...
end
mix deps.get
Next, we'll configure it, using the
Bamboo.SendGridAdapter
:
vim config/config.exs
# ...
# I already have an environment variable with my API Key
config :firestorm_web, FirestormWeb.Mailer,
adapter: Bamboo.SendGridAdapter,
api_key: System.get_env("SENDGRID_API_KEY")
# ...
We also need to set up a
Mailer
module:
vim lib/firestorm_web/mailer.ex
defmodule FirestormWeb.Mailer do
use Bamboo.Mailer, otp_app: :firestorm_web
end
We'll also create a
FirestormWeb.Emails
module that contains functions that
produce the emails we want to send:
vim lib/firestorm_web/emails.ex
I've written the emails already so I'm just going to paste this module in. If you want to study it a bit more you can, but I've also released a drip covering Bamboo previously .
defmodule FirestormWeb.Emails do
import Bamboo.Email
import FirestormWeb.Web.Router.Helpers
import Phoenix.HTML
import Phoenix.HTML.Link
alias FirestormWeb.Web.Endpoint
alias FirestormWeb.Markdown
alias FirestormWeb.Forums.{Post, Thread, User}
def thread_new_post_notification(%User{} = user, %Thread{} = thread, %Post{} = post) do
new_email
|> to(user.email)
|> from("noreply@smoothterminal.com")
|> subject("There was a new post in a thread you are watching on Firestorm")
|> html_body(html_body_for(thread, post))
|> text_body(text_body_for(thread, post))
|> put_header("reply-to", reply_to_thread_address(thread))
end
# I've actually set up SendGrid to handle replying to these emails, we'll
# cover that in a future episode.
defp reply_to_thread_address(%{id: id}) do
"reply-thread-#{id}@#{inbound_email_domain}"
end
defp inbound_email_domain do
# This goes into config at some point
"notifier.firestormforum.org"
end
def thread_url(thread) do
category_thread_url(Endpoint, :show, thread.category_id, thread.id)
end
def post_url(thread, post) do
"#{thread_url(thread)}#post-#{post.id}"
end
defp html_body_for(%Thread{} = thread, %Post{} = post) do
"""
<p>The Firestorm thread #{link(thread.title, to: thread_url(thread)) |> safe_to_string} has received #{link("a reply", to: post_url(thread, post)) |> safe_to_string}:</p>
<hr />
#{Markdown.render(post.body)}
"""
end
defp text_body_for(%Thread{} = thread, %Post{} = post) do
"""
The Firestorm thread #{thread.title} [#{thread_url(thread)}] has received a reply [#{post_url(thread, post)}]:\n\n
#{post.body}
"""
end
end
Finally, we'll want to test that an email is sent, without actually sending
emails when running our tests. To do this, we'll configure our tests to use the
Bamboo.TestAdapter
:
vim config/test.exs
# ...
config :firestorm_web, FirestormWeb.Mailer,
adapter: Bamboo.TestAdapter
Now we'll modify our test to verify that an email was sent:
defmodule FirestormWeb.NotificationsTest do
# ...
use Bamboo.Test # <-- Add this
test "creating a post in a thread notifies everyone involved in the thread" do
# ...
{:ok, yup} = Forums.create_post(otp_is_cool, user, %{body: "yup"})
# We can use assert_delivered_email to verify an email was delivered
assert_delivered_email FirestormWeb.Emails.thread_new_post_notification(user, otp_is_cool, yup)
end
end
If we run the test, of course it fails. That's because we aren't yet delivering
an email in our
Notifications
module. Let's implement that:
defmodule FirestormWeb.Notifications do
# ...
alias FirestormWeb.Forums.Post
alias FirestormWeb.{Repo, Emails, Mailer}
# ...
def handle_cast({:post_created, %Post{} = post}, :nostate) do
# 1) Find all users that are involved in this thread
post = Repo.preload(post, [thread: [posts: :user]])
users =
post.thread.posts
|> Enum.map(&(&1.user))
|> Enum.uniq
# 2) Send each of them an email about it
for user <- users do
Emails.thread_new_post_notification(user, post.thread, post)
|> Mailer.deliver_now
end
# 3) Get really angry users because this isn't remotely smart enough yet
{:noreply, :nostate}
end
end
With this, if we run the tests...they still fail, but we can also see our
GenServer received an unexpected message. This is because the
TestAdapter
sends a message to the caller to notify it of being sent. To find out about this
event from our test process, we need to use Bamboo in shared mode:
defmodule FirestormWeb.NotificationsTest do
# ...
use Bamboo.Test, shared: true
# ...
end
With that, our tests pass and the email is sent.
Summary
Today we introduced a basic GenServer to handle our Notifications system. Right now it's simply sending an email to anyone that's been involved in a thread. Obviously this is not our desired end state, but it's a pretty good start and we got here quickly. I hope you enjoyed it. See you soon!
Comments for Adding Notification Emails with Bamboo