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.6.1] - 2026-08-17

Fixed

  • A registered transport no longer crashes the whole dispatch. Dispatcher.build_inline_content/4 had a case channel.transport with no catch-all, so the :push transport added in 0.6.0 raised CaseClauseError for every event carrying a :push channel — taking down the sibling channels on the same event with it. The case now has a catch-all (a transport without inline content gets %{} and still delivers), and :push has its own branch producing title, message and action_url.

    Same drift as the receipt constraint fixed in 0.6.0: the AshDispatch.Transport behaviour promises "one new file + one registry entry", and two places had not got the memo. A structural test now pins the catch-all.

[0.6.0] - 2026-08-17

Adds a Web Push transport and removes the hardcoded transport lists that made adding one a multi-file hunt.

Added

  • :push transport (AshDispatch.Transports.Push) — Web Push to the browser. Same shape as :sms: ash_dispatch owns routing and the delivery receipt, the consumer supplies a backend module implementing the new AshDispatch.PushBackend behaviour. Configure with config :ash_dispatch, :push_backend, MyApp.Push. With no backend configured the receipt is marked :skipped with error_message: "transport_not_implemented", so an app can declare :push channels before the backend exists.

    VAPID keys, RFC 8291 encryption and the per-endpoint POST stay in the consuming app — they are deployment concerns (key material, egress, retry budget), not library concerns. AshDispatch.PushBackend's docs spell out the contract, including pruning subscriptions on 404/410 and treating 429/5xx as retryable.

    Declare push channels as optional: true: a user who never granted notification permission is a soft-skip, not a delivery failure.

  • AshDispatch.Transport.Registry.receipted_atoms/0 — the transport atoms that can appear on a DeliveryReceipt (registry minus the lightweight :broadcast/:oban).

Fixed

  • The receipt transport constraint no longer drifts. It was hardcoded in two places that had already gone out of sync: AshDispatch.Setup allowed [:email, :in_app, :discord, :sms, :webhook] while DeliveryReceipt.Base allowed those plus :slack. A :slack channel therefore produced a receipt the Setup-generated resource rejected. Both now derive from receipted_atoms/0, so registering a transport is once again "one new file + one registry entry" as AshDispatch.Transport's docs promise.

Compatibility

No migration required. The receipt constraint only widens, and consumers that never declared a :push channel are unaffected.

[0.5.6] - 2026-08-12

Security-focused release absorbing a consumer-proven patch set (policies and retry semantics that one production app had carried as local vendor patches), plus inline-image email support.

Security

  • Notification.Base now ships Ash.Policy.Authorizer with per-user policies: read and update only your own notifications, mark_all_as_read's user_id argument must match the actor, and create/destroy are system-only. Without an authorizer, authorize?: true was a no-op — any signed-in user could read every other user's notification feed (proven in one production deployment) and mark other users' feeds as read. The in-app transport now creates notifications with authorize?: false at both call sites (initial delivery and retry).
  • ManualTrigger.Base is now fail-closed: it declares the authorizer with no base policies, so everything is forbidden until the consuming app opens access with its own policies block (typically a bypass on its admin check). Previously any signed-in user could preview arbitrary records as email bodies and trigger real outbound email. Note for integrators: the base deliberately does NOT declare a forbid_if always() policy — base policies compile before the consumer's, and a later consumer bypass cannot re-open an earlier forbid.
  • DeliveryReceipt.Base write policies: the blanket bypass … authorize_if always() on create/update/destroy let any authenticated actor mutate receipts — including :retry, which re-sends real email. External writes are now gated on the same configured permission as reads (:manage_delivery_receipts via the configured permission_checker). All library-internal writes already run with authorize?: false and are unaffected.
  • EmailEvent reads forbade everyone, super admins included — its two non-bypass policies were AND-ed. The super-admin policy is now a bypass.

