Skip to main content

Communications Hub

The Communications Hub (communication_channels) bridges external chat and email channels (Slack, WhatsApp, Gmail, IMAP+SMTP) into the Messages module. Each provider ships as a separate workspace package implementing the ChannelAdapter contract; the hub itself stays adapter-agnostic.

Two email providers ship today:

  • @saasframe/channel-imap — credential-based IMAP polling + SMTP send
  • @saasframe/channel-gmail — Gmail OAuth2 + History API polling + gmail.users.messages.send

For the end-user perspective see the Communication Channels user guide.

Overview​

Architecture​

┌──────────────────────────────────────────────┐
│ Communications Hub (communication_channels) │
│ │
┌──────────┤ ChannelAdapter registry (process-wide) │──────────┐
│ │ CommunicationChannel + ExternalConversation │ │
│ │ ExternalMessage + MessageChannelLink │ │
│ │ ChannelThreadMapping + MessageReaction │ │
│ └──────────────────────────────────────────────┘ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌────────┐
│ channel- │ │ channel- │ … (future: channel-slack, │ Messages │
│ imap │ │ gmail │ channel-whatsapp, etc.) │ module │
│ (basic │ │ (OAuth) │ └────────┘
│ auth) │ │ │
└──────────┘ └──────────┘

Each provider exports a ChannelAdapter from @saasframe/core/modules/communication_channels/lib/adapter. The hub's registry validates capability/method consistency at registration time (so an adapter that declares capabilities.deleteMessage: true without implementing deleteMessage() throws on boot).

Per-user channels​

Phase 0 of the email integration spec added additive columns to CommunicationChannel:

ColumnPurpose
user_id (uuid, nullable)Owns this channel (NULL = tenant-shared)
is_primary (boolean)True for the user's default outbound channel; enforced by a partial unique index
poll_interval_seconds (int, nullable)Per-channel override of the platform default poll cadence
last_polled_at (timestamptz, nullable)Stamped by the poll worker on every tick
status (text)connected / requires_reauth / disconnected / error
last_error (text, nullable)Classified error string for diagnostics

Plus an additive column on integration_credentials:

ColumnPurpose
user_id (uuid, nullable)Identifies per-user credential rows so tenant-shared and per-user blobs can coexist

Cross-module references use EntityExtension declarations in data/extensions.ts (no direct ORM relationships across module boundaries).

Send path​

Compose (Messages)
│
▼
messages.message.sent event
│
▼
subscribers/outbound-bridge.ts (re-fetches the Message by ID — no payload coupling)
│
▼
deliver-outbound-message command
│
▼
ChannelAdapter.sendMessage ── refreshCredentials() if access token near expiry
│
▼
ExternalMessage + MessageChannelLink rows written

Receive path​

@saasframe/scheduler "poll-channel" cron (5 minutes by default)
│
▼
poll-channel worker (per channel)
│
▼
ChannelAdapter.fetchHistory ── reads previous cursor from channelState
│
▼
ingest-inbound-message command
│
▼
ExternalMessage written + Message materialised into the unified inbox
│
▼
communication_channels.message.received event + notification

Mutation guards​

Phase 4 of the email integration spec added two hub-side guards (packages/core/src/modules/communication_channels/lib/mutation-guards.ts):

  • guardChannelDelete — blocks delete when unread inbound messages remain on the channel. force: true bypasses, for admin offboarding workflows.
  • guardOutboundCreate — maps status='requires_reauth' / 'disconnected' to a 422 with fieldErrors.channelId. Wired into POST /api/communication_channels/send-as-user.

Credential refresh contract​

OAuth providers (Gmail) need their tenant-level OAuth client config — clientId and clientSecret — every time a per-user access token is refreshed. The hub resolves this once before delegating to the adapter via the RefreshCredentialsInput.oauthClient field:

