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@dailydrip.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@firestormforum.org")
    |> 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!

Resources