User Preferences

View Source

Allow 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.NotificationPreferences

2. 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
end

3. 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
    ]
end

How 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 normally

Key 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 skipped

2. 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 notifications

3. 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
end

Event 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 updates

Example 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]
end

Implementation 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
end

Pattern 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
end

Pattern 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)
end

Your 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
end

UserPreference 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
end

Testing 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
end

Integration 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
end

UI 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
end

Best 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
end

2. 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
end

3. 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
end

Performance 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)
end

Batch 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)
end

Troubleshooting

Preferences Not Being Respected

  1. Check configuration:

    config :ash_dispatch, user_preference: MyApp.Preferences
  2. Verify callback implementation:

    @behaviour AshDispatch.UserPreference
    @impl true
    def user_allows?(...) do
      # Implementation here
    end
  3. Check 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
end

Planned: 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.

ReservedWhat it will be
channel time {:digest, window}A delivery time alongside {:in, …} / {:at, …}: accumulate instead of delivering now
AshDispatch.Resources.DigestEntry.BaseThe accumulation resource, one row per (user, event, window), holding the same rendered content receipts hold
AshDispatch.Workers.FlushDigestsThe scheduled flush: collect a window's entries per user, render one mail, deliver through the normal receipt path
user_digest_mode/2 callbackPer-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