export interface RefreshCredentialsInput {
channelId: string
/** Per-user credential blob (accessToken, refreshToken, expiresAt, …) */
credentials: Record<string, unknown>
scope: TenantScope
/**
* Tenant-level OAuth client config resolved by the hub from
* `integration_credentials.scope = oauth_<providerKey>`.
*
* - OAuth providers (Gmail): MUST read clientId / clientSecret from this field.
* - Static-credential providers (IMAP, WhatsApp): ignore.
*/
oauthClient?: OAuthClientConfig
}

export interface OAuthClientConfig {
clientId: string
clientSecret?: string
/** OAuth directory/tenant id for providers that require one (provider-specific). */
tenantId?: string
/** Optional pre-resolved scopes list. */
scopes?: string[]
}

The hub's refreshCredentialsIfNeeded helper resolves oauth_<providerKey> via IntegrationCredentialsService.resolve(...) and passes the result through. Provider adapters that implement OAuth refresh MUST read clientId / clientSecret from input.oauthClient — see packages/channel-gmail/.../lib/adapter.ts:refreshCredentials for the reference implementation.

Legacy credentials._client path is deprecated

Prior to the wiring spec (.ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md), adapters read OAuth client config from credentials._client — a field no production code path ever populated, which caused token refresh to fail silently after ~1 hour. The Gmail adapter still recognizes _client for one minor release with a one-time deprecation warning per process. Remove any test fixtures or callers that rely on _client and pass oauthClient instead.

When the helper cannot resolve the OAuth client (missing oauth_<provider> integration credential row, or credentialsService not registered), oauthClient is left undefined. Adapters then fall through to the legacy _client path; if neither field is present, refresh fails with a clear error and the channel flips to status='requires_reauth' so the operator-visible notification fires.

Inbound reliability & threading (Spec B)​

Shipped 2026-05-27. See .ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md for the full design rationale.

Layered thread matching​

Outbound delivery now injects a per-thread HMAC token (om_<base64url>_<base64url>) into both:

  1. The synthetic <[email protected]> Message-ID appended to the References header (RFC 6761 reserved TLD — guaranteed to round-trip through compliant MTAs).
  2. A hidden <span> (HTML emails) and a bracketed marker (plain-text) at the bottom of the body. Survives mail clients that strip References headers.

Inbound ingestion runs a five-strategy matcher in priority order before falling back to a new thread:

StrategyConfidenceWhen it fires
token-referenceshighReply preserves the om_* Message-ID in References / In-Reply-To. Most common.
token-bodyhighReply quoted our outbound body; the hidden footer survived.
jwz-headersmediumStandard JWZ algorithm against In-Reply-To / References pointing at our outbound Message-Id. Pre-token outbounds.
subject-participantslowNormalized subject match (Re:, Fwd:, [EXTERNAL] stripped) + participant set overlap. Last-ditch.
(none)—New thread.

MessageChannelLink.channelMetadata.threadMatchStrategy + threadMatchConfidence are persisted on every inbound for observability.

Zero-history IMAP bootstrap​

A freshly-connected IMAP channel fetches zero historical messages — fetchHistory records UIDVALIDITY + UIDNEXT and returns immediately. From the next tick onward, the worker does UID FETCH previousUidNext:* capped at SF_CHANNEL_IMAP_HARD_CAP_PER_POLL (default 200). When more remain, hasMore: true triggers immediate re-enqueue without waiting for the next scheduler tick. UIDVALIDITY mismatch (mailbox rename / recreate) re-bootstraps rather than re-syncing the whole inbox.

This eliminates the "we can't scan a new user's 1M-message inbox on connect" failure mode by construction. Backlog import is opt-in via the operator-triggered flow below.

Per-message commit + dead-letter​

poll-channel.ts advances the channel cursor (channel_state.uidNext) per successfully-ingested message. A transient ingest failure (DB blip, network reset) aborts the loop without advancing — the next tick re-fetches the same UIDs and idempotency on (channel_id, external_message_id) skips already-ingested messages. A permanent failure (malformed MIME, schema violation) writes the raw payload to channel_ingest_dead_letters (encrypted) and advances the cursor anyway so the bad blob never re-stalls the channel.

