Oshun Platform · Features

WhatsApp, Email, Push, SMS, Discord, and Slack

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

15sections16 minread4tables

On this page

This page documents every non-Telegram messaging surface V1 ships: WhatsApp Business, email, push (FCM, APNs, Expo, and W3C Web Push), SMS, Discord, and Slack. It serves customers (transactional and grounded delivery on whichever channel they have opted into), tenants and institutions (residency-aware allowlists, per-message cost ledgers, audit), and operators (deliverability monitoring and a credential→transport seam that fails loud instead of faking a send). All of it lives in one Nx library, @oshun/messaging-channels (libs/oshun/messaging-channels/src), and it sits beneath the shared routing spine described in Channel Abstraction, Routing, and Boundaries; its sibling surface page is Telegram Surfaces.

The single rule that shapes everything here is the same rule the channel abstraction enforces: no aaa-creator or operator-admin intent ever reaches a messaging channel, and nothing is reported as "sent" unless a real provider transport actually sent it. Where a transport is a pure planner that still needs live credentials, this page says so explicitly rather than implying a shipped integration.

Where this lives in V1#

Each channel is implemented as a small, pure-data adapter (the planner / payload-builder), plus a real provider transport that the credential seam wires in only when every credential is present:

Channel Adapter (planner / payload) Real transport (HTTP / protocol)
WhatsApp whatsapp/index.ts (planWhatsAppTemplateDelivery) sendWhatsAppViaMetaCloud (transports.ts)
Email email/index.ts (buildTransactionalEmail) sendEmailViaSendgrid (transports.ts), sendEmailViaSmtp (smtp-email-transport.ts)
Push (FCM) push/index.ts (buildPushPayload) sendPushViaFcm (transports.ts)
Push (APNs) push/index.ts sendPushViaApns + buildApnsProviderJwt (transports.ts)
Push (Expo) push/index.ts sendPushViaExpo (transports.ts)
Push (Web Push) push/index.ts sendPushViaWebPush (web-push-transport.ts)
SMS sms/index.ts (planSmsDelivery) sendSmsViaTwilio (transports.ts)
Slack slack/index.ts (renderSlackOshunCommand) sendSlackViaWebApi (transports.ts)
Discord discord/index.ts (renderDiscordAskCommand) sendDiscordViaBot (transports.ts)

The seam between deploy-time secrets and these transports is provider-config-env.ts; the registry that classifies every channel is registry.ts; the routing decision that picks one of them is dispatcher.ts; and the per-channel content-class and audit rules are boundary.ts. The companion engineering reference is ../architecture/messaging-channels.md.

Accuracy note (architecture pointer). Earlier architecture text located the email/push/SMS adapters in libs/shared/inbound-integrations/. The real outbound messaging adapters live in libs/oshun/messaging-channels/src/{email,push,sms} and transports.ts. libs/shared/inbound-integrations is the inbound-connector and payment side, not the outbound transport layer.

The twelve registered channels#

The prose summary historically named a narrower set — "SMS (Twilio)" and "push (FCM/APNs)". The registry has broadened well past that. registry.ts enumerates twelve concrete channel IDs in CHANNEL_IDS:

text
telegram-bot, telegram-channel, telegram-miniapp,
whatsapp-business, discord-bot, slack-app, sms-twilio,
push-fcm, push-apns, push-expo, push-webpush, email-ses

Each ID carries a frozen ChannelEntry describing what the channel can do and how it must be governed. The taxonomy the dispatcher reads is:

Dimension Constant Values
Capabilities CAPABILITY_KINDS text, rich-cards, inline-buttons, voice, files, payments, miniapp
Cost class COST_CLASSES free, low, metered, high
Residency RESIDENCY_PROFILES global, eu-only, us-only, apac-only, self-hosted
Consent CONSENT_PROFILES platform-tos-implied, verified-opt-in-required, double-opt-in-required
Retention RETENTION_PROFILES channel-default, short-30d, standard-365d, long-3y, indefinite

Each non-Telegram channel's frozen registry row (from CHANNEL_REGISTRY):

