Current section

Files

Jump to
ash_dispatch CHANGELOG.md
Raw

CHANGELOG.md

# 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.8.3] - 2026-09-22

### Fixed

- **ash_dispatch compiles without `ash_typescript`** (#31). The dependency is
  declared `optional: true`, but `AshDispatch.Resources.EmailEvent` and
  `AshDispatch.Resources.ManualTrigger` named `AshTypescript.Resource`
  unconditionally — and they are compiled as part of the library itself. An
  app without ash_typescript therefore failed in `mix deps.compile`, on
  `undefined function typescript/1`, and the installer's `--no-typescript`
  could not help. It had been that way since the first version.

  The TypeScript half of both resources now lives in a Spark fragment
  (`…EmailEvent.Typescript`, `…ManualTrigger.Typescript`) that carries the
  extension and `type_name` when ash_typescript is present and is empty when
  it is not. `AshDispatch.Setup` picks its extension list on the same
  condition, evaluated in the consuming app.

  An app **with** ash_typescript sees no difference: the extensions and type
  names (`"EmailEvent"`, `"ManualTrigger"`) are unchanged, so existing
  `typescript_rpc` blocks keep working.

  A new CI job, `compile-without-ash-typescript`, builds the library as a
  dependency of an app without it, pinned to the library's own `mix.lock`.
  It fails on `main` before this change and passes after it.

### Note

If you add ash_typescript to an app later, run
`mix deps.compile ash_dispatch --force` once. Mix does not rebuild a
dependency when an optional dependency appears, so the fragments would
otherwise stay without the extension.

## [0.8.2] - 2026-09-21

### Added

- **SMS goes through the queue.** `:email` and `:webhook` enqueued an Oban job
  and marked the receipt `:scheduled`. `:sms` called the backend's `deliver/4`
  directly — synchronously, inside the `after_action` hook where dispatch
  happens, and therefore inside the action's transaction. A slow provider held
  that transaction open, and `time:` could not be used because there was no job
  to schedule.

  It now takes the same shape as email, with `AshDispatch.Workers.SendSMS`
  mirroring `SendEmail`: queue, `:scheduled`, and therefore `time: {:in, n}`
  and `{:at, dt}` for free. Requires an Oban queue named `:sms`.

  The consent gate arrived in 0.8.0 (`Preferences.with_consent`) and stays
  above the queue — a receipt the recipient opted out of should never become a
  job.

  An existing backend keeps working — it is simply called from the worker
  rather than from the transaction. **But** the job carries only the receipt
  id, so the context it receives is reconstructed from the receipt: `event_id`
  and `audience` are real, `data` and `variables` are empty. A backend that
  read `context.data` should read `receipt.content` instead, which was frozen
  at creation and survives a retry for exactly that reason.

- **`AshDispatch.SMSBackend.Elks`** — 46elks, packaged the way
  `EmailBackend.Swoosh` is, behind the already-optional `req` dependency.
  E.164 normalisation, a `dryrun` flag, and `:req_options` so it can be stubbed
  in tests. `400`, `401` and `403` are marked `:failed_permanent` immediately: a
  malformed number does not become valid after five retries, and while it sits
  as `:failed` it only delays telling the person who can fix it.

- **`AshDispatch.SMSBackend.Phone`** — E.164 from the ways people write phone
  numbers. Its own module with its own test table, because this is the function
  that decides whether a message reaches a person or goes quietly nowhere.

### Fixed

- **A failed SMS was never retried.** `Workers.RetryFailedDeliveries` and
  `Changes.EnqueueRetryJob` knew only `:email` and `:in_app`; everything else
  fell into a catch-all. The consequence was not that the receipt was left
  alone: `:retry` still ran, incremented `retry_count`, and the enqueue failed
  — back to `:failed`. Five cron passes and 75 minutes later it read
  `:failed_permanent`, without having been resent even once. `:retry`,
  `:reopen` and `:send_now` in admin refused it for the same reason, so there
  was no way back at all.

  Deliberately distinct from a transport that has no worker: `:broadcast` and
  `:oban` keep no receipts, and therefore have nothing to retry.

- **The map of what can be retried existed in two copies.** The same three
  branches in the cron and in the admin actions, so a transport could be
  retried by one and not the other. It now lives in
  `AshDispatch.Transport.Retry`, which answers which strategy a transport has.
  Result handling stays with the caller, because that genuinely differs: the
  cron wants to know the receipt is already fully handled, the action wants a
  job id to store.

### Note

`recipient_fields` must carry an `:sms` entry, or **every** recipient raises
`"No identifier field configured for sms transport"` — and the error does not
say where to look:

```elixir
config :ash_dispatch,
  recipient_fields: [
    sms: [identifier: :phone, name: [:display_name, :name]]
  ]
```

## [0.8.1] - 2026-09-20

### Added

- **`reply_to/2`** — an email can finally carry a `Reply-To` header.

  The sender (`from/2`) is the organisation: an address that often does not
  accept replies. But an email WRITTEN by a person — *"we're moving our
  meeting"*, *"here's your quote"* — invites an answer, and without a reply
  header that answer lands in `noreply@` and is read by nobody.

      def reply_to(context, %Channel{transport: :email}) do
        context.data.meeting.user.email
      end

      def reply_to(_context, _channel), do: nil

  The default is `nil`, i.e. exactly today's emails. Anything that is not a
  non-empty string is treated as `nil`, and a callback that raises does not
  bring the send down: an email without a reply header is cheaper than an
  email that never leaves.

  **Why it is carried in `receipt.content` and not only in the job's args.**
  `SendEmail.new_for_receipt/1` builds a retry — and every *send now* — from
  the RECEIPT, and carries no content args at all. A `reply_to` that lived
  only in the first job's args would therefore be lost on every retry,
  silently, and only on the retry. It is the same trap attachments already
  have special handling for (`maybe_put_original_attachments/2`); here it is
  solved by the value being in the receipt. A bonus: the field can be READ
  afterwards, so a consumer can measure that the reply path was actually set
  rather than assume it.

  Old queued jobs lack the key, get `nil`, and behave as before the upgrade.
  `AshDispatch.Workers.SendEmailTest` proves both directions.

  **Why it was needed.** One consumer had solved it with a proxy in front of
  the Swoosh backend that looked the sender up in the database by recipient
  plus subject, per email — a second state machine beside the library's, and
  one read per send. That is a symptom of a missing callback, not a design.

## [0.8.0] - 2026-09-14

### Added

- **`extra_content/2`** — a module may contribute content keys of its OWN.

  A transport's content is a closed list: `:in_app` carries a title, a body and
  a way onward, `:webhook` the same, `:email` its bodies. That is enough while
  the receiver is a notification list. It is not enough when the receiver is a
  surface that can render facts in two columns, several buttons and an icon —
  without this, every new key of that kind is a change to the library.

      def extra_content(context, %Channel{audience: :slack_channel}) do
        %{slack_icon: "contract", slack_fields: [%{label: "Amount", value: "13,995"}]}
      end

  The addition is merged **below** the transport's own keys: a module
  returning `%{message: ...}` does not overwrite `notification_message/2`. It
  is for what is missing, never for rewriting what is there. Anything that is
  not a map is ignored, and a callback that raises does not bring the dispatch
  down — a notification that never arrives costs more than a field that is
  missing.

### Fixed

- **`:webhook` could not carry a title or a way onward from a MODULE.**
  `build_module_content/5` had a branch per transport and `:webhook` fell
  through to the catch-all — `%{message: notification_message(...)}` and
  nothing else. `notification_title/2`, `action_url/2` and `action_label/2`
  were never called for that transport.

  `:in_app` has always carried all four. That `:webhook` did not was no
  declaration, it was that nobody had written the branch. (0.7.3 fixed the same
  class on the DSL side; this is the module side of the same gap.)

  Measured at a consumer: of six distinct channel posts in production, ONE
  carried a title and NONE carried a link.

  The guard `AshDispatch.Transports.WebhookModuleContentTest` checks both
  properties, mutation-tested: three mutations fail three tests.

## [0.7.3] - 2026-09-14

### Fixed

- **The `:webhook` transport could not carry a way onward.** The `:webhook`
  branch of `build_inline_content/4` read `title` and `message` (after 0.7.2)
  but never `action_url`/`action_label`. A declared

      content: [message: "...", action_url: "https://...", action_label: "Open the demo"]

  was therefore half decoration: the text arrived, the way there did not, and
  the sender had no way to notice except by reading what was delivered.
  `:in_app` and `:push` have always carried the keys; `:webhook` was the only
  transport with a receiver that can render a button and no ability to get a
  url to it.

  Reported by a consumer who described their Slack channel posts as *"dead
  notifications"* — the team learned something had happened but could not get
  there.

  `:discord`, `:slack` and `:sms` DELIBERATELY do not get the keys: their
  payloads have no button shape of their own, and a url without a surface that
  renders it is a key that only looks like it does something.

  The guard `AshDispatch.Transports.InlineContentTextTest` now checks both
  properties per branch — that the body is read, and that the way onward is
  read by the transports that can show it. Mutation-tested: removing the lines
  fails two tests.

## [0.7.2] - 2026-09-14

### Fixed

- **The `:webhook` transport dropped its declared body.** The `:webhook` branch
  of `build_inline_content/4` built only `payload` and `webhook_url`. It never
  read `content_config[:message]` — the only one of six body-carrying branches
  that did not.

  The bug was of the silent kind. An event WITH an event module falls back on
  the module's `notification_message/2`, whose generated default is
  `"You have a new notification"`. The merge order in `build_content/5` lets
  the module's value stand for every key inline content does not set, so a
  declared `content: [message: ...]` on a webhook channel became
  **decoration** — and the recipient got the placeholder, the right shape with
  the wrong content. Without a module, `message` would have been missing
  entirely and the receiver could have rejected the envelope; the event having
  a module therefore turned a hard failure into a valid string.

  Measured at a consumer before the fix: **41 of 41** delivered webhook
  receipts carried the placeholder, across nine declared channels. None of the
  written text had ever arrived, for as long as the channels had existed.

  The branch now carries `:message` and `:title` through `maybe_put/3`.

- **`:discord`, `:slack` and `:sms` could overwrite the module's body with
  `nil`.** The same class in the other direction: they set `message:` as an
  unconditional key, and `interpolate(nil, _)` returns `nil`. A channel that
  declared everything EXCEPT the body therefore erased the module's callback
  value. All three now use `maybe_put/3`, as `:in_app` and `:push` already did.

### Added

- `AshDispatch.Transports.InlineContentTextTest` — a guard requiring every
  body-carrying transport branch to read `content_config[:message]` and set it
  with `maybe_put`. The existing structural test only asked whether the branch
  EXISTED, and therefore saw a branch carrying the wrong content as a branch
  that worked.

## [0.7.1] - 2026-09-10

### Fixed

- **The orphaned-provider warning could go silently missing.** The latch that
  makes it log only once per VM was set **before** the condition was checked.
  The very first delivery in a node therefore decided forever whether the
  warning could ever appear: an app that set `:preference_provider` after that
  delivery — an umbrella booting in an unlucky order, a `runtime.exs`, a test
  suite — was latched into silence by a call that had nothing to warn about.

  A warning that can silently go missing is exactly the class of bug the
  warning exists to report. The condition is now checked first and the latch is
  set only when the warning actually logs. The cost is two ETS reads per
  delivery, which is less than the cost of being wrong about it.

## [0.7.0] - 2026-09-10

### Changed

- **Every transport that reaches a person now asks for consent.** Until now
  three of seven did: `:email`, `:in_app` and `:webhook`. `:slack`, `:discord`,
  `:sms` and `:push` delivered regardless of what the recipient had chosen.

  It looked like a gap rather than a decision, and it was of the silent kind: a
  person who turned a notification type off got it anyway — on another channel,
  without anything saying so. The preference was not broken, it was
  **partially** honoured, which is worse than not existing, because the person
  believes it applies.

  This is a BEHAVIOUR CHANGE for existing consumers, and therefore a minor bump
  rather than a patch: a `:slack` channel with a gated audience (`:user` by
  default) will now skip receipts it used to send. Set the audience to
  something ungated, or use `preference_gated_audiences`, if that is not what
  you want.

### Added

- **`AshDispatch.Transports.Preferences.with_consent/5` — the gate in ONE
  place.** The check is eight lines, and eight lines copied six times are six
  chances to drift apart. The copies had already started: `:in_app` logged the
  user id, `:webhook` logged the receipt id, and neither said which was
  intended.

  `:email`, `:in_app` and `:webhook` moved to the same function — not as
  tidying, but so that the reason on a skipped receipt is **one** value you can
  count. A dashboard filtering on opt-out should not miss a transport that
  spelled it differently. `Preferences.reason/0` is public for exactly that
  reason.

  The caveat, written down: this covers the transport gate's reason. The
  `SendEmail` worker still writes `"User opted out of this email category"`
  when its own provider path refuses (see below). Two reasons, two systems — a
  count has to know about both for now.

- `:oban` and `:broadcast` are deliberately outside the rule. They have no
  person to ask, and a consent gate there would imply a recipient that does not
  exist. A test guards that they stay outside, so the next reader sees it is a
  decision and not something forgotten.

### Added

- **`AshDispatch.UserPreference.LegacyProvider` — the bridge between the
  library's TWO preference systems.** This is the real finding in this release,
  and it only surfaced when the above was measured against a real consumer.

  The library has two preference systems that never met:

  | | read by | configured as |
  |---|---|---|
  | `AshDispatch.UserPreference` | every transport, via `allows_receipt?/4` | `:user_preference` |
  | `AshDispatch.Behaviours.PreferenceProvider` | the `SendEmail` worker and manual triggers | `:preference_provider` |

  An app that wired up **only** the second — and the guides pointed there for
  years — gets its rule honoured **on email and nowhere else**. Nothing said
  so. The rule looked configured, was configured, and silently covered one
  transport out of seven.

  That is the same class as the rest of this release, but worse: an app that
  has configured nothing at all at least knows it has no opt-out.

  The bridge lets the existing provider answer for all seven:

      config :ash_dispatch,
        preference_provider: MyApp.PreferenceProvider,
        user_preference: AshDispatch.UserPreference.LegacyProvider

  The semantics are copied from `SendEmail.check_user_preferences/1` —
  including that an `{:error, _}` from the provider **lets the message
  through**. A preference database that is down must not become a mute button:
  a notification that never arrives is invisible to everyone, including the
  recipient.

  Wiring it up is opt-in deliberately. Making the bridge the default would have
  silenced recipients in existing apps on a patch upgrade — precisely what
  makes this class of bug hard to find.

- **The library speaks up about the orphaned provider.** If
  `:preference_provider` is set while `:user_preference` is not, a warning logs
  once per VM with what applies and how to wire up the bridge. Nobody should
  have to discover this the way it was discovered.

### Fixed

- The regression guard for the per-recipient verdict (0.6.1) **measured the
  mechanism, not the property**: it required the literal `allows_receipt?/4`
  line to appear inside `email.ex` and `in_app.ex`. When the six copied blocks
  were replaced by a shared function the guard went red — while the property it
  exists to protect was untouched.

  It now measures the verdict: no delivery path may derive consent from
  `context.user`, and every path that decides at all passes the RECEIPT first.
  The files are enumerated with a glob rather than a list someone maintains,
  because a maintained list is the other half of the same mistake — a transport
  added tomorrow is covered without anyone having to remember it.

## [0.6.14] - 2026-09-09

### Added

- **`metadata.secret_env` — the signing secret's NAME in the DSL, the value at
  send time.** Channels declared in `dispatch do` are compile-time data, but a
  signing secret is operational data. Baked in at compile time, a key rotation
  does not take effect until someone recompiles — and nothing says a word.

  The alternative was to move the whole channel into the event module's
  `channels/1`, which works but sacrifices the DSL for every OTHER property of
  the channel (audience, timing, policy, dedupe). Reading the name at compile
  time and the value at send time keeps both: the channel stays declarative,
  the secret stays operational.

- **`metadata.webhook_url_env`** for the same reason, with a sharper
  consequence: a URL baked in at compile time travels to STAGING, and staging
  then posts to production's receiver. The message arrives — just in the wrong
  place, which does not look like a failure.

- **The envelope carries `source_type` and `source_id`.** Without them a
  receiver knows something happened and to whom, but not about WHICH object —
  and therefore cannot offer an action. A Slack button that should record a
  choice on a meeting needs the meeting's id. The receipt already carried the
  fields; they were simply missing from the envelope.

  `secret` wins when both are given, so a test can set an explicit value.
  `secret_env` is stripped from the forwarded envelope for the same reason as
  `secret`: it reveals no value, but it is configuration rather than event
  data. `secret/1` is public.

## [0.6.13] - 2026-09-08

### Fixed

- **The receipt was scheduled AFTER the job was enqueued, and raced its own
  worker.** From the moment the job is in the queue a worker can pick it up —
  and with `Oban, testing: :inline` it already runs inside `Oban.insert/1`. The
  receipt could therefore become `:sending`, `:sent` or `:failed` before the
  transport marked `:scheduled`, and `schedule` only goes from `:pending`. The
  result was a raised `NoMatchingTransition` in the middle of a **successful**
  delivery: the message arrived, but the caller got an error.

  `:webhook` now marks `:scheduled` before the insert, and re-reads the receipt
  afterwards so the reported status is the real one rather than the expected
  one.

  The same ordering exists in `:slack` and `:discord`. They are untouched here
  — that is a behaviour change for existing consumers and belongs in its own
  change — but the bug is the same and worth knowing about.

## [0.6.12] - 2026-09-08

### Added

- **`:webhook` now actually delivers.** The transport had been in the registry
  and in `@type transport` for a long time, but `deliver/4` logged
  `"Webhook transport not yet implemented"` and marked the receipt `:skipped`
  with `transport_not_implemented`. A channel wired to it therefore sent
  nothing — and did so *silently*: the receipt was a valid terminal status, not
  an error, so no surface upstream had reason to raise anything. That is the
  worst kind, because it only shows up when someone wonders why a notification
  never came.

  The transport now POSTs a stable envelope shape (`event_id`, `receipt_id`,
  `user_id`, `recipient`, `audience`, `content`, `metadata`, `sent_at`) through
  `SendWebhook`, so a receiver can be written once.

- **Signing.** With `metadata.secret` set, the call carries
  `<signature_header>: sha256=<hex>` (default `x-webhook-signature`), computed
  as HMAC-SHA256 over `METHOD \n path \n sorted_query \n body`. Binding the
  path and the query — not only the body — means an intercepted signature
  cannot be replayed against a different endpoint on the same host.
  `canonical_string/3` is public so a receiver can test its verifier against
  ours rather than against its reading of the documentation.

  The secret is stripped from the forwarded metadata: a signing secret must
  never travel inside the body it signs.

- **`SendWebhook` accepts `raw_body`.** A signed webhook must send exactly the
  bytes that were signed. Let the HTTP client re-encode a map and key order and
  float formatting can change, and the signature fails *intermittently* — which
  is worse than always, because it looks like a flake rather than a bug. The
  transport therefore serialises itself and passes the string on. Without
  `raw_body` the behaviour is unchanged (`json: payload`), so the Discord and
  Slack transports are untouched.

- **A `4xx` is no longer retried.** `SendWebhook` treated every non-2xx the
  same and let Oban try five times. But a `4xx` is the receiver saying *this
  request is wrong* — an unknown channel, a revoked webhook URL, a recipient
  that does not exist. Resending exactly the same bytes changes nothing; it
  only hides the failure behind a queue that looks busy. It is now
  `{:cancel, reason}` with the receipt already set to `failed`. The exceptions
  are `408` and `429`, which are about time rather than content, and
  `5xx`/network errors as before.

  This lets a receiver answer `422` when a notification cannot be delivered and
  get an honest `failed` receipt on the first attempt, rather than five
  identical attempts and a truth that arrives minutes later. `permanent?/1` is
  public.

- **The signing secret can no longer be dropped silently.** `metadata` was read
  with an atom key only, while the strip from the payload handled both atom and
  string. A `metadata: %{"secret" => …}` was therefore stripped from the body
  but never used — the call went out unsigned, without a word. The read now
  mirrors `ContentMap.get_content/2` and takes both forms.
  `request_headers/3` is public so the signing contract can be tested from the
  outside.

### Notes

- `:webhook` honours the recipient's opt-out through
  `UserPreference.allows_receipt?/4`, like `:email` and `:in_app` — a webhook
  is often the first hop to a person, and delivering to someone who opted out
  because the last hop happens to be HTTP would be the wrong default.
  `:slack`, `:discord`, `:sms` and `:push` do not yet make that check. It looks
  like a gap rather than a decision, but changing them is a behaviour change
  for existing consumers and belongs in its own change.

## [0.6.11] - 2026-09-07

### Fixed

- **The in-app notification could be delivered once per RECIPIENT rather than
  once per event.** The idempotency key was built by `extract_resource_id/1`,
  which takes the first value in `data` carrying a binary `:id`. When `data`
  holds both the recipient and the thing that happened, the winner is decided
  by the map's iteration order — in practice often the recipient. The key then
  becomes `event:<user>:<audience>:<user>`, constant across all future events
  for that user, and every later event collides with the first. Seen in
  production on a "you reached your goal" event: ONE row in the whole table,
  the rest collided.

  Channels now take `idempotency_source:` — the key in `data` that identifies
  which event this is. Without it the old heuristic applies unchanged, so
  existing events behave the same.

- **A collision could leave the transport as an exception.** `Ash.create`
  returns `{:error, _}` for a uniqueness violation only when it owns the
  transaction. Inside an OUTER transaction — an Ash action dispatching from a
  hook — AshPostgres raises instead, the `case` statement's error branch is
  never reached, and the raise tears down the caller's transaction along with
  everything it had done. A trigger stamping its own idempotency flag before
  sending would then lose the stamp and run again forever. The transport now
  looks the key up before the insert and treats a *raised* uniqueness violation
  as the collision it is.

- **A retry on a receipt that already carries `notification_id` does not create
  a second notification.** The retry path builds the key from the receipt's
  `source_id` and cannot see the channel's `idempotency_source`, so it could
  write a duplicate under a different key. It now acknowledges instead.

## [0.6.10] - 2026-09-02

### Fixed

- **An unreachable recipient no longer takes down the whole channel.** When
  `extract_identifier/4` could not produce an address,
  `extract_recipient_identifier/3` re-raised, and the exception escaped
  `Enum.map/2` in `do_dispatch_channel/3` — even though that function already
  tolerates partial failure and returns success when at least one delivery went
  through. A recipient without an address therefore cost ALL recipients in the
  channel. It now returns `{:error, :recipient_unreachable}` for that row only;
  the error is logged as before.

  Seen in production on 2026-09-02: an audience resolved to the buyer plus
  their company, the company was a login-less grouping row with no email
  address, and the customer's order confirmation was never created. The buyer
  had a perfectly good address and lost their email to someone else's missing
  one.

  Distinct from the `optional: true` skip, which is a deliberate skip and
  counts as success. Merging the two would have reported a channel as delivered
  even though nobody could be reached.

- **The error message stops being the thing that breaks.**
  `raise_extraction_error/5` read `recipient.__struct__` directly. Audiences
  can resolve to plain maps, and on one of those the dot access raised a
  `KeyError` *inside the error message* — the caller saw
  `%KeyError{key: :__struct__}` rather than which recipient and which field it
  was about. The one line that could have explained the failure was the one
  that broke.

## [0.6.9] - 2026-08-24

### Fixed

- **`:send_now` and `:reopen` accept nothing now.** Neither declared an
  `accept` list, so both inherited the resource default: **thirty attributes**,
  including `recipient`, `subject`, `body_html` and `content`.

  "Send this letter again" could therefore be called as "send a different
  letter to a different address" — and because the same call rewrites the
  receipt, nothing in the record would say otherwise afterwards. The receipt is
  the audit trail; an action that edits it while sending it destroys the only
  evidence of what was sent.

  A resend has no inputs. It re-sends what is already there.

  **Breaking if you were passing attributes to `:send_now`** — those calls will
  now fail with `NoSuchInput` instead of silently editing the outgoing mail.
  Change the receipt first with an ordinary update if that was deliberate, then
  send. `:reopen` shipped in 0.6.8 and cannot have dependents yet.

## [0.6.8] - 2026-08-23

### Added

- **`:reopen` — a way out of `:failed_permanent`.** The state was a dead end:
  no transition led out of it, `:send_now` refused it explicitly, and
  `SendEmail` treats it as terminal and completes any job for it as a success.
  A receipt that ended there could never be sent again — not even by a person
  who had just fixed the reason it failed. The only remaining move was to look
  at it.

  That is the wrong shape for the state that a permanent failure lands in. It
  is reached in ordinary operation (a provider outage, a rate limit, a
  mailbox that was full for a day), and every one of those reasons goes away
  on its own.

  `:reopen` moves the receipt back to `:scheduled` and enqueues a job, so the
  existing send path runs unchanged. Three deliberate choices:

  - It starts **only** from `:failed_permanent`. A receipt that actually
    reached its recipient can never be re-sent through it.
  - It is gated by the same `send_now_authorizer` as `:send_now`, because it
    sends real mail.
  - It **resets** `retry_count` rather than incrementing it. The counter
    exists to stop the automatic sweep from looping forever; a human deciding
    to try again is not that loop, and leaving the counter at its ceiling
    would let the sweep abandon the attempt the moment it failed once.

  Consumers that want the old behaviour need do nothing: the action has to be
  called to have an effect, and nothing in the library calls it.

## [0.6.7] - 2026-08-23

### Added

- **The resource bases accept `notifiers:`.** They already accepted
  `extensions:`, and the omission was arbitrary — a notifier is how you
  observe what a resource did without touching how it does it.

  Without it, a consumer wanting to react to receipt transitions has to reach
  for a change instead, and a non-atomic change forces `require_atomic? false`
  onto **every update action in the base** — actions the consumer cannot edit.
  That is a compile error with no way out from the consuming app.

  Two lines in each of `DeliveryReceipt.Base` and `Notification.Base`, both
  defaulting to `[]`, so nothing changes for anyone who does not pass the
  option.

## [0.6.6] - 2026-08-23

### Fixed

- **A receipt can no longer strand in `:scheduled` unnoticed.** That status
  promises a job is coming; nothing ever checked whether one was. A receipt
  whose job died, was pruned, or never enqueued sat there permanently — the
  retry sweep queries `status == :failed`, so it never looked, and no surface
  counted it.

  One production deployment carried **17 such receipts across six months**:
  four order confirmations (two to a customer, not staff), four reseller
  applications and nine product announcements. All showed a provider 429, all
  were moved to `:scheduled` by a retry, none were seen again. The underlying
  race was fixed in 0.5.6; what remained was that nothing recovers the
  receipts already stranded, or any stranded by a future crash or deploy.

  `RetryFailedDeliveries` now sweeps `:scheduled` before its ordinary pass,
  with three outcomes (see `AshDispatch.Workers.Stranded`):

  | age | outcome |
  |---|---|
  | under the grace period | left alone — `:scheduled` is a legitimate transient state |
  | past grace, under the ceiling | moved to `:failed`, so the existing retry machinery takes over |
  | past the ceiling | `:failed_permanent` with a reason — **never sent** |

  The ceiling exists because delivering a January order confirmation in
  August is worse than silence: the recipient has to work out whether
  something went wrong. The invisible debt is the defect, not the unsent
  mail.

  Configurable via `:stranded_stuck_after_minutes` (default 30) and
  `:stranded_stale_after_hours` (default 24). A contradictory configuration
  where the ceiling falls below the grace period resolves to leaving receipts
  alone — never touching one that may still be in flight is the stronger
  safety property, since a double-sent mail cannot be recalled.

  **This acts on existing data on first run.** A consumer holding stranded
  receipts it still wants delivered should raise
  `:stranded_stale_after_hours` before upgrading.

  The decision function is covered by unit tests; the sweep's database
  plumbing is not, for the harness reasons recorded under 0.6.5.

## [0.6.5] - 2026-08-21

### Added

- **The recipient is in the context.** `do_build_receipt_content/4` runs once
  per recipient and has always had it in scope, but nothing downstream could
  see it: `prepare_template_assigns/2` receives `(context, channel)`, and
  template assigns were built from that return value plus
  `Context.template_assigns/1`. Greeting someone by name was impossible
  without reaching outside the render path.

  It now goes into `context.variables`, which is the one place that reaches
  **both** consumers — `Context.template_assigns/1` merges variables into the
  assigns a template sees, and the callback can read
  `context.variables[:recipient]` to PRECOMPUTE per-recipient values.

  That second half is the point. Exposing it only in the final assigns would
  have forced consumers to branch inside templates, which is exactly what a
  precomputed-assigns convention exists to prevent.

  `Map.put_new/3`, so an event that already resolves a richer recipient shape
  keeps its own. Purely additive: a context key nothing reads changes no
  output, and an event that never mentions the recipient renders
  byte-for-byte as before. `subject/2` still receives no recipient — a
  personalised subject line needs a callback signature change and is not part
  of this release.

  **Not covered by a test in this repo.** The suite has no harness for a full
  dispatch-to-receipt flow: no application-level `recipient_fields`, no
  configured `delivery_receipt_resource`, and further gaps behind those. An
  attempt to build one was abandoned as larger than the change it would
  guard. The behaviour is exercised end-to-end by a consumer's mailing
  byte-identity test, which compares a preview rendered for a chosen
  recipient against what that recipient actually receives.

## [0.6.4] - 2026-08-18

Bug fixes in the delivery path, plus additive facades over machinery that
already existed. No behaviour changes for any existing consumer: every new
option defaults to exactly what the previous releases did.

### Fixed
- **User preferences are evaluated per receipt, not per context user.**
  The `:email` and `:in_app` transports asked
  `UserPreference.allows?(context, channel, event_config)` — a question
  about the user on the *context*, i.e. the event's subject. But a receipt
  is one recipient, so on any fan-out (one event, N receipts) that single
  verdict was applied to all N: one customer's opt-out silenced the whole
  send, and one customer's opt-in delivered to people who had opted out.
  Consumers implementing `user_allows?/4` never saw more than one user id
  per event and had no way to notice.

  Both transports now call the new `allows_receipt?/4`, which reads
  `receipt.user_id`. Receipts without a user id (external addresses,
  webhook targets) keep delivering unasked.

- **`{:at, %DateTime{}}` channel times are honored.** `normalize_time/1`
  accepted them, the `time` type documented them and
  `Channel.calculate_delay/1` knew how to compute them — but the email
  transport matched only `{:in, seconds}` and let everything else fall
  into a catch-all `0`, so an absolute-time channel sent IMMEDIATELY,
  silently (and `calculate_delay/1` had zero call sites). The delay is now
  computed from the datetime, clamped at 0 so a past time means "now".
  `{:in, seconds}` is unchanged.

### Added
- **`AshDispatch.preview/3`** — the preview engine behind ManualTrigger,
  exposed as a plain function. Renders subject/HTML/text for each channel
  of an event without delivering:

      AshDispatch.preview("orders.created", %{order_id: order.id},
        transport: :email, audience: :user)
      #=> {:ok, [%{subject: …, html_body: …, text_body: …, recipient: …}]}

  Options: `:audience`, `:transport`, `:actor`, `:recipient_email` (which
  only changes the displayed recipient — preview never sends).

- **`AshDispatch.UserPreference.allows_user?/4`** — the preference
  predicate, extracted from `allows?/3` and callable anywhere a user id is
  known: `allows_user?(user_id, event_id, transport, opts)`. This is how an
  admin screen's "342 recipients · 38 have opted out" stays in agreement
  with what the send path will do. A `nil` user id returns `true`.
  `allows?/3` keeps its behaviour and delegates to it.

- **`AshDispatch.UserPreference.allows_receipt?/4`** — the same question
  asked about a receipt's own recipient. This is the gate the transports run.

- **`config :ash_dispatch, preference_gated_audiences`** — which audiences
  have their recipients' preferences consulted. Defaults to `[:user]`,
  exactly the audience set every earlier release gated; every app-defined
  audience (`:customers`, `:watchers`, permission-scoped admin audiences)
  bypassed preferences entirely and still does until opted in:

      config :ash_dispatch, preference_gated_audiences: [:user, :customers]

  Keep admin/team/system audiences out of it — an operator must not be able
  to silence an operational alert by unticking a marketing box.

### Deprecated
- **Channel time `{:window, map}`.** Business-hours windows were never
  implemented; the spec has always delivered immediately. It still does —
  removing it would break consumers that declared one — but the first
  `{:window, …}` channel after boot now logs a deprecation warning. Use
  `{:in, seconds}` or `{:at, %DateTime{}}`. Removal is deliberately
  postponed.

### Docs
- **"Pattern 3: Frequency-Based Preferences" rewritten.** It instructed a
  side effect (`queue_for_digest/3`) inside `user_allows?/4` followed by
  `false` — i.e. a notification silently moved into a table nobody
  delivers from, behind a receipt claiming the user opted out. The
  predicate stays a predicate; digest mode is modelled as "no individual
  delivery" with the digest job owned by the app.

### Planned (names reserved, nothing shipped)
- **Per-recipient digests.** Reserved: channel time `{:digest, window}`,
  `AshDispatch.Resources.DigestEntry.Base`,
  `AshDispatch.Workers.FlushDigests`, and a `user_digest_mode/2` callback
  on the preference behaviour. Two properties are already fixed: the
  digest unit is per recipient (one body per user, not one body for
  everyone), and a digest still produces a `DeliveryReceipt` — a digest is
  a delivery, not a silence. See the User Preferences topic.

## [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; two consumers 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` 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