Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@treepolitics.net>"

# OpenTelemetry
otel_enabled: bool = False
otel_service_name: str = "api-template"
Expand Down
51 changes: 51 additions & 0 deletions app/email.py
Original file line number Diff line number Diff line change
@@ -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}")
14 changes: 8 additions & 6 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
38 changes: 38 additions & 0 deletions app/routers/contact.py
Original file line number Diff line number Diff line change
@@ -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"}
12 changes: 12 additions & 0 deletions app/schemas/contact.py
Original file line number Diff line number Diff line change
@@ -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 = ""
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
106 changes: 106 additions & 0 deletions tests/test_contact.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading