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

### Added
- **`DeliveryReceipt.Base` `:list_all` takes an `audiences` list argument**
  alongside the existing single `audience` — apps with scoped audience
  families (e.g. permission-scoped admin audiences) can catch the whole
  family in one filter: `audiences: [:admin, :order_admins, …]`.

  (Written for 0.6.2, but the publish workflow triggers on every main push
  and the version bump sat in the first commit of a two-commit stack — so
  hex 0.6.2 was built mid-stack and never got this argument. Lesson: bump
  the version in the LAST commit of a stack, or push the stack atomically.)

## [0.6.2] - 2026-08-17

### Fixed
- **`ManualTrigger.Base` no longer hardcodes the audience/transport
  universe.** The `:preview` arguments and the `:trigger`-path attributes
  constrained `audience` to `one_of: [:user, :admin]` and `transport` to
  `one_of: [:email, :in_app]` — silently rejecting every app-defined
  audience (`:customers`, `:watchers`, permission-scoped admin audiences)
  and every transport added since. Both are now validated at runtime
  against the consuming app's `config :ash_dispatch, :audiences` and
  `Transport.Registry.receipted_atoms/0`, with the allowed universe listed
  in the error message. Same disease as the receipt-constraint drift fixed
  in 0.6.0; a structural test now pins that no hardcoded list returns.

## [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
- `AshDispatch.Resources.ManualTrigger` (the legacy non-Base variant): its
  `:trigger` action fails `Dispatcher.dispatch/3`'s map guard with a
  `FunctionClauseError`. Use `AshDispatch.Resources.ManualTrigger.Base`.
  Removal planned for 0.6.

## [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 `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
