# channel-exit The estate's one way out to a person's inbox or phone. Services that need to reach a human POST here instead of carrying a SendGrid key of their own. Credentials live in one place, one service can be turned off or rate-limited, and a bug in some worker cannot become a bill. ## The contract ``` POST /send Authorization: Bearer Content-Type: application/json {"channel": "email|sms|whatsapp|push", "to": "...", "subject": "...", "body": "..."} ``` | Reply | When | | --- | --- | | `200` | the provider accepted it — `{"ok": true, "provider_status": 202, "id": "..."}` | | `400` | unknown channel, or `to` is not an email / not E.164, or nothing to send | | `401` | missing or wrong bearer token | | `501` | `whatsapp` and `push` — no provider for them in this estate | | `502` | the provider refused or was unreachable; its status is in the body | | `503` | that channel's credentials are not configured here | `GET /healthz` needs no token and reports which channels are wired. The shape is fixed by its first consumer, `reminders-service` (`app/alerts.py: gateway_sender`), which treats any 2xx as delivered. ## Two rules **It listens on loopback and the docker bridge, and nowhere else.** An authenticated relay reachable from the internet is an open spam relay the moment the token leaks — and that token is copied into every consumer's environment. It gets no `0.0.0.0` binding and no cloudflared route, ever. **It does not log what it was asked to send.** The log records channel, a redacted address, the provider's status and message id. Never a subject, never a body, never a credential. ## Consumers From the host: `http://127.0.0.1:/send` From another container: `http://172.17.0.1:/send` — a consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails every delivery while looking correctly configured. ## Running it ```sh cp .env.example .env && chmod 600 .env # fill in, then docker compose up -d --build ``` Every value in `.env` is required except `PROVIDER_TIMEOUT`. `CHANNEL_EXIT_TOKEN` has no default: the service refuses to start without one rather than coming up as an unauthenticated relay. ## Tests ```sh pip install -r requirements-dev.txt && python -m pytest -q ``` The suite replaces `urllib.request.urlopen` with something that raises, so no test can send a real message however it is written.