Added

  • Inline (CID) email images: attachments/2 attachment maps accept optional type: :inline | :attachment and cid: String.t(). Inline attachments flow through the Oban job args and reach the Swoosh backend as type: :inline with cid defaulting to the filename — referenced from HTML as <img src="cid:logo.png">, they render without the recipient approving remote images. Plain attachments are byte-for-byte unaffected, and in-flight jobs enqueued by older versions decode unchanged.
  • App-wide default email attachments: config :ash_dispatch, default_email_attachments: {MyApp.EmailAssets, :defaults, []} (MFA or zero-arity fun returning attachment maps) is merged ahead of each event's own attachments/2 on every outgoing email. Built for the inline-logo case: one config line embeds a type: :inline logo referenced from a shared layout as <img src="cid:logo.png">. Resolution failures log and degrade to no attachments — branding must never block delivery.
  • should_send?/2 is now actually invoked by the send path (per channel, on both the direct-dispatch and notifier paths, including the deduplication path). The callback was declared on the behaviour — and implemented by consumer events as a last-moment guard — but never called, so those guards silently never ran. A guard that raises logs a warning and sends (dispatch is never aborted by a guard).

Fixed

  • Retry race that silently dropped mail: RetryFailedDeliveries now moves the receipt to :scheduled BEFORE enqueueing the worker, and puts it back to :failed (or :failed_permanent once retries are spent) if the enqueue fails; mark_sending additionally accepts :failed as a source state, which also makes Oban's own backoff retries effective. Previously the worker regularly ran while the receipt was still :failed, treated it as a duplicate job, returned :ok, and the receipt stranded on :scheduled where no retry path ever saw it again — in one production deployment this silently dropped 17 emails over eight months.
  • mark_failed no longer increments retry_count (a failure is not a retry). With both mark_failed and :retry incrementing, every failed-and-retried cycle burned the retry budget twice as fast as :max_retries promised.
  • Provider webhook events no longer overwrite each other: record_webhook_event merges into the existing provider_response, and the Resend handler namespaces each payload under its event type ("email.delivered" => %{…}), so the full delivery timeline — and the original send response with the provider id — survives.
  • The email preference check now uses the category the event module actually declares (category in the dispatch DSL), falling back to the old event-id string munging. Munged ids only matched preference fields by coincidence, silently disabling opt-out toggles for events whose id didn't mirror a preference column.
  • Retried emails no longer lose their attachments: retry/send-now jobs are built with SendEmail.new_for_receipt/1, which carries the original job's attachment args forward (attachments are resolved once, at first enqueue, and exist only in job args — a bare %{receipt_id: _} retry job resent the mail without them, breaking inline images).

