AshDispatch.Event behaviour (AshDispatch v0.8.5)

View Source

Behaviour and DSL for defining events.

Events represent business occurrences that trigger notifications across multiple channels (email, in-app, Discord, etc.).

Usage

Define an event module:

defmodule MyApp.Events.Orders.Created do
  use AshDispatch.Event

  dispatch do
    id "orders.created"
    domain :orders

    channels do
      channel :in_app, :user, time: :immediate
      channel :email, :user, time: 5.minutes(), skip_if_read: true
    end

    content do
      subject "Your order has been created"
      notification_title "Order created"
      notification_message "Order #{{order_number}} has been created"
    end
  end

  # Override for complex logic:
  def prepare_template_assigns(context, channel) do
    %{
      order_number: format_order_id(context.data.order)
    }
  end
end

Callbacks

All callbacks are optional with sensible defaults. Override only what you need:

Required (if not in DSL)

  • id/0 - Event identifier
  • channels/1 - List of delivery channels

Content

  • subject/2 - Email subject
  • from/2 - Email from address
  • notification_title/2 - In-app notification title
  • notification_message/2 - In-app notification message

Metadata

  • domain/0 - Event domain
  • category/1 - Email preference category
  • user_configurable?/1 - Can users opt-out?
  • notification_type/1 - Notification type (:info, :success, :warning, :error)

Advanced

  • recipients/2 - Get recipients for channel
  • prepare_template_assigns/2 - Prepare template variables
  • sample_data/0 - Sample data for previews
  • generate_send_variables/2 - Generate real data for sending (tokens, URLs, etc.)
  • counters/2 - Counters to broadcast

See behaviour documentation for complete list.

Summary

Types

An email attachment: raw binary data plus metadata. The transport base64-encodes data for JSONB Oban args; the email backend decodes and attaches it (e.g. via Swoosh.Email.attachment/2).

Callbacks

In-app notification action button label.

Whether this event requires user action.

In-app notification action URL.

Whether this event is applicable for a specific user.

Email attachments for a channel (optional).

Returns the email preference category for this event.

Returns the list of delivery channels for this event.

Counters to broadcast when this event creates notifications.

Returns the key to use for the resource in context.data.

Returns the domain this event belongs to.

The module's OWN content keys — whatever the receiver can render and the library knows nothing about.

Email from address as {name, email} tuple.

Generate real variables for actual event dispatch (manual triggers, etc.).

Returns the unique event identifier.

In-app notification message.

In-app notification title.

Notification type for UI styling.

Prepare additional template assigns.

Returns the recipients for a specific channel.

The email's reply address — Reply-To — or nil for none.

Required resources for manual trigger.

Returns the primary resource module for this event.

Sample data for event previews.

Returns the URL path to the source resource for this event.

Email subject line.

Whether users can opt-out of this event via email preferences.

Returns the event version (for future use).

Types

attachment()

@type attachment() :: %{
  :filename => String.t(),
  :content_type => String.t(),
  :data => binary(),
  optional(:type) => :inline | :attachment,
  optional(:cid) => String.t()
}

An email attachment: raw binary data plus metadata. The transport base64-encodes data for JSONB Oban args; the email backend decodes and attaches it (e.g. via Swoosh.Email.attachment/2).

Optional keys:

  • :type - :attachment (the default when absent) sends a regular downloadable attachment; :inline embeds the file in the message body so it renders without the recipient approving a download.
  • :cid - Content-ID an :inline attachment is referenced by from the HTML template (<img src="cid:...">). Defaults to :filename when absent. Ignored for :attachment.

channel()

@type channel() :: AshDispatch.Channel.t()

context()

@type context() :: AshDispatch.Context.t()

event_id()

@type event_id() :: String.t()

recipient()

@type recipient() :: map()

recipients()

@type recipients() :: [recipient()]

Callbacks

action_label(context, channel)

(optional)
@callback action_label(context(), channel()) :: String.t() | nil

In-app notification action button label.

action_required?(context)

(optional)
@callback action_required?(context()) :: boolean()

Whether this event requires user action.

action_url(context, channel)

(optional)
@callback action_url(context(), channel()) :: String.t() | nil

In-app notification action URL.

applicable_for_user?(user)

