# notification-channels

## Overview

Notification Channels is the abstraction over delivery mediums. The module defines a `NotificationChannel` registry entity (`channelId` enum: IN_APP, EMAIL, SMS, PUSH, SLACK, TEAMS; `kind` distinguishing `PERSONAL` per-recipient fan-out from `DESTINATION` shared-surface broadcast; `displayName`; `capabilities` covering `supportsHtmlBody`, `supportsAttachments`, `supportsRichActions`; and an `enabled` admin toggle) along with a channel adapter port that delivery uses to dispatch a rendered message. The port has the shape `ChannelAdapter.send({ recipientChannelAddress, subject, body, htmlBody?, deepLink?, metadata }) -> { adapterMessageId?, error? }`. Each registered channel is backed by its own adapter implementation; the module only exposes the port, while the host application wires a concrete provider (e.g. SendGrid for EMAIL) at composition time.

The `kind` field selects the dispatch topology. The active **PERSONAL** set is IN_APP plus the optionally-wired EMAIL; **SLACK is an active DESTINATION channel**, dispatched through the [notification-destination-delivery](./notification-destination-delivery.md) stage with its concrete adapter bundled as the opt-in [slack-workspace-integration](./slack-workspace-integration.md) destination (`slack` option). SMS, PUSH, and TEAMS are reserved enum values without adapter implementations and are not dispatched to. Channel adapters are invoked by the **delivery worker** (out of transaction), not at plan time — the plan phase only persists the QUEUED outbox row. The IN_APP "delivery" is the Notification record itself: the `Notification` entity persisted by notification-delivery doubles as the in-app inbox row, so the IN_APP adapter is a no-op that the delivery worker uses to advance the Notification's `deliveryStatus` from `QUEUED` to `SENT` to `DELIVERED`. The EMAIL adapter is an **optional DI port**: the engine retains the EMAIL path even when the host app leaves it unwired (see the module README), and an EMAIL-channel row whose delivery finds no wired adapter is marked `FAILED` (audit `errorClass = ChannelAdapterNotConfigured`, outcome reason `CHANNEL_ADAPTER_FAILED`) — rather than left `QUEUED` — so the configuration gap is visible to operators. When wired, the EMAIL adapter forwards the rendered message to the host-configured email provider. Recipient addressing for PERSONAL channels is resolved through a user-management DI port (at plan time and re-checked at delivery): IN_APP maps to `recipientUserId` and EMAIL maps to `user.email` (DESTINATION channels address through `ChannelRoutingBinding.externalChannelRef` instead — see notification-destination-delivery). Each channel's `enabled` flag acts as a kill-switch — disabling a channel causes the plan phase to skip it for all events without creating Notification rows for that channel.

**EMAIL is best-effort with full provider delegation.** The contract between the dispatcher and the EMAIL adapter is intentionally narrow: the dispatcher invokes `send(...)` once, and the adapter calls the provider's send API once. On send-API success the adapter returns `{ adapterMessageId }` and the Notification's `deliveryStatus` advances to `SENT` and **stops there** — `SENT` is terminal for EMAIL. On send-API failure the adapter returns `{ error }` and `deliveryStatus` becomes `FAILED`. The dispatcher does **not** consume provider webhooks for delivery acknowledgements, hard bounces, complaints, or unsubscribes; suppression-list management, soft-bounce retry, hard-bounce drop-after-N-attempts, and unsubscribe handling are fully delegated to the provider's internal mechanisms (visible in the provider's suppression / bounce dashboards). The dispatcher does **not** perform its own retry — providers such as SendGrid already retry transient failures internally before returning, so a returned error is treated as terminal for the dispatcher. The `reportDelivery` entry point is reserved on the port for webhook ingestion but is not wired.

## Business Purpose

- Prevent each module from growing its own email sender, SMS client, or chat integration by funnelling every outbound notification through a single port
- Allow swapping the underlying provider (e.g. SendGrid to SES) without changing module code, since adapters are wired at the application layer
- Enable a per-channel kill-switch so administrators can pause EMAIL delivery globally without redeploying or touching event subscriptions
- Drive capability-based template authoring: templates can branch on `supportsHtmlBody`, `supportsAttachments`, and `supportsRichActions` instead of hard-coding per-channel logic
- Keep the IN_APP surface coherent by treating the `Notification` record itself as the in-app inbox row, avoiding a parallel "in-app message" store
- Delegate EMAIL deliverability concerns (suppression list, bounce processing, soft-bounce retry, complaint handling, unsubscribe management) to the configured email provider so the dispatcher stays best-effort and provider-agnostic at the contract level
- Route shared-surface channels (SLACK, and TEAMS when added) through the DESTINATION topology selected by `kind`, keeping per-recipient addressing and one-post-per-binding broadcast as separate dispatch stages (see [notification-destination-delivery](./notification-destination-delivery.md))
- Reserve future channel slots (SMS, PUSH, TEAMS) at the enum level so adding an adapter later is purely additive

