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,
)