Phase 4: Tracking Endpoints

Overview

Open and click events from delivered emails are captured by two unauthenticated endpoints (email clients cannot carry JWTs). Both are secured with HMAC-signed tokens generated at send time — a raw UUID in the URL is not a valid token, so forged or guessed requests are rejected without a DB round-trip. Tracking writes are idempotent (a second open/click on the same message is a no-op). Per-IP rate limiting (120 req/min) prevents analytics inflation and DB write storms from scrapers and mail proxies.

Post-review hardening shipped in this phase:

  • Raw UUIDs replaced with HMAC-signed tokens (sign_message_id / sign_click).
  • Click endpoint verifies both the message-id signature and the (message_id, url) signature; non-http(s) schemes are rejected.
  • slowapi per-IP rate limit re-enabled (120/min); limiter.exempt is forbidden on these routes.

Goal

Provide FastAPI endpoints to capture open and click events triggered by the HTML parsing injections.

TDD Acceptance Criteria

  1. pytest tests/api/test_tracking.py::test_track_open_returns_gif_and_updates_db MUST PASS
  2. pytest tests/api/test_tracking.py::test_track_click_returns_302_and_updates_db MUST PASS
  3. pytest tests/api/test_tracking.py::test_tracking_endpoints_have_per_ip_rate_limit MUST PASS

Technical Specifications

GET /api/v1/track/open/{signed_token}.gif

  • Does NOT require JWT authentication.
  • signed_token is an HMAC-signed message-id token (sign_message_id); requests with an invalid signature are rejected without a DB read.
  • Returns a 1x1 transparent GIF with Content-Type: image/gif.
  • Header: Cache-Control: no-cache, max-age=0.
  • Updates outbound_messages.opened_at if it is NULL (idempotent).

GET /api/v1/track/click/{signed_token}

  • Query parameters: url (the original destination), sig (HMAC of message_id + url).
  • Does NOT require JWT authentication.
  • Verifies both the signed_token (message-id HMAC) and sig ((message_id, url) HMAC). Rejects non-http/https schemes.
  • Updates outbound_messages.clicked_at if it is NULL (idempotent).
  • Returns 302 Found with Location set to the verified url.

Rate Limiting

  • Both endpoints apply the standard slowapi per-IP limit: 120 req/min/IP.
  • limiter.exempt MUST NOT be used on these routes. The per-IP limit is the only abuse control — do not remove it.

Sourced from docs/features/04_tracking_endpoints in the repo. Edits go through the same review as code.