## Process Flow

```mermaid
flowchart TD
    A[Delivery worker takes a QUEUED Notification for a channel] --> B{Channel registered, enabled, and PERSONAL?}
    B -->|No| C[deliveryStatus FAILED with ChannelUnavailable]
    B -->|Yes| D[Resolve recipientChannelAddress via user-management port]
    D --> E{Address resolved?}
    E -->|No| F[deliveryStatus FAILED with reason MISSING_RECIPIENT_ADDRESS, audit row written]
    E -->|Yes| G{Channel is IN_APP?}
    G -->|Yes| H[IN_APP adapter no-op: deliveryStatus -> SENT -> DELIVERED]
    G -->|No, EMAIL| W{EMAIL adapter wired by the host app?}
    W -->|No| WF[deliveryStatus FAILED with ChannelAdapterNotConfigured, audit row written]
    W -->|Yes| I[Invoke EMAIL adapter send -> provider send API, with idempotencyKey = Notification id]
    I --> J{Adapter returned ok?}
    J -->|No, send-API failure after provider internal retries| K[deliveryStatus FAILED, error captured in audit. No worker-side retry of a returned failure.]
    J -->|Yes| L[deliveryStatus SENT, store adapterMessageId. Terminal for EMAIL: no DELIVERED, no BOUNCED.]

    note["The email provider handles suppression / soft-bounce retry / hard-bounce drop / complaint / unsubscribe internally. The module does not consume provider webhooks; reportDelivery port is reserved but not wired. A throwing PERSONAL adapter is caught by the drain and leaves the row QUEUED for the redrain rather than marking it FAILED."]
```

## Scenario Patterns

- **IN_APP delivery**: the delivery worker takes the QUEUED IN_APP row; the adapter is a no-op and the Notification's `deliveryStatus` advances `QUEUED → SENT → DELIVERED` in a single pass with no external call
- **EMAIL delivery success**: the provider's send API returns 2xx; adapter returns `{ adapterMessageId }`; `deliveryStatus` becomes `SENT` and stays there. Whether the message is later delivered, soft-bounced, hard-bounced, or marked as spam by the recipient is observable only in the provider's dashboards, not on the Notification row
- **EMAIL delivery sync failure**: the provider's send API returns 4xx (auth / rate-limit / payload-rejection) or 5xx after its own internal retries; adapter returns `{ error }`; `deliveryStatus` becomes `FAILED` with the error captured in the audit row. The dispatcher does not retry — operators reconcile through the audit log and the provider's failure dashboards
- **Suppression handled by provider**: an EMAIL Notification targets an address the provider has previously hard-bounced or marked unsubscribed; providers like SendGrid still return 2xx but silently suppress the actual send (suppression-group behavior). The dispatcher records `deliveryStatus = SENT` regardless — visibility into the suppression event lives with the provider, not in this module. **The notification module does not maintain its own suppression list**
- **Soft bounce handled by provider**: the provider's send API may itself perform internal retries for transient SMTP failures within its own window before returning a final result; from the dispatcher's perspective this is invisible — only the final outcome is observed
- **Missing recipient address**: EMAIL channel selected but the resolved user has no email; `deliveryStatus` FAILED with reason `MISSING_RECIPIENT_ADDRESS`, no provider call made
- **Channel disabled (kill-switch)**: `NotificationChannel.enabled = false`; dispatcher skips the channel for every event and creates no Notification row for it
- **Re-enabling a channel**: admin flips `enabled` back to true; the next dispatch resumes normally with no replay of skipped events
- **Adapter throws unexpected exception**: an uncaught throw from a PERSONAL `send` is caught and logged by the drain; the row is left `QUEUED` and re-driven by the insurance redrain (the provider idempotency key collapses any duplicate send), rather than marked `FAILED`. Only an adapter that *returns* a failure marks the row terminally `FAILED`
- **EMAIL adapter unwired**: the host app enabled the EMAIL channel but did not wire an EMAIL adapter at composition time; the Notification row is persisted and marked `FAILED` with an audit row (`errorClass = ChannelAdapterNotConfigured`), and the dispatch outcome reports the pair as `CHANNEL_ADAPTER_FAILED` — the misconfiguration is observable instead of rows silently lingering at `QUEUED`
- **Reserved channel selected**: dispatcher targets a reserved channel (SMS, PUSH, TEAMS) without a registered adapter; behaves identically to a disabled channel and is skipped
- **DESTINATION channel selected**: dispatcher encounters a `kind = DESTINATION` channel (e.g. `SLACK`); the pair is not part of the PERSONAL fan-out at all — it is routed through the destination stage's binding resolution (see [notification-destination-delivery](./notification-destination-delivery.md))
- **Disabling IN_APP**: allowed but discouraged since it effectively turns off the inbox surface; dispatcher skips IN_APP for all events while the flag is off
- **Provider swap**: replacing SendGrid with SES (or another provider) is a composition-layer change to the EMAIL adapter implementation only; the dispatcher contract is unchanged, and the new adapter is expected to apply the same best-effort semantics (one send-API call, success → SENT, failure → FAILED, no dispatcher-side retry, suppression delegated to the provider)