Channel Capabilities Cost Consent Retention Crisis-capable
whatsapp-business text, rich-cards, inline-buttons, voice, files metered double-opt-in-required short-30d yes
discord-bot text, rich-cards, inline-buttons, voice, files free platform-tos-implied channel-default no
slack-app text, rich-cards, inline-buttons, files free platform-tos-implied channel-default no
sms-twilio text metered double-opt-in-required short-30d yes
push-fcm text, rich-cards, inline-buttons free verified-opt-in-required short-30d no
push-apns text, rich-cards, inline-buttons free verified-opt-in-required short-30d no
push-expo text, rich-cards, inline-buttons free verified-opt-in-required short-30d no
push-webpush text, rich-cards, inline-buttons free verified-opt-in-required short-30d no
email-ses text, rich-cards, files low verified-opt-in-required standard-365d no

crisisCapable is the property that lets the dispatcher route a crisis-hotline intent to WhatsApp or SMS but never to a push platform or a community surface. disclosureRenderable records whether the channel can carry visible disclosure copy in the message body (true for WhatsApp/email; false for SMS and push, where disclosure rides in payload metadata).

Naming artifact. The email channel id is email-ses, but there is no dedicated SES API transport in the lib — the real transports are sendEmailViaSendgrid (SendGrid HTTPS API) and sendEmailViaSmtp (a hand-written RFC 5321 SMTP client that also speaks to AWS SES SMTP, Postmark SMTP, or the dev-stack Mailpit sink). DKIM/SPF/DMARC are deploy-config concerns, not code in this lib.

How an outbound message is routed and sent#

A send is a two-stage pipeline: a pure routing decision followed by a real transport call wired in only when credentials exist.

  1. RoutedispatchMessage(input) (dispatcher.ts) is a pure function from DispatchInput to DispatchDecision. It enforces, in order: the tier guard, crisis suppression, quiet hours, frequency caps, a non-empty tenant allowlist, and then per-channel opt-in / residency / crisis-capability / content-class / capability checks. It returns either { kind: 'dispatch', channel, contentClass, chosenVariant } or { kind: 'suppress', reason } with a typed reason such as tier-not-allowed-on-messaging, no-opt-in, or content-class-not-allowed.
  2. SenddeliverWithEnvProviders({ decision, recipient, ... }) (provider-config-env.ts) builds the provider config from the environment and calls deliverDispatchedMessage, which invokes the channel's real transport. If the chosen channel's credentials are not present, it returns missing-config; if the recipient lacks the needed address, it returns missing-recipient; a non-dispatch decision returns suppressed. None of these is a fabricated success.

The tier and intent model#

The dispatcher's TIERS are ['contemplative', 'curated-creator', 'aaa-creator', 'operator-admin'], but only the first two are in MESSAGING_ALLOWED_TIERS. Any aaa-creator or operator-admin intent is suppressed with tier-not-allowed-on-messaging before any channel is even considered — this is the concrete enforcement of "tier-aware routing." Intents are typed by INTENT_KINDS:

text
transactional-receipt, crisis-hotline, reminder,
session-notification, newsletter, engagement-recap

Each intent maps to a ContentClass via INTENT_CONTENT_CLASSES (for example reminder → ritual-reminder, newsletter → editorial-briefing, engagement-recap → grounded-answer), and each channel boundary lists which content classes it will accept. crisis-hotline is the only intent allowed to bypass crisis suppression, quiet hours, and frequency caps, and it must route only to a crisisCapable channel.

Each message arrives as one or more ContentVariant objects, each carrying a requiredCapability, bodyTextPlain, disclosureCopy, and provenanceFooter. chooseVariantForChannel picks the first variant whose capability the channel supports, falling back to a text variant so a rich-card message still degrades to plain text on SMS rather than being dropped.

WhatsApp Business — transactional template messaging#

WhatsApp delivery is template-only and Meta-compliant. WhatsAppUseCase is the allowed set:

text
receipt, reminder, password-reset, login-code, billing-alert, scheduled-event-nudge

planWhatsAppTemplateDelivery(templates, request) is pure-data so it is testable without a Meta account. Its rules:

  • It throws unless optInActive is true (Meta requires opt-in for every outbound).
  • It enforces the 24-hour customer-initiated session window (SESSION_WINDOW_SECONDS = 24 * 60 * 60). The window is open only when a customerInitiatedAtUnixSeconds is present and within 24h of now.
  • It looks for an approved template matching name, locale, and use-case (findApprovedTemplate, which requires approved === true). If none exists, it returns an email-fallback plan — no-approved-template when the window is open, session-window-closed-no-template when it is closed — and throws if no emailFallbackAddress was supplied.
  • On success it returns the chosen template plus a CostLedgerEntry (channel: 'whatsapp-business', locale, amountMinor, use-case) bound to tenant cost controls.

