Packages

Event-driven notification system for Ash Framework with multiple transport types

Retired package: Release invalid - Use vulcora organization package

Current section

Files

Jump to
ash_dispatch lib resources email_event.ex
Raw

lib/resources/email_event.ex

defmodule AshDispatch.Resources.EmailEvent do
@moduledoc """
Read-only Ash resource exposing email event metadata via RPC.
This resource provides structured access to all email events in the system without
requiring a database table. It reads from the `:ash_dispatch` `:event_modules` config.
## Features
- Lists all registered email events with metadata
- Provides template previews (HTML/text) in development
- Exposes channel configuration (subject, timing, audience)
- Shows event metadata (domain, category, user preferences)
## Usage
Add this resource to your domain and configure RPC + authorization:
defmodule MyApp.Emails do
use Ash.Domain,
extensions: [AshTypescript.Rpc]
typescript_rpc do
resource AshDispatch.Resources.EmailEvent do
rpc_action :list_email_events, :list
rpc_action :get_email_event, :get
end
end
resources do
resource AshDispatch.Resources.EmailEvent do
define :list_email_events, action: :list
define :get_email_event, action: :get, args: [:id], get?: true
end
end
end
**Note:** Add authorization policies in your app's domain configuration.
"""
# Capture Mix.env at compile time since Mix is not available in releases
@compile_env Mix.env()
use Ash.Resource,
domain: AshDispatch.Domain,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshTypescript.Resource]
typescript do
type_name("EmailEvent")
end
ets do
private? true
end
policies do
# Admin users can read all events
policy actor_attribute_equals(:super_admin, true) do
authorize_if always()
end
# Default deny for non-admins
policy always() do
forbid_if always()
end
end
attributes do
attribute :id, :string do
public? true
allow_nil? false
primary_key? true
description "Event identifier (e.g., 'orders.created')"
end
attribute :module, :string do
public? true
allow_nil? false
description "Event module name"
end
attribute :domain, :atom do
public? true
allow_nil? false
description "Domain this event belongs to"
end
attribute :user_configurable, :boolean do
public? true
default false
description "Whether users can configure delivery preferences"
end
attribute :category, :atom do
public? true
allow_nil? true
description "Email preference category"
end
attribute :channel_count, :integer do
public? true
default 0
description "Total number of channels for this event"
end
attribute :email_channel_count, :integer do
public? true
default 0
description "Number of email channels for this event"
end
attribute :channels, {:array, :map} do
public? true
default []
description "List of email channel configurations with subjects, timing, and rendered previews"
end
attribute :action_required, :boolean do
public? true
default false
description "Whether this event requires user action"
end
attribute :notification_type, :atom do
public? true
allow_nil? true
description "Type of notification (success, info, warning, error)"
end
end
actions do
read :list do
primary? true
description "List all email events"
manual fn _query, _opts, _context ->
events =
list_all_events()
|> Enum.sort_by(&{&1.domain, &1.id})
|> Enum.map(&to_ash_struct/1)
{:ok, events}
end
end
read :get do
description "Get a specific email event by ID"
argument :id, :string, allow_nil?: false
manual fn query, _opts, _context ->
id = Ash.Query.get_argument(query, :id)
event = get_event_by_id(id)
result = if event, do: [to_ash_struct(event)], else: []
{:ok, result}
end
end
end
code_interface do
define :list, action: :list
define :get, action: :get, args: [:id]
end
# Public helpers for data transformation
defp list_all_events do
event_modules = Application.get_env(:ash_dispatch, :event_modules, [])
event_modules
|> Enum.map(fn {event_id, module} ->
enrich_event(event_id, module)
end)
|> Enum.filter(&has_email_channels?/1)
end
defp get_event_by_id(id) do
event_modules = Application.get_env(:ash_dispatch, :event_modules, [])
case Enum.find(event_modules, fn {event_id, _module} -> event_id == id end) do
{event_id, module} -> enrich_event(event_id, module)
nil -> nil
end
end
defp enrich_event(event_id, event_module) do
# Use event module's own sample data for accurate previews
# Wrapped in try/rescue to handle factory errors gracefully
sample_data =
if function_exported?(event_module, :sample_data, 0) do
try do
event_module.sample_data()
rescue
_ -> %{}
end
else
%{}
end
# Create context with sample data
sample_context = %AshDispatch.Context{
event_id: event_id,
data: sample_data,
metadata: %{}
}
# Get all channels - first check inline DSL config, then fall back to event module
all_channels = get_channels_for_event(event_id, event_module, sample_context)
email_channels =
all_channels
|> Enum.filter(&(&1.transport == :email))
|> Enum.map(&serialize_channel(&1, event_module, sample_context))
# Get domain (wrapped in try/rescue)
domain =
if function_exported?(event_module, :domain, 0) do
try do
event_module.domain()
rescue
_ -> nil
end
else
nil
end
# Get user_configurable (wrapped in try/rescue)
user_configurable =
if function_exported?(event_module, :user_configurable?, 1) do
try do
event_module.user_configurable?(sample_context)
rescue
_ -> false
end
else
false
end
# Get category (wrapped in try/rescue)
category =
if function_exported?(event_module, :category, 1) do
try do
event_module.category(sample_context)
rescue
_ -> nil
end
else
nil
end
# Get action_required (wrapped in try/rescue)
action_required =
if function_exported?(event_module, :action_required?, 1) do
try do
event_module.action_required?(sample_context)
rescue
_ -> false
end
else
false
end
# Get notification_type (wrapped in try/rescue)
notification_type =
if function_exported?(event_module, :notification_type, 1) do
try do
event_module.notification_type(sample_context)
rescue
_ -> nil
end
else
nil
end
%{
id: event_id,
module: to_string(event_module),
domain: domain,
user_configurable: user_configurable,
category: category,
channel_count: length(all_channels),
email_channel_count: length(email_channels),
channels: email_channels,
action_required: action_required,
notification_type: notification_type
}
end
defp serialize_channel(%AshDispatch.Channel{} = channel, event_module, context) do
# Get variant if available
variant =
if function_exported?(event_module, :template_variant, 2) do
event_module.template_variant(context, channel)
else
nil
end
# Prepare template assigns (wrapped in try/rescue for sample data that may be incomplete)
additional_assigns =
if function_exported?(event_module, :prepare_template_assigns, 2) do
try do
event_module.prepare_template_assigns(context, channel)
rescue
_ -> %{}
end
else
%{}
end
# Get subject first (needed for template layout)
subject =
if function_exported?(event_module, :subject, 2) do
try do
event_module.subject(context, channel)
rescue
_ -> nil
end
else
nil
end
# Add subject to assigns for template rendering
assigns_with_subject = Map.merge(additional_assigns, %{subject: subject})
enhanced_context = %{context | data: Map.merge(context.data, assigns_with_subject)}
# Render template previews (only in development to avoid performance issues)
# Wrapped in try/rescue to handle incomplete sample data gracefully
{html_preview, text_preview} =
if @compile_env == :dev do
try do
# Get event_dir from module source location for template resolution
event_dir = get_event_dir(event_module)
otp_app = Application.get_env(:ash_dispatch, :otp_app)
html =
case AshDispatch.TemplateResolver.render(
event_module: event_module,
event_dir: event_dir,
otp_app: otp_app,
format: :html,
transport: channel.transport,
variant: variant,
assigns: enhanced_context.data
) do
{:ok, html} -> html
{:error, _} -> nil
end
text =
case AshDispatch.TemplateResolver.render(
event_module: event_module,
event_dir: event_dir,
otp_app: otp_app,
format: :text,
transport: channel.transport,
variant: variant,
assigns: enhanced_context.data
) do
{:ok, text} -> text
{:error, _} -> nil
end
{html, text}
rescue
_ -> {nil, nil}
end
else
{nil, nil}
end
%{
transport: channel.transport,
audience: channel.audience,
variant: variant,
subject: subject,
time: serialize_time(channel.time),
policy: channel.policy,
preview_html: html_preview,
preview_text: text_preview
}
end
defp serialize_time(nil), do: %{type: "immediate"}
defp serialize_time({:in, seconds}), do: %{type: "delayed", seconds: seconds}
defp serialize_time({:at, %DateTime{} = dt}),
do: %{type: "scheduled", datetime: DateTime.to_iso8601(dt)}
defp serialize_time(_), do: %{type: "unknown"}
defp has_email_channels?(event), do: Map.get(event, :email_channel_count, 0) > 0
defp to_ash_struct(map) do
struct(__MODULE__, map)
|> Ash.Resource.set_metadata(%{})
end
# Get the directory containing the event module's source file
defp get_event_dir(event_module) do
case event_module.__info__(:compile)[:source] do
nil -> nil
source -> source |> to_string() |> Path.dirname()
end
rescue
_ -> nil
end
# Get channels for an event - inline DSL first, then event module fallback
defp get_channels_for_event(event_id, event_module, context) do
# First check inline DSL channels from resources
case get_inline_dsl_channels(event_id) do
channels when is_list(channels) and channels != [] ->
# Use inline DSL channels
channels
_ ->
# Fall back to event module callback
if function_exported?(event_module, :channels, 1) do
try do
event_module.channels(context)
rescue
_ -> []
end
else
[]
end
end
end
# Get inline DSL channels by scanning resources with dispatch extension
defp get_inline_dsl_channels(event_id) do
# Get all resources that might have dispatch DSL
# We check resources from the otp_app's domains
otp_app = Application.get_env(:ash_dispatch, :otp_app)
if otp_app do
otp_app
|> Application.get_env(:ash_domains, [])
|> Enum.flat_map(&get_domain_resources/1)
|> Enum.find_value(fn resource ->
case get_dispatch_channels(resource, event_id) do
channels when is_list(channels) and channels != [] -> channels
_ -> nil
end
end)
else
nil
end
end
defp get_domain_resources(domain) do
try do
Ash.Domain.Info.resources(domain)
rescue
_ -> []
end
end
defp get_dispatch_channels(resource, event_id) do
try do
dispatch_channels = Spark.Dsl.Extension.get_persisted(resource, :dispatch_channels, %{})
Map.get(dispatch_channels, event_id)
rescue
_ -> nil
end
end
end