Changelog

View Source

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

[0.5.1] - 2026-06-29

Documentation-only release. Rebrands the project for its public launch.

Changed

  • README rebranded for the 0.5 public launch. Hex.pm + HexDocs badges, a prominent "experimental, API may change before 1.0" caveat, install instructions bumped to ~> 0.5, and reworked Project Status / Contributing sections (dropping the pre-launch "being extracted / will be published" framing).
  • Generic MyApp.* module names in the manual-dispatch tutorial (previously referenced an internal application name), and issue links point at the public repo.
  • Corrected the dispatch-flow legend in What is AshDispatch? — email and webhook delivery run on real Oban workers; the mock is only the default email backend.

[0.5.0] - 2026-06-29

First public release on hex.pm since 0.1.4 — brings the public package up to current. Headline additions are two new transports and a formal Transport behaviour.

Added

  • :oban transport. Dispatch an event straight to an Oban worker, eliminating the manual dispatch+enqueue dance. Wired via use AshDispatch.Event, transports: [oban: [...]].

    • Compile-time validation: an :oban channel now requires :oban_worker metadata (previously a soft runtime warning + a :skipped receipt that left operators staring at an empty queue).
    • Dispatch-layer enable-gate via a pluggable config :ash_dispatch, :gate_check_module. A disabled gate skips the enqueue entirely (emitting [:ash_dispatch, :oban, :gated_disabled] telemetry) instead of burning queue capacity on a no-op worker. No gate configured → always enabled; a raising gate → defaults to enabled (over-fire is safer than a silent drop) and logs a warning.
  • :custom_topic transport. A lightweight per-record PubSub broadcaster (AshDispatch.Event.CustomTopic) for fire-and-forget broadcasts that need no recipients, content, or DeliveryReceipts. Topic accepts a string or a {Module, :function} MFA for per-record routing. Generates overridable topic/0,1, event_name/0, safe_broadcast/1,2 helpers wrapping Phoenix.PubSub.broadcast/3 with rescue + log + [:ash_dispatch, :custom_topic, :broadcast_failure] telemetry. The heavyweight Spark DSL path is unchanged when no :transports option is passed.

  • AshDispatch.Transport behaviour + Registry. Dispatcher routing is now derived from a registry of transports rather than hardcoded, giving new transports a single integration point.

  • Module-typed dispatch/3 overload on AshDispatch.Dispatcher, resolving event_id via the EventRegistry.

  • AshDispatch.Naming.wire_event_name/1, consolidating the dotted-split-and-take-last logic previously private to the Broadcast transport so other transports can reuse it.

Fixed

  • RecipientResolver never aborts the parent operation. Dispatch is a side-channel: recipient resolution now wraps its body in try/rescue, so a bad user_resource config or a raise from an auto-loaded calculation (e.g. an unstarted Cloak vault) degrades to [] recipients + a structured warning instead of bubbling an exception up and aborting the caller's transaction.

  • Cleared all Elixir 1.20 compiler warnings (unused requires, unreachable defp clauses, bitstring size(...) pins, always-truthy guards). Behavior-preserving.

[0.4.8] - 2026-05-14

Fixed

  • Process-local Gettext locale leak after dispatch. Gettext.put_locale/2 is process-local. apply_recipient_locale/3 mutates the running process's locale so per-recipient renders pick up the right language. Until now, after build_receipt_content/4 returned, the process was left with the last recipient's locale — which meant a worker that dispatched event A to a locale="en" user and then ran any t() call for its own purposes (audit logging, custom emails, follow-up derivations) would see the leaked "en" locale instead of the locale the worker started with.

    Fix: build_receipt_content/4 now captures current_locale/0 before applying the recipient locale and restores it in an after block. Each receipt build is fully isolated; the caller's process locale is unchanged on return.

    Caught via crash-hunt regression: t() between two dispatches now renders correctly against the worker's surrounding locale.

[0.4.7] - 2026-05-14

This release unlocks DSL-only locale-aware events. Combined with 0.4.6's HEEx auto-escape, an entire event can live in dispatch do … end blocks with just prepare_template_assigns/2 left in the event module for derived assigns.

Added

  • Configurable Gettext domain for DSL content lookups (AshDispatch.Config.gettext_domain/0, default "notifications"). Apps with existing default.po setups can do config :ash_dispatch, :gettext_domain, "default" to share one translation bundle across the codebase.

  • Top-level template_assigns interpolation in VariableInterpolator. When a variable doesn't match a field on the main resource, the interpolator now falls back to top-level keys in data. Lets prepare_template_assigns/2-returned values be addressed directly as {{my_computed_var}} instead of awkwardly stuffing them onto the resource struct.