ingestWhatsAppStatusWebhook normalizes Meta's delivery webhook (sent | delivered | read | failed | opted_out) into a tenant-scoped key. The real provider call, sendWhatsAppViaMetaCloud, POSTs to https://graph.facebook.com/v21.0/<phoneNumberId>/messages with messaging_product: 'whatsapp', type: 'template', and the body parameters; it reads the returned messages[0].id and returns { ok: false } on any non-2xx. The planner is real; sending requires live Meta Cloud API credentials.

Email — SendGrid HTTPS or a hand-written SMTP client#

buildTransactionalEmail builds the deliverable plan with a hard DKIM/SPF/DMARC gate: it throws unless the tenant config has dkimVerified, spfVerified, and a dmarcPolicy that is not none. The plan attaches List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers plus X-Oshun-Locale and X-Oshun-Tenant, and concatenates the disclosure copy and provenance footer onto the body.

Inbound deliverability events flow through ingestEmailWebhookEvent (sent | bounce | complaint | unsubscribe | open | click): a permanent bounce, a complaint, or an unsubscribe suppresses the recipient with a typed EmailSuppressionReason (hard-bounce, spam-complaint, list-unsubscribe, manual-block); isEmailDeliverable then checks the suppression list before the next send.

Two real transports back the email channel:

  • sendEmailViaSendgrid — POSTs to https://api.sendgrid.com/v3/mail/send with the SendGrid v3 shape (personalizations, from, subject, content) and reads the x-message-id response header. Returns { ok: false } on any non-2xx.
  • sendEmailViaSmtp (smtp-email-transport.ts) — a dependency-free RFC 5321 SMTP client over node:net / node:tls. It speaks the protocol directly: greeting → EHLO → optional STARTTLS → optional AUTH (AUTH PLAIN if advertised, else AUTH LOGIN, failing loud if neither) → MAIL FROMRCPT TODATAQUIT. It builds an RFC 5322 message (stripping CR/LF from injected header values to prevent header injection), dot-stuffs the body per RFC 5321 §4.5.2, and returns { ok: false } with the failing status on any non-2xx reply, dropped connection, or timeout — never a fabricated success. The socket factory and the STARTTLS upgrade are injectable, so the full state machine is unit-tested against an in-process SMTP server with real sockets. This is the transport used for AWS SES SMTP, Postmark SMTP, a corporate relay, and the dev-stack Mailpit sink (:1025).

The credential seam (provider-config-env.ts) prefers SMTP when OSHUN_SMTP_HOST is set, otherwise falls back to SendGrid when OSHUN_SENDGRID_API_KEY is set; OSHUN_MESSAGING_EMAIL_FROM is required either way.

Push — FCM, APNs, Expo, and W3C Web Push#

The push surface is wider than the historical "FCM (Android/web) and APNs (iOS)" description. The payload builder, buildPushPayload (push/index.ts), still synthesizes the three core platform shapes named in PushPlatform (fcm-android, fcm-web, apns-ios). That covers silent background-sync pushes (which may not expose user-visible actions), badge management, action-button categories, deep-link click-actions, and thread IDs. But CHANNEL_IDS additionally registers push-expo and push-webpush, each with its own real transport:

  • sendPushViaFcm — POSTs to https://fcm.googleapis.com/v1/projects/<projectId>/messages:send (FCM HTTP v1) and reads the returned name.
  • sendPushViaApns — POSTs to https://api.push.apple.com/3/device/<token> (or the sandbox host) over what must be an HTTP/2 transport. It builds a real ES256 provider JWT with buildApnsProviderJwt (P-256, IEEE-P1363 r‖s signature, { alg:'ES256', kid } header), sets apns-topic / apns-push-type, and reads the apns-id header. Because Node's global fetch is HTTP/1.1, apnsHttp2BootWarning emits a startup warning when APNs is configured but no HTTP/2 fetchImpl (createHttp2TransportFetch) is wired in.
  • sendPushViaExpo — POSTs to https://exp.host/--/api/v2/push/send for React Native / Expo ExponentPushToken[...] clients. It inspects the returned Expo ticket and reports an error ticket (e.g. DeviceNotRegistered) as a failure — never a faked success.
  • sendPushViaWebPush (web-push-transport.ts) — the full W3C Web Push Protocol for PWAs. See the callout below.

