Packages
ash_dispatch
0.9.4-rc.1
0.9.4-rc.1
0.9.4-rc.0
0.9.3
0.9.2
0.9.1
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.3
0.7.2
0.7.1
0.7.0
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.6
0.5.5
0.5.4
0.5.3
0.5.1
0.5.0
0.1.4
retired
0.1.3
retired
0.1.1
retired
0.1.0
retired
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
ash_dispatch
README.md
README.md
# AshDispatch
[](https://hex.pm/packages/ash_dispatch)
[](https://hexdocs.pm/ash_dispatch)
[](https://opensource.org/licenses/MIT)
> **Pre-1.0.** AshDispatch runs notifications in production, but its public API is still settling on the way to 1.0 and **may change between minor versions** — every change, and what to do about it, is in the [CHANGELOG](CHANGELOG.md). Pin the minor version you use (`~> 0.x.y`, not `~> 0.x`) and please [open issues](https://github.com/Vulcora/ash_dispatch/issues).
---
**AshDispatch** is an event-driven notification and messaging system for [Ash Framework](https://ash-hq.org). It provides a declarative DSL for defining events in your resources and automatically dispatching them across multiple transports (email, in-app notifications, broadcast/PubSub, Discord, Slack, webhooks, etc.).
## Why AshDispatch?
### Declarative Event Definitions
Define events directly in your resources using familiar Ash DSL patterns:
```elixir
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
actions do
create :create_from_cart do
accept [:user_id]
# Your action logic...
end
end
dispatch do
event :created,
trigger_on: :create_from_cart,
priority: :standard,
channels: [
[transport: :in_app, audience: :user],
[transport: :email, audience: :user, delay: 300]
],
content: [
subject: "Order #{{order_number}} created",
notification_title: "Your order was created",
notification_message: "Order #{{order_number}} is being processed"
],
metadata: [
notification_type: :success
]
end
end
```
### Key Features
- **🎯 Automatic Dispatch** - Events are automatically triggered by resource actions
- **📬 Multi-Transport** - Email, in-app, Discord, Slack, SMS, webhooks out of the box
- **🚨 Priority Levels** - `:urgent`, `:standard`, `:informational` — consumers gate delivery timing by priority
- **⏰ Delayed Delivery** - Schedule notifications for later delivery
- **👤 User Preferences** - Respect user notification preferences automatically
- **📊 Delivery Tracking** - Full audit trail with delivery receipts
- **🔄 Automatic Retries** - Failed deliveries retry with exponential backoff
- **🎨 Template Interpolation** - `{{variable}}` syntax for dynamic content
- **🌍 Localization (i18n)** - Multi-language templates with dynamic locale resolution from record fields
- **📈 Real-Time Counters** - Declarative counter DSL with automatic Phoenix Channel broadcasting
- **⚡ Zero-Config Helpers** - `ChannelState`, `CounterLoader`, `NotificationLoader` for Phoenix integration
- **⚛️ Generated TypeScript SDK** - Ready-to-use React hooks, NotificationProvider, and NotificationBell components
- **📡 Lightweight Broadcast** - `:broadcast` transport for real-time PubSub events without receipt overhead
- **🔌 Extensible** - Add custom transports and event modules
- **🔍 Previews on the send path** - `AshDispatch.Preview` renders any event exactly as it would be sent; `AshDispatch.Test.assert_all_render/1` makes "every mail renders" a one-line test
- **🔀 Smart Deduplication** - Optional `deduplicate_group` to prevent duplicate notifications when audiences overlap
## Tutorials
- [Getting Started with AshDispatch](lib/documentation/tutorials/getting-started.md) - Basic event setup with inline DSL
- [Manual Dispatch and Event Modules](lib/documentation/tutorials/manual-dispatch-and-events.md) - Standalone events, manual triggers, and the two-path pattern
## Topics
- [What is AshDispatch?](lib/documentation/topics/what-is-ash-dispatch.md)
- [Localization (i18n)](lib/documentation/topics/localization.md) - Multi-language templates with dynamic locale resolution
- [Phoenix Channel Integration](lib/documentation/topics/phoenix-integration.md) - Zero-config helpers for real-time updates
- [Counter Broadcasting](lib/documentation/topics/counter-broadcasting.md) - Declarative counter DSL with auto-discovery
- [TypeScript SDK](lib/documentation/topics/typescript-sdk.md) - Generated React hooks, components, and Zustand store
- [Priority Levels](lib/documentation/topics/priority.md) - Urgent, standard, informational — context-aware delivery gating
- [User Preferences](lib/documentation/topics/user-preferences.md)
- [Tenancy](lib/documentation/topics/tenancy.md) - receipts, notifications and preferences per tenant
- [Recipient Resolution](lib/documentation/topics/recipient-resolution.md)
- [Configuration](lib/documentation/topics/configuration.md)
- [App Integration](lib/documentation/topics/app-integration.md)
- [Code Generation](lib/documentation/topics/code-generation.md)
- [Oban Configuration](lib/documentation/topics/oban-configuration.md)
- [Previews and Testing](lib/documentation/topics/previews-and-testing.md) - Render any event as sent, test that every mail renders, strict templates
## Reference
- [AshDispatch.Resource DSL](lib/documentation/dsls/DSL-AshDispatch-Resource.md)
## Architecture Overview
```mermaid
graph TB
A[Resource Action] -->|triggers| B[Event]
B -->|creates| C[DeliveryReceipt]
C -->|dispatches to| D{Transport}
D -->|in_app| E[Notification]
D -->|email| F[Oban Job]
D -->|discord| G[Webhook]
F -->|sends| H[Email Service]
E -->|updates| I[User UI]
G -->|posts| J[Discord Channel]
```
## Installation
### Quick Start (Recommended)
```bash
mix igniter.install ash_dispatch
```
This creates all required resources, domains, and configuration automatically. Then run migrations:
```bash
mix ash.codegen add_ash_dispatch
mix ash.migrate
```
### Manual Installation
```elixir
def deps do
[
{:ash_dispatch, "~> 0.9.1"}
]
end
```
See [Getting Started](lib/documentation/tutorials/getting-started.md) for complete manual setup instructions.
### Production Configuration (Required for Releases)
**Important:** For production releases, you must enable template compilation. Without this, email templates won't load in production because `lib/` source files aren't included in releases.
```elixir
# config/prod.exs - REQUIRED for releases
config :ash_dispatch,
compile_templates: true
```
This copies templates to `priv/ash_dispatch/templates/` during compilation, ensuring they're available at runtime. See [Code Generation](lib/documentation/topics/code-generation.md) for details.
**Also required: the retry cron.** Failed deliveries are only ever retried by `AshDispatch.Workers.RetryFailedDeliveries` — Oban's own job retries no-op once a receipt is `:failed`. Schedule it (and give it its `:default` queue):
```elixir
config :my_app, Oban,
queues: [emails: 10, default: 5],
plugins: [
{Oban.Plugins.Cron,
crontab: [{"*/15 * * * *", AshDispatch.Workers.RetryFailedDeliveries}]}
]
```
Since 0.9.3 `mix ash_dispatch.install` writes this for you — queues and both scheduled workers — adding only what is missing and never changing a value you had set. To run the retries in the queue your mail runs in, so pausing that queue pauses them too: `config :ash_dispatch, retry_queue: :emails`.
Optional hardening, both new in 0.5.4: `AshDispatch.WebhookHandlers.Resend.verify/3` verifies the Svix signature on Resend webhooks before you process them, and `AshDispatch.Workers.ScrubSensitiveContent` (cron) blanks stored bodies of events declaring `metadata: [sensitive_content: true]` — for OTP codes and reset links that should not live in the database forever. See [Delivery Receipts](lib/documentation/topics/delivery-receipts.md).
### Whether deliveries really go out
One setting answers that for every transport — email, SMS, push, in-app and webhooks alike (new in 0.9.3; see `AshDispatch.DeliveryMode`):
```elixir
# config/dev.exs — render and record everything, send nothing
config :ash_dispatch, delivery_mode: :dry_run
# config/staging.exs — the team can be mailed, nobody else
config :ash_dispatch,
delivery_mode: [
default: :blocked,
email: {:allow_list, ["ops@example.com", "@example.test"]},
in_app: :live
]
```
- `:live` — send. **The default**; a host that sets nothing is unaffected.
- `:blocked` — never send. The receipt is `:skipped` and says it was held back.
- `:dry_run` — render, create the receipt, enqueue the job, run the worker, send nothing. The receipt holds the letter that would have gone out.
- `{:allow_list, [...]}` — send only to these recipients; everyone else is treated as blocked. Entries match an exact address, a domain (`"@example.test"`), a phone prefix (`"+4670"`), an explicit prefix (`"team-*"`) or `"*"`.
A held-back delivery is `:skipped`, never `:failed` and never reported as sent, and the reason is one of `"delivery_blocked"`, `"delivery_dry_run"` or `"delivery_not_allowed"` so it can be counted.
`config :ash_dispatch, skip_email_delivery: true` still works and still writes the reason it always wrote; it now means `delivery_mode: [email: :blocked]`.
### Whether a recipient may be reached at all
A preference is what a person *wants*, and a mandatory event (`metadata: [user_configurable: false]`) overrides it on purpose. A closed account is a different question, and until 0.9.3 the library had no way to ask it. Now it does (`AshDispatch.EligibleRecipient`):
```elixir
config :ash_dispatch, eligible_recipient: MyApp.Notifications.Eligibility
defmodule MyApp.Notifications.Eligibility do
@behaviour AshDispatch.EligibleRecipient
@impl true
def eligible?(_recipient, opts) do
case MyApp.Accounts.get_user(opts[:user_id]) do
{:ok, %{deactivated_at: nil, blocked: false}} -> true
{:ok, _user} -> {false, "account closed"}
_ -> true
end
end
end
```
Asked for every event, mandatory ones included; asked when the recipients are resolved and again at the moment of sending, because an account can be closed while a delayed delivery waits in the queue. An ineligible recipient gets a `:skipped` receipt saying `"recipient_ineligible: account closed"` — never a silent drop. Without the setting, nothing is asked and nothing changes.
### One schema per tenant
A host with Ash `multitenancy strategy :context` can put the library's own rows in the tenant's schema (new in 0.9.4; see the [Tenancy](lib/documentation/topics/tenancy.md) guide):
```elixir
use AshDispatch.Resources.DeliveryReceipt.Base,
repo: MyApp.Repo,
domain: MyApp.Deliveries,
notification_resource: MyApp.Notifications.Notification,
multitenancy: :context
config :ash_dispatch, tenants: MyApp.Tenants
```
The tenant is then carried through dispatch → the Oban job's args → the resolver → the send → the receipt → the provider webhook → the cron (one pass per tenant) → previews. A dispatch without a tenant is refused rather than written to `public`, a tenant that arrives from outside is checked against your `AshDispatch.Tenants` before it reaches a query, and `job_args: :minimal` keeps rendered bodies out of `oban_jobs`, which is one table for every tenant.
For a host that keeps receipts in one schema but wants to know which tenant each was for, `tenant_column: true` adds a nullable `tenant` column instead.
**A host that does nothing sees nothing of this**: a resource without `multitenancy:` is never handed a tenant, which the suite asserts rather than claims.
### A gate on the event
An event can declare a condition that decides whether it goes out at all (`AshDispatch.Gate`):
```elixir
dispatch do
event :reminder,
trigger_on: :manual,
gate: {MyApp.Bookings, :still_booked?},
channels: [
[transport: :email, audience: :user, time: {:in, 86_400}],
[transport: :in_app, audience: :user]
]
end
```
Asked at dispatch, once per channel, and **again at the moment of sending** — which is what makes it work for a reminder queued for tomorrow morning and a booking cancelled tonight. When the gate is closed, a `:skipped` receipt with the reason exists for *every channel that would have been used*, so "we never sent it because the booking had been cancelled" is a question the receipts can answer. Only `true` and `:ok` let the event out; a gate that raises does not send and still leaves the record. An event module can implement `gate/1` instead of using the DSL option.
## Quick Example
```elixir
# 1. Add extension to resource
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
extensions: [AshDispatch.Resource]
# 2. Define events
dispatch do
# Inline-complete event (no module needed!)
# Works out of the box with just DSL configuration
event :created,
trigger_on: :create,
channels: [
[transport: :in_app, audience: :user]
],
content: [
notification_title: "Ticket Created",
notification_message: "{{user_name}} created ticket #{{id}}"
],
metadata: [
notification_type: :success
]
# Event with email requires templates
# Run `mix ash.codegen` to generate template stubs
event :assigned,
trigger_on: :assign,
channels: [
[transport: :in_app, audience: :user],
[transport: :email, audience: :user]
],
content: [
subject: "Ticket #{{id}} assigned to you",
notification_title: "Ticket Assigned"
]
# Complex event with custom module (your override)
event :escalated,
trigger_on: :escalate,
module: MyApp.Events.Tickets.Escalated
end
end
# 3. That's it! Events dispatch automatically when actions run
Ticket
|> Ash.Changeset.for_create(:create, %{title: "Bug report"})
|> Ash.create!()
# -> Automatically dispatches :created event
# -> Creates in-app notification for user (no templates needed!)
```
### Hybrid Architecture
AshDispatch uses a **hybrid approach** where DSL configuration takes precedence, and generated modules provide fallbacks:
1. **Define events in DSL** - Configure channels, content, and metadata directly in your resource
2. **Run `mix ash.codegen`** - Generates event modules with templates for each event (unless you provide `module:` override)
3. **Hybrid dispatch** - At runtime, DSL content is used first; module callbacks fill in gaps (templates, recipients, etc.)
**Override with custom modules:** Set `module:` to provide your own implementation for complex events (custom recipients, conditional sending, etc.)
## Real-Time Counter Broadcasting
AshDispatch also provides **automatic real-time counter updates** with zero boilerplate:
```elixir
# 1. Define counters in your resource
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their own pending orders
counter :pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: ["orders"]
# Admins see ALL pending orders
counter :admin_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :admin_pending_orders,
query_filter: [status: :pending],
audience: :admin,
invalidates: ["orders", "analytics"]
end
end
# 2. Configure broadcasting (one line!)
# config/config.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
# 3. Use helper in Phoenix Channel (one line!)
defmodule MyAppWeb.UserChannel do
alias AshDispatch.Helpers.ChannelState
def handle_info(:after_join, socket) do
# Loads ALL counters automatically - no manual queries!
initial_state = ChannelState.build(socket.assigns.user_id)
# => %{"counters" => %{"pending_orders" => 5}, "notifications" => [...]}
push(socket, "initial_state", initial_state)
{:noreply, socket}
end
end
# 4. That's it! Counters update in real-time automatically
Order.create!(%{status: :pending})
# -> Automatically broadcasts counter update to Phoenix Channel
# -> Frontend receives "counter_updated" event with new value
```
**Zero configuration, automatic discovery, real-time updates!**
See [Counter Broadcasting](lib/documentation/topics/counter-broadcasting.md) and [Phoenix Integration](lib/documentation/topics/phoenix-integration.md) for complete guides.
## Internal Architecture
AshDispatch uses centralized resolver modules to ensure consistent behavior across all dispatch pathways:
### ChannelResolver
`AshDispatch.ChannelResolver` handles all channel resolution with consistent priority logic:
- **DSL channels take precedence** over module callbacks
- Converts various channel formats (DSL structs, maps, keyword lists) to `%Channel{}` structs
- Supports future `strategy: :merge` option to combine both sources
```elixir
# Used internally by Dispatcher, ManualTrigger, preview generators, etc.
channels = AshDispatch.ChannelResolver.resolve(event_id, event_module, context,
dsl_channels: event_config.channels
)
```
### EventResolver
`AshDispatch.EventResolver` centralizes event module discovery and safe callback execution:
- Find event modules by ID
- Call callbacks with consistent error handling
- Build sample contexts for previews
```elixir
# Find an event module
{:ok, module} = AshDispatch.EventResolver.find_module("orders.created")
# Safe callback execution with defaults
subject = AshDispatch.EventResolver.subject(module, context, channel, default: "No subject")
```
### Naming
`AshDispatch.Naming` handles consistent filename and label generation for multi-audience templates:
```elixir
# Generate audience-specific filenames
AshDispatch.Naming.filename("email", :admin, "summary", "html")
# => "email.admin.summary.html"
# Generate display labels
AshDispatch.Naming.label(:email, :admin, "summary")
# => "email (admin, summary)"
```
## Design Principles
### 1. Resource-Centric
Events are defined in resources, just like actions, attributes, and relationships.
### 2. Progressive Complexity
Start with simple inline events. Upgrade to callback modules when you need custom logic.
### 3. Receipt-First Pattern
All deliveries create a receipt record before dispatch, enabling full audit trails and reliable retries.
### 4. Fail-Safe Defaults
User preferences, rate limiting, and delivery policies protect users from notification fatigue.
### 5. Framework Integration
Deep integration with Ash actions, Oban jobs, and the Ash ecosystem.
### 6. Centralized Logic
Channel resolution, event discovery, and naming all use centralized modules to ensure consistency and maintainability.
## Project Status
**Pre-1.0.** AshDispatch is published on [Hex](https://hex.pm/packages/ash_dispatch) and runs in production:
- ✅ **Working today:** the resource DSL, runtime dispatcher, delivery receipts, in-app / email / SMS / push / webhook / Discord / Slack / broadcast / `:oban` / `:custom_topic` transports, Oban-backed async delivery and retries, real-time counters, localization, and the generated TypeScript SDK.
- 🧪 **Still settling:** the public API may change between minor versions before `1.0`; the [CHANGELOG](CHANGELOG.md) says what changed and what to do. Some transports and helpers are less battle-tested than others.
- 🗺️ **Toward 1.0:** API stabilization, broader transport coverage, and more docs/examples.
## Contributing
AshDispatch is open source and contributions are welcome. Issues and PRs go to [github.com/Vulcora/ash_dispatch](https://github.com/Vulcora/ash_dispatch). Since the API is still evolving, please open an issue to discuss larger changes before submitting a PR.
## License
MIT License - see LICENSE file for details.
## Acknowledgments
Built on the excellent [Ash Framework](https://ash-hq.org) by Zach Daniel and the Ash community.
Inspired by patterns from AshStateMachine, AshAuthentication, and years of building notification systems.