Changelog
View SourceAll notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.8.5] - 2026-09-23
Documentation
- Four guides that existed but were never published.
architecture.md,delivery-receipts.md,localization.mdandpriority.mdsit inlib/documentation/topics/and are linked from other pages, but were missing fromextrasinmix.exs— so every link to them was dead on HexDocs. They are published now. - References to things that do not exist.
AshDispatch.Namingand the broadcast transport both described a per-eventwire_event_name/0callback onAshDispatch.Event; there is no such callback and never was. The text now says what actually happens: every transport routes throughAshDispatch.Naming.wire_event_name/1. Stale references to private and renamed functions are corrected too, which takesmix docsfrom 15 warnings to 2 — both of them about a module hidden in ash_typescript's own docs.
Internal
Not part of the published package (config/ and mix.lock are not shipped),
but worth recording:
- Dependencies refreshed. ash 3.9.0 → 3.33.9, ash_postgres 2.6 → 2.13,
spark 2.3 → 2.7, ash_typescript 0.7 → 0.18, igniter 0.7 → 0.8, plus oban,
swoosh, req and the rest. This clears 16 packages carrying published
advisories, several rated HIGH — and, more to the point, the suite had been
running against Ash 3.9 while
mix deps.getin a fresh app resolves 3.33. All 576 tests pass on both Elixir 1.17 and 1.18. config :ash, :default_string_length_countis now set (:codepoints) for this project's own builds: Ash 3.33 makes every application choose. Consuming apps are unaffected and make their own choice — Ash skips the check for resources compiled as a dependency.- CI moves to Elixir 1.18 / OTP 27, because igniter now pulls in
ex_ast, which requires 1.18.compile-without-optional-depsruns on 1.17 instead, so the library is still held to a floor rather than to whatever is newest. It cannot be 1.15: Ash 3.33 usesDurationand does not compile there, although it declares~> 1.11.mix.exskeeps~> 1.15, which stays true for an app that pins an older Ash — ash_dispatch's own code compiles there.
[0.8.4] - 2026-09-22
Fixed
mix igniter.install ash_dispatchgenerates a project that compiles. It never had:- every module name came out as
MyApp..Notifications…—module_name(igniter, "")concatenates an empty segment; - every module was nested inside a second copy of itself —
create_module/3wraps its contents indefmoduleand was handed a wholedefmodule, leaving the outer module empty; - the resources used
AshDispatch.NotificationandAshDispatch.DeliveryReceipt, which have never existed.
The resources are now built on
Notification.BaseandDeliveryReceipt.Basewith the project's repo and user resource, as the integration guide does. The installer also sets:repo,:notification_resourceand:delivery_receipt_resource, which the runtime reads and which it never configured.Further down the same path:
- The generated
UserChanneldefinedbroadcast_counter/3and pushed"counter_update".CounterHandlercalls the configured function with four arguments and the SDK listens for"counter_updated", so no counter update ever arrived. It is now the reference channel from the Phoenix guide. - Without a user resource it generated a
RecipientResolvermatching on a struct that doesn't exist. It now skips the resolver and says how to add one. --no-typescriptnow also keeps the generated resources plain. Without it, they getAshTypescript.Resourceand a type name only when ash_typescript is a dependency.
- every module name came out as
use AshDispatch.Setupworks. It never compiled: the resource body was spliced into the domain module instead of being passed toModule.create/3as quoted code, souse Ash.Domainright after it failed with "can be called only one time". It is now a thin wrapper overDeliveryReceipt.Baserather than a hand-kept copy that had drifted from it (no:slack, nosend_now, noget_by_provider_id, …), and aliases in its options resolve.swoosh is optional in fact, not only in
mix.exs.AshDispatch.EmailBackend.Swooshdidimport Swoosh.Email, which needs the module at compile time, so an app without swoosh could not compile ash_dispatch. Without it the backend now returns{:error, "swoosh is missing — …"}, the way the Elks backend reports a missing req. The webhook worker does the same for req instead of raisingUndefinedFunctionError.No compile warnings for absent optional dependencies. An app without swoosh, req, plug_crypto or phoenix_pubsub got 13 "is undefined" warnings from ash_dispatch. plug_crypto and phoenix_pubsub were also called without being declared; they are now
optional: truedependencies.
Changed
AshDispatch.Transports.Webhook'shemlighet/1is nowsecret/1. The Swedish name remains as a deprecated alias.- The Swedish that 0.8.3's translation missed — written without å/ä/ö, so a
search for those letters could not find it — is now English too: private
helpers (
leverera), test names, comments and fixtures.
CI
Two new jobs. compile-without-optional-deps (formerly
compile-without-ash-typescript) builds ash_dispatch as an app's only
dependency and fails on any warning. installer runs
mix ash_dispatch.install in a bare Ash app, adds a domain that uses
AshDispatch.Setup, and requires the result to compile without warnings and
to produce migrations.
Compatibility
use AshDispatch.Setup now requires :notification_resource. Since it never
compiled before, nothing that worked breaks. Projects generated by an earlier
installer never compiled either; re-running the installer on a fresh branch
is simpler than repairing one.
[0.8.3] - 2026-09-22
Fixed
ash_dispatch compiles without
ash_typescript(#31). The dependency is declaredoptional: true, butAshDispatch.Resources.EmailEventandAshDispatch.Resources.ManualTriggernamedAshTypescript.Resourceunconditionally — and they are compiled as part of the library itself. An app without ash_typescript therefore failed inmix deps.compile, onundefined function typescript/1, and the installer's--no-typescriptcould 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 andtype_namewhen ash_typescript is present and is empty when it is not.AshDispatch.Setuppicks 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 existingtypescript_rpcblocks 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 ownmix.lock. It fails onmainbefore 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.
:emailand:webhookenqueued an Oban job and marked the receipt:scheduled.:smscalled the backend'sdeliver/4directly — synchronously, inside theafter_actionhook where dispatch happens, and therefore inside the action's transaction. A slow provider held that transaction open, andtime:could not be used because there was no job to schedule.It now takes the same shape as email, with
AshDispatch.Workers.SendSMSmirroringSendEmail: queue,:scheduled, and thereforetime: {: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_idandaudienceare real,dataandvariablesare empty. A backend that readcontext.datashould readreceipt.contentinstead, which was frozen at creation and survives a retry for exactly that reason.AshDispatch.SMSBackend.Elks— 46elks, packaged the wayEmailBackend.Swooshis, behind the already-optionalreqdependency. E.164 normalisation, adryrunflag, and:req_optionsso it can be stubbed in tests.400,401and403are marked:failed_permanentimmediately: a malformed number does not become valid after five retries, and while it sits as:failedit 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.RetryFailedDeliveriesandChanges.EnqueueRetryJobknew only:emailand:in_app; everything else fell into a catch-all. The consequence was not that the receipt was left alone::retrystill ran, incrementedretry_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,:reopenand:send_nowin admin refused it for the same reason, so there was no way back at all.Deliberately distinct from a transport that has no worker:
:broadcastand:obankeep 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:
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 aReply-Toheader.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 innoreply@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: nilThe default is
nil, i.e. exactly today's emails. Anything that is not a non-empty string is treated asnil, 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.contentand not only in the job's args.SendEmail.new_for_receipt/1builds a retry — and every send now — from the RECEIPT, and carries no content args at all. Areply_tothat 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.SendEmailTestproves 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_appcarries a title, a body and a way onward,:webhookthe same,:emailits 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"}]} endThe addition is merged below the transport's own keys: a module returning
%{message: ...}does not overwritenotification_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
:webhookcould not carry a title or a way onward from a MODULE.build_module_content/5had a branch per transport and:webhookfell through to the catch-all —%{message: notification_message(...)}and nothing else.notification_title/2,action_url/2andaction_label/2were never called for that transport.:in_apphas always carried all four. That:webhookdid 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.WebhookModuleContentTestchecks both properties, mutation-tested: three mutations fail three tests.
[0.7.3] - 2026-09-14
Fixed
The
:webhooktransport could not carry a way onward. The:webhookbranch ofbuild_inline_content/4readtitleandmessage(after 0.7.2) but neveraction_url/action_label. A declaredcontent: [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_appand:pushhave always carried the keys;:webhookwas 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,:slackand:smsDELIBERATELY 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.InlineContentTextTestnow 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
:webhooktransport dropped its declared body. The:webhookbranch ofbuild_inline_content/4built onlypayloadandwebhook_url. It never readcontent_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 inbuild_content/5lets the module's value stand for every key inline content does not set, so a declaredcontent: [message: ...]on a webhook channel became decoration — and the recipient got the placeholder, the right shape with the wrong content. Without a module,messagewould 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
:messageand:titlethroughmaybe_put/3.:discord,:slackand:smscould overwrite the module's body withnil. The same class in the other direction: they setmessage:as an unconditional key, andinterpolate(nil, _)returnsnil. A channel that declared everything EXCEPT the body therefore erased the module's callback value. All three now usemaybe_put/3, as:in_appand:pushalready did.
Added
AshDispatch.Transports.InlineContentTextTest— a guard requiring every body-carrying transport branch to readcontent_config[:message]and set it withmaybe_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_providerafter that delivery — an umbrella booting in an unlucky order, aruntime.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_appand:webhook.:slack,:discord,:smsand:pushdelivered 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
:slackchannel with a gated audience (:userby default) will now skip receipts it used to send. Set the audience to something ungated, or usepreference_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_applogged the user id,:webhooklogged the receipt id, and neither said which was intended.:email,:in_appand:webhookmoved 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/0is public for exactly that reason.The caveat, written down: this covers the transport gate's reason. The
SendEmailworker 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.:obanand:broadcastare 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.UserPreferenceevery transport, via allows_receipt?/4:user_preferenceAshDispatch.Behaviours.PreferenceProviderthe SendEmailworker and manual triggers:preference_providerAn 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.LegacyProviderThe semantics are copied from
SendEmail's private preference check — 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_provideris set while:user_preferenceis 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?/4line to appear insideemail.exandin_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 indispatch doare 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_envfor 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_typeandsource_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.secretwins when both are given, so a test can set an explicit value.secret_envis stripped from the forwarded envelope for the same reason assecret: it reveals no value, but it is configuration rather than event data.secret/1is 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: :inlineit already runs insideOban.insert/1. The receipt could therefore become:sending,:sentor:failedbefore the transport marked:scheduled, andscheduleonly goes from:pending. The result was a raisedNoMatchingTransitionin the middle of a successful delivery: the message arrived, but the caller got an error.:webhooknow marks:scheduledbefore 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
:slackand: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
:webhooknow actually delivers. The transport had been in the registry and in@type transportfor a long time, butdeliver/4logged"Webhook transport not yet implemented"and marked the receipt:skippedwithtransport_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) throughSendWebhook, so a receiver can be written once.Signing. With
metadata.secretset, the call carries<signature_header>: sha256=<hex>(defaultx-webhook-signature), computed as HMAC-SHA256 overMETHOD \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/3is 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.
SendWebhookacceptsraw_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. Withoutraw_bodythe behaviour is unchanged (json: payload), so the Discord and Slack transports are untouched.A
4xxis no longer retried.SendWebhooktreated every non-2xx the same and let Oban try five times. But a4xxis 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 tofailed. The exceptions are408and429, which are about time rather than content, and5xx/network errors as before.This lets a receiver answer
422when a notification cannot be delivered and get an honestfailedreceipt on the first attempt, rather than five identical attempts and a truth that arrives minutes later.permanent?/1is public.The signing secret can no longer be dropped silently.
metadatawas read with an atom key only, while the strip from the payload handled both atom and string. Ametadata: %{"secret" => …}was therefore stripped from the body but never used — the call went out unsigned, without a word. The read now mirrorsContentMap.get_content/2and takes both forms.request_headers/3is public so the signing contract can be tested from the outside.
Notes
:webhookhonours the recipient's opt-out throughUserPreference.allows_receipt?/4, like:emailand: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,:smsand:pushdo 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 indatacarrying a binary:id. Whendataholds 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 becomesevent:<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 indatathat 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.createreturns{: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, thecasestatement'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_iddoes not create a second notification. The retry path builds the key from the receipt'ssource_idand cannot see the channel'sidempotency_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/4could not produce an address,extract_recipient_identifier/3re-raised, and the exception escapedEnum.map/2indo_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: trueskip, 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/5readrecipient.__struct__directly. Audiences can resolve to plain maps, and on one of those the dot access raised aKeyErrorinside 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_nowand:reopenaccept nothing now. Neither declared anacceptlist, so both inherited the resource default: thirty attributes, includingrecipient,subject,body_htmlandcontent."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 withNoSuchInputinstead of silently editing the outgoing mail. Change the receipt first with an ordinary update if that was deliberate, then send.:reopenshipped 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_nowrefused it explicitly, andSendEmailtreats 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.
:reopenmoves the receipt back to:scheduledand 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_authorizeras:send_now, because it sends real mail. - It resets
retry_countrather 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.
- It starts only from
[0.6.7] - 2026-08-23
Added
The resource bases accept
notifiers:. They already acceptedextensions:, 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? falseonto 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.BaseandNotification.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
:scheduledunnoticed. 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 queriesstatus == :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
:scheduledby 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.RetryFailedDeliveriesnow sweeps:scheduledbefore its ordinary pass, with three outcomes (seeAshDispatch.Workers.Stranded):age outcome under the grace period left alone — :scheduledis a legitimate transient statepast grace, under the ceiling moved to :failed, so the existing retry machinery takes overpast the ceiling :failed_permanentwith a reason — never sentThe 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_hoursbefore 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/4runs once per recipient and has always had it in scope, but nothing downstream could see it:prepare_template_assigns/2receives(context, channel), and template assigns were built from that return value plusContext.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/1merges variables into the assigns a template sees, and the callback can readcontext.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/2still 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 configureddelivery_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
:emailand:in_apptransports askedUserPreference.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 implementinguser_allows?/4never saw more than one user id per event and had no way to notice.Both transports now call the new
allows_receipt?/4, which readsreceipt.user_id. Receipts without a user id (external addresses, webhook targets) keep delivering unasked.{:at, %DateTime{}}channel times are honored.normalize_time/1accepted them, thetimetype documented them andChannel.calculate_delay/1knew how to compute them — but the email transport matched only{:in, seconds}and let everything else fall into a catch-all0, so an absolute-time channel sent IMMEDIATELY, silently (andcalculate_delay/1had 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 fromallows?/3and 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. Aniluser id returnstrue.allows?/3keeps 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) insideuser_allows?/4followed byfalse— 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 auser_digest_mode/2callback 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 aDeliveryReceipt— a digest is a delivery, not a silence. See the User Preferences topic.
[0.6.3] - 2026-08-17
Added
DeliveryReceipt.Base:list_alltakes anaudienceslist argument alongside the existing singleaudience— 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.Baseno longer hardcodes the audience/transport universe. The:previewarguments and the:trigger-path attributes constrainedaudiencetoone_of: [:user, :admin]andtransporttoone_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'sconfig :ash_dispatch, :audiencesandTransport.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/4had acase channel.transportwith no catch-all, so the:pushtransport added in 0.6.0 raisedCaseClauseErrorfor every event carrying a:pushchannel — 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:pushhas its own branch producingtitle,messageandaction_url.Same drift as the receipt constraint fixed in 0.6.0: the
AshDispatch.Transportbehaviour 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
:pushtransport (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 newAshDispatch.PushBackendbehaviour. Configure withconfig :ash_dispatch, :push_backend, MyApp.Push. With no backend configured the receipt is marked:skippedwitherror_message: "transport_not_implemented", so an app can declare:pushchannels 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 on404/410and treating429/5xxas 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 aDeliveryReceipt(registry minus the lightweight:broadcast/:oban).
Fixed
- The receipt
transportconstraint no longer drifts. It was hardcoded in two places that had already gone out of sync:AshDispatch.Setupallowed[:email, :in_app, :discord, :sms, :webhook]whileDeliveryReceipt.Baseallowed those plus:slack. A:slackchannel therefore produced a receipt theSetup-generated resource rejected. Both now derive fromreceipted_atoms/0, so registering a transport is once again "one new file + one registry entry" asAshDispatch.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.Basenow shipsAsh.Policy.Authorizerwith per-user policies: read and update only your own notifications,mark_all_as_read'suser_idargument must match the actor, and create/destroy are system-only. Without an authorizer,authorize?: truewas 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 withauthorize?: falseat both call sites (initial delivery and retry).ManualTrigger.Baseis now fail-closed: it declares the authorizer with no base policies, so everything is forbidden until the consuming app opens access with its ownpoliciesblock (typically abypasson 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 aforbid_if always()policy — base policies compile before the consumer's, and a later consumerbypasscannot re-open an earlier forbid.DeliveryReceipt.Basewrite policies: the blanketbypass … 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_receiptsvia the configuredpermission_checker). All library-internal writes already run withauthorize?: falseand are unaffected.EmailEventreads forbade everyone, super admins included — its two non-bypass policies were AND-ed. The super-admin policy is now abypass.
Added
- Inline (CID) email images:
attachments/2attachment maps accept optionaltype: :inline | :attachmentandcid: String.t(). Inline attachments flow through the Oban job args and reach the Swoosh backend astype: :inlinewithciddefaulting 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 ownattachments/2on every outgoing email. Built for the inline-logo case: one config line embeds atype: :inlinelogo 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?/2is 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:
RetryFailedDeliveriesnow moves the receipt to:scheduledBEFORE enqueueing the worker, and puts it back to:failed(or:failed_permanentonce retries are spent) if the enqueue fails;mark_sendingadditionally accepts:failedas 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:scheduledwhere no retry path ever saw it again — in one production deployment this silently dropped 17 emails over eight months. mark_failedno longer incrementsretry_count(a failure is not a retry). With bothmark_failedand:retryincrementing, every failed-and-retried cycle burned the retry budget twice as fast as:max_retriespromised.- Provider webhook events no longer overwrite each other:
record_webhook_eventmerges into the existingprovider_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 (
categoryin 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?: falsecounter-scoping guard inResourceIntrospection.resolve_user_id_path_for_scoping/2(present in 0.2.x, lost in the notifier-era refactor): a counter withauthorize?: falseand no explicitscope/user_id_pathis 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. CounterLoaderaudience 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?: falseexplicitly (receipt_status, every transport, the dispatcher's unknown-transport skip). They previously relied onDeliveryReceipt.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.BaseMUST add apoliciesblock 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 endApps exposing
DeliveryReceiptactions (:retry,:send_now, reads) to their frontend need apermission_checkerconfigured whose:manage_delivery_receiptspermission matches their admin model.In-app retries now consume retry budget (
retry_countincrements on the synchronous in-app retry path as well).
[0.5.5] - 2026-08-12
Fixed
SendWebhookpattern-matched on the%Req.Response{}struct althoughreqis 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 runtimeReq.post/2call 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 usesAshDispatch.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 oversvix-id.svix-timestamp.raw_bodywith 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) blanksbody_text/body_htmlof receipts whose event declaresmetadata: [sensitive_content: true]once they are older thanconfig :ash_dispatch, :scrub_after_hours(default 24). Receipts in:failedare 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.ymlruns format check + tests on every PR and push to main;publish.ymlgained a version guard so re-pushing an already-published version no longer fails the pipeline.
Changed
- Widened optional
hackneyconstraint to~> 1.9 or ~> 4.0so 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.
SendEmailwith no:email_backendconfigured 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:retryaction) now readsconfig :ash_dispatch, :max_retries(default 5) instead of a hardcoded 5 that silently overrode the same knobRetryFailedDeliverieshonors.
Deprecated
AshDispatch.Resources.ManualTrigger(the legacy non-Base variant): its:triggeraction failsDispatcher.dispatch/3's map guard with aFunctionClauseError. UseAshDispatch.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.5public launch. Hex.pm + HexDocs badges, a prominent "experimental, API may change before 1.0" caveat, install instructions bumped to~> 0.5, and reworked Project Status / Contributing sections (dropping the pre-launch "being extracted / will be published" framing). - Generic
MyApp.*module names in the manual-dispatch tutorial (previously referenced an internal application name), and issue links point at the public repo. - Corrected the dispatch-flow legend in What is AshDispatch? — email and webhook delivery run on real Oban workers; the mock is only the default email backend.
[0.5.0] - 2026-06-29
First public release on hex.pm since 0.1.4 — brings the public package
up to current. Headline additions are two new transports and a formal
Transport behaviour.
Added
:obantransport. Dispatch an event straight to an Oban worker, eliminating the manual dispatch+enqueue dance. Wired viause AshDispatch.Event, transports: [oban: [...]].- Compile-time validation: an
:obanchannel now requires:oban_workermetadata (previously a soft runtime warning + a:skippedreceipt that left operators staring at an empty queue). - Dispatch-layer enable-gate via a pluggable
config :ash_dispatch, :gate_check_module. A disabled gate skips the enqueue entirely (emitting[:ash_dispatch, :oban, :gated_disabled]telemetry) instead of burning queue capacity on a no-op worker. No gate configured → always enabled; a raising gate → defaults to enabled (over-fire is safer than a silent drop) and logs a warning.
- Compile-time validation: an
:custom_topictransport. A lightweight per-record PubSub broadcaster (AshDispatch.Event.CustomTopic) for fire-and-forget broadcasts that need no recipients, content, orDeliveryReceipts. Topic accepts a string or a{Module, :function}MFA for per-record routing. Generates overridabletopic/0,1,event_name/0,safe_broadcast/1,2helpers wrappingPhoenix.PubSub.broadcast/3with rescue + log +[:ash_dispatch, :custom_topic, :broadcast_failure]telemetry. The heavyweight Spark DSL path is unchanged when no:transportsoption is passed.AshDispatch.Transportbehaviour + Registry. Dispatcher routing is now derived from a registry of transports rather than hardcoded, giving new transports a single integration point.Module-typed
dispatch/3overload onAshDispatch.Dispatcher, resolvingevent_idvia theEventRegistry.AshDispatch.Naming.wire_event_name/1, consolidating the dotted-split-and-take-last logic previously private to the Broadcast transport so other transports can reuse it.
Fixed
RecipientResolvernever aborts the parent operation. Dispatch is a side-channel: recipient resolution now wraps its body intry/rescue, so a baduser_resourceconfig or a raise from an auto-loaded calculation (e.g. an unstarted Cloak vault) degrades to[]recipients + a structured warning instead of bubbling an exception up and aborting the caller's transaction.Cleared all Elixir 1.20 compiler warnings (unused requires, unreachable
defpclauses, bitstringsize(...)pins, always-truthy guards). Behavior-preserving.
[0.4.8] - 2026-05-14
Fixed
Process-local Gettext locale leak after dispatch.
Gettext.put_locale/2is process-local.apply_recipient_locale/3mutates the running process's locale so per-recipient renders pick up the right language. Until now, afterbuild_receipt_content/4returned, the process was left with the last recipient's locale — which meant a worker that dispatched event A to alocale="en"user and then ran anyt()call for its own purposes (audit logging, custom emails, follow-up derivations) would see the leaked "en" locale instead of the locale the worker started with.Fix:
build_receipt_content/4now capturescurrent_locale/0before applying the recipient locale and restores it in anafterblock. Each receipt build is fully isolated; the caller's process locale is unchanged on return.Caught via crash-hunt regression:
t()between two dispatches now renders correctly against the worker's surrounding locale.
[0.4.7] - 2026-05-14
This release unlocks DSL-only locale-aware events. Combined with
0.4.6's HEEx auto-escape, an entire event can live in
dispatch do … end blocks with just prepare_template_assigns/2
left in the event module for derived assigns.
Added
Configurable Gettext domain for DSL content lookups (
AshDispatch.Config.gettext_domain/0, default"notifications"). Apps with existingdefault.posetups can doconfig :ash_dispatch, :gettext_domain, "default"to share one translation bundle across the codebase.Top-level
template_assignsinterpolation inVariableInterpolator. When a variable doesn't match a field on the main resource, the interpolator now falls back to top-level keys indata. Letsprepare_template_assigns/2-returned values be addressed directly as{{my_computed_var}}instead of awkwardly stuffing them onto the resource struct.
Fixed
translate_content/2no longer overwrites recipient locale. Previously, whencontext.localewas nil the function unconditionally reset Gettext to"en"— silently undoing the per-recipient locale thatapply_recipient_locale/3had just set. Now only overrides on explicit non-empty locale; trusts the process-level locale otherwise.action_labelnow goes throughinterpolate/2for:in_appchannels — parity withtitle/message/subjectso DSL-declared labels participate in both{{var}}substitution AND the gettext translation pipeline. Previously rendered raw.
[0.4.6] - 2026-05-13
Security
Auto-escape
{@var}expansions in HTML email templates.TemplateResolver.render_template_content/4previously rewrote HEEx-style{@var}markers to plain EEx<%= @var %>and evaluated the result viaEEx.eval_string/2, which does NOT HTML-escape interpolated values. Any user-controlled string flowing throughprepare_template_assigns/2(lead name, contract recipient, customer comment, etc.) landed raw in the rendered email — a real markup injection vector.The preprocessor now wraps every auto-converted
{@var}expansion inAshDispatch.SafeRender.escape/1forformat: :htmlso escape is the default, matching Phoenix HEEx semantics. Text formats are unaffected —email.text.eexand similar still emit<%= @var %>plain (text/plain has no HTML semantics).Migration: if your templates intentionally embed safe pre-rendered HTML, mark those expressions explicitly:
<p>{raw(@trusted_block)}</p> <!-- or, fully qualified --> <p>{AshDispatch.SafeRender.raw(@trusted_block)}</p>{:safe, iodata}tuples (Phoenix.HTML's standard "already escaped" marker) also pass throughescape/1unchanged, so existing Phoenix.HTML interop keeps working.
Added
AshDispatch.SafeRendermodule (escape/1+raw/1).
[0.4.5] - 2026-05-13
Added
Per-recipient locale resolution. When a channel resolves to a multi-recipient audience (e.g. seller + admin), each recipient's rendered notification content now follows their own
recipient.localefield. The resolution priority is:1. channel.locale (static override) 2. channel.locale_from (channel-level dynamic on primary record) 3. recipient.locale (NEW — auto-detected when recipient struct has it) 4. event/resource locale_from + auto-detected visitor_locale/locale 5. context.locale + Config.default_locale()This makes multilingual sends — e.g. a customer-facing email to a Swedish lead, plus an internal email to an English admin — render in each recipient's preferred language from one event dispatch, with no per-recipient code in the calling worker. The recipient struct just needs a
:localefield (typically aUserrecord); audiences that expose user records viaRecipientResolver.to_recipient/1get this for free.
Changed
Dispatcher.build_receipt_content/4now threadsrecipientintobuild_module_content,build_inline_content, andrender_inline_email_templates. Subject + html/text bodies are now rendered per recipient with the correct locale, instead of once per channel. Pre-render side: the resolved locale is also stamped on the receipt for analytics/traceability.Gettext.put_locale/2is now invoked automatically insidebuild_receipt_content(via the newapply_recipient_locale/3helper) when:gettext_backendis configured. Consumer code that was previously callingGettext.put_localeitself beforeDispatcher.dispatch/2to influence content can drop that — the dispatcher handles it per-recipient.
[0.4.4] - 2026-05-12
Added
- Pluggable SMS transport backend.
AshDispatch.Transports.SMSnow delegates to a consumer-configured module implementing the newAshDispatch.SMSBackendbehaviour. Configure withconfig :ash_dispatch, :sms_backend, MyApp.SMS. When no backend is configured the receipt is still marked:skippedwitherror_message: "transport_not_implemented", preserving the prior stub behavior for consumers that haven't wired SMS yet. optional: truechannel option. When a channel is marked optional and recipient identifier extraction fails (e.g. SMS channel for a user with nophone_number), the dispatcher logs and skips that channel rather than crashing the whole dispatch. Non-optional channels still re-raise as before.
0.4.3 - 2026-05-12
Fixed
- Catch the remaining 5
channel.on/socket.on/channel.join().receivecallsites the v0.4.2 sweep missed. 0.4.2 only widened 3 of the 8 typed-payload callbacks in the SDK generator; consumers running TS strict mode still sawTS2345on the rest:hooks/use-channel.ts—channel.join().receive('ok', (response: ChannelJoinResponse) → unknown)andchannel.on('counter_updated', (payload: CounterUpdatePayload) → unknown)hooks/use-notifications.ts(standalone mode) —channel.on('initial_state', (payload: { counters?: ... }) → unknown),channel.on('new_notification', (notification: Notification) → unknown), andsocket.on('new_notification', ...)All 8 sites now use the same(rawX: unknown) => { const x = rawX as T; ... }pattern.
0.4.2 - 2026-05-12
Fixed
- TypeScript SDK generator emits strict-mode-clean channel handlers.
Previously,
channel.on('initial_state', (payload: {...}) => {...})failed to type-check in consumers runningstrict: truebecause phoenix-js types the callback parameter as(payload: unknown)and TS function-parameter contravariance rejects narrower handler types. Generator now widens allchannel.on/socket.oncallbacks to(rawPayload: unknown)and narrows via an inlineas-cast. Affectssocket-provider.tsx(3 sites:initial_state,counter_updated,entity_change) andhooks/use-notifications.ts(2 sites:channel.on('counter_updated')+socket.on('counter_updated')). notification-bell.tsxno longer imports unuseduseState. Was emitting aTS6133violation undernoUnusedLocals.
0.4.1 - 2026-05-12
Added
:tableoption onNotification.BaseandDeliveryReceipt.Base. Lets consumer apps override the Postgres table name when their app already ownsnotifications/delivery_receiptsfor a legacy notification system and ash_dispatch needs to coexist rather than collide. Defaults preserve current behavior ("notifications"/"delivery_receipts"), so existing consumers upgrade transparently.Example:
defmodule MyApp.Dispatch.Notification do use AshDispatch.Resources.Notification.Base, repo: MyApp.Repo, domain: MyApp.Dispatch, table: "dispatch_notifications" end
0.4.0 - 2026-05-12
Changed (substrate retrofit — tx-semantics)
- DispatchEvent and BroadcastCounterUpdate now route through
Ash.Notifier, notAsh.Changeset.after_action/2. Pre-retrofit, these changes fired synchronously inside the action's transaction BEFORE commit/rollback, allowing phantom dispatches and counter broadcasts when a wrappingAsh.transaction/2rolled back. Post-retrofit, work runs inAsh.Notifier's commit-deferred firing path and is dropped on rollback (see Ash'stransaction/2defer-and-fire-or-drop semantics). New shape: singleAshDispatch.Notifiermodule +AshDispatch.Notifier.InfoSpark Info reader; per-action config persisted intodsl_stateby theInjectDispatchChangesandInjectCounterBroadcaststransformers and read at runtime by the notifier. MirrorsAsh.Notifier.PubSub's canonical pattern. - Behaviour fix: receipt creation is now post-commit only.
DeliveryReceiptrows previously could land for events whose triggering action subsequently rolled back. Post-retrofit they only land for actually-committed actions. Orphan receipts on rollback were a bug, not a feature. - Removed
lib/changes/dispatch_event.exandlib/changes/broadcast_counter_update.ex(845 LOC). Their orchestration logic moved tolib/notifier/dispatch_handler.exandlib/notifier/counter_handler.exrespectively, exposed as public entry points the notifier calls. - Canary regression net added at
test/notifier_tx_semantics_test.exs— two tests (refute_receiveafter force-rollback via raise,refute_receiveinside the txn before commit) that lock in the contract going forward. - DeliveryReceipt: allow
:failed → :senttransition for retry-after-failure paths. Previously the receipt was stuck in:failedeven after a successful re-send. - Broadcast transport: drop per-event log warning when
pubsub_module: nil(documented passive-shell posture); consumers wanting a presence check should readConfig.pubsub_module()once at app boot.
Added
- Initial release of AshDispatch
- Event-driven notification system for Ash Framework
- Multiple transport types:
- Email transport with Swoosh backend
- In-app notifications
- Discord webhooks
- Slack webhooks
- SMS transport (stub)
- Generic webhook transport
- Delivery receipt tracking with state machine
- Automatic retry system for failed deliveries
- User preference checking for email notifications
- Recipient resolution behaviours
- Event DSL with template interpolation
- Comprehensive documentation and guides
- Testing utilities and helpers
Fixed
- Hybrid mode callback fallback: Inline DSL now properly falls back to event module callbacks when fields are not provided. Previously, nil values from inline DSL would overwrite module callback results. Now, only non-nil inline DSL values are included in the content map, preserving module callbacks for dynamic content like
notification_message/2,subject/2, andaction_url/2
0.1.0 - 2025-01-17
Added
- First alpha release
- Core dispatcher and event system
- Basic transport implementations
- Oban worker integration
- DeliveryReceipt and Notification resources
- Documentation structure with ex_doc