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. On a manual event it becomes `context.actor`, as `Dispatcher.dispatch/4`'s `:actor` does - `:tenant` - the tenant the event sees (`context.tenant`) - `: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] ++ Keyword.take(opts, [:actor, :tenant]) ) 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), tenant: Keyword.get(opts, :tenant), 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