(optional)
@callback applicable_for_user?(user :: term()) :: boolean()

Whether this event is applicable for a specific user.

Used by manual trigger to filter events based on user state. For example, only show email confirmation event if user is not confirmed.

This callback is only used for filtering the manual trigger event list in admin UIs. It does NOT affect normal event dispatch.

Example

# Only show in manual trigger if user hasn't confirmed
def applicable_for_user?(user) do
  is_nil(user.confirmed_at)
end

# Only show if user is archived
def applicable_for_user?(user) do
  not is_nil(user.archived_at)
end

attachments(context, channel)

(optional)
@callback attachments(context(), channel()) :: [attachment()]

Email attachments for a channel (optional).

Return a list of attachment/0 maps. Events without this callback send no attachments. Only the email transport consults it.

def attachments(context, %Channel{transport: :email}) do
  [%{filename: "invoice.pdf", content_type: "application/pdf", data: pdf_binary}]
end

Inline images (CID)

Add type: :inline to embed an image in the message body instead of appending it as a download, then reference it from the HTML template by its Content-ID:

def attachments(_context, %Channel{transport: :email}) do
  [
    %{
      filename: "logo.png",
      content_type: "image/png",
      data: File.read!("priv/static/images/logo.png"),
      type: :inline
    }
  ]
end

# templates/email.html.heex
# <img src="cid:logo.png" alt="Logo" />

The Content-ID defaults to :filename; pass cid: "logo" to reference the same image as <img src="cid:logo" /> instead.

category(context)

(optional)
@callback category(context()) :: String.t() | nil

Returns the email preference category for this event.

Maps to UserEmailPreferences field. If nil, email is not user-configurable.

channels(context)

@callback channels(context()) :: [channel()]

Returns the list of delivery channels for this event.

Example

def channels(_context) do
  [
    %Channel{transport: :in_app, audience: :user},
    %Channel{transport: :email, audience: :user, time: {:in, 300}}
  ]
end

counters(context, channel)

(optional)
@callback counters(context(), channel()) :: [atom()]

Counters to broadcast when this event creates notifications.

Return list of counter atoms for specific channel.

Example

def counters(_ctx, %Channel{transport: :in_app, audience: :user}) do
  [:pending_orders, :cart_items]
end

def counters(_ctx, _channel), do: []

data_key()

(optional)
@callback data_key() :: atom()

Returns the key to use for the resource in context.data.

Defaults to deriving from the resource module name if not specified.

Example

def data_key, do: :user

domain()

(optional)
@callback domain() :: atom()

Returns the domain this event belongs to.

Example

def domain, do: :orders

enrich_context(context, channel)

(optional)
@callback enrich_context(context(), channel()) :: context()

extra_content(context, channel)

(optional)
@callback extra_content(context(), channel()) :: map()

The module's OWN content keys — whatever the receiver can render and the library knows nothing about.

Why it exists

A transport's content is a closed list: :in_app carries a title, a body and a way onward, :webhook the same, :email its bodies. That is enough while the receiver is a notification list. It is not enough when the receiver is a Slack surface that can render facts in two columns, several buttons and an icon — without this, every new key of that kind is a change to the library.

Return a map. The keys land in receipt.content and travel in the envelope like everything else.

def extra_content(context, %Channel{audience: :slack_channel}) do
  %{
    slack_icon: "contract",
    slack_fields: [%{label: "Amount", value: "13,995"}],
    slack_buttons: [%{text: "Open customer", url: customer_link(context)}]
  }
end

def extra_content(_context, _channel), do: %{}

What it may NOT do

The addition is merged BELOW the transport's own keys. A module returning %{message: ...} here therefore does not overwrite notification_message/2 — anyone wanting to change the body already has a callback for that. The addition is for what is missing, never for rewriting what is there.

The library does NOT validate the content: the keys are an agreement between sender and receiver, and a transport that does not recognise them carries them unchanged. The receiver must therefore tolerate junk — see the consumer's own entry point.

from(context, channel)

(optional)
@callback from(context(), channel()) :: {String.t(), String.t()}

Email from address as {name, email} tuple.

generate_send_variables(context, map)

(optional)
@callback generate_send_variables(context(), map()) :: {:ok, map()} | {:error, String.t()}

