Packages

Notifications for Phoenix: one event to an in-app feed, email and channels of your own, with read and unread, grouping, digests, preferences, a log of every decision, a JSON API for any frontend and a LiveView bell.

Current section

Files

Jump to
chime guides channels.md
Raw

guides/channels.md

# Channels

A channel is a way to tell a person. Chime has the feed, email, and push
(see [Push](push.md)). Your app adds its own.

## The feed

The bell and its list in your app: `deliver :feed`. A notification is in
the feed at once, unless the person turned the feed off for its
category. Its `feed/2` says what it shows: `:title`, and `:body`, `:url`
and `:icon`, and anything else, which the JSON API gives as `extra`.

## Email

`deliver :email`, with the config's `:mailer` (a `Swoosh.Mailer`) and
`:email_from`. The notifier's `email/3` gives the subject and the body,
as a `Swoosh.Email`; Chime gives it:

* `To:`, the person's address and name (a person with no address is
  skipped, and the log says so);
* `From:`, the config's, unless the email has one;
* unless it is required, the headers of one-click unsubscribe (RFC 8058),
  `List-Unsubscribe` and `List-Unsubscribe-Post`, which Gmail and Yahoo
  ask of a sender of many emails, and the same address in the email's
  assigns as `:unsubscribe_url`, for its footer.

The mailer's id of the email is kept as the delivery's `ref`.

## Read everywhere

A link of an email that opens the notification reads it in the feed too:

```elixir
~s(<a href="#{Chime.link(notification, "/stories/42")}">Read it</a>)
```

`Chime.link/2` is an address of `chime_mail`, signed: opened, it marks
the notification read and its `clicked_at`, tells the person's open
pages, and redirects to the address (a path goes on the config's
`:url`). Each email has the link of the feed's `:url` in its assigns, as
`:link`, and the digests' links are such links. A channel of yours can
use `Chime.link/2` the same way.

A notifier's email in your app's templates:

```elixir
def email(data, recipient, notification) do
  Swoosh.Email.new(subject: "#{Chime.actors(notification)} commented on #{data["story"]}")
  |> Swoosh.Email.html_body(
    MyAppWeb.Emails.new_comment(
      data: data,
      recipient: recipient,
      unsubscribe_url: Chime.unsubscribe_url(recipient, notification)
    )
  )
end
```

## Slack, Discord, Teams, webhooks and SMS

Chime has these too, each a module you name in the config, with its
options:

```elixir
{Chime, ...,
 channels: [
   slack: {Chime.Channels.Slack, token: System.fetch_env!("SLACK_BOT_TOKEN")},
   discord: {Chime.Channels.Discord, to: System.fetch_env!("DISCORD_WEBHOOK_URL")},
   teams: {Chime.Channels.Teams, to: System.fetch_env!("TEAMS_WEBHOOK_URL")},
   webhook: {Chime.Channels.Webhook, to: "https://hooks.myapp.com/chime", secret: System.fetch_env!("CHIME_WEBHOOK_SECRET")},
   sms:
     {Chime.Channels.Twilio,
      account_sid: System.fetch_env!("TWILIO_ACCOUNT_SID"),
      auth_token: System.fetch_env!("TWILIO_AUTH_TOKEN"),
      from: "+15551234567"}
 ]}
```

| Module | Where it writes |
| --- | --- |
| `Chime.Channels.Slack` | with a bot's `:token`, `chat.postMessage` to the person's Slack id (`data.slack`) or a channel (`to: "#ops"`); without, an incoming webhook's URL |
| `Chime.Channels.Discord` | a channel's webhook URL: `to:` for everyone, or `data.discord` |
| `Chime.Channels.Teams` | a workflow's or an incoming webhook's URL, as an Adaptive Card |
| `Chime.Channels.Webhook` | your address, the notification as JSON, signed with `:secret` (`x-chime-signature`) |
| `Chime.Channels.Twilio` | the person's phone (`data.phone`, E.164), from `:from` or a `:messaging_service_sid` |

