navree
Documentation

Notification channels

Slack, Microsoft Teams, SMS, WhatsApp and browser push for notifications: routes, drivers for Twilio, Vonage, Amazon SNS and the WhatsApp Cloud API, VAPID keys, the delivery log, STOP opt-outs, provider callbacks and a fake per channel.

On this page

Introduction

Featurenotify Notifications reach users inside the app and by mail. Some messages belong where people already are: the team's Slack channel, a chat with your Teams bot, a text message, WhatsApp, a push notification on their phone. Five more channels take the same notification there:

ChannelRenders withGoes toSent through
Channel::Slackto_slack → SlackMessageroute_slack, or the message's own routeAn incoming webhook, or chat.postMessage with a bot token
Channel::MicrosoftTeamsto_teams → TeamsMessageroute_teams, or the message's own routeAn incoming webhook, or a proactive message from your bot (Bot Framework)
Channel::Smsto_sms → SmsMessageroute_sms, read as E.164Twilio, Vonage or Amazon SNS
Channel::WhatsAppto_whatsapp → WhatsAppMessageroute_whatsapp, read as E.164Twilio or the WhatsApp Cloud API
Channel::WebPushto_web_push → WebPushMessageEvery push subscription of the notifiableThe browsers' push services, encrypted and signed with your VAPID key

Every delivery over these channels is a row in notification_deliveries. Status callbacks from the providers move it to delivered, read or failed, a reply of STOP opts the number out, and each channel has a fake for tests.

Routing a notifiable

A notifiable says where each channel reaches it. Every route_* method has a default of None, which skips the channel for that recipient, so implement the ones you use:

src/modules/people/employee.rs
MethodReturnsUsed by
route_slackOption<SlackRoute>Channel::Slack
route_teamsOption<TeamsRoute>Channel::MicrosoftTeams
route_smsOption<String>: a phone number in any formatChannel::Sms
route_whatsappOption<String>: a phone number in any formatChannel::WhatsApp

Web push has no route method: it sends to the push subscriptions the notifiable saved (see web push).

Rendering for each channel

A notification renders itself for each channel its via lists, as it does for mail and the database. This one goes to the app's notification list, the employee's Teams chat, their phone and their browsers:

src/modules/training/notifications.rs
MethodReturnsDefault
to_slackOption<SlackMessage>None: no Slack message
to_teamsOption<TeamsMessage>None
to_smsOption<SmsMessage>None
to_whatsappOption<WhatsAppMessage>None
to_web_pushOption<WebPushMessage>None

Send it as any notification, with notifier.send(&employee, TrainingDue { .. }). A message that names its own recipient (SlackMessage::to, TeamsMessage::to, SmsMessage::to, WhatsAppMessage::to) goes there instead of to the notifiable's route, so a notification can post to a team channel whoever it was sent to:

Rust

The new channels go out like mail and broadcasts: after the request's transaction commits, or as one notify.send job each with notify.queue on (see queueing). The job carries the rendered message, so a worker sends exactly what the request rendered. For web push, the subscriptions are read when the job runs.

Configuration

Each channel reads its own table of the notify section. A channel with nothing configured still works where it needs nothing (Slack and Teams webhooks); the others fail with NotifyError::Unavailable until they have a driver.

config/app.toml
KeyDefaultMeaning
notify.deliveriestrueRecord every delivery over the five channels in notification_deliveries (needs DatabaseModule).
notify.slack.bot_tokenemptyThe bot token of channel routes without a connection.
notify.slack.endpointhttps://slack.com/apiThe Web API's base URL.
notify.teams.app_idnoneThe Microsoft app id of your Azure Bot, for conversation routes without a connection.
notify.teams.app_passwordemptyIts client secret. Required with app_id.
notify.teams.tenantbotframework.comThe tenant that issues the bot's tokens: botframework.com for a multi-tenant bot, your tenant id for a single-tenant one.
notify.teams.token_urlhttps://login.microsoftonline.com/{tenant}/oauth2/v2.0/tokenThe token endpoint.
notify.sms.drivernonetwilio, vonage, sns (also spelled aws_sns) or log. Without one, the SMS channel is unavailable.
notify.sms.fromnoneThe default sender.
notify.sms.default_regionnoneThe ISO 3166-1 country numbers without a country code are read in. Without it, such numbers are invalid.
notify.sms.status_callbacknoneThe URL the provider reports statuses to: Twilio's StatusCallback, Vonage's webhook_url.
notify.whatsapp.drivernonetwilio, meta or log.
notify.whatsapp.fromnoneThe sender's number (Twilio).
notify.whatsapp.status_callbacknoneTwilio's StatusCallback for WhatsApp.
notify.web_push.public_keynoneThe VAPID public key (base64url). Web push is on when it is set.
notify.web_push.private_keyemptyThe VAPID private key. Required with public_key.
notify.web_push.subjectemptyA mailto: or https: contact push services can reach you at. Required with public_key.
notify.web_push.ttl_secs86400How long a push service keeps a message for an offline device.
notify.twilio.account_sidemptyThe account SID, for twilio drivers.
notify.twilio.auth_tokenemptyThe auth token, or the secret of api_key.
notify.twilio.api_keynoneAn API key SID (SK...) to authenticate with instead of the account SID.
notify.vonage.api_key, api_secretemptyVonage credentials, for sms.driver = "vonage".
notify.sns.region, access_key_id, secret_access_key, session_tokenemptyAWS credentials, for sms.driver = "sns".
notify.sns.sms_typeTransactionalTransactional or Promotional.
notify.meta.phone_number_id, access_tokenemptyWhatsApp Cloud API credentials, for whatsapp.driver = "meta".
notify.meta.api_versionv21.0The Graph API version.

