Files
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

177 lines
7.0 KiB
Python

"""The estate's one way out to a person's inbox or phone.
Every service that needs to reach a human hands the message here instead of
carrying a SendGrid key of its own. That is the whole point: the credentials
live in one place, one service can be rate-limited or turned off, and a bug in
some reminder worker cannot become a bill.
Which makes the two rules below non-negotiable.
1. **It never listens anywhere but loopback.** An authenticated relay reachable
from the internet is an open relay the moment the token leaks, and the token
travels in plaintext to every consumer. The bind address, not the token, is
what keeps this off the public internet — so it is not published through any
tunnel, and the compose file binds 127.0.0.1 explicitly.
2. **It never logs what it was asked to send.** Reminders are private. The log
records that a message went to a redacted address on a channel and what the
provider said, which is everything needed to debug a delivery and nothing
that would put someone's private reminder in a log file.
The request shape is fixed by its first consumer, reminders-service:
{"channel": "email|sms|whatsapp|push", "to": ..., "subject": ..., "body": ...}
"""
from __future__ import annotations
import hmac
import json
import logging
import re
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app import providers
from app.config import Settings, get_settings
log = logging.getLogger("channel-exit")
CHANNELS = {"email", "sms", "whatsapp", "push"}
# Deliberately narrow. This is not RFC 5322 — it is the subset SendGrid will
# accept, and rejecting an odd-but-legal address is a better failure than
# handing a provider something that makes it 400 the whole request.
EMAIL = re.compile(r"^[^@\s,;<>\"]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
# E.164: a plus, a non-zero country digit, up to fifteen digits in total.
# Anything looser and a typo becomes a message to a stranger.
E164 = re.compile(r"^\+[1-9]\d{7,14}$")
MAX_BODY = 100_000
def redact(channel: str, address: str) -> str:
"""Enough of an address to recognise a delivery, not enough to reach it."""
if channel == "email" and "@" in address:
local, _, domain = address.partition("@")
return f"{local[:1]}***@{domain}"
if len(address) > 4:
return f"***{address[-4:]}"
return "***"
def _error(status: int, message: str) -> JSONResponse:
return JSONResponse({"ok": False, "error": message}, status_code=status)
def _authorised(request: Request, settings: Settings) -> bool:
header = request.headers.get("authorization", "")
scheme, _, presented = header.partition(" ")
if scheme.lower() != "bearer" or not presented:
return False
# Constant-time: a plain == leaks the token one character at a time to
# anyone who can measure the reply.
return hmac.compare_digest(presented.strip(), settings.token)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
settings.require_token()
app = FastAPI(title="channel-exit", docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/healthz")
def healthz() -> dict[str, Any]:
# Which channels are wired is operational fact, not a secret, and it is
# the first thing anyone debugging a silent delivery wants to know.
return {"ok": True, "channels": {"email": settings.email_ready, "sms": settings.sms_ready}}
@app.post("/send")
async def send(request: Request) -> Any:
if not _authorised(request, settings):
return _error(401, "a bearer token is required")
raw = await request.body()
if len(raw) > MAX_BODY:
return _error(413, "message too large")
try:
payload = json.loads(raw or b"{}")
except ValueError:
return _error(400, "body must be JSON")
if not isinstance(payload, dict):
return _error(400, "body must be a JSON object")
channel = str(payload.get("channel") or "").strip().lower()
to = str(payload.get("to") or "").strip()
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "")
if channel not in CHANNELS:
return _error(400, f"unknown channel; expected one of {', '.join(sorted(CHANNELS))}")
if channel in {"whatsapp", "push"}:
# 501 and not 503: these are not misconfigured here, they have no
# provider in this estate at all. A consumer should stop asking.
return _error(501, f"{channel} is not configured on this gateway; only email and sms are wired")
if not to:
return _error(400, "'to' is required")
if not subject and not body:
return _error(400, "nothing to send: 'subject' and 'body' are both empty")
if channel == "email":
if not EMAIL.match(to) or len(to) > 254:
return _error(400, "'to' must be an email address for channel 'email'")
if not settings.email_ready:
return _error(503, "email is not configured on this gateway")
outcome = providers.send_email(
to=to,
subject=subject,
body=body,
api_key=settings.sendgrid_key,
sender=settings.email_from,
timeout=settings.timeout,
)
else:
if not E164.match(to):
return _error(400, "'to' must be an E.164 phone number (e.g. +15551234567) for channel 'sms'")
if not settings.sms_ready:
return _error(503, "sms is not configured on this gateway")
outcome = providers.send_sms(
to=to,
subject=subject,
body=body,
account_sid=settings.twilio_sid,
auth_token=settings.twilio_token,
sender=settings.twilio_from,
timeout=settings.timeout,
)
log.info(
"channel=%s to=%s ok=%s provider_status=%s id=%s detail=%s",
channel,
redact(channel, to),
outcome.ok,
outcome.status,
outcome.message_id or "-",
outcome.detail if not outcome.ok else "-",
)
if not outcome.ok:
# 502, because the failure is upstream: the caller's request was
# fine and retrying it here would be pointless. The provider's
# status travels in the body so the caller can tell a bad address
# from an outage without reading our logs.
return JSONResponse(
{"ok": False, "provider_status": outcome.status, "error": outcome.detail},
status_code=502,
)
return {"ok": True, "channel": channel, "provider_status": outcome.status, "id": outcome.message_id}
return app
def asgi() -> FastAPI:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
return create_app()