A person's address is in their recipient's data, under the channel's name
(`to: {:data, :key}` for another key), or one address for everyone with
`to: "..."`: Slack and Discord for your team's own alerts, for example.
A person with none is skipped, and the log says so.

Each says the notifier's function of the channel's name when it has one
(`slack/3`, `sms/3`: a text, or `%{title:, body:, url:}`), else the
feed's words, with the link that reads the notification. A refusal that
will not change (an address gone, a number that replied STOP) is
skipped; the rest is tried again. They send with `Req`: add
`{:req, "~> 0.5"}` to your deps.

## Your own

Telegram, SMS, Slack, Discord, your own push: a module of
`Chime.Channel`, named in the config.

```elixir
{Chime, ..., channels: [telegram: {MyApp.Chime.Telegram, token: System.fetch_env!("TELEGRAM_TOKEN")}]}
```

```elixir
defmodule MyApp.Chime.Telegram do
  @behaviour Chime.Channel

  @impl true
  def deliver(%Chime.Message{} = message, opts) do
    case message.recipient.data.telegram_chat do
      nil ->
        {:skip, "the person has no Telegram chat"}

      chat ->
        text = Chime.Message.render(message)

        case MyApp.Telegram.send(opts[:token], chat, text) do
          {:ok, %{"message_id" => id}} -> {:ok, to_string(id)}
          {:error, :blocked} -> {:skip, "the person blocked the bot"}
          {:error, reason} -> {:error, reason}
        end
    end
  end
end
```

`deliver/2` gets the `Chime.Message`: the notification, the person as
the resolver gives them now, the notifier's data, and whether it is
required. `Chime.Message.render/1` calls the notifier's function of the
channel's name, `telegram(data, recipient, notification)`, when it has
one, else gives what the feed shows.

It answers:

* `{:ok, ref}` or `:ok` - sent; `ref` is kept;
* `{:skip, reason}` - not sent, on purpose: not tried again, and the
  reason goes in the log;
* `{:error, reason}` - failed: Oban tries again with its backoff, five
  attempts in all, and the delivery is `"failed"` after the last.

Whether it goes at all (the preferences, `:if`, `:unless`) is decided
before `deliver/2` is called.

A channel is in the preferences too: a person can turn it off for a
category, as the feed and email.

## Rules

Some rules hold a delivery for later. They are decided at its time, and
the log says what held it:

```elixir
# Quick messages go as one email, a minute after the last (at most 5
# after the first); no more than one email of a conversation in 15 minutes.
deliver :email, wait: {1, :minute}, debounce: true, max_wait: {5, :minute}, throttle: {15, :minute}
```

* `throttle:` - at most one by the channel for a group (or, with no
  group, for the notifier) in a time. One that comes sooner waits for
  the end of it, and goes as the group is then.
* `debounce: true` - with `wait:`, each event that joins the group moves
  the delivery's time to `wait` after it, so a quick run goes as one;
  `max_wait:` is the most it waits from the first.
* Quiet hours, in the config, in each person's time zone: nothing but the
  feed goes in them, and what comes waits for the morning. A person's own
  (`:quiet_hours` of their `Chime.Recipient`, or `false` for none) comes
  first; a channel with `quiet: false` goes all the same.

  ```elixir
  {Chime, ..., quiet_hours: {22, 8}}
  ```

* Limits, in the config: the most each person gets by a channel in a
  time. One past it waits until they are under it again.

  ```elixir
  {Chime, ..., limits: [email: {5, :hour}, sms: {3, :day}]}
  ```

Required notifications are held by none of them.

## When it goes

Each delivery is an Oban job at its time, of the worker Chime.Worker,
given to Oban in the transaction of the notification, so that both are
kept or neither is. Give Chime the queue (`oban: [name: Oban, queue:
:notifications]`) and run it:

```elixir
config :my_app, Oban, queues: [default: 10, notifications: 20]
```

A delivery is decided and sent once: a second job of it, or a retry
after it was sent, does nothing.