Web Push is real, RFC-grade cryptography#

web-push-transport.ts implements the Web Push stack end-to-end: RFC 8030 (protocol), aes128gcm payload encryption per RFC 8291 + RFC 8188, and VAPID application-server identification per RFC 8292. Because the lib is client-transpiled (so node:crypto is unavailable), every primitive comes from the audited noble suite: @noble/curves for P-256 ECDH and ES256, @noble/hashes for HKDF/SHA-256, and @noble/ciphers for AES-128-GCM. To deliver, it derives a fresh ephemeral P-256 key against the client's p256dh, derives the content-encryption key and nonce (deriveWebPushEncryptionKeys), AES-128-GCM encrypts and frames the payload, and signs a VAPID ES256 JWT (buildVapidAuthorizationHeader). The encryption is verified byte-for-byte against the RFC 8291 Appendix A known-answer test in web-push-transport.test.ts. A non-2xx push-service response is reported as { ok: false }, never faked.

ingestPushDeliveryReceipt maps a provider receipt (sent | delivered | failed | revoked) onto a metric event and a token status, flipping a token to revoked so it stops receiving sends.

SMS — verified-identity flows only, with A2P 10DLC and a cost ledger#

SMS is restricted to verified-identity and safety flows. SmsUseCase is:

text
login-code, password-reset, billing-alert, crisis-hotline, verified-reminder

planSmsDelivery(request) enforces the rules and computes cost:

  • It throws for a US recipient when a2p10DlcRegistered is false (A2P 10DLC compliance is mandatory for US delivery).
  • It computes segmentCount at 160 chars/segment (smsSegments) and prices the message from SMS_PRICING_MINOR_USD, a per-country minor-USD table (US 1, CA 1, GB 4, IE 5, DE 8, FR 8, ES 5, IT 7, AU 5, NZ 8, MX 5, BR 4, IN 3, ZA 3), with a SMS_PRICING_FALLBACK_MINOR_USD of 6 for unlisted countries.
  • It returns a SmsCostLedgerEntry (channel: 'sms-twilio', country, use-case, amountMinor = price * segmentCount, currency: 'USD', segmentCount).

ingestSmsStatusWebhook maps the Twilio status (queued | sent | delivered | failed | undelivered) to a metric event and suppresses the recipient on failed/undelivered. The real transport, sendSmsViaTwilio, POSTs x-www-form-urlencoded To/From/Body to https://api.twilio.com/2010-04-01/Accounts/<sid>/Messages.json with HTTP Basic auth and reads the returned sid. The planner and pricing are real; live sending needs Twilio credentials (OSHUN_TWILIO_ACCOUNT_SID, OSHUN_TWILIO_AUTH_TOKEN, OSHUN_TWILIO_FROM, optional OSHUN_TWILIO_A2P_10DLC_COUNTRIES).

Discord — AAA-creator community surfaces#

The Discord adapter is deny-by-default and bound to specific communities. DiscordServerBinding.community is the literal union 'yemaya-aaa-creator-community' | 'project-obsidian-production'. assertDiscordEntitlement(binding) throws unless the binding is installed and holds both REQUIRED_DISCORD_SCOPES (applications.commands, bot); an unbound server is refused. renderDiscordAskCommand reuses the Telegram grounded-answer renderer (renderGroundedTelegramAnswer) so Sophia grounding discipline matches the Telegram bot's. The boundary entry for discord-bot allows only the editorial-briefing content class — Discord is for creator-community editorial surfaces, not transactional or crisis delivery, and it is not exposed on the contemplative product. The real transport sendDiscordViaBot POSTs to https://discord.com/api/v10/channels/<id>/messages with a Bot <token> authorization header.

Slack — institutional Metis delivery#