Fixed (0.2.x parity regressions)

  • Restored the authorize?: false counter-scoping guard in ResourceIntrospection.resolve_user_id_path_for_scoping/2 (present in 0.2.x, lost in the notifier-era refactor): a counter with authorize?: false and no explicit scope/user_id_path is system-wide again, instead of silently auto-deriving a user scope and reading 0 for admin badges. Both callers already passed the option; it was ignored.
  • CounterLoader audience matching now fails CLOSED for audiences configured as MFA/function resolvers: they cannot be evaluated against a single user, and the old fallthrough parsed them as an empty filter — "matches everyone" — broadcasting admin counters to every signed-in user. Counter audiences should use the declarative list form.
  • All library-internal receipt/notification state writes now pass authorize?: false explicitly (receipt_status, every transport, the dispatcher's unknown-transport skip). They previously relied on DeliveryReceipt.Base's blanket write bypass, which this release removed — without this, the tightened policies broke email/in-app delivery end-to-end for any consumer.

Upgrade notes

  • #{@var} template interpolation is no longer converted. The 0.2.x preprocessor rewrote #{@var} in mail templates to EEx; the current resolver deliberately skips #{ (it can be legitimate Elixir interpolation inside a HEEx attribute expression). Body-position #{@var} now renders as literal text — migrate templates to {@var} (also auto-escaped since 0.4.6).

  • Apps using ManualTrigger.Base MUST add a policies block to their trigger resource or the admin UI built on it will see empty lists:

    policies do
      bypass always() do
        authorize_if MyApp.PolicyHelpers.AdminCheck
      end
    end
  • Apps exposing DeliveryReceipt actions (:retry, :send_now, reads) to their frontend need a permission_checker configured whose :manage_delivery_receipts permission matches their admin model.

  • In-app retries now consume retry budget (retry_count increments on the synchronous in-app retry path as well).

[0.5.5] - 2026-08-12

Fixed

  • SendWebhook pattern-matched on the %Req.Response{} struct although req is an optional dependency — any app without req failed to COMPILE the library in prod builds (dev builds often hid it via a transitive dev-only req). Now matches plain maps; the runtime Req.post/2 call is unaffected and still requires req only when the webhook transport is actually used.

Fixed

  • The i18n catalog generator (mix ash_dispatch.gen) registered msgids under a hardcoded "notifications" domain while the Dispatcher looks them up via the configurable :gettext_domain — for any app setting that config, every dispatch translation silently missed. The generator now uses Config.gettext_domain/0.

[0.5.4] - 2026-08-11

0.5.2 was never published — its changes ship here. (An earlier changelog revision folded them into 0.5.3; in fact 0.5.3 had already been published 2026-07-14 with the attachment work alone, so they ship as 0.5.4.)

Added

  • Resend webhook signature verification: AshDispatch.WebhookHandlers.Resend.verify/3 — Svix HMAC over svix-id.svix-timestamp.raw_body with constant-time comparison, multi-signature support (secret rotation) and a replay window. Ported from a client app, where it was the only verified endpoint in the fleet; siteflow/magasin expose unauthenticated receipt mutation today.
  • Sensitive-content scrubbing: AshDispatch.Workers.ScrubSensitiveContent (cron) blanks body_text/ body_html of receipts whose event declares metadata: [sensitive_content: true] once they are older than config :ash_dispatch, :scrub_after_hours (default 24). Receipts in :failed are left for the retry path first. Replaces app-level scrub workers .
  • Dispatcher.dispatch_safely/3 — rescue-and-log wrapper for fire-and-forget dispatch from code paths that must never be felled by a notification failure. mosis carries two hand-rolled copies of this (Mosis.AshDispatch.dispatch_safely, AshDispatchAdapters.BestEffort); they can be retired on upgrade.
  • BACKLOG.md: design-level findings from the 2026-08-10 cross-app integration audit (retry semantics, ManualTrigger trigger no-op arguments, dead surface).
  • CI: ci.yml runs format check + tests on every PR and push to main; publish.yml gained a version guard so re-pushing an already-published version no longer fails the pipeline.

Changed

  • Widened optional hackney constraint to ~> 1.9 or ~> 4.0 so the library coexists with dependencies that require hackney 4.x (e.g. stripity_stripe 3.x). hackney is only used when Swoosh is configured with a hackney-based API client; projects using other adapters are unaffected.
  • Downgraded the per-dispatch "No :user_module configured" log line from warning to debug. An app without a user resource is a valid configuration (custom recipient resolvers handle non-user recipients); the two recipient-resolution failure diagnostics keep their warning level.
  • SendEmail with no :email_backend configured now marks the receipt :skipped ("no email_backend configured") with a warning, instead of logging [MOCK] and marking it :sent — a receipt claiming a delivery that never happened.
  • ValidateCanRetry (the receipt :retry action) now reads config :ash_dispatch, :max_retries (default 5) instead of a hardcoded 5 that silently overrode the same knob RetryFailedDeliveries honors.

Deprecated

[0.5.3] - 2026-07-14

Added

  • End-to-end email attachment support: events can implement attachments/2; attachments flow through the Oban job (base64) into the Swoosh backend (#5).

[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