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

@@ -0,0 +1,43 @@
{#
HTML body for the admin contact-form notification email.
Deliberately plain — email clients are hostile to CSS. ``message`` is
rendered inside a <pre> so newlines submitted by the visitor are
preserved without us having to manually convert them to <br>s (which
bleach would strip anyway).
Jinja2 autoescape is on for .html templates; every field below is
rendered via ``{{ ... }}`` without ``| safe``, so user input cannot
escape into the DOM.
Context:
- submission_name : str
- submission_email : str
- message : str
- submitted_at : ISO-8601 str
- ip : str
#}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>New contact submission &mdash; Chicken Babies R Us</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
<h1 style="font-size: 20px; margin: 0 0 16px;">New contact submission</h1>
<p style="margin: 0 0 8px;"><strong>Name:</strong> {{ submission_name }}</p>
<p style="margin: 0 0 8px;">
<strong>Email:</strong>
<a href="mailto:{{ submission_email }}" style="color: #5D8AA8;">{{ submission_email }}</a>
</p>
<p style="margin: 0 0 8px;"><strong>Submitted at:</strong> {{ submitted_at }}</p>
<p style="margin: 0 0 16px;"><strong>IP:</strong> {{ ip }}</p>
<h2 style="font-size: 16px; margin: 16px 0 8px;">Message</h2>
<pre style="white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 14px; background: #FAF3E7; padding: 12px 16px; border-radius: 6px; margin: 0;">{{ message }}</pre>
<p style="color: #6b7a80; font-size: 13px; margin-top: 24px;">
Replying to this email will respond directly to the sender
({{ submission_email }}).
</p>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{# Plaintext body for the admin contact-form notification email.
Mirrors the HTML version so clients that reject HTML still get a
readable message. #}New contact submission
======================
Name: {{ submission_name }}
Email: {{ submission_email }}
Submitted at: {{ submitted_at }}
IP: {{ ip }}
Message
-------
{{ message }}
--
Replying to this email will respond directly to the sender
({{ submission_email }}).

View File

@@ -1,18 +1,30 @@
{#
Contact page — Phase 1 version.
Contact page — live Phase 5 form.
The form is deliberately inert: no `method`, no `action`, all inputs
and the submit button carry the `disabled` attribute. A muted note
explains the form is coming soon; if `ADMIN_CONTACT_EMAIL` is set in
the environment we render a `mailto:` link above the form so visitors
still have a way to reach the farm.
POSTs to /contact. Honeypot + hCaptcha + SlowAPI rate-limit protect
the endpoint. Every field carries an id/label pair for a11y and a
maxlength/minlength to match the server-side validator — the HTML5
attributes are a UX hint only, not the security boundary.
Phase 5 replaces this template with a working POST handler, hCaptcha,
honeypot, and rate limiting.
Honeypot:
- The ``website`` field is wrapped in a .visually-hidden container
marked ``aria-hidden="true"`` so assistive tech hides it too.
- It is NOT ``required`` and has ``tabindex="-1"`` so a keyboard
user can't accidentally focus it.
- The server rejects any submission where the field is non-empty.
hCaptcha:
- When ``hcaptcha_site_key`` is truthy the widget div + script tag
render. When empty (dev) we skip them and rely on the dev-mode
fallback in :class:`HCaptchaService`.
Context:
- contact_email : str | None (from settings.admin_contact_email)
- active_nav : "contact"
- contact_email : str | None (from settings.admin_contact_email)
- active_nav : "contact"
- errors : dict[str, str] (field_name -> message)
- form : dict[str, str] (prior submitted values)
- form_error : str | None (top-level error banner)
- hcaptcha_site_key : str | None (rendered when truthy)
#}
{% extends "public/base.html" %}
@@ -32,30 +44,45 @@
{% if contact_email %}
<p class="contact-mailto">
The easiest way to reach us right now is email:
Prefer email? Reach us at
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
</p>
{% else %}
<p class="contact-mailto contact-mailto--muted">
A direct email address will be posted here soon.
</p>
{% endif %}
<p class="contact-form__note" role="note">
Secure contact form coming soon.
</p>
{% if form_error %}
<p class="contact-form__error" role="alert">{{ form_error }}</p>
{% endif %}
<form class="contact-form"
method="POST"
action="/contact"
aria-describedby="contact-form-note">
{# Honeypot — visually hidden + aria-hidden so neither sighted
users nor screen readers encounter it. Bots fill it in and
get silently filed as spam. #}
<div class="visually-hidden" aria-hidden="true">
<label for="contact-website">Website</label>
<input type="text"
id="contact-website"
name="website"
tabindex="-1"
autocomplete="off"
class="contact-hp"
value="">
</div>
{# action="" and no method = form cannot submit. Every input is
disabled so screen readers and the keyboard both respect the
"not-yet-available" state. #}
<form class="contact-form" action="" aria-describedby="contact-form-note" novalidate>
<div class="contact-form__field">
<label for="contact-name">Name</label>
<input type="text"
id="contact-name"
name="name"
autocomplete="name"
disabled>
required
maxlength="80"
value="{{ (form.name if form else '') or '' }}">
{% if errors and errors.name %}
<p class="contact-form__field-error" role="alert">{{ errors.name }}</p>
{% endif %}
</div>
<div class="contact-form__field">
@@ -64,7 +91,12 @@
id="contact-email"
name="email"
autocomplete="email"
disabled>
required
maxlength="254"
value="{{ (form.email if form else '') or '' }}">
{% if errors and errors.email %}
<p class="contact-form__field-error" role="alert">{{ errors.email }}</p>
{% endif %}
</div>
<div class="contact-form__field">
@@ -72,14 +104,31 @@
<textarea id="contact-message"
name="message"
rows="6"
disabled></textarea>
required
minlength="10"
maxlength="4000">{{ (form.message if form else '') or '' }}</textarea>
{% if errors and errors.message %}
<p class="contact-form__field-error" role="alert">{{ errors.message }}</p>
{% endif %}
</div>
{% if hcaptcha_site_key %}
<div class="contact-form__captcha">
<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>
</div>
{% else %}
{# hCaptcha disabled in dev — HCaptchaService returns True. #}
{% endif %}
<div class="contact-form__actions">
<button type="submit" class="btn btn--primary" disabled>
<button type="submit" class="btn btn--primary">
Send message
</button>
</div>
</form>
</article>
{% if hcaptcha_site_key %}
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,31 @@
{#
Contact success page — rendered after a successful POST /contact
OR after a silent spam rejection (honeypot tripped / hCaptcha
failed). Copy MUST stay identical across those branches so a bot
operator can't use the response body to distinguish "we accepted
your message" from "we filed your message under spam".
Context:
- active_nav : "contact"
#}
{% extends "public/base.html" %}
{% block title %}Message sent &mdash; Chicken Babies R Us{% endblock %}
{% block meta_description %}Thanks for reaching out to Chicken Babies R Us.{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Thanks for reaching out</h1>
</header>
<p>
Your message is on its way to Head Hen. We'll get back to you as
soon as the chickens let us.
</p>
<p>
<a class="btn btn--primary" href="/">Back to the home page</a>
</p>
</article>
{% endblock %}