177 lines
7.0 KiB
Python
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()
|