# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 `DeliveryReceipt`s.
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
- `AshDispatch.SafeRender` module (`escape/1` + `raw/1`).
## [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.ts` — `channel.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
[Unreleased]: https://github.com/Vulcora/ash_dispatch/compare/v0.4.3...HEAD
[0.4.3]: https://github.com/Vulcora/ash_dispatch/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/Vulcora/ash_dispatch/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Vulcora/ash_dispatch/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Vulcora/ash_dispatch/compare/v0.1.0...v0.4.0
[0.1.0]: https://github.com/Vulcora/ash_dispatch/releases/tag/v0.1.0