Changelog
View SourceAll 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.5public 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
:obantransport. Dispatch an event straight to an Oban worker, eliminating the manual dispatch+enqueue dance. Wired viause AshDispatch.Event, transports: [oban: [...]].- Compile-time validation: an
:obanchannel now requires:oban_workermetadata (previously a soft runtime warning + a:skippedreceipt 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.
- Compile-time validation: an
:custom_topictransport. A lightweight per-record PubSub broadcaster (AshDispatch.Event.CustomTopic) for fire-and-forget broadcasts that need no recipients, content, orDeliveryReceipts. Topic accepts a string or a{Module, :function}MFA for per-record routing. Generates overridabletopic/0,1,event_name/0,safe_broadcast/1,2helpers wrappingPhoenix.PubSub.broadcast/3with rescue + log +[:ash_dispatch, :custom_topic, :broadcast_failure]telemetry. The heavyweight Spark DSL path is unchanged when no:transportsoption is passed.AshDispatch.Transportbehaviour + Registry. Dispatcher routing is now derived from a registry of transports rather than hardcoded, giving new transports a single integration point.Module-typed
dispatch/3overload onAshDispatch.Dispatcher, resolvingevent_idvia theEventRegistry.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
RecipientResolvernever aborts the parent operation. Dispatch is a side-channel: recipient resolution now wraps its body intry/rescue, so a baduser_resourceconfig 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
defpclauses, bitstringsize(...)pins, always-truthy guards). Behavior-preserving.
[0.4.8] - 2026-05-14
Fixed
Process-local Gettext locale leak after dispatch.
Gettext.put_locale/2is process-local.apply_recipient_locale/3mutates the running process's locale so per-recipient renders pick up the right language. Until now, afterbuild_receipt_content/4returned, the process was left with the last recipient's locale — which meant a worker that dispatched event A to alocale="en"user and then ran anyt()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/4now capturescurrent_locale/0before applying the recipient locale and restores it in anafterblock. 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 existingdefault.posetups can doconfig :ash_dispatch, :gettext_domain, "default"to share one translation bundle across the codebase.Top-level
template_assignsinterpolation inVariableInterpolator. When a variable doesn't match a field on the main resource, the interpolator now falls back to top-level keys indata. Letsprepare_template_assigns/2-returned values be addressed directly as{{my_computed_var}}instead of awkwardly stuffing them onto the resource struct.
Fixed
translate_content/2no longer overwrites recipient locale. Previously, whencontext.localewas nil the function unconditionally reset Gettext to"en"— silently undoing the per-recipient locale thatapply_recipient_locale/3had just set. Now only overrides on explicit non-empty locale; trusts the process-level locale otherwise.action_labelnow goes throughinterpolate/2for:in_appchannels — parity withtitle/message/subjectso 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/4previously rewrote HEEx-style{@var}markers to plain EEx<%= @var %>and evaluated the result viaEEx.eval_string/2, which does NOT HTML-escape interpolated values. Any user-controlled string flowing throughprepare_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 inAshDispatch.SafeRender.escape/1forformat: :htmlso escape is the default, matching Phoenix HEEx semantics. Text formats are unaffected —email.text.eexand 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 throughescape/1unchanged, so existing Phoenix.HTML interop keeps working.
Added
AshDispatch.SafeRendermodule (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.localefield. 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
:localefield (typically aUserrecord); audiences that expose user records viaRecipientResolver.to_recipient/1get this for free.
Changed
Dispatcher.build_receipt_content/4now threadsrecipientintobuild_module_content,build_inline_content, andrender_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/2is now invoked automatically insidebuild_receipt_content(via the newapply_recipient_locale/3helper) when:gettext_backendis configured. Consumer code that was previously callingGettext.put_localeitself beforeDispatcher.dispatch/2to influence content can drop that — the dispatcher handles it per-recipient.
[0.4.4] - 2026-05-12
Added
- Pluggable SMS transport backend.
AshDispatch.Transports.SMSnow delegates to a consumer-configured module implementing the newAshDispatch.SMSBackendbehaviour. Configure withconfig :ash_dispatch, :sms_backend, MyApp.SMS. When no backend is configured the receipt is still marked:skippedwitherror_message: "transport_not_implemented", preserving the prior stub behavior for consumers that haven't wired SMS yet. optional: truechannel option. When a channel is marked optional and recipient identifier extraction fails (e.g. SMS channel for a user with nophone_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().receivecallsites 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 sawTS2345on the rest:hooks/use-channel.ts—channel.join().receive('ok', (response: ChannelJoinResponse) → unknown)andchannel.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), andsocket.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 runningstrict: true(saleflow) because phoenix-js types the callback parameter as(payload: unknown)and TS function-parameter contravariance rejects narrower handler types. Generator now widens allchannel.on/socket.oncallbacks to(rawPayload: unknown)and narrows via an inlineas-cast. Affectssocket-provider.tsx(3 sites:initial_state,counter_updated,entity_change) andhooks/use-notifications.ts(2 sites:channel.on('counter_updated')+socket.on('counter_updated')). notification-bell.tsxno longer imports unuseduseState. Was emitting aTS6133violation undernoUnusedLocals.
0.4.1 - 2026-05-12
Added
:tableoption onNotification.BaseandDeliveryReceipt.Base. Lets consumer apps override the Postgres table name when their app already ownsnotifications/delivery_receiptsfor 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, notAsh.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 wrappingAsh.transaction/2rolled back. Post-retrofit, work runs inAsh.Notifier's commit-deferred firing path and is dropped on rollback (see Ash'stransaction/2defer-and-fire-or-drop semantics). New shape: singleAshDispatch.Notifiermodule +AshDispatch.Notifier.InfoSpark Info reader; per-action config persisted intodsl_stateby theInjectDispatchChangesandInjectCounterBroadcaststransformers and read at runtime by the notifier. MirrorsAsh.Notifier.PubSub's canonical pattern. - Behaviour fix: receipt creation is now post-commit only.
DeliveryReceiptrows 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.exandlib/changes/broadcast_counter_update.ex(845 LOC). Their orchestration logic moved tolib/notifier/dispatch_handler.exandlib/notifier/counter_handler.exrespectively, exposed as public entry points the notifier calls. - Canary regression net added at
test/notifier_tx_semantics_test.exs— two tests (refute_receiveafter force-rollback via raise,refute_receiveinside the txn before commit) that lock in the contract going forward. - DeliveryReceipt: allow
:failed → :senttransition for retry-after-failure paths. Previously the receipt was stuck in:failedeven 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 readConfig.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, andaction_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