FullstackPhoenix

Send HTML Email and In-App Notifications in Phoenix

phoenixemailnotificationsliveviewswoosh

A SaaS application usually needs two communication channels at once. The email channel reaches the user wherever they are, and the in-app channel updates them while they are looking at the page. The two are not interchangeable, and I want both.

In this tutorial I install the html_emails feature for styled transactional email and the notifications feature for a /notifications LiveView that updates over PubSub. The starting point is a project with authentication and layouts already installed, since notifications depends on both.

Step 1 - Install html_emails

First step is to install the email feature.

mix saaskit.feature.install html_emails
mix deps.get

The feature adds premailex to mix.exs. Premailex inlines CSS at send time, which is what most email clients still expect, and it also produces a plain-text fallback from the HTML.

A few modules and templates are generated.

  • MyApp.Mailer — wraps Swoosh and exposes base_email/0,
  • render_body/3, and premail/1 for building styled mail.
  • MyAppWeb.EmailHTML — embeds all HEEx templates from
  • lib/my_app_web/emails/.
  • lib/my_app_web/emails/layout.html.heex and layout.text.heex — the
  • wrapper templates every email renders into.

Because authentication is installed, the feature also replaces the default auth notifier with one that renders through these templates.

lib/my_app_web/emails/user_magic_link_instructions.html.heex
lib/my_app_web/emails/user_confirmation_instructions.html.heex
lib/my_app_web/emails/user_update_email_instructions.html.heex

If teams is also installed, the team invitation email (invite_user.html.heex) and a matching MyApp.Teams.InvitationNotifier are generated in the same pass. So once this feature is in, the existing auth and team emails go out as proper HTML instead of plain text.

Step 2 - Verify in the dev mailbox

Phoenix already ships with a local mailbox at /dev/mailbox in development. I trigger a real email by visiting /users/log_in and using the magic-link form, then open /dev/mailbox in another tab to see the styled email. The inlined styles from Premailex are already on each tag.

Note that this only confirms the rendering. Production email needs an actual Swoosh adapter (SMTP, Resend, Mailgun, etc.) wired up, which is a configuration choice and outside the scope of this tutorial.

Step 3 - Install notifications

Next step is to install the in-app channel.

mix saaskit.feature.install notifications
mix ecto.migrate

The feature declares dependencies = ["authentication", "layouts"]. The migration creates a notifications table.

notifications
subject :string
topic :string not null
status :string not null, default "pending"
data :map
user_id references(users)

The schema lives at MyApp.Notifications.Notification, with an embedded Data struct that holds :title, :message, and :url. So a notification is a row in Postgres plus a small JSON payload, not a broadcast-only signal.

Step 4 - Look at the LiveView and the dispatcher

The feature mounts MyAppWeb.NotificationLive at /notifications and adds a bell icon to the app layout that links to it.

<.link navigate={~p"/notifications"} class="btn btn-ghost btn-circle">
<span class="sr-only">{gettext("View notifications")}</span>
<.icon name="hero-bell" class="h-5 w-5" />
</.link>

The LiveView subscribes to a per-user PubSub topic.

# lib/my_app_web/live/notification_live.ex
Phoenix.PubSub.subscribe(MyApp.PubSub, "notification:#{user_id}")

The Notifications context exposes create_notification/2, which takes a Scope and the attrs.

# lib/my_app/notifications.ex
MyApp.Notifications.create_notification(scope, %{
subject: "Welcome",
topic: "system",
data: %{title: "Welcome", message: "Glad you are here.", url: "/"}
})

The context inserts the row, then calls MyApp.Notifications.Dispatcher.dispatch/1. The dispatcher decides which delivery methods apply to that topic. Right now the only delivery channel is Delivery.InApp, which broadcasts to the per-user PubSub topic the LiveView is subscribed to.

So a notification appears in the user's bell icon list within milliseconds, without a page refresh.

Step 5 - Send one and watch it land

With these changes in place, I can verify the channel from iex.

# iex -S mix
user = MyApp.Users.get_user_by_email("me@example.com")
scope = MyApp.Accounts.Scope.for_user(user)
MyApp.Notifications.create_notification(scope, %{
subject: "Hello",
topic: "system",
data: %{title: "Hello", message: "Live update.", url: "/"}
})

I keep /notifications open in the browser and watch the new row appear at the top of the list without refreshing. Discarding it from the "Discard all" button flips the status column to "discarded" and removes it from the stream.

A note on keeping the two channels distinct

The dispatcher is what makes the design honest. If I want a future notification topic to also go out by email, I add a Delivery.Email module that builds a Swoosh message through MyApp.Mailer and update Dispatcher.determine_delivery_methods/1 to include it. The html_emails feature provides the rendering layer; the notifications feature decides per topic which channels to use. However, neither feature creates an automatic email-for-every-notification hook, which keeps me in control of where each topic lands.

Final Result

I can now send a styled transactional email and post an in-app notification that updates without a refresh. The two channels are independent but composable through the dispatcher. A natural next step is to install oauth so users have a second sign-in path that also benefits from the new email templates for confirmations.