Oshun Platform · Features

Channel Abstraction, Routing, and Boundaries

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

14sections17 minread4tables

On this page

This page documents the channel-abstraction layer that lets V1 deliver content over many messaging surfaces — Telegram, WhatsApp, email, push, SMS, Discord, Slack — through one common registry, one routing dispatcher, and one boundary contract, instead of bespoke glue per channel. It serves customers (delivery to their chosen channel) and tenants/operators (residency-aware allowlists and a full audit trail). It sits beneath the per-surface pages Telegram Surfaces and WhatsApp, Email, Push, SMS, Discord, and Slack, which describe each transport in depth. The single rule that shapes everything here: no AAA-creator or operator-admin intent ever reaches a messaging channel, and nothing is ever reported as "sent" unless a real provider transport actually sent it. Almost all of this is implemented in libs/oshun/messaging-channels/src; the honest gaps are named explicitly at the end.

Where this lives in V1#

The whole subsystem is one Nx library, @oshun/messaging-channels (libs/oshun/messaging-channels/src), whose index.ts re-exports the registry, dispatcher, boundary, transports, provider-config seam, per-channel adapters, the Telegram bot/security stack, reminder workers, the V3 deep-link/session modules, and the channel-binding store. Three files form the spine:

  • registry.ts — the per-channel capability and policy matrix (what a channel can do, its cost, residency, consent, and retention profile).
  • dispatcher.ts — the pure routing function that turns an (audience, intent, content variants, persona, residency) tuple into a DispatchDecision of dispatch (to a concrete channel) or suppress (with a typed reason).
  • boundary.ts — the content-class allowlist, audit envelope, and memory ingestion guard that govern what may cross a channel and how it is recorded.

provider-config-env.ts is the seam from those pure-data decisions to the real provider transports in transports.ts, smtp-email-transport.ts, and web-push-transport.ts. The companion engineering reference is ../architecture/messaging-channels.md.

The channel registry — a per-channel policy matrix#

registry.ts enumerates twelve concrete channels in CHANNEL_IDS, not the narrower "Telegram + WhatsApp + email + push (FCM/APNs) + SMS + Discord + Slack" list the prose summary suggests. The full set is:

