User Preferences
View SourceAllow users to control which notifications they receive by implementing preference checking.
Overview
AshDispatch checks user preferences before delivering notifications, giving users control over their notification experience. Users can opt out of specific categories, transports, or combinations of both.
Quick Start
1. Configure Your Preference Checker
# config/config.exs
config :ash_dispatch,
user_preference: MyApp.NotificationPreferences2. Implement the Behaviour
defmodule MyApp.NotificationPreferences do
@behaviour AshDispatch.UserPreference
@impl true
def user_allows?(user_id, event_id, transport, opts) do
category = opts[:category]
# Query your UserPreference resource
case Ash.get(MyApp.Accounts.UserPreference, user_id) do
{:ok, prefs} ->
# Check if user disabled this category
category not in prefs.disabled_categories and
transport not in prefs.disabled_transports
_ ->
true # Allow if no preferences found
end
end
end3. Add Categories to Events
dispatch do
event :promotional_offer,
trigger_on: :create,
channels: [[transport: :email, audience: :user]],
metadata: [
category: :marketing # Users can opt out of this
]
endHow It Works
Preference Check Flow
1. Event triggers
2. Dispatcher creates one DeliveryReceipt per recipient (status: :pending)
3. Transport checks AshDispatch.UserPreference.allows_receipt?()
for THIS receipt's recipient (receipt.user_id)
├─ If false → Mark receipt :skipped (error: "user_opted_out")
└─ If true → Continue with delivery
4. Delivery proceeds normallyKey Point: Receipts are always created for audit purposes, even if skipped.
Key Point: The check is per recipient. One event fanned out to 300
customers asks the checker 300 times and produces 300 independent verdicts —
some receipts :skipped, the rest delivered.
Fixed in 0.6.4
Before 0.6.4 the transports asked allows?/3, which reads the user from the
event context — the event's subject, not the recipient being delivered to.
On a fan-out that single verdict was applied to everyone: one customer's
opt-out silenced the whole send, or one customer's opt-in delivered to people
who had opted out. If you implemented user_allows?/4 and never saw it called
with more than one user id per event, this was why.
When Preferences Are Checked
✅ Checked:
- Events with
audience: :user - Any audience listed in
preference_gated_audiences(see below)
❌ Not Checked:
- Events with
audience: :admin - Events with
audience: :team - Events with
audience: :system - Recipients without a
user_id(external email addresses, webhook targets) - System-critical notifications (auth, password reset)
Gating Your Own Audiences
Only :user is gated by default. An app that fans a marketing-style event out
to an audience of its own (:customers, :subscribers, …) has to opt that
audience in, otherwise its recipients' preferences are never consulted:
config :ash_dispatch,
preference_gated_audiences: [:user, :customers]Keep admin/team/system audiences out of this list. An operator must not be able to silence an operational alert by unticking a marketing box.
Checking Preferences Outside the Send Path
AshDispatch.UserPreference.allows_user?/4 is the same predicate the transports
use, callable anywhere a user id is known — so an admin screen's "will be sent
to 342 customers · 38 have opted out" cannot drift from what the send path
actually does:
recipients =
Enum.filter(candidates, fn user ->
AshDispatch.UserPreference.allows_user?(user.id, "mailing.sent", :email,
category: :marketing
)
end)It returns true for a nil user id, and true when no checker is configured.
Preference Granularity
Users can control notifications at three levels:
1. By Category
Opt out of entire event categories:
# In your UserPreference resource
attribute :disabled_categories, {:array, :atom}, default: []
# User opts out of marketing
user_preference.disabled_categories = [:marketing, :promotional]
# Events with category: :marketing will be skipped2. By Transport
Opt out of specific delivery methods:
attribute :disabled_transports, {:array, :atom}, default: []
# User opts out of all emails
user_preference.disabled_transports = [:email]
# User still gets :in_app notifications3. Combined
Fine-grained control over category + transport combinations:
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case Ash.get(UserPreference, user_id) do
{:ok, prefs} ->
# User can disable marketing emails but still get marketing in-app
cond do
{category, transport} in prefs.disabled_combinations ->
false
category in prefs.disabled_categories ->
false
transport in prefs.disabled_transports ->
false
true ->
true
end
_ ->
true
end
endEvent Categories
Define categories that make sense for your application:
Common Categories:
:transactional # Order confirmations, password resets (usually not configurable)
:marketing # Promotional emails, product announcements
:billing # Invoices, payment reminders
:social # Comments, mentions, likes
:product_updates # New features, changelogs
:system # Maintenance notices, service updatesExample Event Categorization:
dispatch do
# Transactional - usually not user-configurable
event :order_confirmed,
metadata: [category: :transactional, user_configurable: false]
# Marketing - user can opt out
event :weekly_newsletter,
metadata: [category: :marketing, user_configurable: true]
# Social - user can control frequency
event :comment_reply,
metadata: [category: :social, user_configurable: true]
endImplementation Patterns
Pattern 1: Simple Category Blocking
defmodule MyApp.Preferences do
@behaviour AshDispatch.UserPreference
@impl true
def user_allows?(user_id, _event_id, _transport, opts) do
category = opts[:category]
# Don't check preferences for critical categories
if category in [:transactional, :security] do
true
else
case get_user_preferences(user_id) do
{:ok, prefs} -> category not in prefs.disabled_categories
_ -> true
end
end
end
defp get_user_preferences(user_id) do
MyApp.Accounts.UserPreference
|> Ash.get(user_id)
end
endPattern 2: Transport-Specific Preferences
@impl true
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Check transport-specific preferences
case transport do
:email ->
prefs.email_enabled and category not in prefs.email_disabled_categories
:in_app ->
prefs.in_app_enabled and category not in prefs.in_app_disabled_categories
:sms ->
prefs.sms_enabled and category not in prefs.sms_disabled_categories
_ ->
true
end
_ ->
true
end
endPattern 3: Frequency-Based Preferences (digests)
The predicate must stay a predicate. user_allows?/4 answers one question —
"may this user receive this?" — and must have no side effects. It is called once
per recipient, inside the delivery path, and its false marks the receipt
:skipped. Writing a digest queue (or anything else) from inside it means the
notification silently disappears into a table nobody delivers from, with a
receipt that says the user opted out.
Until digests are a library feature (see Planned: per-recipient digests), model "digest mode" as what the user asked for — no individual delivery — and own the digest itself in your app:
@impl true
def user_allows?(user_id, _event_id, _transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Digest mode simply means: no individual delivery for this category.
# Nothing is written here — accumulating and sending the digest is a
# separate, scheduled job in your app that reads the same preferences.
category not in prefs.disabled_categories and
not digest_mode?(prefs, category)
_ ->
true
end
end
defp digest_mode?(prefs, category) do
# User gets a daily digest instead of individual emails
Enum.member?(prefs.digest_categories, category)
endYour digest job then owns its own accumulation, its own watermark, and its own receipts — and a failure there is visible instead of being a mail that never arrived.
Pattern 4: Time-Based Preferences
@impl true
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# Respect quiet hours
if in_quiet_hours?(prefs) and category != :urgent do
false # Skip non-urgent notifications during quiet hours
else
category not in prefs.disabled_categories
end
_ ->
true
end
end
defp in_quiet_hours?(prefs) do
now = Time.utc_now()
quiet_start = prefs.quiet_hours_start || ~T[22:00:00]
quiet_end = prefs.quiet_hours_end || ~T[08:00:00]
Time.compare(now, quiet_start) != :lt and
Time.compare(now, quiet_end) != :gt
endUserPreference Resource Example
Here's a complete UserPreference resource:
defmodule MyApp.Accounts.UserPreference do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
postgres do
table "user_preferences"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
# User reference (no relationship to stay flexible)
attribute :user_id, :uuid do
allow_nil? false
end
# Category-based preferences
attribute :disabled_categories, {:array, :atom}, default: []
# Transport-based preferences
attribute :email_enabled, :boolean, default: true
attribute :in_app_enabled, :boolean, default: true
attribute :sms_enabled, :boolean, default: false
# Per-transport category preferences
attribute :email_disabled_categories, {:array, :atom}, default: []
attribute :in_app_disabled_categories, {:array, :atom}, default: []
# Digest preferences
attribute :digest_categories, {:array, :atom}, default: []
attribute :digest_frequency, :atom, default: :daily # :daily, :weekly
# Quiet hours
attribute :quiet_hours_start, :time
attribute :quiet_hours_end, :time
timestamps()
end
actions do
defaults [:read, :update]
create :create do
accept [:user_id]
end
update :disable_category do
accept []
argument :category, :atom, allow_nil?: false
change fn changeset, _ ->
category = Ash.Changeset.get_argument(changeset, :category)
current = Ash.Changeset.get_attribute(changeset, :disabled_categories) || []
Ash.Changeset.change_attribute(
changeset,
:disabled_categories,
Enum.uniq([category | current])
)
end
end
update :enable_category do
accept []
argument :category, :atom, allow_nil?: false
change fn changeset, _ ->
category = Ash.Changeset.get_argument(changeset, :category)
current = Ash.Changeset.get_attribute(changeset, :disabled_categories) || []
Ash.Changeset.change_attribute(
changeset,
:disabled_categories,
List.delete(current, category)
)
end
end
end
code_interface do
define :create
define :disable_category, args: [:category]
define :enable_category, args: [:category]
end
identities do
identity :unique_user_id, [:user_id]
end
endTesting Preferences
Test Your Preference Checker
defmodule MyApp.PreferencesTest do
use ExUnit.Case
alias MyApp.NotificationPreferences
setup do
# Create test user with preferences
user = create_user()
{:ok, prefs} = UserPreference.create(%{
user_id: user.id,
disabled_categories: [:marketing],
email_enabled: false
})
%{user: user, prefs: prefs}
end
test "user who disabled marketing category", %{user: user} do
# Should block marketing
refute NotificationPreferences.user_allows?(
user.id,
"promo.new",
:email,
category: :marketing
)
# Should allow transactional
assert NotificationPreferences.user_allows?(
user.id,
"order.created",
:email,
category: :transactional
)
end
test "user who disabled email transport", %{user: user} do
# Should block all emails
refute NotificationPreferences.user_allows?(
user.id,
"any.event",
:email,
category: :billing
)
# Should allow in-app
assert NotificationPreferences.user_allows?(
user.id,
"any.event",
:in_app,
category: :billing
)
end
endIntegration Tests
test "opted-out user gets receipt marked as skipped" do
user = create_opted_out_user()
# Trigger event
{:ok, product} = create_product(%{user_id: user.id})
# Check receipt was created but skipped
receipts = DeliveryReceipt
|> Ash.Query.filter(event_id == "product.created")
|> Ash.read!()
assert length(receipts) == 1
assert hd(receipts).status == :skipped
assert hd(receipts).error_message == "user_opted_out"
# Verify no notification created
notifications = Notification
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.read!()
assert length(notifications) == 0
endUI Integration
Preference Settings Page
# LiveView component
defmodule MyAppWeb.PreferencesLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
user = socket.assigns.current_user
{:ok, prefs} = UserPreference.get_or_create(user.id)
{:ok,
socket
|> assign(:preferences, prefs)
|> assign(:categories, available_categories())}
end
def handle_event("toggle_category", %{"category" => category}, socket) do
category_atom = String.to_existing_atom(category)
user_id = socket.assigns.current_user.id
if category_atom in socket.assigns.preferences.disabled_categories do
UserPreference.enable_category(user_id, category_atom)
else
UserPreference.disable_category(user_id, category_atom)
end
{:ok, prefs} = UserPreference.get(user_id)
{:noreply, assign(socket, :preferences, prefs)}
end
defp available_categories do
[
%{key: :marketing, label: "Marketing & Promotions", description: "Product announcements, special offers"},
%{key: :billing, label: "Billing & Payments", description: "Invoices, payment reminders"},
%{key: :social, label: "Social Activity", description: "Comments, mentions, likes"},
%{key: :product_updates, label: "Product Updates", description: "New features, changelogs"}
]
end
endBest Practices
1. Always Allow Critical Notifications
def user_allows?(user_id, event_id, transport, opts) do
category = opts[:category]
# Never block critical system notifications
if category in [:security, :transactional] do
true
else
check_user_preferences(user_id, category, transport)
end
end2. Default to Opt-In for New Categories
def user_allows?(user_id, _event_id, _transport, opts) do
category = opts[:category]
case get_user_preferences(user_id) do
{:ok, prefs} ->
# If category is unknown, allow (opt-in by default)
if category in known_categories() do
category not in prefs.disabled_categories
else
true
end
_ ->
true
end
end3. Provide Clear Category Descriptions
Make it easy for users to understand what they're opting out of:
@category_descriptions %{
marketing: "Promotional emails, product announcements, special offers",
billing: "Invoices, payment reminders, subscription updates",
social: "Comments, mentions, likes, and other social interactions",
system: "Important system announcements and maintenance notices"
}4. Log Preference Decisions
def user_allows?(user_id, event_id, transport, opts) do
result = check_preferences(user_id, event_id, transport, opts)
unless result do
Logger.info("""
User #{user_id} opted out of notification
Event: #{event_id}
Transport: #{transport}
Category: #{opts[:category]}
""")
end
result
endPerformance Considerations
Caching
User preferences are read-heavy. Consider caching:
defmodule MyApp.PreferenceCache do
use Nebulex.Cache,
otp_app: :my_app,
adapter: Nebulex.Adapters.Local
end
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
cache_key = {:user_prefs, user_id}
prefs = PreferenceCache.get(cache_key, fn ->
case get_user_preferences(user_id) do
{:ok, prefs} -> prefs
_ -> nil
end
end)
check_preferences(prefs, category, transport)
endBatch Checking
For multi-recipient events:
def batch_allows?(user_ids, event_id, transport, opts) when is_list(user_ids) do
# Fetch all preferences in one query
prefs = UserPreference
|> Ash.Query.filter(user_id in ^user_ids)
|> Ash.read!()
|> Map.new(&{&1.user_id, &1})
# Return map of user_id => boolean
Map.new(user_ids, fn user_id ->
pref = Map.get(prefs, user_id)
{user_id, check_single_preference(pref, event_id, transport, opts)}
end)
endTroubleshooting
Preferences Not Being Respected
Check configuration:
config :ash_dispatch, user_preference: MyApp.PreferencesVerify callback implementation:
@behaviour AshDispatch.UserPreference @impl true def user_allows?(...) do # Implementation here endCheck event category:
metadata: [category: :marketing]
All Notifications Being Skipped
Check that your user_allows?/4 isn't accidentally returning false for all cases:
# Add logging
def user_allows?(user_id, event_id, transport, opts) do
result = check_preferences(...)
Logger.debug("""
Preference check:
User: #{user_id}
Event: #{event_id}
Transport: #{transport}
Result: #{result}
""")
result
endPlanned: per-recipient digests
Digest delivery — "don't mail me every mention, mail me once a day" — is a
planned library feature, not something to build inside user_allows?/4. The
names below are reserved so apps don't paint over them; nothing of this
ships yet, and none of it is callable today.
| Reserved | What it will be |
|---|---|
channel time {:digest, window} | A delivery time alongside {:in, …} / {:at, …}: accumulate instead of delivering now |
AshDispatch.Resources.DigestEntry.Base | The accumulation resource, one row per (user, event, window), holding the same rendered content receipts hold |
AshDispatch.Workers.FlushDigests | The scheduled flush: collect a window's entries per user, render one mail, deliver through the normal receipt path |
user_digest_mode/2 callback | Per-user, per-category window resolution (:off, :daily, :weekly) on the preference behaviour |
Two properties are deliberate and will not change: the digest unit is per
recipient (one body per user, not one body for everyone), and every delivery
still produces a DeliveryReceipt — a digest is a delivery, not a silence.
If you build digests in your app in the meantime, avoid these names, keep the
opt-out in user_allows?/4 (so it keeps agreeing with the send path), and keep
accumulation out of the predicate.
See Also
- Recipient Resolution - Finding notification recipients
- Getting Started - Basic setup
- DSL Reference - Event configuration