Fixed

  • translate_content/2 no longer overwrites recipient locale. Previously, when context.locale was nil the function unconditionally reset Gettext to "en" — silently undoing the per-recipient locale that apply_recipient_locale/3 had just set. Now only overrides on explicit non-empty locale; trusts the process-level locale otherwise.

  • action_label now goes through interpolate/2 for :in_app channels — parity with title/message/subject so DSL-declared labels participate in both {{var}} substitution AND the gettext translation pipeline. Previously rendered raw.

[0.4.6] - 2026-05-13

Security

  • Auto-escape {@var} expansions in HTML email templates. TemplateResolver.render_template_content/4 previously rewrote HEEx-style {@var} markers to plain EEx <%= @var %> and evaluated the result via EEx.eval_string/2, which does NOT HTML-escape interpolated values. Any user-controlled string flowing through prepare_template_assigns/2 (lead name, contract recipient, customer comment, etc.) landed raw in the rendered email — a real markup injection vector.

    The preprocessor now wraps every auto-converted {@var} expansion in AshDispatch.SafeRender.escape/1 for format: :html so escape is the default, matching Phoenix HEEx semantics. Text formats are unaffected — email.text.eex and similar still emit <%= @var %> plain (text/plain has no HTML semantics).

    Migration: if your templates intentionally embed safe pre-rendered HTML, mark those expressions explicitly:

    <p>{raw(@trusted_block)}</p>
    <!-- or, fully qualified -->
    <p>{AshDispatch.SafeRender.raw(@trusted_block)}</p>

    {:safe, iodata} tuples (Phoenix.HTML's standard "already escaped" marker) also pass through escape/1 unchanged, so existing Phoenix.HTML interop keeps working.

Added

[0.4.5] - 2026-05-13

Added

  • Per-recipient locale resolution. When a channel resolves to a multi-recipient audience (e.g. seller + admin), each recipient's rendered notification content now follows their own recipient.locale field. The resolution priority is:

    1. channel.locale       (static override)
    2. channel.locale_from  (channel-level dynamic on primary record)
    3. recipient.locale     (NEW  auto-detected when recipient struct has it)
    4. event/resource locale_from + auto-detected visitor_locale/locale
    5. context.locale + Config.default_locale()

    This makes multilingual sends — e.g. a customer-facing email to a Swedish lead, plus an internal email to an English admin — render in each recipient's preferred language from one event dispatch, with no per-recipient code in the calling worker. The recipient struct just needs a :locale field (typically a User record); audiences that expose user records via RecipientResolver.to_recipient/1 get this for free.

Changed

  • Dispatcher.build_receipt_content/4 now threads recipient into build_module_content, build_inline_content, and render_inline_email_templates. Subject + html/text bodies are now rendered per recipient with the correct locale, instead of once per channel. Pre-render side: the resolved locale is also stamped on the receipt for analytics/traceability.
  • Gettext.put_locale/2 is now invoked automatically inside build_receipt_content (via the new apply_recipient_locale/3 helper) when :gettext_backend is configured. Consumer code that was previously calling Gettext.put_locale itself before Dispatcher.dispatch/2 to influence content can drop that — the dispatcher handles it per-recipient.

[0.4.4] - 2026-05-12

Added

  • Pluggable SMS transport backend. AshDispatch.Transports.SMS now delegates to a consumer-configured module implementing the new AshDispatch.SMSBackend behaviour. Configure with config :ash_dispatch, :sms_backend, MyApp.SMS. When no backend is configured the receipt is still marked :skipped with error_message: "transport_not_implemented", preserving the prior stub behavior for consumers that haven't wired SMS yet.
  • optional: true channel option. When a channel is marked optional and recipient identifier extraction fails (e.g. SMS channel for a user with no phone_number), the dispatcher logs and skips that channel rather than crashing the whole dispatch. Non-optional channels still re-raise as before.

0.4.3 - 2026-05-12

Fixed

  • Catch the remaining 5 channel.on/socket.on/channel.join().receive callsites the v0.4.2 sweep missed. 0.4.2 only widened 3 of the 8 typed-payload callbacks in the SDK generator; consumers running TS strict mode still saw TS2345 on the rest:
    • hooks/use-channel.tschannel.join().receive('ok', (response: ChannelJoinResponse) → unknown) and channel.on('counter_updated', (payload: CounterUpdatePayload) → unknown)
    • hooks/use-notifications.ts (standalone mode) — channel.on('initial_state', (payload: { counters?: ... }) → unknown), channel.on('new_notification', (notification: Notification) → unknown), and socket.on('new_notification', ...) All 8 sites now use the same (rawX: unknown) => { const x = rawX as T; ... } pattern.

0.4.2 - 2026-05-12

Fixed

  • TypeScript SDK generator emits strict-mode-clean channel handlers. Previously, channel.on('initial_state', (payload: {...}) => {...}) failed to type-check in consumers running strict: true (saleflow) because phoenix-js types the callback parameter as (payload: unknown) and TS function-parameter contravariance rejects narrower handler types. Generator now widens all channel.on/socket.on callbacks to (rawPayload: unknown) and narrows via an inline as-cast. Affects socket-provider.tsx (3 sites: initial_state, counter_updated, entity_change) and hooks/use-notifications.ts (2 sites: channel.on('counter_updated') + socket.on('counter_updated')).
  • notification-bell.tsx no longer imports unused useState. Was emitting a TS6133 violation under noUnusedLocals.

0.4.1 - 2026-05-12

Added

  • :table option on Notification.Base and DeliveryReceipt.Base. Lets consumer apps override the Postgres table name when their app already owns notifications / delivery_receipts for a legacy notification system and ash_dispatch needs to coexist rather than collide. Defaults preserve current behavior ("notifications" / "delivery_receipts"), so existing consumers upgrade transparently.

    Example:

    defmodule MyApp.Dispatch.Notification do
      use AshDispatch.Resources.Notification.Base,
        repo: MyApp.Repo,
        domain: MyApp.Dispatch,
        table: "dispatch_notifications"
    end

0.4.0 - 2026-05-12

Changed (substrate retrofit — tx-semantics)

  • DispatchEvent and BroadcastCounterUpdate now route through Ash.Notifier, not Ash.Changeset.after_action/2. Pre-retrofit, these changes fired synchronously inside the action's transaction BEFORE commit/rollback, allowing phantom dispatches and counter broadcasts when a wrapping Ash.transaction/2 rolled back. Post-retrofit, work runs in Ash.Notifier's commit-deferred firing path and is dropped on rollback (see Ash's transaction/2 defer-and-fire-or-drop semantics). New shape: single AshDispatch.Notifier module + AshDispatch.Notifier.Info Spark Info reader; per-action config persisted into dsl_state by the InjectDispatchChanges and InjectCounterBroadcasts transformers and read at runtime by the notifier. Mirrors Ash.Notifier.PubSub's canonical pattern.
  • Behaviour fix: receipt creation is now post-commit only. DeliveryReceipt rows previously could land for events whose triggering action subsequently rolled back. Post-retrofit they only land for actually-committed actions. Orphan receipts on rollback were a bug, not a feature.
  • Removed lib/changes/dispatch_event.ex and lib/changes/broadcast_counter_update.ex (845 LOC). Their orchestration logic moved to lib/notifier/dispatch_handler.ex and lib/notifier/counter_handler.ex respectively, exposed as public entry points the notifier calls.
  • Canary regression net added at test/notifier_tx_semantics_test.exs — two tests (refute_receive after force-rollback via raise, refute_receive inside the txn before commit) that lock in the contract going forward.
  • DeliveryReceipt: allow :failed → :sent transition for retry-after-failure paths. Previously the receipt was stuck in :failed even after a successful re-send.
  • Broadcast transport: drop per-event log warning when pubsub_module: nil (documented passive-shell posture); consumers wanting a presence check should read Config.pubsub_module() once at app boot.

Added

  • Initial release of AshDispatch
  • Event-driven notification system for Ash Framework
  • Multiple transport types:
    • Email transport with Swoosh backend
    • In-app notifications
    • Discord webhooks
    • Slack webhooks
    • SMS transport (stub)
    • Generic webhook transport
  • Delivery receipt tracking with state machine
  • Automatic retry system for failed deliveries
  • User preference checking for email notifications
  • Recipient resolution behaviours
  • Event DSL with template interpolation
  • Comprehensive documentation and guides
  • Testing utilities and helpers

Fixed

  • Hybrid mode callback fallback: Inline DSL now properly falls back to event module callbacks when fields are not provided. Previously, nil values from inline DSL would overwrite module callback results. Now, only non-nil inline DSL values are included in the content map, preserving module callbacks for dynamic content like notification_message/2, subject/2, and action_url/2

0.1.0 - 2025-01-17

Added

  • First alpha release
  • Core dispatcher and event system
  • Basic transport implementations
  • Oban worker integration
  • DeliveryReceipt and Notification resources
  • Documentation structure with ex_doc