text
CHANNEL_IDS = [
  '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 maps to a frozen ChannelEntry in CHANNEL_REGISTRY carrying a ReadonlySet<CapabilityKind> plus a cost, residency, consent, retention, disclosure-renderable flag, and a crisisCapable flag. The five enumerations the dispatcher reads are:

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

The matrix is what makes routing data-driven rather than hand-coded. A few representative entries from CHANNEL_REGISTRY:

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

Two helpers query the matrix: lookupChannel(id) returns an entry, and listChannelsWith(capability) returns every channel id that supports a given capability — the dispatcher uses the latter family of facts to fall through from a rich variant to a plain-text one when a channel can't render it.

Why crisisCapable is a first-class field#

Crisis content (verified hotlines, plain operator voice) must never be routed to a surface that can't carry it safely. The registry marks telegram-bot, telegram-miniapp, whatsapp-business, and sms-twilio as crisisCapable: true and everything else false. The dispatcher refuses to send a crisis-hotline intent to a non-crisis-capable channel even if it is otherwise allowed — see the routing walkthrough below.

The dispatcher — tier-aware routing as a pure function#

dispatchMessage(input: DispatchInput): DispatchDecision in dispatcher.ts is a pure function: pure-data in, pure-data out, no I/O. The actual provider call is the runtime's job, downstream of the decision. This separation is what makes the routing policy exhaustively unit-testable.

Audience tiers and the messaging guard#

text
TIERS = ['contemplative', 'curated-creator', 'aaa-creator', 'operator-admin']

The internal constant MESSAGING_ALLOWED_TIERS is Set(['contemplative', 'curated-creator']). The very first check in dispatchMessage is the tier guard: any input whose tier is aaa-creator or operator-admin returns { kind: 'suppress', reason: 'tier-not-allowed-on-messaging' } before any channel is even considered. This is the concrete, code-level enforcement of the audience-tier discipline described narratively in the product promise — AAA production coordination and operator/admin surfaces simply have no path to a messaging channel. See Generation Audience Tiers and Surface Boundaries for the same tier vocabulary on the generation side, and §26.1 in the backlog.

Intent kinds and content variants#

The dispatcher reasons over six intent kinds, each statically mapped to a content class:

IntentKind Mapped ContentClass
transactional-receipt transactional
crisis-hotline crisis
reminder ritual-reminder
session-notification transactional
newsletter editorial-briefing
engagement-recap grounded-answer

contentClassForIntent(intentKind) exposes that mapping; an explicit contentClass on the input overrides it. The caller supplies an ordered list of ContentVariants, each declaring a requiredCapability, the plain body text, a disclosureCopy string, and a provenanceFooter. chooseVariantForChannel walks the variants in order and picks the first whose requiredCapability the channel supports; if none match, it falls back to a text variant (or text-coerces the first variant) — but only when the channel supports text at all. This is the "per-channel capability fall-through" the prose names: a rich-card newsletter degrades to a text briefing on SMS rather than being dropped, while preserving its disclosure and provenance footer.

The routing order — and every reason it can suppress#

dispatchMessage evaluates guards in a deliberate order, each producing a typed suppress reason so the runtime can audit why nothing went out:

  1. Tier guardtier-not-allowed-on-messaging.
  2. Crisis-state suppression. If crisisSuppressionActive and the intent is not in CRISIS_SUPPRESSION_BYPASS (which contains only crisis-hotline) → crisis-suppression. A crisis flag on the user therefore silences non-essential traffic but never blocks the hotline itself.
  3. Quiet hoursquiet-hours (same crisis-hotline bypass).
  4. Frequency capfrequency-cap (same bypass).
  5. Empty tenant allowlisttenant-allowlist-empty.
  6. Preferred-channel loop. For each channel in preferredChannels, in order, the channel must be in the tenantAllowlist, in the user's userOptIns, satisfy the residency requirement (residencyMatchesglobal matches anything, otherwise an exact match), be crisisCapable if the intent is crisis-hotline, and allow the content class per the boundary table. The first channel that clears all of these and has a feasible variant yields { kind: 'dispatch', channel, contentClass, chosenVariant }.
  7. Diagnosis. If no channel dispatched, the function works out the precise reason: no-opt-in, no-channel-meets-residency, content-class-not-allowed, or finally no-channel-meets-capability.

The result is that "route by user preference, entitlement, residency, opt-in, quiet hours, and per-channel capability fall-through" is not aspirational prose — it is the literal branch structure of one tested function.

Tenant allowlists with residency-driven defaults#

resolveTenantChannelAllowlist(input: TenantChannelAllowlistInput) computes the per-tenant allowlist. If the tenant supplies an explicitAllowlist, it is filtered through tenantPolicyAllowsChannel; otherwise residency drives the default set:

  • eu-only['whatsapp-business', 'email-ses']
  • us-only['email-ses', 'sms-twilio']
  • self-hosted['email-ses']
  • everything else (global) → ['telegram-bot', 'telegram-miniapp', 'whatsapp-business', 'sms-twilio', 'email-ses']

Telegram and the four push channels are then added only behind explicit data-residency disclosures for non-global tenants: telegramDataResidencyDisclosureAccepted re-admits the three Telegram channels, and pushDataResidencyDisclosureAccepted (or a us-only/global profile) re-admits push-fcm/push-apns. Slack is gated on institutionalMessagingEnabled, Discord on creatorCommunityMessagingEnabled. This is exactly the "EU tenant favors WhatsApp + email; Telegram opt-in with data-residency disclosure" behavior the prose describes — encoded, not assumed.

The channel boundary — what may cross, and how it is recorded#

boundary.ts defines the per-channel contract: which content classes are permitted, the residency and retention profile, the consent language, whether provenance survives, the disclosure mechanism, and the audit-event names. The ContentClass union is:

text
type ContentClass =
  | 'transactional' | 'grounded-answer' | 'ritual-reminder'
  | 'editorial-briefing' | 'identity-verification' | 'billing' | 'crisis'

CHANNEL_BOUNDARIES lists one ChannelBoundary per channel. The allowedContentClasses field is the gate the dispatcher consults via channelAllowsContentClass. A few load-bearing rows:

Channel Allowed content classes Disclosure mechanism Provenance preserved
telegram-bot grounded-answer, ritual-reminder, identity-verification, crisis body-copy-and-link yes
whatsapp-business transactional, identity-verification, billing, ritual-reminder body-copy yes
sms-twilio identity-verification, billing, crisis, ritual-reminder body-copy no
discord-bot editorial-briefing body-copy-and-link yes
email-ses transactional, billing, editorial-briefing, grounded-answer, ritual-reminder body-copy-and-link yes
push-* transactional, ritual-reminder, identity-verification payload-metadata no

Note the deliberate asymmetries: SMS and push do not preserve provenance (there is nowhere to render a citation footer), so they are kept to terse content classes. Discord is restricted to editorial-briefing only, reflecting its community-broadcast role. WhatsApp carries no grounded-answer because its template-only model can't host a live grounded Q&A.

The audit envelope#

Every send and receive carries a full ChannelAuditEnvelope, built by buildChannelAuditEnvelope. Its shape is the complete record the prose calls for:

text
interface ChannelAuditEnvelope {
  channelId: ChannelId;
  recipientId: string;
  intentClass: ContentClass;
  persona: string;
  policyHash: string;
  provenanceBundleId: string;
  residencyTag: ResidencyProfile;
  retentionClass: RetentionProfile;
  disclosureVerificationResult: 'passed' | 'failed';
}

The Telegram bot (telegram/bot.ts) constructs one of these on every BotResponse, so an operator can later reconstruct which persona spoke, under which policy hash, with which provenance bundle, and whether the disclosure copy verified — for any message the platform ever sent over chat.

The Iris memory-ingestion guard#

decideMemoryIngestion(...) is the gate that decides whether chat content may become a durable Iris memory. It returns a MemoryIngestionDecision whose durableWriteAllowed is true only when both the user has opted into "remember from chat" (rememberFromChatOptIn) and the channel is in DURABLE_CHAT_INGESTION_CHANNELS (telegram-bot, telegram-miniapp, whatsapp-business, discord-bot, slack-app). Push and SMS payloads can never seed durable memory. The decision also carries the channel's retentionClass and a list of redactionRules — a base set (strip-channel-handles, strip-phone-numbers, strip-payment-tokens) plus channel-specific rules such as strip-initdata / strip-miniapp-session-token for the Mini App, strip-subscription-endpoint for Web Push, and strip-one-time-codes for SMS. This is the concrete implementation of "user must explicitly opt in before any durable Iris write." See Iris Memory and Identity and Privacy, Consent, Data Portability, and User Controls.

  • consentRevocationCascade(...) returns the ordered list of actions a binding revocation triggers: revoke-binding, terminate-miniapp-session:* for each active session, cancel-scheduled-send:* for each scheduled send, redact-chat-cache, and post-revocation-notice. Pulling a binding therefore tears down subscriptions, sessions, and scheduled sends and redacts cached state in one pass.
  • crisisCrossChannelSuppression(...) returns the deduplicated set of channels to suppress for a user once a crisis flag is raised on any one channel — feeding the dispatcher's crisisSuppressionActive guard so a crisis on Telegram silences non-essential sends on email and push too.
  • tenantResidencyReport(...) renders a per-tenant, per-channel residency / content-class / retention summary string for compliance review.

The credential seam — fail-loud, never faked#

provider-config-env.ts is the bridge between deploy-time secrets and the real transports, and it embodies the single most important safety property in this area: a channel is included in the live MessageProviderConfig only when every credential it needs is present. buildMessageProviderConfigFromEnv(env) reads each channel's env vars and adds the channel only when all required values are non-empty:

  • EmailOSHUN_MESSAGING_EMAIL_FROM plus a transport: OSHUN_SMTP_HOST (preferred when set, also used for the Mailpit dev sink, AWS SES SMTP, or Postmark SMTP) or OSHUN_SENDGRID_API_KEY.
  • SMSOSHUN_TWILIO_ACCOUNT_SID, OSHUN_TWILIO_AUTH_TOKEN, OSHUN_TWILIO_FROM (optional OSHUN_TWILIO_A2P_10DLC_COUNTRIES).
  • Push (FCM)OSHUN_FCM_ACCESS_TOKEN, OSHUN_FCM_PROJECT_ID.
  • APNsOSHUN_APNS_TEAM_ID, OSHUN_APNS_KEY_ID, OSHUN_APNS_PRIVATE_KEY, OSHUN_APNS_BUNDLE_ID.
  • Expo — optional OSHUN_EXPO_ACCESS_TOKEN.
  • Web Push — all three of OSHUN_VAPID_PUBLIC_KEY, OSHUN_VAPID_PRIVATE_KEY, OSHUN_VAPID_SUBJECT.
  • WhatsAppOSHUN_WHATSAPP_ACCESS_TOKEN, OSHUN_WHATSAPP_PHONE_NUMBER_ID, OSHUN_WHATSAPP_TEMPLATE, OSHUN_WHATSAPP_LANGUAGE.
  • Slack / DiscordOSHUN_SLACK_BOT_TOKEN / OSHUN_DISCORD_BOT_TOKEN.

When credentials are missing, the channel is simply omitted from the config, so deliverDispatchedMessage reports missing-config for it — an explicit fail-loud refusal, never a fabricated send. The module's own header states the contract: "No secret is ever logged, defaulted, or invented." deliverWithEnvProviders(...) composes this env config with the delivery router so a reminder scheduler or notification worker can take a DispatchDecision + recipient and actually send, with fetchImpl injectable for tests. configuredChannelsFromEnv(env) reports which channels are live in a given environment, and apnsHttp2BootWarning(...) returns a startup warning when APNs is configured but no HTTP/2 transport is wired (Node's global fetch is HTTP/1.1; APNs needs H2) — catching a silent runtime gap before it can drop sends.

