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:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user