The Slack adapter is gated to Metis institutions. assertSlackEntitlement requires the binding to be installed, the metisInstitutionId to start with the literal prefix metis-institution:, and both REQUIRED_SLACK_SCOPES (commands, chat:write) to be present — otherwise it throws. Workspace binding happens through a per-workspace OAuth install.

renderSlackOshunCommand handles /oshun today | save | ask with the same grounding discipline as the Telegram bot:

  • /oshun ask requires a real grounded answer and GroundingState, rendered through renderGroundedTelegramAnswer; it throws if grounding input is missing.
  • /oshun today renders the recipient's real todayItems"Nothing is scheduled for you today." when the list is empty, never a fabricated agenda.
  • /oshun save returns a real confirmation only when a savedSummary is present; otherwise it honestly states "Saving from Slack is not connected, so I will not claim it saved."

The boundary allows the slack-app channel the transactional, grounded-answer, and editorial-briefing content classes. The real transport sendSlackViaWebApi POSTs to https://slack.com/api/chat.postMessage and treats Slack's HTTP-200-with-{ ok:false } logical errors as failures.

The channel boundary, audit envelope, and memory guard#

boundary.ts is the implementation behind the narrative "channel boundary." Its ContentClass union is:

text
transactional, grounded-answer, ritual-reminder, editorial-briefing,
identity-verification, billing, crisis

CHANNEL_BOUNDARIES lists, per channel, the allowedContentClasses, residency, retention, the human-readable consent requirement, whether provenance is preserved, the disclosureMechanism (body-copy | body-copy-and-link | payload-metadata), and the auditEvents emitted. For example, sms-twilio allows identity-verification, billing, crisis, ritual-reminder with provenancePreserved: false, while email-ses allows transactional, billing, editorial-briefing, grounded-answer, ritual-reminder with body-copy-and-link disclosure and standard-365d retention.

Every send/receive emits a ChannelAuditEnvelope built by buildChannelAuditEnvelope, with the fields the docs describe narratively:

json
{
  "channelId": "email-ses",
  "recipientId": "...",
  "intentClass": "grounded-answer",
  "persona": "...",
  "policyHash": "...",
  "provenanceBundleId": "...",
  "residencyTag": "global",
  "retentionClass": "standard-365d",
  "disclosureVerificationResult": "passed"
}

decideMemoryIngestion is the Iris memory guard: a durable write (durableWriteAllowed) requires both the rememberFromChatOptIn flag and a channel in DURABLE_CHAT_INGESTION_CHANNELS (Telegram bot/Mini App, WhatsApp, Discord, Slack — never push, SMS, email, or a Telegram channel). It returns the channel's retentionClass, a list of channel-specific redactionRules (e.g. strip-phone-numbers, strip-device-tokens, strip-subscription-endpoint for Web Push), and the provenance tuple { channelId, externalMessageId }. consentRevocationCascade, crisisCrossChannelSuppression, and tenantResidencyReport complete the boundary contract; see Channel Abstraction, Routing, and Boundaries and Privacy, Consent, Data Portability, and User Controls for the cascade detail.

The credential→transport seam — fail loud, never fake#

provider-config-env.ts is the safety property that ties everything together: buildMessageProviderConfigFromEnv(env) includes a channel in the live MessageProviderConfig only when every credential it needs is present. A channel with missing credentials is simply omitted, so deliverDispatchedMessage reports missing-config for it rather than silently faking a send. No secret is ever logged, defaulted, or invented. The env keys per channel are:

Channel Required env vars
Email (SMTP) OSHUN_MESSAGING_EMAIL_FROM + OSHUN_SMTP_HOST (+ optional _PORT/_SECURE/_STARTTLS/_USERNAME/_PASSWORD)
Email (SendGrid) OSHUN_MESSAGING_EMAIL_FROM + OSHUN_SENDGRID_API_KEY
SMS OSHUN_TWILIO_ACCOUNT_SID, OSHUN_TWILIO_AUTH_TOKEN, OSHUN_TWILIO_FROM
Push (FCM) OSHUN_FCM_ACCESS_TOKEN, OSHUN_FCM_PROJECT_ID
Push (APNs) OSHUN_APNS_TEAM_ID, OSHUN_APNS_KEY_ID, OSHUN_APNS_PRIVATE_KEY, OSHUN_APNS_BUNDLE_ID
Push (Web Push) OSHUN_VAPID_PUBLIC_KEY, OSHUN_VAPID_PRIVATE_KEY, OSHUN_VAPID_SUBJECT
WhatsApp OSHUN_WHATSAPP_ACCESS_TOKEN, OSHUN_WHATSAPP_PHONE_NUMBER_ID, OSHUN_WHATSAPP_TEMPLATE, OSHUN_WHATSAPP_LANGUAGE
Slack OSHUN_SLACK_BOT_TOKEN
Discord OSHUN_DISCORD_BOT_TOKEN