Auto-recovery sweep​

poll-tick.ts enumerates two pools: (a) status='connected' channels due for normal polling, (b) status='error' channels whose lastFailureAt is older than SF_CHANNEL_AUTO_RECOVER_MINUTES. A successful poll in pool (b) flips status back to connected automatically — operators no longer have to manually reconnect after a transient outage.

Operator-triggered backlog import​

The POST /api/communication_channels/channels/<id>/import-history route (gated by the communication_channels.channel.import_history ACL feature) creates a ProgressJob and enqueues a channel-import-history worker job (concurrency 1) that calls adapter.importHistory({ sinceDays, contactEmails?, maxMessages, cursor }) in a pagination loop:

FieldRangePurpose
sinceDays1..365Server-side SEARCH SINCE window.
contactEmailsoptional, up to 1000Server-side OR FROM … filter, chunked to ≤30 senders per SEARCH to keep the IMAP tag buffer bounded.
maxMessages1..5000Hard cap across all pages. Worker stops paging as soon as the cap is hit.

A 429 is returned if another import is in-flight for the same channel (active-jobs scan, plus the worker's concurrency: 1 is the second line of defence). Progress streams to the existing ProgressTopBar via ProgressService.updateProgress(...). Adapter contract is additive optional — adapters that don't implement importHistory() get a 400 envelope back from the route (backlog import is IMAP-only; the Gmail adapter does not implement importHistory).

The UI lives on /backend/profile/communication-channels as a per-row "Import history" button → modal dialog (Cmd/Ctrl+Enter submits, Escape cancels). On success the dialog calls router.refresh() instead of window.location.reload() — preserves scroll position and avoids the flash that a full reload causes.

Provider push delivery (Spec C)​

Shipped 2026-05-27. See .ai/specs/implemented/2026-05-27-email-integration-inbound-reliability-and-threading.md for the full design rationale.

Spec B established the polling foundation: 60-second cadence for IMAP and Gmail alike. Spec C replaces the 60s polling cadence for Gmail with native push delivery, dropping inbound latency from ≤60s to 5–15s typical while leaving 30-minute polling as a belt-and-suspenders fallback.

Adapter contract (additive)​

Three optional methods on ChannelAdapter:

  • registerPush(input): Promise<PushRegistration> — Gmail: calls users.watch with the operator-configured Pub/Sub topic.
  • unregisterPush(input): Promise<void> — Counterpart for disconnect / reauth. Gmail: users.stop. Idempotent on 404.
  • applyPushNotification(input): Promise<HistoryPage> — Turns a verified inbound notification into the same HistoryPage shape fetchHistory returns. Gmail: walks history.list, delegating to fetchHistory so the pagination + cursor-recovery logic stays in one place.

Adapters that don't implement these methods (IMAP, chat providers) stay on Spec B's polling.

Gmail Pub/Sub flow​

  1. Operator one-time setup: create a Pub/Sub topic, grant [email protected] publisher, attach a push subscription to the topic pointing at https://<your-host>/api/communication_channels/webhooks/gmail with a service-account identity. Set SF_GMAIL_PUBSUB_TOPIC, SF_GMAIL_PUBSUB_AUDIENCE, SF_GMAIL_PUBSUB_SERVICE_ACCOUNT_EMAIL.
  2. On channel connect (or via the operator "Re-register push" route), the hub calls adapter.registerPush(...), which invokes gmail.users.watch. The returned historyId + watchExpirationMs are persisted to channelState.
  3. Gmail publishes { emailAddress, historyId } to the topic on every mailbox change (rate-capped at 1/s/user).
  4. Pub/Sub POSTs the envelope to the webhook with a Google-signed JWT. lib/gmail-pubsub-jwt.ts verifies the RS256 signature, aud claim, and email claim.
  5. The webhook enqueues a communication-channels-gmail-history-sync job per matching channel (we look up across all tenants by emailAddress and dispatch per-tenant — same emailAddress connected to multiple tenants is supported).
  6. gmail-history-sync worker calls adapter.applyPushNotification(...) (which walks history.list from the stored cursor) and feeds each new message through ingest-inbound-message. Cursor advances per-message commit (Spec B contract).

Renewal cron​

  • Gmail watch expires after ~7 days. Daily 04:00 UTC cron (gmail-renew-watch queue, registered per-org in setup.ts) finds channels within SF_PUSH_RENEWAL_GMAIL_LEAD_HOURS (default 24h) of expiry and triggers pushRegister(...) again.

Polling fallback​

pollIntervalSeconds automatically flips from 60 to 1800 (30 min) when pushStatus='active' is persisted by registerPush. If push fails (e.g. the Gmail Pub/Sub topic is misconfigured) the channel keeps polling at 60s, so no message is ever lost. The pushStatus='failed' channel surfaces in the operator UI with a "Re-register push" button calling POST /api/communication_channels/channels/<id>/push/register.

OAuth setup​

Gmail (@saasframe/channel-gmail)​

1. Create a Google Cloud project​

  1. Go to Google Cloud Console, create or select a project for your tenant.
  2. Enable the Gmail API under APIs & Services → Library.
  3. Configure the OAuth consent screen:
    • User type: Internal if your tenant is a Workspace org; External if you ship to consumers.
    • Scopes: add https://www.googleapis.com/auth/gmail.modify (or the narrower gmail.send + gmail.readonly combo if you prefer), plus userinfo.email and userinfo.profile.
    • Test users: while in Testing status add every user you intend to onboard.

2. Create the OAuth Client ID​

  1. APIs & Services → Credentials → Create credentials → OAuth client ID.
  2. Application type: Web application.
  3. Authorized redirect URIs: add https://<your-host>/api/communication_channels/oauth/gmail/callback for every environment you run (production, staging, local dev).
  4. Copy the Client ID and Client Secret.

3. Register the client in Open Saasframe​

In Backend → Integrations → Gmail add the client ID and secret. Scopes leave blank for defaults, or override with a comma-separated list matching what you configured in the consent screen.

The credentials are stored encrypted on IntegrationCredentials and resolved at runtime when a user clicks Connect Gmail. Users never see the client secret.

4. Move to In production​

When you're ready to onboard more than 100 users, submit the OAuth app for verification. The hub keeps working without verification while you're under that limit and inside Workspace test-user lists.

IMAP + SMTP (@saasframe/channel-imap)​

No tenant setup required. The integrations admin page lets you toggle the provider on or off; individual users provide their own host/port/credentials when they connect.

Environment variables​

The hub reads a small set of env vars; sensible defaults exist for all of them.

VariableDefaultPurpose
SF_HUB_OAUTH_STATE_KEYderived from AUTH_SECRETAES-256-GCM key used to encrypt the OAuth state cookie. Rotate this on a schedule — see runbook below.
SF_HUB_OAUTH_STATE_TTL_SECONDS300 (5 minutes)TTL on the state cookie. OAuth callbacks older than this are rejected.
SF_HUB_POLL_DEFAULT_SECONDS300 (5 minutes)Default poll cadence applied to channels that don't set poll_interval_seconds.
SF_HUB_POLL_CONCURRENCY10Max parallel poll-channel worker jobs per process. Bound by your DB connection pool.
SF_HUB_OUTBOUND_RETRY_MAX5Outbound delivery retries before marking the Message delivery_failed.
SF_HUB_POLL_SCHEDULER_TICK_SECONDS60Interval between poll-tick scheduler runs that enumerate due channels and fan out to per-channel poll jobs. Lower-bounded at 10 s.
SF_CHANNEL_IMAP_HARD_CAP_PER_POLL200Maximum number of IMAP UIDs fetched per fetchHistory page. When more remain, the adapter sets hasMore: true and the hub re-enqueues immediately. Tune lower if a single page's wall-clock budget is too tight; never set above ~500 (DB transaction size).
SF_CHANNEL_AUTO_RECOVER_MINUTES30Spec B § B5 auto-recovery sweep window. poll-tick re-enqueues status='error' channels whose lastFailureAt is older than this. Set 0 in tests to force immediate retry.
SF_THREAD_TOKEN_SECRETderived from KMS_MASTER_KEY via HKDFHMAC key used by Spec B's lib/thread-token.ts to mint per-thread om_* tokens injected into outbound References headers + a hidden body footer. Rotating invalidates all existing tokens (older threads fall back to JWZ + subject-participants strategies — no data loss).
SF_GMAIL_PUBSUB_TOPIC(none — required for Gmail push)Fully-qualified Pub/Sub topic, e.g. projects/saasframe-prod/topics/gmail-inbound. Passed to gmail.users.watch.
SF_GMAIL_PUBSUB_AUDIENCE(none — required for Gmail push)Expected aud claim in the Pub/Sub-signed JWT. Set this to the webhook URL or a stable identifier configured on the Pub/Sub push subscription.
SF_GMAIL_PUBSUB_SERVICE_ACCOUNT_EMAIL(none — required for Gmail push)Expected email claim in the Pub/Sub JWT. Typically the service account configured on the push subscription (NOT [email protected] — that's the publisher into the topic).
SF_PUSH_RENEWAL_GMAIL_LEAD_HOURS24Renewal lead time for gmail.users.watch. Lower means we renew earlier; higher means we cut closer to the 7-day expiration.

Deploy runbook​

Initial deploy​

  1. Provision the Postgres schema — yarn db:migrate runs the hub's migration plus the per-user-columns migration (Migration20260526154135_communication_channels).
  2. Sync the ACL grants — yarn saasframe auth sync-role-acls so the new communication_channels.* features (including communication_channels.connect_user_channel) are granted to the appropriate roles.
  3. Run the structural cache purge — yarn saasframe configs cache structural --all-tenants. Mandatory after any modules.ts change that adds a provider (slices 3e/3f enable channel_imap and channel_gmail).
  4. Configure the OAuth apps — follow the per-provider setup sections above and register the client IDs/secrets via the Integrations admin UI.
  5. Optional: set SF_HUB_OAUTH_STATE_KEY — derived from AUTH_SECRET by default; set explicitly if you want to rotate independently.

Routine ops​

TaskWhenHow
Rotate the OAuth state-cookie keyEvery 90 days (recommended), or after any suspected compromiseSet SF_HUB_OAUTH_STATE_KEY to a new random 32-byte base64 value and roll the deploy. In-flight OAuth flows fail with state expired and the user retries — that's expected and safe.
Bump a tenant's OAuth client secretAfter a provider rotates its credentialsUpdate the secret in Backend → Integrations → <Provider> for the tenant. No restart required; the next refresh-token call picks it up.
Force a poll outside the 5-minute cadenceUser reports "I sent mail and don't see it"Click Refresh on the channel row in the user's profile page, or call POST /api/communication_channels/channels/<id>/test-send for end-to-end.
Purge a disconnected user's channelsWhen a user leaves the orgThe auth.user.deleted cascade subscriber handles it automatically (see Phase 4 implementation status in the email integration spec). Manually: hit the disconnect command for each channel.

Adding a new provider​

The hub deliberately stays adapter-agnostic. To add a third email provider (e.g. Yahoo, ProtonMail Bridge variant), follow the same shape as the two shipping packages:

  1. mkdir packages/channel-<provider> and copy the build/watch/jest/tsconfig templates from packages/channel-imap.
  2. Implement ChannelAdapter in src/modules/channel_<provider>/lib/adapter.ts.
  3. Register the adapter in src/modules/channel_<provider>/setup.ts via registerChannelAdapter(getAdapter()).
  4. Add { id: 'channel_<provider>', from: '@saasframe/channel-<provider>' } to apps/saasframe/src/modules.ts.
  5. Run yarn generate && yarn build:packages && yarn test, then yarn saasframe configs cache structural --all-tenants.

The hub's webhook router, OAuth router, send-as-user route, and admin UI auto-discover the new provider by providerKey; no hub changes required.

Security posture​

  • Credentials at rest: encrypted by the integrations module via TenantDataEncryptionService (AES-256-GCM, per-tenant DEKs). The adapter receives plaintext only inside the request scope; no SQL probe leaks plaintext.
  • OAuth state cookie: 5-minute TTL, AES-256-GCM, HKDF-derived from SF_HUB_OAUTH_STATE_KEY, bound to the initiating user's id. Catches replays + replay-across-users.
  • OAuth PKCE: S256 challenge generated with crypto.randomBytes(64). The verifier is persisted only in the state cookie — never logged or stored server-side.
  • HTML sanitization: every channel payload renders through packages/core/src/modules/communication_channels/lib/sanitize-channel-html.ts before dangerouslySetInnerHTML. Allowlist tuned for email + chat; strips <script>, <iframe>, event handlers, and javascript: / non-image data: URLs.
  • Webhook auth: provider webhook routes verify provider signatures via the adapter's verifyWebhook. Routes that find no adapter for the providerKey return 404, never 5xx.
  • RBAC: the hub declares communication_channels.view / .manage / .react / .assign / .connect_user_channel / .admin. Page metadata uses requireFeatures (never requireRoles).

Integration tests​

Each slice ships its own module-local integration specs under __integration__/:

TestLocationWhat it covers
TC-045D-001..006hubHub schema + adapter contract + entity extensions
TC-CHANNEL-EMAIL-HUB-001..003hubPer-user channel API contract + profile page
TC-CHANNEL-EMAIL-001..003channel-imapIMAP provider registration + webhook no-op + profile page
TC-CHANNEL-EMAIL-006..008channel-gmailGmail OAuth router + webhook no-op + profile page
TC-CHANNEL-EMAIL-014..020hubPhase 4 cross-provider scenarios: primary swap, admin RBAC, tenant OAuth override, disconnect, send-as-user guard, user-deleted cascade, rich-content widget

Run with:

yarn test:integration # full suite
SF_INTEGRATION_MODULES=communication_channels,channel-imap,channel-gmail \
yarn test:integration # email-foundation slice only

Provider package contract​

A ChannelAdapter is the only thing a provider package must expose. The complete contract lives at packages/core/src/modules/communication_channels/lib/adapter.ts. Methods are split into required core, optional extensions, and OAuth helpers — providers only implement the methods that match their declared capabilities (the registry validates consistency on registration).

Required (every adapter)​

  • sendMessage(input) — outbound delivery, returns { externalMessageId, status, … }
  • verifyWebhook(input) — signature verification on inbound POSTs; polling-only providers return eventType: 'other'
  • getStatus(input) — best-effort delivery status for a previously-sent message
  • convertOutbound(input) — hub-canonical body → provider-native shape
  • normalizeInbound(raw) — provider-native payload → NormalizedInboundMessage
  • capabilities — declares feature support; validated against implemented methods

Optional extensions​

  • fetchHistory(input) — required for polling-based providers (realtimePush: false)
  • validateCredentials(input) — credential-based providers (IMAP)
  • buildOAuthAuthorizeUrl(input) / exchangeOAuthCode(input) / refreshCredentials(input) — OAuth providers
  • deleteMessage(input) — when capabilities.deleteMessage: true
  • sendReaction(input) / removeReaction(input) / normalizeInboundReaction(raw) — when capabilities.reactions: true
  • editMessage(input) — when capabilities.editMessage: true
  • resolveContact(input) — best-effort CRM contact resolution; returns null when no hint is available
  • listSenders(input) — adapter-driven sender enumeration (Slack channels, WhatsApp templates, etc.)

See the three shipping providers for end-to-end examples:

  • IMAP: packages/channel-imap/src/modules/channel_imap/lib/adapter.ts
  • Gmail: packages/channel-gmail/src/modules/channel_gmail/lib/adapter.ts