> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Adaptadores de canal — email, in-app, webhook, push

> Cuando esto se entregue, un mensaje resuelto efectivamente alcanza a una persona — por correo, en la app, como push, o hacia el propio sistema del tenant.

> Traducción. Autoritativo: [`fs-msg-0006-channel-adapters.md`](/modules/messaging/features/fs-msg-0006-channel-adapters).

## Contexto

Cuatro adaptadores, un contrato. `NotificationChannelPort` (enmienda A1) dice que un adaptador hace
exactamente una cosa: `send(destinatario, contenidoRenderizado, opts) -> ProviderResult`. No decide si
enviar, no elige canal, no verifica consentimiento. Esas decisiones se tomaron aguas arriba, y un
adaptador que las cuestiona es un adaptador que va a discrepar con la cascada.

Van como una sola entrega y no cuatro porque implementan el mismo contrato con los mismos tests;
separarlos repetiría un spec cuatro veces e invitaría cuatro interpretaciones.

El adaptador de webhook saliente es el raro: su destinatario es un sistema y no una persona, pero pasa
por la misma cascada, los mismos carriles y el mismo seguimiento de estados. Esa uniformidad vale más
que la pequeña incomodidad.

## Alcance *(normativo)*

* `ResendEmailAdapter`, `InAppAdapter`, `WebhookAdapter`, `FcmPushAdapter`.
* `messaging.in_app_notifications` con estado de lectura.
* `messaging.webhook_endpoints` con firma HMAC y protección SSRF.
* Normalización del resultado del proveedor a nuestro vocabulario de estados.
* Clasificación de fallas por adaptador: reintentable o terminal.

## Fuera de alcance *(normativo)*

* SMS — FS-MSG-0009, tras su ADR de proveedor.
* WhatsApp y Live Activities — F2.
* Actualizaciones de wallet passes, que son un adaptador de `NotificationChannelPort` entregado con
  FS-LOY-0014.
* Decidir, renderizar o encolar.

## Comportamiento *(normativo)*

1. Un adaptador **entrega y reporta, nada más**. Nunca consulta consentimiento, supresión ni
   configuración. Si lo necesitara, la cascada estaba mal.
2. Todo adaptador devuelve un **resultado normalizado**: aceptado con id del proveedor, o fallido con
   clasificación `retryable` o `terminal`. Un error que el adaptador no puede clasificar es
   `retryable` — reintentar una falla terminal desperdicia un job, pero no reintentar una transitoria
   pierde un mensaje.
3. **Ningún SDK de proveedor se importa fuera de su archivo de adaptador.** Impuesto por lint (R21).
4. Las notificaciones in-app se guardan con estado de lectura y son el único canal donde el
   destinatario consulta en vez de recibir. Obedecen la cascada igual que cualquier otro.
5. Los webhooks salientes van **firmados con HMAC y timestamp**, protegidos contra SSRF (sin rangos
   privados, sin redirecciones hacia ellos), y su inclusión de PII es opt-in por endpoint, apagada por
   defecto.
6. El push se entrega por FCM para ambas plataformas. Un token inválido devuelve terminal y
   **suprime ese token**, igual que un rebote duro.
7. Los adaptadores son **sin estado y desplegables independientemente**. Agregar uno es un archivo
   nuevo más una fila de catálogo, nunca un cambio en el despachador.
8. Todo adaptador registra la latencia del proveedor, para que un proveedor lento sea visible antes de
   volverse una cola.

## Datos *(normativo)*

| Tabla                            | Invariantes clave                                                                                                                                                       |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.in_app_notifications` | `tenant_id`, `contact_id`; FK envío; `read_at` nullable; `expires_at`; candidata a partición con volumen                                                                |
| `messaging.webhook_endpoints`    | `tenant_id`; `url` solo https, nunca un rango privado; `secret_hash`; `events` suscritos; `include_pii` por defecto false; `is_active`; contador de fallas consecutivas |

## API *(normativo)*

| Endpoint                                         | Clase      | Permiso                                     | Presupuesto  |
| ------------------------------------------------ | ---------- | ------------------------------------------- | ------------ |
| `GET /v1/messaging/in-app`                       | Runtime    | token de member                             | p95 \<150 ms |
| `POST /v1/messaging/in-app/{id}/read`            | Runtime    | token de member                             | p95 \<150 ms |
| `GET/POST/PATCH /v1/messaging/webhook-endpoints` | Management | `messaging.webhooks.{read\|create\|update}` | p95 \<1 s    |
| `POST /v1/messaging/webhook-endpoints/{id}/test` | Management | `messaging.webhooks.test`                   | p95 \<5 s    |

## Eventos *(normativo)*

Ninguno emitido por los adaptadores. Producen resultados de proveedor, que FS-MSG-0003 convierte en
historial y eventos.

## Criterios de aceptación *(normativo)*

1. Ningún SDK de proveedor se importa fuera de un archivo de adaptador — impuesto por lint con fixture.
2. Todo adaptador devuelve resultado normalizado, y un error inclasificable es `retryable`.
3. Un webhook hacia un rango de IP privado se rechaza antes de que salga cualquier request.
4. Una firma de webhook verifica con el algoritmo documentado y un timestamp reproducido se rechaza.
5. Un token de push inválido devuelve terminal y suprime ese token.
6. Las notificaciones in-app respetan la cascada.
7. Un endpoint que falla consecutivamente sobre el umbral se desactiva y se notifica al tenant.
8. **Negativo:** ningún adaptador lee consentimiento, supresión ni configuración de canal.

## Ejecución

Un solo slice desde la perspectiva del worker. Los adaptadores viven junto a `backend/workers`; el
puerto en `packages/core`. Segunda mitad de TS-002.

## Preguntas abiertas

| # | Pregunta                                                                                | Decide | Para             |
| - | --------------------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | Fallas consecutivas de webhook antes de desactivar — ¿10, o una tasa sobre una ventana? | Daniel | antes de aprobar |
| 2 | ¿Las notificaciones in-app expiran, y en cuánto tiempo?                                 | Daniel | antes de aprobar |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
