Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/preview.ex
defmodule AshDispatch.Preview.Rendered do
@moduledoc """
One rendered letter from `AshDispatch.Preview.render/3`.
For an `:email` channel the envelope fields — `:to`, `:from`, `:subject`,
`:reply_to`, `:html_body`, `:text_body`, `:attachments`, `:headers` — are
the params the email backend would have been called with, built by the same
functions the send path uses. For other transports `:title`, `:message`,
`:action_url` and `:action_label` carry the content and the envelope is
empty.
- `:content` - the full content map the receipt would have stored
- `:context` - the `AshDispatch.Context` the letter was rendered in
- `:recipient` - the identifier the receipt would have been addressed to
(or the `:recipient_email` override); `nil` when nobody could be resolved
- `:recipient_name` - the recipient's display name, when configured
- `:seed` - the seed the sample data was drawn with (`:sample` source only),
so the same letter can be rendered again
- `:error` - why the letter could not be rendered, or `nil`. A channel that
fails renders as a `Rendered` with `:error` set; it never takes the other
channels down
"""
@type t :: %__MODULE__{
event_id: String.t(),
module: module() | nil,
transport: atom(),
audience: atom(),
variant: atom() | String.t() | nil,
locale: String.t() | nil,
channel: AshDispatch.Channel.t(),
seed: integer() | nil,
recipient: String.t() | nil,
recipient_name: String.t() | nil,
to: String.t() | nil,
from: String.t() | {String.t(), String.t()} | nil,
reply_to: String.t() | nil,
subject: String.t() | nil,
html_body: String.t() | nil,
text_body: String.t() | nil,
attachments: [map()],
headers: %{String.t() => String.t()},
title: String.t() | nil,
message: String.t() | nil,
action_url: String.t() | nil,
action_label: String.t() | nil,
content: map(),
context: AshDispatch.Context.t() | nil,
error: String.t() | nil
}
defstruct [
:event_id,
:module,
:transport,
:audience,
:variant,
:locale,
:channel,
:seed,
:recipient,
:recipient_name,
:to,
:from,
:reply_to,
:subject,
:html_body,
:text_body,
:title,
:message,
:action_url,
:action_label,
:context,
:error,
attachments: [],
headers: %{},
content: %{}
]
end
defmodule AshDispatch.Preview do
@moduledoc """
Renders an event exactly as it would be sent — without sending it.
Two functions:
- `events/0` — the catalogue: one row per event × channel (transport,
audience, variant), with the event's metadata. What an admin page lists.
- `render/3` — one event, rendered from sample data, a stored record or a
data map of your own, into `AshDispatch.Preview.Rendered` structs.
## The same path as the send
A preview is only worth having if it shows what the recipient gets. So
there is no preview renderer here: `render/3` opens the event the way the
send path does (`Dispatcher.prepare/3`, or the notifier's preparation when
the event is triggered by a resource action), resolves the recipient the
way the dispatcher does, renders through the function that builds the
receipt (`Dispatcher.build_receipt/4`), and builds the envelope with the
email transport's job args and the `SendEmail` worker's backend params.
What differs is only what is left out: no receipt row, no job, no delivery.
`AshDispatch.preview/3`, `AshDispatch.Resources.EmailEvent` and the
ManualTrigger `:preview` action all render through here.
## Sources
- `:sample` (the default) — the event's sample data:
`sample_data/1` with the seed, falling back on `sample_data/0`. Without
either, the first record of the event's resource is read instead.
- `{:record, id}` — the record with that id, from the event's resource. For
an event triggered by a resource action this runs that action's path in
full: the event's `load` and `prepare_data/2` (with a bare
`Ash.Changeset.new(record)`, since there is no action).
- a map — the event's data, taken as it is (what you would pass to
`Dispatcher.dispatch/3`).
## Options
- `:seed` - seed for `:sample` data. The process's `:rand` state is seeded
with it for the duration of the render and restored afterwards, so the
same seed gives the same letter. Without one a seed is drawn and returned
in `Rendered.seed`, so whatever was shown can be shown again
- `:transport`, `:audience`, `:variant` - only render matching channels
- `:locale` - render in this locale, whatever the channel or recipient
would have chosen
- `:recipient` - render for this recipient (a user struct or map) instead
of the first resolved one
- `:recipient_email` - show this address as the recipient. Only what is
displayed changes; nothing is sent
- `:actor` - the actor the event sees, as on an action-triggered send
- `:authorize?` - read the record (`{:record, id}`, or the fallback for
`:sample`) with authorization, as `:actor`. Defaults to `false`, as for the
ManualTrigger resource, which is guarded by its own policies
- `:variables` - template variables, as the third argument of
`Dispatcher.dispatch/3`
- `:read_records?` - when `:sample` finds no sample data, read the first
record of the event's resource instead. Default `true`
- `:resolve_recipients?` - resolve the recipient the way the dispatcher
does (which can query the database). Default `true`; with `false` the
letter is rendered without a recipient unless `:recipient` is given
## Example
{:ok, [letter]} =
AshDispatch.Preview.render("orders.created", :sample,
audience: :user,
seed: 42
)
letter.subject
#=> "Order #4711 confirmed"
## What it never does
It never calls `generate_send_variables/2`. That callback is where events
mint what a send needs — a reset token, an invitation JWT, a database row —
and a preview must not create any of it. A letter that prints such a
variable is rendered with the one passed in `:variables` (or none).
## What it cannot show
Whether the letter would be sent at all — `should_send?/2`, the
recipient's preferences and the `skip_if_read` policy are decided at send
time — and anything a database read during the send would change, such as
the link to an in-app notification.
"""
require Logger
alias AshDispatch.{ChannelResolver, Context, Dispatcher, EventResolver, Naming}
alias AshDispatch.Notifier.DispatchHandler
alias AshDispatch.Preview.Rendered
alias AshDispatch.Transports.Email
alias AshDispatch.Workers.SendEmail
@type source :: :sample | {:record, term()} | map()
@doc """
The catalogue: one row per event × channel, sorted by event id.
Each row:
%{
event_id: "orders.created",
module: MyApp.Orders.Events.Created.Event,
resource: MyApp.Orders.Order, # nil for a standalone event module
domain: :orders,
trigger_on: :create, # nil for a standalone event module
transport: :email,
audience: :user,
variant: nil,
locales: [],
time: nil,
category: :orders,
user_configurable?: true,
sensitive?: false, # metadata sensitive_content: true
metadata: [], # the event's DSL metadata
channel: %AshDispatch.Channel{}
}
Channels and variants are resolved against the event's sample data (seed
`0`), the same way the send path resolves them against real data — an
event whose channels depend on the data lists the ones its sample gives.
A row can be passed straight to `render/3`: its transport, audience and
variant become the filters.
"""
@spec events() :: [map()]
def events do
EventResolver.all_events()
|> Enum.uniq_by(fn {event_id, _module} -> event_id end)
|> Enum.flat_map(fn {event_id, module} -> event_rows(event_id, module) end)
|> Enum.sort_by(&{&1.event_id, to_string(&1.transport), to_string(&1.audience)})
end
defp event_rows(event_id, module) do
registry = registry_event(event_id)
metadata = (registry && registry[:metadata]) || []
context =
with_seed(0, fn ->
%Context{
event_id: event_id,
data: EventResolver.sample_data(module, seed: 0),
metadata: %{}
}
end)
channels =
ChannelResolver.resolve(event_id, module, context,
dsl_channels: registry && registry[:channels]
)
Enum.map(channels, fn channel ->
%{
event_id: event_id,
module: module,
resource: registry && registry[:resource],
domain: EventResolver.domain(module),
trigger_on: registry && registry[:trigger_on],
transport: channel.transport,
audience: channel.audience,
variant: variant(module, context, channel),
locales: channel.locales || [],
time: channel.time,
category: EventResolver.category(module, context),
user_configurable?: EventResolver.user_configurable?(module, context) == true,
sensitive?: Keyword.get(List.wrap(metadata), :sensitive_content, false) == true,
metadata: metadata,
channel: channel
}
end)
rescue
error ->
Logger.warning("Preview.events/0: skipping #{event_id}: #{Exception.message(error)}")
[]
end
@doc """
Renders an event's channels from `source` without delivering anything.
`event` is an event id, an event module, or a row from `events/0`. See the
moduledoc for the sources and options.
Returns `{:ok, [Rendered.t()]}` — one per matching channel, `[]` when the
filters match none — or `{:error, reason}` when the event is unknown or its
data could not be had.
"""
@spec render(String.t() | module() | map(), source(), keyword()) ::
{:ok, [Rendered.t()]} | {:error, term()}
def render(event, source \\ :sample, opts \\ [])
def render(%{event_id: event_id} = row, source, opts) do
filters =
row
|> Map.take([:transport, :audience, :variant])
|> Enum.to_list()
render(event_id, source, Keyword.merge(filters, opts))
end
def render(event, source, opts) when is_atom(event) do
case Enum.find(EventResolver.all_events(), fn {_id, module} -> module == event end) do
{event_id, _module} -> render(event_id, source, opts)
nil -> {:error, {:event_module_not_registered, event}}
end
end
def render(event_id, source, opts) when is_binary(event_id) do
case EventResolver.find_module(event_id) do
{:ok, module} ->
seed = seed_for(source, opts)
with_seed(seed, fn ->
with {:ok, prepared} <- prepare(event_id, module, source, seed, opts) do
{:ok,
prepared.channels
|> Enum.filter(&matches?(&1, module, prepared.context, opts))
|> Enum.map(&render_channel(&1, module, prepared, seed, opts))}
end
end)
{:error, :not_found} ->
{:error, "Event not found: #{event_id}"}
end
end
# ── Opening the event ───────────────────────────────────────────────
# An event triggered by a resource action is sent by the notifier, which
# builds its context from the record (resource key, locale, base url, the
# actor, prepared data). Everything else — manual events, standalone event
# modules — is sent by `Dispatcher.dispatch/3`. The preview opens the event
# the way its own send opens it.
defp prepare(event_id, module, source, seed, opts) do
variables = Keyword.get(opts, :variables, %{})
with {:ok, kind, data} <- source_data(event_id, module, source, seed, opts) do
prepared =
case action_config(event_id) do
{config, resource} -> prepare_from_record(config, resource, kind, data, variables, opts)
nil -> nil
end
case prepared do
nil -> Dispatcher.prepare(event_id, data, variables, send_variables?: false)
prepared -> prepared
end
|> put_locale(Keyword.get(opts, :locale))
end
rescue
# A preview reports; it does not crash the admin page that asked for it.
error ->
Logger.error("Preview of #{event_id} failed to prepare: #{Exception.message(error)}")
{:error, "Failed to prepare #{event_id}: #{Exception.message(error)}"}
end
# Only when the data actually holds a record of the event's resource under
# its data key — the notifier always has one. Otherwise the event is
# previewed the way `dispatch/3` would send that data.
defp prepare_from_record(config, resource, kind, data, variables, opts) do
data_key = config.event_config[:data_key] || Naming.data_key(resource)
case Map.get(data, data_key) do
%{__struct__: ^resource} = record ->
sample = if kind == :record, do: nil, else: Map.delete(data, data_key)
{:ok, prepared} =
DispatchHandler.prepare(record, config, config.event_config,
actor: Keyword.get(opts, :actor),
sample: sample,
send_variables?: false
)
{:ok, %{prepared | context: merge_variables(prepared.context, variables)}}
_ ->
nil
end
end
defp merge_variables(context, variables) when map_size(variables) == 0, do: context
defp merge_variables(context, variables) do
%{context | variables: Map.merge(variables, context.variables || %{})}
end
defp put_locale({:ok, prepared}, locale) when is_binary(locale) and locale != "" do
{:ok, %{prepared | context: %{prepared.context | locale: locale}}}
end
defp put_locale(result, _locale), do: result
# The per-action config the notifier dispatches with, if the event is
# triggered by an action (`trigger_on` other than `:manual`).
defp action_config(event_id) do
with %{resource: resource} when not is_nil(resource) <- registry_event(event_id),
config when not is_nil(config) <-
resource
|> AshDispatch.Notifier.Info.all_dispatch_events()
|> Map.values()
|> List.flatten()
|> Enum.find(&(&1.event_id == event_id)) do
{config, resource}
else
_ -> nil
end
end
defp registry_event(event_id) do
case AshDispatch.EventRegistry.find_event(event_id) do
{:ok, event} -> event
_ -> nil
end
end
# ── Sources ─────────────────────────────────────────────────────────
defp source_data(_event_id, _module, data, _seed, _opts) when is_map(data) do
{:ok, :map, data}
end
defp source_data(event_id, module, :sample, seed, opts) do
read_records? = Keyword.get(opts, :read_records?, true)
case sample_data(module, seed) do
{:ok, data} when map_size(data) > 0 ->
{:ok, :sample, data}
{:ok, _empty} when not read_records? ->
{:error, "#{inspect(module)} defines no sample data"}
{:ok, _empty} ->
# No sample data: read a record instead, as the ManualTrigger preview
# always has.
with {:ok, resource, data_key, load} <- record_source(event_id, module),
{:ok, record} <- read_first(resource, load, opts) do
{:ok, :record, %{data_key => record}}
else
{:error, :no_records} ->
{:error,
"No records found and no sample_data/0 or sample_data/1 defined in #{inspect(module)}"}
{:error, :no_resource} ->
{:error, "#{inspect(module)} defines no sample data and no resource/0 to read from"}
error ->
error
end
{:error, _} = error ->
error
end
end
defp source_data(event_id, module, {:record, id}, _seed, opts) do
with {:ok, resource, data_key, load} <- record_source(event_id, module) do
case read(resource, id, load, opts) do
{:ok, record} ->
{:ok, :record, %{data_key => record}}
{:error, error} ->
Logger.error(
"Preview: failed to load #{inspect(resource)} #{inspect(id)}: #{inspect(error)}"
)
{:error, "Failed to load #{inspect(resource)} with id #{inspect(id)}"}
end
else
{:error, :no_resource} -> {:error, "#{inspect(module)} has no resource to load from"}
end
end
defp source_data(_event_id, _module, source, _seed, _opts) do
{:error, "Unknown preview source: #{inspect(source)}"}
end
defp sample_data(module, seed) do
data =
if EventResolver.exports?(module, :sample_data, 1) do
module.sample_data(seed: seed)
else
EventResolver.sample_data(module)
end
{:ok, if(is_map(data), do: data, else: %{})}
rescue
error ->
{:error, "sample_data raised in #{inspect(module)}: #{Exception.message(error)}"}
end
# The resource, data key and load list for reading the event's record: from
# the DSL event when there is one, else from the module's callbacks.
defp record_source(event_id, module) do
case dsl_event(event_id) do
{event, resource} ->
load =
case action_config(event_id) do
# The notifier loads the action's (audience-derived) load itself.
{_config, _resource} -> []
nil -> event.load || []
end
{:ok, resource, event.data_key || Naming.data_key(resource), load}
nil ->
case EventResolver.resource(module) do
nil ->
{:error, :no_resource}
resource ->
data_key = EventResolver.data_key(module) || Naming.data_key(resource)
{:ok, resource, data_key, module_channel_load(module)}
end
end
end
defp dsl_event(event_id) do
with %{resource: resource} when not is_nil(resource) <- registry_event(event_id),
event when not is_nil(event) <-
resource
|> AshDispatch.Resource.Info.events()
|> Enum.find(&(&1.event_id == event_id)) do
{event, resource}
else
_ -> nil
end
rescue
_ -> nil
end
defp module_channel_load(module) do
module
|> ChannelResolver.get_module_channels(%Context{event_id: "", data: %{}, metadata: %{}})
|> Enum.flat_map(&(Map.get(&1, :load) || []))
|> Enum.uniq()
end
defp read(resource, id, load, opts) do
Ash.get(resource, id, [load: load] ++ read_opts(resource, opts))
end
defp read_first(resource, load, opts) do
case Ash.read(resource, [load: load, page: [limit: 1]] ++ read_opts(resource, opts)) do
{:ok, %{results: [record | _]}} -> {:ok, record}
{:ok, [record | _]} -> {:ok, record}
{:ok, _} -> {:error, :no_records}
error -> error
end
end
defp read_opts(resource, opts) do
[
domain: Ash.Resource.Info.domain(resource),
actor: Keyword.get(opts, :actor),
authorize?: Keyword.get(opts, :authorize?, false)
]
end
# ── Channels ────────────────────────────────────────────────────────
defp matches?(channel, module, context, opts) do
Enum.all?([:transport, :audience, :variant], fn key ->
case Keyword.fetch(opts, key) do
:error ->
true
{:ok, nil} when key != :variant ->
true
{:ok, wanted} when key == :variant ->
same_variant?(variant(module, context, channel), wanted)
{:ok, wanted} ->
Map.get(channel, key) == wanted
end
end)
end
defp same_variant?(nil, nil), do: true
defp same_variant?(nil, _), do: false
defp same_variant?(_, nil), do: false
defp same_variant?(a, b), do: to_string(a) == to_string(b)
# The dispatcher's own precedence: the channel's variant, then the module's.
defp variant(module, context, channel) do
channel.variant || EventResolver.template_variant(module, context, channel)
end
defp render_channel(channel, module, prepared, seed, opts) do
channel =
case Keyword.get(opts, :locale) do
locale when is_binary(locale) and locale != "" -> %{channel | locale: locale}
_ -> channel
end
base = %Rendered{
event_id: prepared.context.event_id,
module: module,
transport: channel.transport,
audience: channel.audience,
variant: variant(module, prepared.context, channel),
channel: channel,
seed: seed
}
{context, recipient} = recipient(prepared.context, channel, prepared.event_config, opts)
{_status, attrs} =
Dispatcher.build_receipt(context, channel, prepared.event_config, recipient)
recipient_identifier = Keyword.get(opts, :recipient_email) || attrs.recipient
%{
base
| locale: attrs.locale,
recipient: recipient_identifier && to_string(recipient_identifier),
recipient_name: AshDispatch.ContentMap.get_content(attrs.content, :recipient_name),
content: attrs.content,
context: context
}
|> put_envelope(%{attrs | recipient: recipient_identifier}, context, channel)
rescue
error ->
Logger.error(
"Preview of #{prepared.context.event_id} (#{channel.transport}/#{channel.audience}) " <>
"failed: #{Exception.message(error)}"
)
%Rendered{
event_id: prepared.context.event_id,
module: module,
transport: channel.transport,
audience: channel.audience,
variant: channel.variant,
channel: channel,
seed: seed,
recipient: Keyword.get(opts, :recipient_email),
error: Exception.message(error)
}
end
# The first recipient the dispatcher would have written to. Resolution can
# read the database (a recipient resolver, an audience query), which sample
# data is not in — a failure there costs the recipient, not the preview.
defp recipient(context, channel, event_config, opts) do
case Keyword.fetch(opts, :recipient) do
{:ok, recipient} ->
{context, recipient}
:error ->
if Keyword.get(opts, :resolve_recipients?, true) do
resolve_first_recipient(context, channel, event_config)
else
{context, nil}
end
end
end
defp resolve_first_recipient(context, channel, event_config) do
case Dispatcher.recipients_for(context, channel, event_config) do
{context, [first | _]} -> {context, first}
{context, []} -> {context, nil}
end
rescue
error ->
Logger.debug("Preview: recipients for #{context.event_id} raised: #{inspect(error)}")
{context, nil}
end
# The envelope, built the way the send builds it: the email transport's job
# args from the receipt attributes, through JSON as Oban stores them, then
# the worker's backend params from those args.
defp put_envelope(rendered, %{transport: :email} = attrs, context, channel) do
receipt = attrs |> cast_receipt() |> Map.put(:id, nil)
args =
receipt
|> Email.job_args(context, channel)
|> Jason.encode!()
|> Jason.decode!()
params = SendEmail.email_params(receipt, args)
%{
rendered
| to: params.to,
from: params.from,
reply_to: params.reply_to,
subject: params.subject,
html_body: params.html_body,
text_body: params.text_body,
attachments: params.attachments,
headers: Map.get(params, :headers, %{})
}
end
defp put_envelope(rendered, attrs, _context, _channel) do
content = attrs.content
%{
rendered
| title: AshDispatch.ContentMap.get_content(content, :title),
message: AshDispatch.ContentMap.get_content(content, :message),
action_url: AshDispatch.ContentMap.get_content(content, :action_url),
action_label: AshDispatch.ContentMap.get_content(content, :action_label)
}
end
# The worker builds its job from the CREATED receipt, so what it sends has
# been through the receipt resource's casting — an Ash `:string` is trimmed,
# for one, so a template's final newline never leaves. The preview casts the
# same attributes through the same resource's `:create`, without creating
# anything. (The parity test found this: preview and send differed by a
# trailing "\n".)
defp cast_receipt(attrs) do
case AshDispatch.Config.delivery_receipt_resource() do
nil ->
attrs
resource ->
changeset =
Ash.Changeset.for_create(resource, :create, attrs,
skip_unknown_inputs: :*,
authorize?: false
)
Enum.reduce([:recipient, :subject, :body_html, :body_text, :content], attrs, fn key,
acc ->
if Map.has_key?(changeset.attributes, key),
do: Map.put(acc, key, Map.get(changeset.attributes, key)),
else: acc
end)
end
rescue
_ -> attrs
end
# ── Seeding ─────────────────────────────────────────────────────────
defp seed_for(:sample, opts) do
case Keyword.get(opts, :seed) do
seed when is_integer(seed) -> seed
# Not :rand — drawing from it would move the caller's own sequence.
nil -> :binary.decode_unsigned(:crypto.strong_rand_bytes(3)) + 1
other -> :erlang.phash2(other)
end
end
defp seed_for(_source, opts) do
case Keyword.get(opts, :seed) do
seed when is_integer(seed) -> seed
_ -> nil
end
end
# Seeds the process's `:rand` for `fun`, and puts the previous state back —
# a preview must not leave a caller's random numbers predictable.
defp with_seed(nil, fun), do: fun.()
defp with_seed(seed, fun) do
previous = :rand.export_seed()
:rand.seed(:exsss, {seed, seed, seed})
try do
fun.()
after
case previous do
:undefined -> Process.delete(:rand_seed)
state -> :rand.seed(state)
end
end
end
end