Files
channel-exit/README.md
T
nirpa e4d6230dd1 The estate's one channel exit
Services that need to reach a person hand the message here instead of each
carrying a SendGrid key. Email goes to SendGrid, SMS to Twilio; whatsapp and
push answer 501 rather than pretending.

It binds loopback and the docker bridge only. An authenticated relay on the
public internet is an open spam relay the moment the token leaks, and that
token is copied into every consumer's environment.

The log names a channel, a redacted address and the provider's answer. Never
the subject, never the body.
2026-08-18 15:41:28 -04:00

71 lines
2.4 KiB
Markdown

# 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
**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:<CHANNEL_EXIT_PORT>/send`
From another container: `http://172.17.0.1:<CHANNEL_EXIT_PORT>/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.