Behaviour for a notification delivery channel — an external destination a user can route their notifications to (Telegram, email, …), on top of the in-app inbox.
Channels are pluggable: core ships some, feature modules contribute more via
PhoenixKit.Module's notification_channels/0, and
PhoenixKit.Notifications.Channels.list/0 merges them. Adding a channel is
"implement this behaviour + register it" — the notifications core, the routing
layer, the delivery worker, and the settings UI all work off the behaviour, so
none of them change.
Contract shape (why it's built this way)
deliver/2takes anenvelope/0, not a raw notification. The envelope is a channel-neutral, already-rendered payload with an absolute URL — so a channel never reaches into the notification/activity schema and every channel gets the same well-formed input. Channels that need more structure (email wants subject + HTML) read the extra envelope fields; simple ones use:text. The recipient's locale rides along in:localefor channels that localize, but the core's built-in rendering is English today (i18n of the rendered strings is not wired yet) — so treat:localeas a hint, not a guarantee the:textis already translated.- Results carry a permanent/transient taxonomy.
deliver/2returnsresult/0so the delivery worker can decide retry-vs-give-up generically, instead of every channel leaking its own retry policy (e.g. "bot blocked" is permanent, a 429/timeout is transient with an optionalretry_after). configured?/2andvalidate_config/1keep per-user setup out of the core. The router asksconfigured?/2before enqueuing; the settings page asksvalidate_config/1before saving.
The per-user config map (config/0) is opaque to the core — its shape is the
channel's business — except that the core stores it under
custom_fields["notification_channel:<key>"] and reads the reserved
"enabled" / "types" keys for routing (see
PhoenixKit.Notifications.ChannelConfig).
Summary
Types
A channel's opaque per-user configuration map (string keys, JSONB-safe).
A channel-neutral, fully-rendered notification ready to deliver.
Delivery outcome.
Callbacks
Whether user_uuid is fully set up to receive on this channel with config
(e.g. a connection is selected AND a chat_id has been linked). The router
checks this before enqueuing a delivery.
Delivers a rendered envelope/0 for config. Runs inside the delivery
worker (async), so a blocking network call here is fine. MUST resolve any
credential owner-scoped to envelope.recipient_uuid — never trust an owner
stored in config.
Heroicon name for the settings UI (e.g. "hero-paper-airplane").
Stable string key identifying the channel (e.g. "telegram").
Human-readable label for the settings UI (e.g. "Telegram").
Validates / normalizes a user-submitted config map before it's persisted (settings save). Returns the cleaned map or an error. Optional — defaults to accepting the map unchanged.
Types
A channel's opaque per-user configuration map (string keys, JSONB-safe).
@type envelope() :: %{ recipient_uuid: String.t(), type_key: String.t() | nil, notification_uuid: String.t() | nil, locale: String.t() | nil, icon: String.t(), title: String.t() | nil, text: String.t(), url: String.t() | nil }
A channel-neutral, fully-rendered notification ready to deliver.
:url is absolute (or nil). :title is set for channels that have a
subject line (email); text-only channels ignore it. :type_key /
:notification_uuid are for logging/idempotency, not display.
@type result() :: :ok | {:error, {:permanent, term()}} | {:error, {:transient, term()}} | {:error, {:transient, term()}, retry_after :: non_neg_integer()}
Delivery outcome.
:ok— delivered.{:error, {:permanent, reason}}— do not retry (bad chat_id, bot blocked, revoked connection). The worker gives up and should surface a soft-disable.{:error, {:transient, reason}}— retry (network, 5xx, rate limit). The optionalretry_after(ms) hints the backoff.
Callbacks
Whether user_uuid is fully set up to receive on this channel with config
(e.g. a connection is selected AND a chat_id has been linked). The router
checks this before enqueuing a delivery.
Delivers a rendered envelope/0 for config. Runs inside the delivery
worker (async), so a blocking network call here is fine. MUST resolve any
credential owner-scoped to envelope.recipient_uuid — never trust an owner
stored in config.
@callback icon() :: String.t()
Heroicon name for the settings UI (e.g. "hero-paper-airplane").
@callback key() :: String.t()
Stable string key identifying the channel (e.g. "telegram").
@callback label() :: String.t()
Human-readable label for the settings UI (e.g. "Telegram").
Validates / normalizes a user-submitted config map before it's persisted (settings save). Returns the cleaned map or an error. Optional — defaults to accepting the map unchanged.