Generate real variables for actual event dispatch (manual triggers, etc.).

This callback is called when actually sending an event, allowing events to generate real data (tokens, URLs, etc.) instead of using sample data from sample_data/0.

The callback receives:

  • context - The event context with loaded resource data
  • opts - The current variables map passed to dispatch

Returns:

  • {:ok, enhanced_opts} - Success with enhanced variables map
  • {:error, reason} - Failure (will abort dispatch, not send with sample data)

When it's called

  • Manual triggers: Always called when sending (not previewing)
  • Normal dispatch: Only called if variables are missing
  • Previews: Never called (uses sample_data/0 instead)

Security Note

IMPORTANT: For security-critical events (password reset, magic links, invitations), always return {:error, reason} if token generation fails. Never send emails with sample/fallback tokens - this creates a security vulnerability!

Example

For password reset events that need real JWT tokens:

def generate_send_variables(context, opts) do
  user = context.data[:user]

  # Only generate if not already provided
  if user && not Map.has_key?(opts, :reset_token) do
    case generate_password_reset_token(user) do
      {:ok, token} ->
        {:ok, Map.put(opts, :reset_token, token)}

      {:error, reason} ->
        # SECURITY: Fail dispatch instead of sending sample token!
        {:error, "Failed to generate password reset token: #{inspect(reason)}"}
    end
  else
    {:ok, opts}
  end
end

defp generate_password_reset_token(user) do
  AshAuthentication.Jwt.token_for_user(user, %{
    purpose: :password_reset,
    token_lifetime: {24, :hours}
  })
end

For invitation events:

def generate_send_variables(context, opts) do
  invited_user = context.data[:invited_user]

  if invited_user && not Map.has_key?(opts, :invitation_token) do
    case generate_invitation_token(invited_user) do
      {:ok, token} ->
        {:ok, Map.put(opts, :invitation_token, token)}

      {:error, reason} ->
        {:error, "Failed to generate invitation token: #{inspect(reason)}"}
    end
  else
    {:ok, opts}
  end
end

Best practices

  • Always check if variable already exists before generating
  • Return {:error, reason} on generation failure for security-critical data
  • Use this for secure tokens, dynamic URLs, or time-sensitive data
  • Don't duplicate logic from normal action flows - they can provide variables directly
  • Single Source of Truth: Keep token generation logic ONLY in this callback, not scattered across RPC actions, senders, or other entry points

id()

@callback id() :: event_id()

Returns the unique event identifier.

Example

def id, do: "orders.created"

notification_message(context, channel)

(optional)
@callback notification_message(context(), channel()) :: String.t()

In-app notification message.

notification_title(context, channel)

(optional)
@callback notification_title(context(), channel()) :: String.t()

In-app notification title.

notification_type(context)

(optional)
@callback notification_type(context()) :: :info | :success | :warning | :error

Notification type for UI styling.

prepare_data(any, any)

(optional)
@callback prepare_data(any(), any()) :: map()

prepare_template_assigns(context, channel)

(optional)
@callback prepare_template_assigns(context(), channel()) :: map()

Prepare additional template assigns.

Return a map that will be available in templates.

Example

def prepare_template_assigns(context, channel) do
  %{
    order_number: format_order_id(context.data.order),
    order_url: build_url(context.data.order, channel)
  }
end

recipients(context, channel)

@callback recipients(context(), channel()) :: recipients()

Returns the recipients for a specific channel.

Default implementation delegates to RecipientResolver.from_audience/2.

reply_to(context, channel)

(optional)
@callback reply_to(context(), channel()) :: String.t() | nil

The email's reply address — Reply-To — or nil for none.

Why it exists

The sender (from/2) is the organisation: an address that often does not accept replies. But an email WRITTEN by a person — "we're moving our meeting", "here's your quote" — invites an answer, and without a reply header that answer lands in noreply@ and is read by nobody.

Until 0.8.1 there was no way at all. One consumer worked around it with a proxy in front of the Swoosh backend that looked the sender up in the database by recipient plus subject, per email — a second state machine beside the library's, and one read per send. That is a symptom of a missing callback, not a design.

Shape

def reply_to(context, %Channel{transport: :email}) do
  context.data.meeting.user.email
end

def reply_to(_context, _channel), do: nil

