# 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 publishes one host port, on `127.0.0.1`, and nothing 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, no bridge-IP 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://channel-exit:8080/send`. The relay joins the docker network its consumers already run on (`CHANNEL_EXIT_NETWORK`) and is reached there by name, on the **container** port — `CHANNEL_EXIT_PORT` is a host-side publish and does not exist inside that network. A consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails every delivery while looking correctly configured. That is the one mistake to watch for here. ## 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.