## Test Cases

- Registering a channel with `enabled = true` should make it eligible for dispatch
- Registering a channel with `enabled = false` should cause the dispatcher to skip every event for that channel
- Toggling `NotificationChannel.enabled` from false to true should allow the next dispatch to proceed without replaying past events
- Delivering a QUEUED IN_APP row should advance the Notification's `deliveryStatus` from `QUEUED` to `SENT` to `DELIVERED` in a single delivery-worker pass without invoking any external adapter
- Delivering a QUEUED EMAIL row with a successful adapter response should set `deliveryStatus = SENT` and persist the returned `adapterMessageId`; the Notification should not advance to `DELIVERED` or `BOUNCED`
- Delivering a QUEUED EMAIL row when the adapter returns a failure should set `deliveryStatus = FAILED` and write an audit row containing the error, with no worker-side retry
- Delivering a QUEUED EMAIL row when the user has no email address should set `deliveryStatus = FAILED` with reason MISSING_RECIPIENT_ADDRESS
- Resolving the recipient channel address for IN_APP should return the `recipientUserId`
- Resolving the recipient channel address for EMAIL should return `user.email` from the user-management port
- An adapter that throws an unexpected exception should be caught and surface as `deliveryStatus = FAILED` with a generic error class
- The dispatcher should not invoke any provider-webhook callback path; the `reportDelivery` port may exist on the adapter contract but is not exercised by the dispatcher
- The dispatcher should not maintain a per-recipient suppression list; consecutive EMAIL dispatches to the same address the provider has internally suppressed should each return SENT (because the provider's send API returns 2xx for suppressed addresses) without dispatcher-side filtering
- The dispatcher should not retry FAILED EMAIL deliveries; a single send-API call per dispatch is the entire interaction with the provider
- Dispatching to EMAIL when the host app has not wired an EMAIL adapter should persist the Notification row, mark it `FAILED`, write an audit row with `errorClass = ChannelAdapterNotConfigured`, and report the outcome reason `CHANNEL_ADAPTER_FAILED`
- Dispatching to a reserved channel (SMS, PUSH, TEAMS) without a registered adapter should be skipped like a disabled channel
- A `kind = DESTINATION` channel (e.g. `SLACK`) should not participate in the PERSONAL fan-out; its dispatch is owned by the destination stage
- The channel adapter port `send` contract should accept `recipientChannelAddress`, `subject`, `body`, optional `htmlBody`, optional `deepLink`, and `metadata`
- The channel adapter port `send` contract should return optional `adapterMessageId` and optional `error`
- Channel `capabilities.supportsHtmlBody`, `supportsAttachments`, and `supportsRichActions` should be readable by template rendering for capability-based branching
- Disabling IN_APP should be allowed by the registry and result in inbox events being skipped while the flag is off
- Successive sync failures on EMAIL should each produce a distinct audit row without altering the channel's `enabled` flag

## Reference Links

- [Notification module README](../../README.md)
- [notification-delivery feature](./notification-delivery.md)
- [notification-destination-delivery feature](./notification-destination-delivery.md) — the DESTINATION topology dispatched for `kind = DESTINATION` channels (e.g. `SLACK`)
- [slack-workspace-integration feature](./slack-workspace-integration.md) — the opt-in Slack destination: adapter and workspace connection
- [NotificationChannel model](../model/NotificationChannel.md)
- [notification-delivery-audit feature](./notification-delivery-audit.md)