The default is nil, i.e. today's behaviour. Anything that is not a string is ignored: an email without a reply header is cheaper than an email that never leaves.

The value is carried in receipt.content and not only in the job's args. Two reasons: a retry builds its args from the receipt (SendEmail.new_for_receipt/1) and would otherwise lose the header — the same trap attachments already have special handling for — and the field becomes readable afterwards, so a consumer can MEASURE that the reply path was actually set.

required_resources()

(optional)
@callback required_resources() :: keyword(module() | {module(), keyword()})

Required resources for manual trigger.

Declares which resources this event needs to render properly, along with optional Ash filters to restrict which records can be selected.

Used by manual trigger UIs to show resource selectors. For example, an "order processed" event needs an Order resource, and should only show orders with status: :processed.

Returns a keyword list where:

  • Key is the data key (e.g., :order, :ticket)
  • Value is either:
    • Just the resource module: ProductOrder
    • Tuple with module and filter: {ProductOrder, filter: [status: :processed]}

Examples

# Event needs an order, any status
def required_resources do
  [order: MyApp.Orders.ProductOrder]
end

# Event needs a processed order only
def required_resources do
  [order: {MyApp.Orders.ProductOrder, filter: [status: :processed]}]
end

# Event needs multiple resources
def required_resources do
  [
    order: {MyApp.Orders.ProductOrder, filter: [status: :completed]},
    user: MyApp.Accounts.User
  ]
end

# Event needs no resources (just user context)
def required_resources do
  []
end

The filter keyword list is passed directly to Ash queries, supporting any valid Ash filter syntax.

resource()

(optional)
@callback resource() :: module()

Returns the primary resource module for this event.

This is used by the manual trigger system to know what resource to load. Mirrors the DSL pattern where events are defined on a resource.

Example

def resource, do: MyApp.Accounts.User

sample_data()

(optional)
@callback sample_data() :: map()

Sample data for event previews.

Return a map with sample data matching the expected event data structure.

Example

def sample_data do
  %{
    order: MyApp.Factory.build(MyApp.Orders.Order),
    user: MyApp.Factory.build(MyApp.Accounts.User)
  }
end

should_send?(context, channel)

(optional)
@callback should_send?(context(), channel()) :: boolean()

source_url(context, channel)

(optional)
@callback source_url(context(), channel()) :: String.t() | nil

Returns the URL path to the source resource for this event.

This callback enables linking delivery receipts back to their source resources (e.g., linking an "order created" receipt to the order itself).

The callback receives:

  • context - The event context with loaded resource data
  • channel - The channel being dispatched to (allows audience-specific paths)

Returns:

  • String.t() - The URL path (e.g., "/admin/orders/uuid-here")
  • nil - If no source URL is applicable

Pattern Matching on Audience

Different audiences typically have different paths to the same resource:

@impl true
def source_url(context, %{audience: :admin}) do
  path = Application.get_env(:my_app, :app_paths)[:admin][:order]
  String.replace(path, ":id", to_string(context.data.order.id))
end

def source_url(context, %{audience: :user}) do
  path = Application.get_env(:my_app, :app_paths)[:user][:order]
  String.replace(path, ":id", to_string(context.data.order.id))
end

def source_url(_context, _channel), do: nil

Usage

The URL is computed at runtime via a calculation on DeliveryReceipt. This allows the same receipt to show different URLs based on who is viewing (admin vs user).

To load the source URL on a receipt:

receipt = Ash.load!(receipt, [:source_url])

The calculation uses source_type and source_id (persisted on the receipt) to look up the event module and call this callback.

subject(context, channel)

(optional)
@callback subject(context(), channel()) :: String.t()

Email subject line.

Can be overridden per-channel via pattern matching:

def subject(_ctx, %Channel{audience: :user}), do: "Your order"
def subject(_ctx, %Channel{audience: :admin}), do: "New order"

template_variant(context, channel)

(optional)
@callback template_variant(context(), channel()) :: atom() | nil

user_configurable?(context)

(optional)
@callback user_configurable?(context()) :: boolean()

Whether users can opt-out of this event via email preferences.

version()

(optional)
@callback version() :: pos_integer()

Returns the event version (for future use).

Functions

dispatch(body)

(macro)