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.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 usesConfig.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; siteflow/magasin 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: true(saleflow) because phoenix-js types the callback parameter as(payload: unknown)and TS function-parameter contravariance rejects narrower handler types. Generator now widens allchannel.on/socket.oncallbacks to(rawPayload: unknown)and narrows via an inlineas-cast. Affectssocket-provider.tsx(3 sites:initial_state,counter_updated,entity_change) andhooks/use-notifications.ts(2 sites:channel.on('counter_updated')+socket.on('counter_updated')). notification-bell.tsxno longer imports unuseduseState. Was emitting aTS6133violation undernoUnusedLocals.
0.4.1 - 2026-05-12
Added
:tableoption onNotification.BaseandDeliveryReceipt.Base. Lets consumer apps override the Postgres table name when their app already ownsnotifications/delivery_receiptsfor a legacy notification system and ash_dispatch needs to coexist rather than collide. Defaults preserve current behavior ("notifications"/"delivery_receipts"), so existing consumers upgrade transparently.Example:
defmodule MyApp.Dispatch.Notification do use AshDispatch.Resources.Notification.Base, repo: MyApp.Repo, domain: MyApp.Dispatch, table: "dispatch_notifications" end
0.4.0 - 2026-05-12
Changed (substrate retrofit — tx-semantics)
- DispatchEvent and BroadcastCounterUpdate now route through
Ash.Notifier, notAsh.Changeset.after_action/2. Pre-retrofit, these changes fired synchronously inside the action's transaction BEFORE commit/rollback, allowing phantom dispatches and counter broadcasts when a wrappingAsh.transaction/2rolled back. Post-retrofit, work runs inAsh.Notifier's commit-deferred firing path and is dropped on rollback (see Ash'stransaction/2defer-and-fire-or-drop semantics). New shape: singleAshDispatch.Notifiermodule +AshDispatch.Notifier.InfoSpark Info reader; per-action config persisted intodsl_stateby theInjectDispatchChangesandInjectCounterBroadcaststransformers and read at runtime by the notifier. MirrorsAsh.Notifier.PubSub's canonical pattern. - Behaviour fix: receipt creation is now post-commit only.
DeliveryReceiptrows previously could land for events whose triggering action subsequently rolled back. Post-retrofit they only land for actually-committed actions. Orphan receipts on rollback were a bug, not a feature. - Removed
lib/changes/dispatch_event.exandlib/changes/broadcast_counter_update.ex(845 LOC). Their orchestration logic moved tolib/notifier/dispatch_handler.exandlib/notifier/counter_handler.exrespectively, exposed as public entry points the notifier calls. - Canary regression net added at
test/notifier_tx_semantics_test.exs— two tests (refute_receiveafter force-rollback via raise,refute_receiveinside the txn before commit) that lock in the contract going forward. - DeliveryReceipt: allow
:failed → :senttransition for retry-after-failure paths. Previously the receipt was stuck in:failedeven after a successful re-send. - Broadcast transport: drop per-event log warning when
pubsub_module: nil(documented passive-shell posture); consumers wanting a presence check should readConfig.pubsub_module()once at app boot.
Added
- Initial release of AshDispatch
- Event-driven notification system for Ash Framework
- Multiple transport types:
- Email transport with Swoosh backend
- In-app notifications
- Discord webhooks
- Slack webhooks
- SMS transport (stub)
- Generic webhook transport
- Delivery receipt tracking with state machine
- Automatic retry system for failed deliveries
- User preference checking for email notifications
- Recipient resolution behaviours
- Event DSL with template interpolation
- Comprehensive documentation and guides
- Testing utilities and helpers
Fixed
- Hybrid mode callback fallback: Inline DSL now properly falls back to event module callbacks when fields are not provided. Previously, nil values from inline DSL would overwrite module callback results. Now, only non-nil inline DSL values are included in the content map, preserving module callbacks for dynamic content like
notification_message/2,subject/2, andaction_url/2
0.1.0 - 2025-01-17
Added
- First alpha release
- Core dispatcher and event system
- Basic transport implementations
- Oban worker integration
- DeliveryReceipt and Notification resources
- Documentation structure with ex_doc