feat: phase 5 contact form — hCaptcha, honeypot, rate limit, notify

Working /contact POST flow: honeypot → hCaptcha server-verify →
field validation → SlowAPI 3/hr IP rate limit → contact_submissions
row → best-effort Resend notification (Reply-To = submitter) →
generic success page. Spam paths don't persist and render the same
success page (anti-enumeration). Send failures don't break the
request path — the row is already durable.

New services: HCaptchaService (async httpx + dev fallback),
ContactService. EmailService gains send_contact_notification.
Production config validator now requires ADMIN_CONTACT_EMAIL,
HCAPTCHA_SECRET, HCAPTCHA_SITE_KEY. 23 new tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 06:47:06 -05:00
parent 67c848f329
commit d9090f5055
16 changed files with 1671 additions and 39 deletions

159
app/services/contact.py Normal file
View File

@@ -0,0 +1,159 @@
"""Contact-form persistence + notification orchestration.
Thin service wired at app startup:
- :meth:`ContactService.record_submission` — insert a row into
``contact_submissions`` and return a mapped
:class:`ContactSubmission` entity.
- :meth:`ContactService.send_notification` — best-effort pass through
to :class:`EmailService.send_contact_notification`. NEVER raises.
The route handler (``/contact``) short-circuits the spam path (honeypot
tripped or hCaptcha failed) BEFORE calling either method, so anything
that reaches this service has already passed the bot screen. The audit
log is written by the route, not here — keeps this service free of
request-scoped context (ip/ua are persisted on the row itself).
Security notes
--------------
- Parameterized SQL only (sqlalchemy ``text("...:param...")``).
- Datetimes are timezone-aware UTC and serialized via ``.isoformat()``,
matching the rest of the codebase.
- Raw message bodies never flow through logs or audit detail; only
``len(message)`` and a short preview (if any) appear in audit rows,
and that is the route's responsibility.
"""
from __future__ import annotations
from datetime import datetime, timezone
import structlog
from sqlalchemy import Engine, text
from app.config import Settings
from app.models.entities import ContactSubmission
from app.models.mappers import row_to_contact_submission
from app.services.audit import AuditService
from app.services.email import EmailService
_log = structlog.get_logger(__name__)
class ContactService:
"""Persist contact submissions and trigger notification emails."""
def __init__(
self,
engine: Engine,
email: EmailService,
audit: AuditService,
settings: Settings,
) -> None:
"""Store collaborators by reference."""
self._engine: Engine = engine
self._email: EmailService = email
self._audit: AuditService = audit
self._settings: Settings = settings
# ------------------------------------------------------------------
# record_submission
# ------------------------------------------------------------------
def record_submission(
self,
*,
name: str,
email: str,
message: str,
ip: str,
user_agent: str,
) -> ContactSubmission:
"""Insert a ``contact_submissions`` row and return the entity.
All inputs are taken at face value — validation is the route's
responsibility. The mapper guarantees tz-aware UTC on read so
callers can safely format ``submitted_at.isoformat()`` in
downstream emails.
"""
submitted_at = datetime.now(timezone.utc)
submitted_iso = submitted_at.isoformat()
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO contact_submissions"
" (name, email, message, ip, user_agent,"
" submitted_at, handled)"
" VALUES (:name, :email, :message, :ip, :user_agent,"
" :submitted_at, 0)"
),
{
"name": name,
"email": email,
"message": message,
"ip": ip or "",
"user_agent": user_agent or "",
"submitted_at": submitted_iso,
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, name, email, message, ip, user_agent,"
" submitted_at, handled"
" FROM contact_submissions WHERE id = :id"
),
{"id": new_id},
).mappings().first()
# ``row`` is always present — we just inserted it in the same
# transaction — but we type-guard defensively so mypy is happy.
if row is None: # pragma: no cover — impossible path
raise RuntimeError(
"contact_submission row disappeared between insert and select"
)
return row_to_contact_submission(row)
# ------------------------------------------------------------------
# send_notification
# ------------------------------------------------------------------
def send_notification(self, submission: ContactSubmission) -> None:
"""Dispatch the admin notification email; never raise.
Best-effort: any exception is caught and logged under
``contact_send_failed`` so the caller can still render the
success page. The submission row is already persisted when
this method is invoked, so the admin has a DB trail even if
the outbound email fails.
Silently no-ops when ``ADMIN_CONTACT_EMAIL`` is unset. That
only happens in dev (production validator requires the field)
and is logged for visibility.
"""
to = self._settings.admin_contact_email
if not to:
_log.info(
"contact_notification_skipped_no_recipient",
reason="admin_contact_email_unset",
)
return
try:
self._email.send_contact_notification(
to=to,
submission_name=submission.name,
submission_email=submission.email,
message=submission.message,
submitted_at=submission.submitted_at,
ip=submission.ip,
)
except Exception: # noqa: BLE001
# The email service is itself defensive (it swallows Resend
# errors internally). This belt-and-braces catch protects
# the request path from any unexpected programming error.
_log.exception(
"contact_send_failed",
submission_id=submission.id,
)

View File