The slack, twilio, vonage, sns and meta tables also take an endpoint, the API's base URL, for a regional endpoint or a local mock server in tests. A driver configured without its credentials fails the boot with the keys it needs (notify.sms.driver \twilio` needs notify.twilio.account_sid and notify.twilio.auth_token`). Tokens, secrets and keys are read from the configuration and never serialized, logged or shown in errors; keep them in environment variables.

Slack

A SlackRoute is an incoming webhook or a channel:

Rust

A webhook route is posted the message's JSON. A channel route calls chat.postMessage with the channel (or a user id, for a direct message), authenticated with notify.slack.bot_token, or with the bot token of an integrations connection when the route names one: each customer workspace that installed your Slack app posts with its own token.

SlackMessage
SlackMessage::new(text)The text: shown in notifications, and the fallback of blocks
.blocks(json!([...]))Block Kit blocks
.thread(ts)A reply in the thread of message ts (channel routes)
.to(route)Where it goes instead of route_slack
unfurl_linksWhether Slack unfurls the text's links

Slack's answer is the message's ts for a channel route, recorded as the delivery's provider id; a webhook returns no id. A refusal is a ChannelError::Refused with Slack's code (channel_not_found, no_service, invalid_payload).

Microsoft Teams

A TeamsRoute is an incoming webhook or a conversation with your bot:

Rust

A webhook receives the message as an Adaptive Card: the message's card, or one holding its text. A conversation receives a proactive message from your bot, POST {serviceUrl}/v3/conversations/{id}/activities, with the text, the summary and the card attached. Save the service URL and conversation id when the bot is installed for someone (its conversationUpdate activity), and return them from route_teams.

The bot authenticates with a Bot Framework token for notify.teams.app_id (client credentials, cached until a minute before it expires), or with an integrations connection's token when the route names one, for a bot registered in each customer's tenant.

TeamsMessage
TeamsMessage::new(text)The text (Teams renders a subset of Markdown)
.card(json!({ "type": "AdaptiveCard", .. }))An Adaptive Card
.summary(text)The activity feed summary (conversations)
.to(route)Where it goes instead of route_teams

A conversation's answer is the new activity's id. A refusal carries the Bot Framework's code, such as BotNotInConversationRoster when the bot was removed from the conversation.

SMS

Pick a driver with notify.sms.driver:

DriverSends withMessage idStatus callbacks
twilioThe Messages API, POST /2010-04-01/Accounts/{sid}/Messages.json; a from starting with MG is a messaging serviceSM...Yes, to status_callback
vonageThe Messages API, POST /v1/messages (channel sms)message_uuidYes, to status_callback
snsAmazon SNS Publish to a phone number, SigV4 signed; a from with a + is the origination number, otherwise the sender idMessageIdNo: SNS reports to CloudWatch Logs
logNothing: writes each message to the log (navree::notify, the text at DEBUG under navree::notify::body)log-1, log-2...No

Numbers are read as E.164 with navree-validation's Phone, in notify.sms.default_region when they have no country code: (415) 555-0100 with default_region = "US" is +14155550100. A number that isn't valid fails the channel with NotifyError::InvalidRecipient, and its delivery is recorded as failed.

SmsMessage
SmsMessage::new(body)The text
.from(sender)The sender instead of notify.sms.from
.to(number)The recipient instead of route_sms

Opt-outs

Text messages must stop when the recipient asks. The SMS and WhatsApp channels keep an opt-out list per channel, notification_opt_outs, and skip a number on it: its delivery is recorded as opted_out, and send succeeds.

