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