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:
159
app/services/contact.py
Normal file
159
app/services/contact.py
Normal 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,
|
||||
)
|
||||
@@ -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
132
app/services/hcaptcha.py
Normal 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
|
||||
Reference in New Issue
Block a user