@@ -52,6 +52,84 @@ class EmailService:
self._settings: Settings = settings
self._templates: Jinja2Templates = templates
def send_contact_notification(
self,
*,
to: str,
submission_name: str,
submission_email: str,
message: str,
submitted_at: datetime,
ip: str,
) -> None:
"""Send the contact-form notification email to the admin inbox.
Behavior
--------
- If ``settings.resend_api_key`` (or ``resend_from``) is falsy,
log a ``contact_notification_dev_fallback`` event at INFO and
return. Dev convenience only — the production validator
refuses to boot without a Resend key.
- Otherwise render both template bodies, set ``Reply-To`` to the
submitter's email (so Head Hen just hits reply), and dispatch
via Resend. Transport errors are logged (``contact_notification_failed``)
and never re-raised — the request path must always complete
with the generic success page.
Subject format (fixed) matches the Phase 5 brief:
``"New contact submission from {submission_name}"``.
"""
ctx = {
"submission_name": submission_name,
"submission_email": submission_email,
"message": message,
"submitted_at": submitted_at.isoformat(),
"ip": ip or "",
}
html_body = self._render("emails/contact_notification.html", ctx)
text_body = self._render("emails/contact_notification.txt", ctx)
api_key: Optional[str] = self._settings.resend_api_key
sender: Optional[str] = self._settings.resend_from
# Dev fallback — log enough to confirm the flow reached the
# email layer without echoing the whole message body at INFO.
if not api_key or not sender:
_log.info(
"contact_notification_dev_fallback",
to=to,
submission_name=submission_name,
submission_email=submission_email,
message_length=len(message or ""),
)
return
subject = f"New contact submission from {submission_name}"
try:
import resend # type: ignore[import-untyped]
resend.api_key = api_key
resend.Emails.send(
{
"from": sender,
"to": to,
"subject": subject,
"html": html_body,
"text": text_body,
# Reply-To lets Head Hen reply directly from her
# inbox without exposing the From: address to
# public rewriting.
"reply_to": submission_email,
}
)
_log.info("contact_notification_sent", to=to)
except Exception: # noqa: BLE001
# Never raise from the request path — see docstring.
_log.exception(
"contact_notification_failed",
to=to,
)
def send_magic_link(
self,
*,

132
app/services/hcaptcha.py Normal file
View File

@@ -0,0 +1,132 @@
"""hCaptcha server-side verification.
Wraps the hCaptcha ``siteverify`` endpoint with a dev-mode bypass so
local work does not require a real site-key / secret pair.
Security and UX rules
---------------------
- **Never raise from the request path.** The caller (``/contact``)
treats a ``False`` return as "reject the submission as spam" and a
``True`` return as "continue to validation". Any network hiccup /
non-200 / malformed-JSON surfaces as ``False`` — it is safer to drop
a legitimate visitor's submission than to accept a spam flood if
hCaptcha is temporarily unavailable.
- **Dev fallback:** when ``settings.hcaptcha_secret`` is falsy we log a
structured ``hcaptcha_dev_fallback`` event at INFO and return
``True``. The production config validator refuses to boot without a
secret, so this path only runs locally.
- Raw tokens are single-use, short-lived, and bound to the submitter's
session — we do not persist or log them.
"""
from __future__ import annotations
from typing import Any, Optional
import httpx
import structlog
from app.config import Settings
_log = structlog.get_logger(__name__)
# hCaptcha's server-side verify endpoint. Documented at
# https://docs.hcaptcha.com/#verify-the-user-response-server-side.
_SITEVERIFY_URL: str = "https://hcaptcha.com/siteverify"
# Bounded network timeout. hCaptcha's own advice is 5s; longer would
# block the request path unnecessarily under an incident.
_TIMEOUT_SECONDS: float = 5.0
class HCaptchaService:
"""Verify hCaptcha responses server-side.
Usage
-----
``await hcaptcha_service.verify(token, remote_ip)`` returns a bool.
``True`` means "treat the request as human"; ``False`` means "reject".
"""
def __init__(self, settings: Settings) -> None:
"""Store settings by reference so rotation at runtime works."""
self._settings: Settings = settings
async def verify(self, token: str, remote_ip: str) -> bool:
"""Verify ``token`` against hCaptcha; return True on success.
Behavior
--------
- If ``settings.hcaptcha_secret`` is falsy → log
``hcaptcha_dev_fallback`` and return ``True`` (dev).
- Otherwise POST to ``/siteverify`` with a 5s timeout.
- Non-200, network error, malformed JSON, or ``success=False``
all return ``False``.
The method never raises — errors are logged and converted to
``False`` so the caller can render the generic thank-you page
without leaking internal state.
"""
secret: Optional[str] = self._settings.hcaptcha_secret
if not secret:
# Dev bypass. The config validator forbids this in
# production, so reaching this branch is always a dev/test
# condition and safe to trust.
_log.info("hcaptcha_dev_fallback", remote_ip=remote_ip or "")
return True
payload = {
"secret": secret,
"response": token or "",
"remoteip": remote_ip or "",
}
data = await self._post_siteverify(payload)
if data is None:
# Timeout / transport error / non-200 / parse failure —
# already logged by the helper. Treat as spam.
return False
success = bool(data.get("success", False))
if not success:
# Log error codes if present so operators can diagnose
# misconfigured keys without exposing the token.
_log.info(
"hcaptcha_verify_failed",
error_codes=list(data.get("error-codes") or []),
)
return success
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
async def _post_siteverify(self, payload: dict) -> Optional[dict[str, Any]]:
"""POST to hCaptcha's verify endpoint and return parsed JSON.
Returns ``None`` on any failure (timeout, non-200, transport
error, malformed JSON). Kept separate from :meth:`verify` so
tests can monkeypatch the HTTP boundary without touching the
decision logic.
"""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
resp = await client.post(_SITEVERIFY_URL, data=payload)
except httpx.HTTPError:
# Network error / timeout. Do not raise; do not log the
# payload (contains the secret).
_log.exception("hcaptcha_request_failed")
return None
if resp.status_code != 200:
_log.info(
"hcaptcha_non_200",
status_code=resp.status_code,
)
return None
try:
return resp.json()
except ValueError:
_log.exception("hcaptcha_malformed_json")
return None