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

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