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 exposesbase_email/0,render_body/3, andpremail/1for building styled mail.MyAppWeb.EmailHTML— embeds all HEEx templates fromlib/my_app_web/emails/.lib/my_app_web/emails/layout.html.heexandlayout.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.