configuredChannelsFromEnv reports which channels are live in a given environment, and TransportFetch (plus createHttp2TransportFetch for APNs and the BinaryTransportFetch for Web Push) is injectable so every transport is unit-testable without network or credentials. This is also why the deliverability monitoring the prose describes — bounce/complaint rates (email), delivery/undelivered (SMS), revoked tokens (push), template status (WhatsApp) — is fed by real webhook-ingestion functions, not synthetic numbers.

V3 programming wires into these channels through two modules absent from the narrative docs:

  • v3-deep-links.ts — canonical share links with V3_SHARE_LINK_CANONICAL_HOST = 'app.oshun.com', the custom scheme oshun://v3 (V3_SHARE_LINK_CUSTOM_SCHEME = 'oshun', V3_SHARE_LINK_CUSTOM_HOST = 'v3', path prefix /v3), and the share-channel set V3_MESSAGING_SHARE_CHANNELS = ['telegram', 'whatsapp', 'push', 'email', 'sms']. Surfaces include Tara bookings, Arete continuations, tickets, follows, and Saraswati editions.
  • v3-session-reminders.ts — binds the V3 calendar to the dispatcher, enforcing the launch rule that a reminder may only be delivered inside the 30-minute pre-session window (V3_SESSION_REMINDER_MAX_LEAD_TIME_MINUTES = 30). It maps each share channel to a default channel ID (telegram → telegram-bot, whatsapp → whatsapp-business, push → push-fcm, email → email-ses, sms → sms-twilio).

The customer message center#

The prose mentions a "customer-visible message center"; the real substrate is @oshun/customer-message-center (customer-message-center/src/message-center.ts). It defines CUSTOMER_MESSAGE_CHANNELS = ['in-app', 'email', 'push', 'sms', 'voice'], ten CUSTOMER_MESSAGE_CATEGORIES (onboarding, milestone, reengagement, billing, privacy, support, incident, content-update, social, system), four CUSTOMER_MESSAGE_PRIORITIES (low, normal, high, critical), and an eight-stage receipt model CUSTOMER_MESSAGE_RECEIPT_STAGES = ['queued', 'sent', 'delivered', 'opened', 'clicked', 'failed', 'bounced', 'suppressed']. This is the inbox state model that gives customers a unified view across the channels above and flows into the BFF (apps/oshun/bff/src/routes/customer-message-center.ts).

Honest status — what is shipped vs. provider-gated#

This area is implemented to an unusually high degree, and nothing in it is a result-faking stub. The honest edges:

  • The policy core, planners, payload builders, and boundary are real and tested. The dispatcher, the cost ledgers, the WhatsApp session-window logic, the SMS pricing/A2P gate, the email DKIM/SPF/DMARC gate, and the entitlement asserts for Slack/Discord all run as pure, deterministic, unit-tested code.
  • The transports are real protocol clients — the SMTP client (RFC 5321) and the Web Push transport (RFC 8030/8291/8188/8292, verified against the RFC 8291 Appendix A KAT) are genuine, dependency-light implementations, not wrappers.
  • WhatsApp and SMS transports are planners whose live provider HTTP calls require real credentials. With no credentials configured, the seam reports missing-config — the fail-loud, never-fake behavior.
  • Outbound Telegram delivery is the noted gap. Per the completeness audit, the BFF end-to-end outbound Telegram send loop is unimplemented; the policy core and Sophia grounding are real. The Telegram voice-STT path is now a genuine fail-closed seam, not a stub — any doc calling it a stub is stale. See Telegram Surfaces for that surface in depth.

The backlog for this subsystem is §26 (channels); the feature-hub section lives in ../features.md. Residency and consent obligations are tracked under deps§26.