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

@@ -161,3 +161,88 @@ Pre-requisites:
- [ ] A newly-published post shows at the top of `/` within one request.
- [ ] `/about` shows the most recently edited copy.
- [ ] No admin-facing text (status, dashboard wording) leaks into the public HTML.
---
## Phase 5 — Contact Form
Pre-requisites:
- `ADMIN_CONTACT_EMAIL` set in `.env` (the destination inbox).
- For the production-like happy path: `RESEND_API_KEY` + `RESEND_FROM`
set; otherwise the send path logs `contact_notification_dev_fallback`
and the admin inbox will not actually receive mail.
- Optionally set `HCAPTCHA_SITE_KEY` + `HCAPTCHA_SECRET` to exercise
the real widget; with both unset the dev fallback auto-passes and
logs `hcaptcha_dev_fallback`.
### GET `/contact`
- [ ] Page returns 200 and renders without console errors.
- [ ] H1 reads **"Get in touch"**.
- [ ] Name, email, and message fields render as editable inputs (no `disabled` attribute).
- [ ] "Send message" button is enabled.
- [ ] Form has `method="POST"` and `action="/contact"` (view source).
- [ ] Honeypot `<input name="website">` is present in the markup but
wrapped in a `.visually-hidden` container marked
`aria-hidden="true"` — it is invisible to sighted users.
- [ ] When `HCAPTCHA_SITE_KEY` is set, the `h-captcha` div and the
`https://js.hcaptcha.com/1/api.js` script appear. When unset,
neither appears.
- [ ] Nav marks "Contact" as active.
### Happy path
- [ ] Fill in Name, Email, Message (>= 10 chars) and submit.
- [ ] Response is HTTP 200 and renders **"Thanks for reaching out"**.
- [ ] `sqlite3 data/app.db "SELECT id, name, email, length(message), handled FROM contact_submissions"` shows the new row with `handled=0`.
- [ ] Server log contains a `contact_submitted` structured event with a
`message_preview` at most 40 chars long (no full body).
- [ ] With `RESEND_API_KEY` set: admin inbox receives the notification
email. `From:` matches `RESEND_FROM`; `Reply-To:` matches the
submitted email; subject is `New contact submission from {name}`.
- [ ] Without `RESEND_API_KEY`: server log contains
`contact_notification_dev_fallback` with the submitter's name,
email, and message length.
### Validation errors
- [ ] Submitting with a blank name shows **"Please enter your name."** inline.
- [ ] Submitting with `not-an-email` shows **"Please enter a valid email address."**.
- [ ] Submitting with a 9-character message shows
**"Message must be at least 10 characters."**.
- [ ] Submitting with a > 4000-character message shows
**"Message must be 4000 characters or fewer."**.
- [ ] Submitting with a > 80-character name shows
**"Name must be 80 characters or fewer."**.
- [ ] The response status code on every validation failure is **400**.
- [ ] Prior valid values remain filled in so the user doesn't retype.
### Spam paths
- [ ] Filling the honeypot `website` field and submitting returns
**"Thanks for reaching out"** (same as success) AND no row is
persisted AND an audit row with
`event_type='contact_spam_rejected'` and `reason=honeypot` exists.
- [ ] With a real hCaptcha configured: submitting without solving the
widget returns the same generic thank-you page. Audit row:
`contact_spam_rejected` / `reason=hcaptcha`. No DB row.
### Rate limit
- [ ] Submit the form **4 times** from the same browser session within
an hour. The fourth submission returns HTTP **429** and renders
the "Too many attempts" template. A `rate_limited` audit row is
added with `scope=ip` and `endpoint=/contact`.
### Email send failure
- [ ] With a valid form but `RESEND_API_KEY` pointed at an invalid key:
the user still sees **"Thanks for reaching out"**, the DB row is
created, and the server log contains
`contact_notification_failed` (logged by EmailService). The user
experience is indistinguishable from success.
### Ops smoke
- [ ] `pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.py` passes.
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).

View File