A number is opted out when:

  • it replies with an opt-out keyword: STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT or REVOKE, matched on the whole message, ignoring case and trailing punctuation (Twilio's own OptOutType wins when it sends one);
  • the provider refuses it as opted out: Twilio's error 21610, the WhatsApp Cloud API's 131050, also when it arrives in a status callback;
  • your code opts it out.

START, YES, UNSTOP and OPTIN opt it back in. Replies reach the app through the provider callbacks. In code, inject Arc<OptOuts>:

Rust

Numbers are normalized the same way as for sending, so every format of one number is one entry. Inside a request handler, these writes join the request's transaction.

WhatsApp

Pick a driver with notify.whatsapp.driver: twilio (the Messages API with whatsapp: addresses, sharing notify.twilio's credentials with SMS), meta (the WhatsApp Cloud API, POST /{api_version}/{phone_number_id}/messages) or log.

WhatsApp takes free text only inside the 24-hour window after the recipient last wrote to you. Outside it, send an approved template:

Rust
twiliometa
WhatsAppMessage::text(body)BodyA text message
WhatsAppTemplate::new(name, language)name is the Content SID (HX...); ContentSidA template message with name and language.code
.parameter(value)ContentVariables: {"1": .., "2": ..}The body component's parameters, in order
Message idSM...wamid...

Web push

Web push shows a notification on the user's device, through the push service of their browser (Firebase Cloud Messaging for Chrome, Mozilla's for Firefox, Apple's for Safari). Each message is encrypted for one subscription (RFC 8291, aes128gcm) and signed with your VAPID key (RFC 8292, ES256 on ring through navree_crypto), so only that browser can read it and push services know it comes from you.

  1. Generate a VAPID key pair

    Shell
    Text

    Keep the private key secret. A new pair invalidates every subscription, so generate it once per app.

  2. Subscribe in the browser

    The signed-in user's browser fetches the public key from GET /api/notifications/push-key, subscribes, and saves the subscription. With the typed client:

    web/src/lib/push.ts
  3. Show the notification in the service worker

    public/sw.js

The routes are part of the notification routes (notify.routes), for the signed-in user:

RouteResult
GET /api/notifications/push-key{"public_key": "BGp8...Q"}. Mounted when notify.web_push.public_key is set.
POST /api/notifications/push-subscriptions201 with the saved subscription (id, endpoint, user_agent, created_at, last_used_at; never its keys). The body is subscription.toJSON().
DELETE /api/notifications/push-subscriptions204. The body is {"endpoint": "..."}; 404 when it isn't one of yours.

A subscription needs an https endpoint (http only on localhost), a P-256 p256dh key and an auth secret of at least 16 bytes; otherwise the answer is a 400 naming the field. Saving an endpoint that is already saved moves it to the signed-in user, with the new keys: a browser profile belongs to whoever signed in on it last. In code, PushSubscriptions has subscribe, unsubscribe, of(notifiable_type, notifiable_id) and delete.

The channel sends to every subscription of the notifiable when the notification goes out. A push service that answers 404 or 410 has dropped the subscription, so it is deleted, and the notification still succeeds. With notify.deliveries on, a retried notify.send job skips the subscriptions an earlier attempt reached, so a failure at one push service doesn't push twice to the others. Push endpoints come from browsers, so the driver calls them with the default SSRF policy: an endpoint at an internal address is refused.

WebPushMessage
WebPushMessage::new(title)The title
.body(text), .icon(url), .url(url), .tag(tag), .data(json)The rest of what the service worker receives
.ttl_secs(secs)How long an offline device's push service keeps it, instead of notify.web_push.ttl_secs
.urgency(Urgency::High)VeryLow, Low, Normal or High (the Urgency header)
.topic(name)A newer message with the same topic replaces an undelivered one

The service worker receives payload(): a JSON object with title and each of body, icon, url, tag and data that is set. One push message holds at most 3993 bytes of it.

The delivery log

With notify.deliveries on and DatabaseModule in the app, every attempt over the five channels is a row of notification_deliveries, the navree-orm model NotificationDelivery:

Column
idA UUID v7
notification_id, notification_typeThe notification (send's return value) and its TYPE
notifiable_type, notifiable_idThe recipient
channelslack, teams, sms, whatsapp or web_push
providerThe driver: slack, teams, twilio, vonage, sns, meta, web_push, log or fake
recipientThe E.164 number, the Slack channel, the Teams conversation, the push subscription id, or webhook:<host> for a webhook (its URL is a secret)
provider_message_idThe provider's id of the message, which status callbacks name
statussent, delivered, read, failed or opted_out
errorWhy it failed
created_at, updated_atWhen it was first attempted, and when its status last changed

A retried notify.send job updates its row instead of adding one. Status callbacks only move a delivery forward, because providers send them out of order: sent becomes anything, delivered becomes read, and read, failed and opted_out are final.

Inject Arc<DeliveryLog> to read it: for_notification(id) lists a notification's deliveries and find_by_message(provider, id) finds one by its provider id. For anything else, query the model:

Rust

Provider callbacks

Twilio, Vonage and the WhatsApp Cloud API report what happened to each message, and forward the replies people send: a status callback moves its delivery forward, a STOP opts the number out. With the webhooks feature on too (both are in full), WebhooksModule receives them for you, verified like any incoming webhook. It mounts one public route for each of these sources you configure, as long as NotifyModule is in the app:

RouteSourceConfigurePoint at it
POST /api/webhooks/notify/twilioTwiliowebhooks.incoming.twilio.secret: the auth tokennotify.sms.status_callback and notify.whatsapp.status_callback, and the number's "A message comes in" webhook
POST /api/webhooks/notify/vonageVonagewebhooks.incoming.vonage.secret: the signature secret (signed webhooks on)notify.sms.status_callback, and the application's inbound URL
GET and POST /api/webhooks/notify/metaMetawebhooks.incoming.meta.secret: the app secret; verify_tokenThe Meta app's WhatsApp webhook, subscribed to messages
config/app.toml

The Twilio route answers with an empty TwiML <Response/>, so a reply sends nothing back; the others answer 200. A request that fails verification is a 400 and changes nothing. Meta's GET ?hub.mode=subscribe handshake is answered with the challenge when its hub.verify_token matches. Twilio signs the URL it called, so behind a proxy set webhooks.incoming.twilio.url to the callback route's public URL, or let the proxy pass X-Forwarded-Proto and X-Forwarded-Host; the source's url is one URL, so a Twilio route of your own on the same source needs the forwarded headers.

To receive them on a route of your own, verify with the source and hand the body to the log. ChannelCallback::from_twilio, from_vonage and from_meta read the providers' payloads, and DeliveryLog::ingest applies them:

src/modules/sms/hooks.rs

ingest returns Ingested { updated, opted_out, opted_in }. A status for a message id the log doesn't know is ignored.

Connections from integrations

A Slack or Teams route can name a connection of Integrations: SlackRoute::channel("C0123").connection(id), TeamsRoute::conversation(url, conversation).connection(id). The driver then sends with that connection's current access token, refreshed when it is due: a workspace's Slack install posts with its own bot token, and a bot registered in a customer's Microsoft tenant sends with an app-only token of a microsoft connection whose scope set asks for https://api.botframework.com/.default.

IntegrationsModule provides the tokens (dyn ConnectionTokens) when the integrations and notify features are both on; both are in full. To keep tokens elsewhere, register your own Arc<dyn ConnectionTokens>. A route that names a connection in an app without one fails with a ChannelError::Config that says so.

Testing

Each channel has a fake that records what it is asked to send: SlackFake, TeamsFake, SmsFake, WhatsAppFake and WebPushFake. Install it in place of the driver with override_value::<dyn SmsDriver>(fake.driver()), and so on. The SMS and WhatsApp fakes also play the provider: deliver, fail (and read for WhatsApp) report a status for the last message to a number, and reply sends the app a message from one, through the same DeliveryLog::ingest the real callbacks use.

tests/training.rs
FakeRecordsCan also
SlackFake, TeamsFakesent(): each route and message, with the id it gaverefuse(reason), accept()
SmsFake, WhatsAppFakesent(), sent_to(number)refuse, opt_out_at_provider(number), deliver, fail, reply; read for WhatsApp
WebPushFakesent(), sent_to(endpoint)refuse, expire(endpoint): the endpoint answers as gone

Every fake has assert_sent(|sent| ..) and assert_nothing_sent(), and records deliveries as provider fake. To test a real driver against a local mock server, give its table an endpoint: the HTTP client's SSRF policy allows a configured endpoint's host.

Errors

A driver that does not send fails its channel with NotifyError::Channel { channel, source }, and the delivery is recorded as failed; the other channels still deliver. source is a ChannelError:

ChannelErrorWhen
Refused { provider, status, code, message }The provider refused the message: its status, its error code (21211, channel_not_found, 131030) and its message.
Unreachable { provider, source }The provider could not be reached, or the URL was refused by the SSRF policy.
OptedOut { provider }The recipient opted out at the provider. The notifier records the opt-out and treats it as handled, not as an error.
InvalidRecipient(reason)A push subscription's keys are malformed, or a Teams service URL is not an http(s) URL.
GoneThe push subscription expired. The notifier deletes it and treats it as handled.
Config(reason)The driver lacks what this message needs: a bot route without a token, a message without a sender.

NotifyError::InvalidRecipient is a number that isn't valid E.164, and NotifyError::Unavailable a channel without a driver, such as SMS without notify.sms.driver. Messages never contain message bodies, tokens or webhook URLs.