diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d74b8ea..72c1ac4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -51,6 +51,6 @@ jobs: --service-account=treepolitics-api-runtime@treepolitics-prod.iam.gserviceaccount.com \ --add-cloudsql-instances=treepolitics-prod:us-east1:treepolitics-db \ --set-env-vars="^|^ENVIRONMENT=production|COOKIE_DOMAIN=.treepolitics.net|FRONTEND_URL=https://treepolitics.net|CORS_ORIGINS=https://treepolitics.net,https://www.treepolitics.net" \ - --set-secrets="DATABASE_URL=DATABASE_URL:latest,SECRET_KEY=SECRET_KEY:latest" \ + --set-secrets="DATABASE_URL=DATABASE_URL:latest,SECRET_KEY=SECRET_KEY:latest,RESEND_API_KEY=RESEND_API_KEY:latest,CONTACT_TO_EMAIL=CONTACT_TO_EMAIL:latest" \ --allow-unauthenticated \ --project=treepolitics-prod diff --git a/app/config.py b/app/config.py index 9488291..bfa6457 100644 --- a/app/config.py +++ b/app/config.py @@ -32,6 +32,12 @@ class Settings(BaseSettings): # Logging log_level: str = "INFO" + # Contact form — Resend delivery. Leave unset to disable the endpoint + # (it returns 503 until both key and destination are configured). + resend_api_key: str = "" + contact_to_email: str = "" + contact_from_email: str = "Tree Politics " + # OpenTelemetry otel_enabled: bool = False otel_service_name: str = "api-template" diff --git a/app/email.py b/app/email.py new file mode 100644 index 0000000..5f23551 --- /dev/null +++ b/app/email.py @@ -0,0 +1,51 @@ +"""Outbound email via the Resend HTTP API.""" + +import httpx +import structlog + +from app.config import settings +from app.schemas.contact import ContactCreate + +logger = structlog.get_logger("app.email") + +RESEND_API_URL = "https://api.resend.com/emails" + + +class EmailDeliveryError(Exception): + """Raised when the email provider rejects or fails a send.""" + + +async def send_contact_email(contact: ContactCreate) -> None: + """Deliver a contact-form submission to the site inbox. + + The submitter's address goes in Reply-To so replying from the inbox + reaches them directly; From stays on the verified sending domain. + """ + body = ( + f"From: {contact.name} <{contact.email}>\nSubject: {contact.subject}\n\n{contact.message}\n" + ) + payload = { + "from": settings.contact_from_email, + "to": [settings.contact_to_email], + "reply_to": [contact.email], + "subject": f"[Contact] {contact.subject}", + "text": body, + } + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + RESEND_API_URL, + json=payload, + headers={"Authorization": f"Bearer {settings.resend_api_key}"}, + ) + except httpx.HTTPError as exc: + logger.error("contact_email_transport_error", error=str(exc)) + raise EmailDeliveryError("email provider unreachable") from exc + + if response.status_code >= 400: + logger.error( + "contact_email_rejected", + status_code=response.status_code, + body=response.text[:500], + ) + raise EmailDeliveryError(f"email provider returned {response.status_code}") diff --git a/app/main.py b/app/main.py index d1db77b..afeb491 100644 --- a/app/main.py +++ b/app/main.py @@ -17,7 +17,7 @@ from app.features import router as features_router from app.logging import setup_logging from app.models.user import User -from app.routers import admin_router, notes_router +from app.routers import admin_router, contact_router, notes_router from app.routers.auth_refresh import router as auth_refresh_router from app.schemas.user import UserCreate, UserRead from app.telemetry import setup_telemetry @@ -69,18 +69,19 @@ async def get_current_user(user: User = Depends(current_active_user)): return user -# Path-specific rate limits for auth endpoints -_AUTH_RATE_LIMITS: dict[str, RateLimitItem] = { +# Path-specific rate limits for sensitive public endpoints +_PATH_RATE_LIMITS: dict[str, RateLimitItem] = { "/auth/jwt/login": parse("5/minute"), "/auth/register": parse("3/minute"), "/auth/refresh": parse("30/minute"), + "/contact": parse("3/minute"), } @app.middleware("http") -async def rate_limit_auth(request: Request, call_next) -> Response: - """Apply rate limits to auth endpoints.""" - rate_limit = _AUTH_RATE_LIMITS.get(request.url.path) +async def rate_limit_paths(request: Request, call_next) -> Response: + """Apply rate limits to sensitive endpoints.""" + rate_limit = _PATH_RATE_LIMITS.get(request.url.path) if rate_limit and request.method == "POST": key = get_remote_address(request) if not limiter._limiter.hit(rate_limit, key): @@ -153,6 +154,7 @@ async def request_logging_middleware(request: Request, call_next) -> Response: # API routes app.include_router(admin_router) +app.include_router(contact_router) app.include_router(notes_router) app.include_router(features_router) diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 958073c..c54cf15 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,4 +1,5 @@ from app.routers.admin import router as admin_router +from app.routers.contact import router as contact_router from app.routers.notes import router as notes_router -__all__ = ["admin_router", "notes_router"] +__all__ = ["admin_router", "contact_router", "notes_router"] diff --git a/app/routers/contact.py b/app/routers/contact.py new file mode 100644 index 0000000..00d2149 --- /dev/null +++ b/app/routers/contact.py @@ -0,0 +1,38 @@ +import structlog +from fastapi import APIRouter, HTTPException, status + +from app.config import settings +from app.email import EmailDeliveryError, send_contact_email +from app.schemas.contact import ContactCreate + +logger = structlog.get_logger("app.contact") + +router = APIRouter(prefix="/contact", tags=["contact"]) + + +@router.post("", status_code=status.HTTP_202_ACCEPTED) +async def submit_contact(contact: ContactCreate) -> dict[str, str]: + """Accept a contact-form submission and email it to the site inbox. + + Public endpoint (no auth); rate-limited in main. Honeypot submissions + are accepted but silently dropped so bots get no signal. + """ + if contact.website: + logger.info("contact_honeypot_tripped") + return {"status": "accepted"} + + if not settings.resend_api_key or not settings.contact_to_email: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Contact form is not configured", + ) + + try: + await send_contact_email(contact) + except EmailDeliveryError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Message could not be sent", + ) from exc + + return {"status": "accepted"} diff --git a/app/schemas/contact.py b/app/schemas/contact.py new file mode 100644 index 0000000..ab9777a --- /dev/null +++ b/app/schemas/contact.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, EmailStr, Field + + +class ContactCreate(BaseModel): + """A message submitted through the public contact form.""" + + name: str = Field(min_length=1, max_length=120) + email: EmailStr + subject: str = Field(min_length=1, max_length=200) + message: str = Field(min_length=10, max_length=5000) + # Honeypot: hidden field in the form; humans leave it empty, bots fill it. + website: str = "" diff --git a/pyproject.toml b/pyproject.toml index 14b3de8..a562e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc>=1.40.0", "opentelemetry-instrumentation-fastapi>=0.61b0", "scalar-fastapi>=1.0.0", + "httpx>=0.28.1", ] [dependency-groups] diff --git a/tests/test_contact.py b/tests/test_contact.py new file mode 100644 index 0000000..19017b1 --- /dev/null +++ b/tests/test_contact.py @@ -0,0 +1,106 @@ +import pytest +from httpx import AsyncClient + +import app.routers.contact as contact_module +from app.config import settings +from app.email import EmailDeliveryError +from app.main import app + +VALID_PAYLOAD = { + "name": "A Reader", + "email": "reader@example.com", + "subject": "About the cherry trees post", + "message": "I have a question about the sources in that post.", +} + + +@pytest.fixture(autouse=True) +def reset_rate_limiter(): + """/contact is rate-limited at 3/minute; reset the in-memory window per test.""" + app.state.limiter._limiter.storage.reset() + yield + app.state.limiter._limiter.storage.reset() + + +@pytest.fixture +def configured(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings, "resend_api_key", "test-key") + monkeypatch.setattr(settings, "contact_to_email", "inbox@example.com") + + +@pytest.fixture +def sent(monkeypatch: pytest.MonkeyPatch) -> list: + """Replace the Resend call; record payloads instead of sending.""" + calls: list = [] + + async def fake_send(contact) -> None: + calls.append(contact) + + monkeypatch.setattr(contact_module, "send_contact_email", fake_send) + return calls + + +async def test_submit_sends_email(client: AsyncClient, configured, sent: list): + response = await client.post("/contact", json=VALID_PAYLOAD) + + assert response.status_code == 202 + assert response.json() == {"status": "accepted"} + assert len(sent) == 1 + assert sent[0].email == "reader@example.com" + assert sent[0].subject == VALID_PAYLOAD["subject"] + + +async def test_honeypot_accepted_but_not_sent(client: AsyncClient, configured, sent: list): + response = await client.post("/contact", json={**VALID_PAYLOAD, "website": "spam.example"}) + + assert response.status_code == 202 + assert sent == [] + + +async def test_unconfigured_returns_503(client: AsyncClient, sent: list): + response = await client.post("/contact", json=VALID_PAYLOAD) + + assert response.status_code == 503 + assert sent == [] + + +async def test_delivery_failure_returns_502( + client: AsyncClient, configured, monkeypatch: pytest.MonkeyPatch +): + async def failing_send(contact) -> None: + raise EmailDeliveryError("provider down") + + monkeypatch.setattr(contact_module, "send_contact_email", failing_send) + + response = await client.post("/contact", json=VALID_PAYLOAD) + + assert response.status_code == 502 + + +@pytest.mark.parametrize( + "overrides", + [ + {"email": "not-an-email"}, + {"name": ""}, + {"message": "short"}, + {"subject": ""}, + ], +) +async def test_invalid_payload_returns_422( + client: AsyncClient, configured, sent: list, overrides: dict +): + response = await client.post("/contact", json={**VALID_PAYLOAD, **overrides}) + + assert response.status_code == 422 + assert sent == [] + + +async def test_rate_limited_after_three_requests(client: AsyncClient, configured, sent: list): + for _ in range(3): + response = await client.post("/contact", json=VALID_PAYLOAD) + assert response.status_code == 202 + + response = await client.post("/contact", json=VALID_PAYLOAD) + + assert response.status_code == 429 + assert len(sent) == 3 diff --git a/uv.lock b/uv.lock index befc459..1042f84 100644 --- a/uv.lock +++ b/uv.lock @@ -70,6 +70,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "fastapi-users", extra = ["sqlalchemy"] }, + { name = "httpx" }, { name = "limits" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, @@ -101,6 +102,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.135.0" }, { name = "fastapi-users", extras = ["sqlalchemy"], specifier = ">=15.0.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "limits", specifier = ">=5.8.0" }, { name = "opentelemetry-api", specifier = ">=1.40.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.40.0" },