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.
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()
|