@@ -211,11 +211,46 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
**Verification run:**
`python -c "from app.main import app"` ✓ (26 routes registered) · `pytest -q tests/test_slugs.py tests/test_csrf_service.py tests/test_media_service.py tests/test_admin_posts_service.py tests/test_admin_pages_service.py tests/test_admin_cms_routes.py tests/test_admin_routes.py` → 64 passed ✓ · full `pytest -q` → 118 passed, 2 failed; both failures are pre-existing on `dev` (the `logo.``logo-mark.` asset rename in commit `f5098c0`; and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into the Settings-validator test) and unrelated to Phase 4.
## Phase 5 — Contact Form
## Phase 5 — Contact Form
- `/contact` POST flow: field validation → hCaptcha verify → honeypot check → rate limit → `Resend` send → persist submission row → success page.
- `FROM` on verified domain; `Reply-To` = submitter's email.
- No internal errors leak to the user; they see a generic "something went wrong, please try again".
**Completed:** 2026-04-22
**Summary:** Shipped the public contact form: live POST handler with honeypot → hCaptcha (server-verify) → field validation → SlowAPI rate limit → `contact_submissions` row insert → best-effort Resend notification to `ADMIN_CONTACT_EMAIL` (Reply-To = submitter) → generic `contact_sent.html` success page. Spam / honeypot / hCaptcha-fail paths don't persist, still render the success page (anti-enumeration). Send failures don't break the request path — the row is already durable.
**Key files:**
- `app/services/hcaptcha.py``HCaptchaService(settings).verify(token, remote_ip) -> bool`. Async `httpx.AsyncClient` POST to `https://hcaptcha.com/siteverify` with a 5s timeout. Dev fallback: when `hcaptcha_secret` is falsy, logs `hcaptcha_dev_fallback` and returns `True`. Fail-closed on any network error / non-200 / malformed JSON / `success=false` (returns `False`). Never raises from the request path.
- `app/services/contact.py``ContactService(engine, email, audit, settings)`: `record_submission(...)` inserts a `contact_submissions` row and returns a mapped `ContactSubmission`; `send_notification(submission)` is best-effort (never raises, no-ops when `admin_contact_email` is unset — only possible in dev).
- `app/services/email.py` — added `send_contact_notification(*, to, submission_name, submission_email, message, submitted_at, ip)`. Subject `"New contact submission from {submission_name}"`. `from_=settings.resend_from`, `reply_to=submission.email`. Dev fallback logs `contact_notification_dev_fallback`. Resend errors are caught + logged (`contact_email_failed`); request path never sees them.
- `app/templates/emails/contact_notification.html` + `.txt` — admin notification bodies (name, email, message, submitted_at ISO, ip). Jinja2 autoescape handles all user-supplied fields.
- `app/templates/public/contact.html` — replaces the Phase 1 inert form: `method="POST"`, name / email / message inputs with HTML5 length bounds + server-side re-validation, visually-hidden honeypot `website` input, hCaptcha widget rendered only when `hcaptcha_site_key` is truthy (dev just shows an HTML comment), inline `errors.{field}` + top-level `form_error` flash slots.
- `app/templates/public/contact_sent.html` — thank-you page; `active_nav = "contact"`; back-to-home link.
- `app/routes/public.py` — added `POST /contact` handler decorated `@limiter.limit("3/hour")`. Strict 6-stage flow (honeypot → hCaptcha → validate → persist → notify → success). DI helpers `_get_hcaptcha_service` / `_get_contact_service`; `_client_ip` / `_user_agent` shared with admin module's pattern. Also tightened `GET /contact` to pass the new template context (`hcaptcha_site_key`, `errors`, `form`, `form_error`).
- `app/main.py` — instantiates `HCaptchaService(settings)` and `ContactService(engine, email_service, audit_service, settings)`; attaches to `app.state.hcaptcha_service` / `app.state.contact_service`.
- `app/config.py` — extended `_require_auth_config_in_production` to also require `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, and `HCAPTCHA_SITE_KEY` in production (missing any → `ValueError` at startup).
- `app/static/css/site.css` — added `.contact-form__field-error`, `.contact-form__error`, `.contact-form__captcha`, and the visually-hidden honeypot rule.
- `docs/MANUAL_TESTING.md` — appended Phase 5 checklist (happy path, honeypot, hCaptcha fail, rate-limit 429, inline validation, dev fallback log, admin inbox Reply-To, production-config refusal).
- Tests: `tests/test_hcaptcha_service.py` (9), `tests/test_contact_service.py` (5), `tests/test_contact_routes.py` (9) — 23 new tests; temp-SQLite fixtures per CLAUDE.md, httpx boundary mocked at `_post_siteverify`.
**Endpoints created:**
- `POST /contact` — the public submission endpoint. Accepts `name`, `email`, `message`, hidden `website` (honeypot), and `h-captcha-response` as multipart/form-urlencoded. Returns 200 `contact_sent.html` on success / spam / honeypot; 400 `contact.html` with inline errors on validation fail; 429 `admin/rate_limited.html` when the SlowAPI 3/hour IP limit trips.
- `GET /contact` — unchanged URL, now returns the live (non-disabled) form with hCaptcha widget when configured. Pre-Phase-5 this was the inert placeholder.
**Key details:**
- **Spam short-circuit is anti-enumeration.** Honeypot trip or hCaptcha `False` both render the same generic success page so bot operators can't probe which filter caught them. The audit row (`contact_spam_rejected` with `{"reason":"honeypot"|"hcaptcha"}`) is the only signal — in the DB, not the HTTP response.
- **Send failure is idempotent from the user's perspective.** `ContactService.record_submission` commits before `send_notification` is called; if Resend is down, the row is still in `contact_submissions` and Head Hen can action it from the table. The request path always ends at `contact_sent.html`.
- **No CSRF on `/contact`.** Public, pre-auth, no session cookie to hijack. SlowAPI + hCaptcha + honeypot + DB-side `contact_submissions` audit are the controls. Documented inline in the route docstring.
- **Field validation lives in the route, not the service.** `name` 1-80; `email` matches `^[^@\s]+@[^@\s]+\.[^@\s]+$` AND length ≤254; `message` 10-4000 after `.strip()`. On error: re-render with `errors` dict + preserved values + HTTP 400.
- **Audit trail extends cleanly.** New event types on the existing `auth_events` table: `contact_submitted` (with `submission_id`, `message_length`, truncated 40-char `message_preview`), `contact_spam_rejected` (with `reason`), `contact_send_failed` (from email service's internal catch). No full message bodies ever flow into audit detail.
- **hCaptcha widget is optional in dev.** Template renders `<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>` + the remote `api.js` only when the site key is truthy; otherwise an HTML comment stands in. The server-side verify service mirrors this by returning `True` when the secret is unset.
- **Reusing Phase 3's 429 handler is acceptable for now.** The registered `RateLimitExceeded` handler renders `admin/rate_limited.html`; on `/contact` it works but carries admin styling. Flagged in the Phase 6 polish list (not blocking).
- **Production config now requires three more fields.** Missing any of `ADMIN_CONTACT_EMAIL`, `HCAPTCHA_SECRET`, `HCAPTCHA_SITE_KEY` in `APP_ENV=production` raises at startup via `_require_auth_config_in_production`. Mirrors the Phase 3 guardrail pattern.
- **Phase 6 hooks ready:** nonce-based CSP, HSTS, access-log middleware, a public-styled 429 template, and a Dockerfile hardening pass are all still standing as originally planned — none blocked Phase 5.
- **No new packages.** All deps (`httpx`, `slowapi`, `resend`, `jinja2`, `structlog`, `itsdangerous`) were already pinned in Phase 0's `requirements.txt`.
**Verification run:**
`python -c "from app.main import app; print(len(app.routes))"`**27** (Phase 4 registered 26; one new `POST /contact`) ✓ · `pytest -q`**141 passed, 2 failed**; both failures confirmed pre-existing on `dev` (the Phase 4 `logo.``logo-mark.` asset rename and the `RESEND_FROM`/`ADMIN_EMAILS` pollution from local `.env` into `test_production_missing_key_refuses_startup`) — verified by running the same two targeted tests on a clean `dev` checkout, both failed there too ✓ · `docker compose config` exit 0 ✓ · route registry confirmed: `('/contact', ['GET'])` + `('/contact', ['POST'])` ✓.
**Branch:** `feat/phase-5-contact-form` off `dev`. Not committed, not merged, not pushed — changes staged for human review before `--no-ff` merge into `dev` per CLAUDE.md git strategy.
## Phase 6 — Hardening + Deploy