The real transports behind the seam#

Once a channel is configured, the decision flows to a real provider transport. These are not plan-builders — each speaks the provider's actual protocol and returns { ok: false } on any non-success. TransportFetch is injectable throughout, so the transports are unit-tested without network or credentials.

  • transports.ts exports sendEmailViaSendgrid (SendGrid v3 api.sendgrid.com/v3/mail/send), sendSmsViaTwilio (api.twilio.com/2010-04-01/Accounts/.../Messages.json), sendWhatsAppViaMetaCloud (graph.facebook.com/v21.0/.../messages), sendPushViaFcm (fcm.googleapis.com/v1/...), sendPushViaApns, sendPushViaExpo (exp.host/--/api/v2/push/send, with buildApnsProviderJwt), plus sendSlackViaWebApi and sendDiscordViaBot.
  • smtp-email-transport.ts is a dependency-free RFC 5321 SMTP client over node:net / node:tls. It speaks the protocol directly — greeting → EHLO → optional STARTTLS → optional AUTHMAIL FROMRCPT TODATAQUIT — reads multiline replies, dot-stuffs the body, and returns { ok: false } on any non-2xx reply, dropped connection, or timeout. The socket factory (SmtpConnectionFactory) and STARTTLS upgrade (SmtpTlsUpgrade) are injectable, so the full state machine is tested against a real in-process SMTP server.
  • web-push-transport.ts implements the W3C Web Push Protocol (RFC 8030) with aes128gcm payload encryption (RFC 8291 + RFC 8188) and VAPID application-server identification (RFC 8292). Because the lib is client-transpiled (node:crypto is noop'd by webpack), every primitive is the audited noble suite: @noble/curves (P-256 ECDH + ES256), @noble/hashes (HKDF/SHA-256), @noble/ciphers (AES-128-GCM). The encryption is verified byte-for-byte against the RFC 8291 Appendix A known-answer test in web-push-transport.test.ts.
  • http2-transport-fetch.ts exports createHttp2TransportFetch — the HTTP/2 transport APNs requires, also tested against an in-process h2c server.

Telegram security and the bot command surface#

The Telegram surface carries the strongest auth code in the area. telegram/security.ts uses real HMAC with @noble/hashes:

  • verifyTelegramMiniAppInitData derives secret = hmacSha256('WebAppData', botToken), then compares the supplied hash against hmacSha256Hex(secret, dataCheckString) in constant time — the exact key derivation Telegram specifies for Mini App initData.
  • verifyTelegramLoginWidgetPayload uses secret = sha256(botToken) instead, per the Login Widget spec.
  • Both reject stale (auth_date outside maxAgeSeconds/skew) and replayed (query_id/id in consumedIds) payloads, and issueTelegramMiniAppSession mints a scoped HS256 JWT (iss: 'oshun-telegram', aud: 'oshun-telegram-miniapp') over the allowed scopes content:read, notebook:save, ritual:play, sky:view, qa:ask.

telegram/bot.ts handles TelegramUpdateKind ∈ {message, callback_query, inline_query, edited_message, my_chat_member} and implements all nine documented commands. They split honestly into two groups:

  • Read-only commands answered directly by handleTelegramCommand: /start, /menu, /today, /sources, /help.
  • Side-effect commands that route through the effects port: /save, /quiet, /stop, /voice (and /unlink). When no effects port is wired, each returns honest copy from SIDE_EFFECT_NOT_CONNECTED — e.g. "Saving from Telegram is not connected yet, so I will not pretend it saved." — rather than faking the action.

When Sophia cannot ground an answer, the bot emits TELEGRAM_ABSTENTION_COPY ("I can't ground that in sources right now, so I won't guess.") instead of guessing — the persona/grounding discipline of Sophia Grounding and Lilith Persona Policy carried onto chat.

The deployed bot lives in apps/oshun/telegram-bot as a real grammY app (package.json dep grammy ^1.42.0, main = src/index.ts). TELEGRAM_BOT_ENVIRONMENTS configures dev/staging/prod instances with env-var tokens and secrets, and the UNCONFIGURED_PROD_BOT_TOKEN = 'prod-token' sentinel makes createOshunGrammyBot fail fast in prod rather than run with a placeholder token. On the BFF, apps/oshun/bff/src/routes/telegram.ts guards /telegram/webhook with requireWebhookSecret() (checking the x-telegram-bot-api-secret-token header), and apps/oshun/bff/src/telegram/webhook.ts wires grounding with createTelegramSophiaGrounder({ retriever, minimumSources: 1 }), falling back to a fixture grounder only when no retriever is present.

The seven Telegram Mini App surfaces#

apps/oshun/telegram-miniapp is a Next app whose src/app/surface-data.ts defines seven surface kinds — today (Tara), sophia, veritas, nyx, arete, nisaba, and illustration. The veritas surface carries a veritas-claim with counterclaims, and illustration carries an illustration-card, mirroring the domains they front. telegram-webapp.ts bridges the Telegram.WebApp runtime. These are detailed in Telegram Surfaces.

The per-channel adapters (planners and their gates)#

Each thin-channel adapter encodes the policy specific to its provider, even where the live HTTP call is provider-gated:

  • WhatsApp (whatsapp/index.ts) — WhatsAppUseCase ∈ {receipt, reminder, password-reset, login-code, billing-alert, scheduled-event-nudge}. planWhatsAppTemplateDelivery enforces the Meta 24-hour customer-initiated session window (SESSION_WINDOW_SECONDS = 24*60*60) and falls back to a template message or email when no approved template applies; it emits a CostLedgerEntry per message.
  • SMS (sms/index.ts) — SmsUseCase ∈ {login-code, password-reset, billing-alert, crisis-hotline, verified-reminder} (verified-identity flows only). SMS_PRICING_MINOR_USD is a real per-country table (US/CA 1, GB 4, IE 5, DE/FR 8, …, fallback 6) and US delivery requires A2P 10DLC registration or the planner throws.
  • Slack (slack/index.ts) — assertSlackEntitlement requires the workspace to be installed, the metisInstitutionId to start with metis-institution:, and all REQUIRED_SLACK_SCOPES = ['commands', 'chat:write'] to be present. renderSlackOshunCommand handles /oshun today|save|ask over real todayItems (no fabricated list).
  • Discord (discord/index.ts) — DiscordServerBinding.community ∈ {yemaya-aaa-creator-community, project-obsidian-production}, REQUIRED_DISCORD_SCOPES = ['applications.commands', 'bot'], deny-by-default for unbound servers.
  • Push (push/index.ts) — PushPlatform enum is 'fcm-android' | 'fcm-web' | 'apns-ios' for payload synthesis, while CHANNEL_IDS additionally carries push-expo and push-webpush for their own transports.

All of these are detailed in WhatsApp, Email, Push, SMS, Discord, and Slack.

V3 cross-domain integration#

Two modules wire V3 programming into messaging:

  • v3-deep-links.ts defines V3_SHARE_LINK_CANONICAL_HOST = 'app.oshun.com', the custom scheme oshun with host v3 (i.e. oshun://v3...), path prefix /v3, and V3_MESSAGING_SHARE_CHANNELS = ['telegram', 'whatsapp', 'push', 'email', 'sms']. It produces both canonical HTTPS and custom-scheme share links for surfaces like Tara bookings, Arete continuations, tickets, follows, and the Saraswati wallet/edition.
  • v3-session-reminders.ts enforces a 30-minute pre-session reminder window (V3_SESSION_REMINDER_MAX_LEAD_TIME_MINUTES = 30); a reminder scheduled earlier than that returns outside-thirty-minute-window.

The customer message center substrate#

libs/oshun/customer-message-center/src/message-center.ts is the customer-visible inbox state model that the dispatcher feeds. CUSTOMER_MESSAGE_CHANNELS = ['in-app', 'email', 'push', 'sms', 'voice']; CUSTOMER_MESSAGE_CATEGORIES has ten values (onboarding, milestone, reengagement, billing, privacy, support, incident, content-update, social, system); and CUSTOMER_MESSAGE_RECEIPT_STAGES = ['queued', 'sent', 'delivered', 'opened', 'clicked', 'failed', 'bounced', 'suppressed'] is the delivery-receipt lifecycle each message moves through. Deliverability metrics (bounce, open, click, push-grant, SMS-failure) are derived from this delivery-status lifecycle in message-center.ts.

What is real, and what is honestly still planned#

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

  • The tier guard, the registry/boundary matrices, the dispatcher's full suppress-reason taxonomy, the SMTP and Web Push RFC-grade transports, the Telegram HMAC verification, all nine bot commands, all seven Mini App surfaces, the audit envelope, and the missing-config fail-loud seam are all real and tested.
  • The voice speech-to-text path on Telegram was upgraded from a stub to a real fail-closed seam (VoiceProvider in telegram/stt-provider.ts); any older doc calling it a stub is stale.

The honest aspirational edge, per the V1 completeness/audit notes:

  • End-to-end outbound Telegram delivery in the BFF e2e is unimplemented. The policy core, grounding, webhook auth, and per-message audit are real; the live outbound send loop is the gap.
  • WhatsApp, SMS, and payment transports are planners until real provider credentials are supplied. Their HTTP clients exist (transports.ts, libs/shared/inbound-integrations/src/payment.ts) and are exercised at deploy time with live keys; with no credentials the seam reports missing-config rather than faking a send.

These are explicit, fail-loud absences — never fabricated success.

A note on two stale references#

  • The platform architecture doc points readers to libs/shared/inbound-integrations for outbound email/push/SMS adapters. In reality the outbound messaging adapters live in libs/oshun/messaging-channels/src/{email,push,sms} plus transports.ts (SendGrid, the hand-written SMTP client, Twilio, FCM/APNs/Expo, and Web Push); libs/shared/inbound-integrations is the inbound connector and payment.ts side. Look in @oshun/messaging-channels for outbound transports.
  • The registry channel id email-ses is a naming artifact. The actual email transports are sendEmailViaSendgrid (SendGrid HTTPS API) and the RFC 5321 SMTP client (sendEmailViaSmtp, usable for AWS SES SMTP, Postmark, or Mailpit) — there is no dedicated SES-API transport, and DKIM/SPF/DMARC are deploy-config concerns, not code in this lib. Likewise, the documented push surface is broader than "FCM/APNs": it also covers Expo (push-expo, sendPushViaExpo) and W3C Web Push for PWAs (push-webpush, the full RFC 8030/8291/8292 web-push-transport.ts).