2026-08-18 15:41:28 -04:00
|
|
|
# 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 <CHANNEL_EXIT_TOKEN>
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-18 19:25:11 -04:00
|
|
|
**It publishes one host port, on `127.0.0.1`, and nothing else.** An
|
2026-08-18 15:41:28 -04:00
|
|
|
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
|
2026-08-18 19:25:11 -04:00
|
|
|
environment. It gets no `0.0.0.0` binding, no bridge-IP binding, and no
|
|
|
|
|
cloudflared route, ever.
|
2026-08-18 15:41:28 -04:00
|
|
|
|
|
|
|
|
**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:<CHANNEL_EXIT_PORT>/send`
|
|
|
|
|
|
2026-08-18 19:25:11 -04:00
|
|
|
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.
|
2026-08-18 15:41:28 -04:00
|
|
|
|
|
|
|
|
## 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.
|