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

@@ -183,11 +183,22 @@ class Settings(BaseSettings):
missing.append("RESEND_FROM")
if not self.admin_emails or not self.admin_emails_list:
missing.append("ADMIN_EMAILS")
# Phase 5: the public contact form needs a destination inbox
# and a real hCaptcha key pair. Missing any of these would
# either drop submissions on the floor (no recipient) or open
# the form to unverified bot traffic (no captcha), so we
# enforce them at boot.
if not self.admin_contact_email:
missing.append("ADMIN_CONTACT_EMAIL")
if not self.hcaptcha_secret:
missing.append("HCAPTCHA_SECRET")
if not self.hcaptcha_site_key:
missing.append("HCAPTCHA_SITE_KEY")
if missing:
raise ValueError(
"Production configuration is missing required values: "
+ ", ".join(missing)
+ ". These are needed for magic-link admin auth."
+ ". These are needed for admin auth and the contact form."
)
return self

View File

@@ -66,8 +66,10 @@ from app.services.admin_pages import AdminPagesService
from app.services.admin_posts import AdminPostsService
from app.services.audit import AuditService
from app.services.auth import AuthService
from app.services.contact import ContactService
from app.services.csrf import CSRF_COOKIE_NAME, CSRFService
from app.services.email import EmailService
from app.services.hcaptcha import HCaptchaService
from app.services.markdown import MarkdownService
from app.services.media import MediaService
from app.services.pages import PageService
@@ -236,6 +238,20 @@ def create_app() -> FastAPI:
application.state.session_service = session_service
application.state.auth_service = auth_service
# --- Phase 5 wiring -----------------------------------------------------
# Public contact form. HCaptchaService has no DB; ContactService
# persists via the shared engine and delegates email to the
# existing EmailService so the dev-fallback semantics match.
hcaptcha_service = HCaptchaService(settings)
contact_service = ContactService(
engine=engine,
email=email_service,
audit=audit_service,
settings=settings,
)
application.state.hcaptcha_service = hcaptcha_service
application.state.contact_service = contact_service
# --- Phase 4 wiring -----------------------------------------------------
# CSRF signer: separate salt so a session cookie never validates
# as a CSRF token (domain separation via salt).

View File

@@ -7,7 +7,9 @@ Phase 2 scope:
- ``GET /about`` — DB-backed; loads the ``about`` row from the
``pages`` table via :class:`PageService` and
renders its ``body_html_cached`` directly.
- ``GET /contact`` — inert contact form UI + optional ``mailto:`` link.
- ``GET /contact`` — form UI + optional ``mailto:`` link.
- ``POST /contact`` — Phase 5 live submission endpoint (hCaptcha +
honeypot + rate-limit + persist + notify).
- ``GET /shop`` — "Coming soon" card.
Every handler is thin: it resolves its dependencies, calls any service
@@ -17,16 +19,21 @@ is constructed in Python.
from __future__ import annotations
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.models.entities import Page
from app.models.posts import PostSummary
from app.services.contact import ContactService
from app.services.hcaptcha import HCaptchaService
from app.services.pages import PageService, get_page_service
from app.services.posts import PostService, get_post_service
from app.services.rate_limit import limiter
# Module-level router. Mounted without a prefix by ``app.main.create_app``
@@ -37,6 +44,19 @@ router: APIRouter = APIRouter(tags=["public"])
_log = structlog.get_logger(__name__)
# Same loose email shape used on the admin login form. Intentionally
# permissive: the mapper layer / Resend does the real work; we only
# want to reject obvious junk before hitting the service.
_EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Field length guards — matched on the server regardless of the
# HTML5 attributes the template emits.
_NAME_MAX: int = 80
_EMAIL_MAX: int = 254
_MESSAGE_MIN: int = 10
_MESSAGE_MAX: int = 4000
def get_templates(request: Request) -> Jinja2Templates:
"""Return the shared :class:`Jinja2Templates` instance.
@@ -49,6 +69,31 @@ def get_templates(request: Request) -> Jinja2Templates:
return request.app.state.templates
def _get_hcaptcha_service(request: Request) -> HCaptchaService:
"""Return the app-scoped :class:`HCaptchaService`."""
return request.app.state.hcaptcha_service
def _get_contact_service(request: Request) -> ContactService:
"""Return the app-scoped :class:`ContactService`."""
return request.app.state.contact_service
def _client_ip(request: Request) -> str:
"""Best-effort client IP from the request.
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
middleware Uvicorn installs with ``--proxy-headers``; that means
``request.client.host`` is the real client IP.
"""
return request.client.host if request.client else ""
def _user_agent(request: Request) -> str:
"""Return the submitted User-Agent header (empty string if missing)."""
return request.headers.get("user-agent", "")
@router.get("/", response_class=HTMLResponse, summary="Blog index")
def home(
request: Request,
@@ -110,12 +155,12 @@ def contact(
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
) -> HTMLResponse:
"""Render the inert contact page.
"""Render the contact page.
The form fields are marked ``disabled`` and the form has no ``method``
attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured
the template renders a ``mailto:`` link so visitors still have a way
to reach the farm before Phase 5 wires up the real POST flow.
Phase 5 wires the form up to a real POST handler; this GET now
returns the blank form. ``ADMIN_CONTACT_EMAIL`` is still surfaced
as a secondary ``mailto:`` link for visitors who prefer their own
inbox over the form.
"""
return templates.TemplateResponse(
request,
@@ -126,10 +171,176 @@ def contact(
# case. We pass the value through settings so tests can
# override it without touching environment variables.
"contact_email": settings.admin_contact_email,
"hcaptcha_site_key": settings.hcaptcha_site_key,
"errors": {},
"form": {"name": "", "email": "", "message": ""},
"form_error": None,
},
)
@router.post("/contact", summary="Submit the contact form")
@limiter.limit("3/hour")
async def contact_submit(
request: Request,
name: str = Form(default=""),
email: str = Form(default=""),
message: str = Form(default=""),
website: str = Form(default=""),
templates: Jinja2Templates = Depends(get_templates),
settings: Settings = Depends(get_settings),
hcaptcha: HCaptchaService = Depends(_get_hcaptcha_service),
contact_service: ContactService = Depends(_get_contact_service),
) -> Response:
"""Handle the contact-form POST.
Flow (strict order — each stage short-circuits):
1. Honeypot: if ``website`` is non-empty → silent spam. Audit
``contact_spam_rejected`` with reason ``honeypot``; render the
generic success page so the bot operator cannot tell they were
filtered.
2. hCaptcha: call :meth:`HCaptchaService.verify`. On False → audit
``contact_spam_rejected`` with reason ``hcaptcha``; render the
success page. Same anti-enumeration rationale.
3. Validate fields. On any error → re-render the form with inline
error messages + HTTP 400 + submitted values preserved.
4. Persist: :meth:`ContactService.record_submission`. After this
point the message is durable even if the email fails.
5. Notify: :meth:`ContactService.send_notification`. Best-effort;
if it raises (it never should — service is defensive) we still
fall through to the success page.
6. Render ``public/contact_sent.html``.
CSRF is NOT required here: the endpoint is pre-auth, has no
session to hijack, and adding a cookie dance would break the
first-contact UX. SlowAPI + hCaptcha + honeypot are the controls.
"""
ip = _client_ip(request)
ua = _user_agent(request)
audit = request.app.state.audit_service
# --- Stage 1: honeypot ------------------------------------------------
if (website or "").strip():
audit.record(
"contact_spam_rejected",
ip=ip,
user_agent=ua,
detail={"reason": "honeypot"},
)
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
# --- Stage 2: hCaptcha ------------------------------------------------
token = (
(request.headers.get("h-captcha-response") or "")
or ""
)
# hCaptcha's widget posts the token as a form field named
# ``h-captcha-response``. Starlette's ``Request.form()`` is async;
# re-reading it here is fine because FastAPI caches the parsed form
# for the lifetime of the request.
form_data = await request.form()
captcha_token = str(form_data.get("h-captcha-response", token) or "")
ok = await hcaptcha.verify(captcha_token, ip)
if not ok:
audit.record(
"contact_spam_rejected",
ip=ip,
user_agent=ua,
detail={"reason": "hcaptcha"},
)
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
# --- Stage 3: validation ---------------------------------------------
clean_name = (name or "").strip()
clean_email = (email or "").strip()
clean_message = (message or "").strip()
errors: dict[str, str] = {}
if not clean_name:
errors["name"] = "Please enter your name."
elif len(clean_name) > _NAME_MAX:
errors["name"] = f"Name must be {_NAME_MAX} characters or fewer."
if not clean_email:
errors["email"] = "Please enter your email address."
elif len(clean_email) > _EMAIL_MAX or not _EMAIL_RE.match(clean_email):
errors["email"] = "Please enter a valid email address."
if not clean_message:
errors["message"] = "Please include a message."
elif len(clean_message) < _MESSAGE_MIN:
errors["message"] = (
f"Message must be at least {_MESSAGE_MIN} characters."
)
elif len(clean_message) > _MESSAGE_MAX:
errors["message"] = (
f"Message must be {_MESSAGE_MAX} characters or fewer."
)
if errors:
return templates.TemplateResponse(
request,
"public/contact.html",
{
"active_nav": "contact",
"contact_email": settings.admin_contact_email,
"hcaptcha_site_key": settings.hcaptcha_site_key,
"errors": errors,
"form": {
"name": clean_name,
"email": clean_email,
"message": clean_message,
},
"form_error": None,
},
status_code=400,
)
# --- Stage 4: persist -------------------------------------------------
submission = contact_service.record_submission(
name=clean_name,
email=clean_email,
message=clean_message,
ip=ip,
user_agent=ua,
)
# Audit the successful submission. Store the message length + a
# short preview so on-call can tell whether a flood is substantive
# without exposing full bodies in the audit log.
audit.record(
"contact_submitted",
email=clean_email,
ip=ip,
user_agent=ua,
detail={
"submission_id": submission.id,
"message_length": len(clean_message),
"message_preview": clean_message[:40],
},
)
# --- Stage 5: notify --------------------------------------------------
contact_service.send_notification(submission)
# --- Stage 6: success page -------------------------------------------
return templates.TemplateResponse(
request,
"public/contact_sent.html",
{"active_nav": "contact"},
)
@router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder")
def shop(
request: Request,

159
app/services/contact.py Normal file
View File

@@ -0,0 +1,159 @@
"""Contact-form persistence + notification orchestration.
Thin service wired at app startup:
- :meth:`ContactService.record_submission` — insert a row into
``contact_submissions`` and return a mapped
:class:`ContactSubmission` entity.
- :meth:`ContactService.send_notification` — best-effort pass through
to :class:`EmailService.send_contact_notification`. NEVER raises.
The route handler (``/contact``) short-circuits the spam path (honeypot
tripped or hCaptcha failed) BEFORE calling either method, so anything
that reaches this service has already passed the bot screen. The audit
log is written by the route, not here — keeps this service free of
request-scoped context (ip/ua are persisted on the row itself).
Security notes
--------------
- Parameterized SQL only (sqlalchemy ``text("...:param...")``).
- Datetimes are timezone-aware UTC and serialized via ``.isoformat()``,
matching the rest of the codebase.
- Raw message bodies never flow through logs or audit detail; only
``len(message)`` and a short preview (if any) appear in audit rows,
and that is the route's responsibility.
"""
from __future__ import annotations
from datetime import datetime, timezone
import structlog
from sqlalchemy import Engine, text
from app.config import Settings
from app.models.entities import ContactSubmission
from app.models.mappers import row_to_contact_submission
from app.services.audit import AuditService
from app.services.email import EmailService
_log = structlog.get_logger(__name__)
class ContactService:
"""Persist contact submissions and trigger notification emails."""
def __init__(
self,
engine: Engine,
email: EmailService,
audit: AuditService,
settings: Settings,
) -> None:
"""Store collaborators by reference."""
self._engine: Engine = engine
self._email: EmailService = email
self._audit: AuditService = audit
self._settings: Settings = settings
# ------------------------------------------------------------------
# record_submission
# ------------------------------------------------------------------
def record_submission(
self,
*,
name: str,
email: str,
message: str,
ip: str,
user_agent: str,
) -> ContactSubmission:
"""Insert a ``contact_submissions`` row and return the entity.
All inputs are taken at face value — validation is the route's
responsibility. The mapper guarantees tz-aware UTC on read so
callers can safely format ``submitted_at.isoformat()`` in
downstream emails.
"""
submitted_at = datetime.now(timezone.utc)
submitted_iso = submitted_at.isoformat()
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO contact_submissions"
" (name, email, message, ip, user_agent,"
" submitted_at, handled)"
" VALUES (:name, :email, :message, :ip, :user_agent,"
" :submitted_at, 0)"
),
{
"name": name,
"email": email,
"message": message,
"ip": ip or "",
"user_agent": user_agent or "",
"submitted_at": submitted_iso,
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, name, email, message, ip, user_agent,"
" submitted_at, handled"
" FROM contact_submissions WHERE id = :id"
),
{"id": new_id},
).mappings().first()
# ``row`` is always present — we just inserted it in the same
# transaction — but we type-guard defensively so mypy is happy.
if row is None: # pragma: no cover — impossible path
raise RuntimeError(
"contact_submission row disappeared between insert and select"
)
return row_to_contact_submission(row)
# ------------------------------------------------------------------
# send_notification
# ------------------------------------------------------------------
def send_notification(self, submission: ContactSubmission) -> None:
"""Dispatch the admin notification email; never raise.
Best-effort: any exception is caught and logged under
``contact_send_failed`` so the caller can still render the
success page. The submission row is already persisted when
this method is invoked, so the admin has a DB trail even if
the outbound email fails.
Silently no-ops when ``ADMIN_CONTACT_EMAIL`` is unset. That
only happens in dev (production validator requires the field)
and is logged for visibility.
"""
to = self._settings.admin_contact_email
if not to:
_log.info(
"contact_notification_skipped_no_recipient",
reason="admin_contact_email_unset",
)
return
try:
self._email.send_contact_notification(
to=to,
submission_name=submission.name,
submission_email=submission.email,
message=submission.message,
submitted_at=submission.submitted_at,
ip=submission.ip,
)
except Exception: # noqa: BLE001
# The email service is itself defensive (it swallows Resend
# errors internally). This belt-and-braces catch protects
# the request path from any unexpected programming error.
_log.exception(
"contact_send_failed",
submission_id=submission.id,
)

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

132
app/services/hcaptcha.py Normal file
View File

@@ -0,0 +1,132 @@
"""hCaptcha server-side verification.
Wraps the hCaptcha ``siteverify`` endpoint with a dev-mode bypass so
local work does not require a real site-key / secret pair.
Security and UX rules
---------------------
- **Never raise from the request path.** The caller (``/contact``)
treats a ``False`` return as "reject the submission as spam" and a
``True`` return as "continue to validation". Any network hiccup /
non-200 / malformed-JSON surfaces as ``False`` — it is safer to drop
a legitimate visitor's submission than to accept a spam flood if
hCaptcha is temporarily unavailable.
- **Dev fallback:** when ``settings.hcaptcha_secret`` is falsy we log a
structured ``hcaptcha_dev_fallback`` event at INFO and return
``True``. The production config validator refuses to boot without a
secret, so this path only runs locally.
- Raw tokens are single-use, short-lived, and bound to the submitter's
session — we do not persist or log them.
"""
from __future__ import annotations
from typing import Any, Optional
import httpx
import structlog
from app.config import Settings
_log = structlog.get_logger(__name__)
# hCaptcha's server-side verify endpoint. Documented at
# https://docs.hcaptcha.com/#verify-the-user-response-server-side.
_SITEVERIFY_URL: str = "https://hcaptcha.com/siteverify"
# Bounded network timeout. hCaptcha's own advice is 5s; longer would
# block the request path unnecessarily under an incident.
_TIMEOUT_SECONDS: float = 5.0
class HCaptchaService:
"""Verify hCaptcha responses server-side.
Usage
-----
``await hcaptcha_service.verify(token, remote_ip)`` returns a bool.
``True`` means "treat the request as human"; ``False`` means "reject".
"""
def __init__(self, settings: Settings) -> None:
"""Store settings by reference so rotation at runtime works."""
self._settings: Settings = settings
async def verify(self, token: str, remote_ip: str) -> bool:
"""Verify ``token`` against hCaptcha; return True on success.
Behavior
--------
- If ``settings.hcaptcha_secret`` is falsy → log
``hcaptcha_dev_fallback`` and return ``True`` (dev).
- Otherwise POST to ``/siteverify`` with a 5s timeout.
- Non-200, network error, malformed JSON, or ``success=False``
all return ``False``.
The method never raises — errors are logged and converted to
``False`` so the caller can render the generic thank-you page
without leaking internal state.
"""
secret: Optional[str] = self._settings.hcaptcha_secret
if not secret:
# Dev bypass. The config validator forbids this in
# production, so reaching this branch is always a dev/test
# condition and safe to trust.
_log.info("hcaptcha_dev_fallback", remote_ip=remote_ip or "")
return True
payload = {
"secret": secret,
"response": token or "",
"remoteip": remote_ip or "",
}
data = await self._post_siteverify(payload)
if data is None:
# Timeout / transport error / non-200 / parse failure —
# already logged by the helper. Treat as spam.
return False
success = bool(data.get("success", False))
if not success:
# Log error codes if present so operators can diagnose
# misconfigured keys without exposing the token.
_log.info(
"hcaptcha_verify_failed",
error_codes=list(data.get("error-codes") or []),
)
return success
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
async def _post_siteverify(self, payload: dict) -> Optional[dict[str, Any]]:
"""POST to hCaptcha's verify endpoint and return parsed JSON.
Returns ``None`` on any failure (timeout, non-200, transport
error, malformed JSON). Kept separate from :meth:`verify` so
tests can monkeypatch the HTTP boundary without touching the
decision logic.
"""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
resp = await client.post(_SITEVERIFY_URL, data=payload)
except httpx.HTTPError:
# Network error / timeout. Do not raise; do not log the
# payload (contains the secret).
_log.exception("hcaptcha_request_failed")
return None
if resp.status_code != 200:
_log.info(
"hcaptcha_non_200",
status_code=resp.status_code,
)
return None
try:
return resp.json()
except ValueError:
_log.exception("hcaptcha_malformed_json")
return None

View File

@@ -490,6 +490,26 @@ a:focus-visible {
margin-top: var(--space-2);
}
/* Phase 5: inline field errors + top-level banner. Scoped to the
contact form so the red tone does not bleed into other forms. */
.contact-form__field-error,
.contact-form__error {
margin: var(--space-1) 0 0;
color: #8b2e2e;
font-size: 0.95rem;
}
.contact-form__error {
padding: var(--space-2) var(--space-3);
background-color: #fbe9e7;
border: 1px solid #d9a8a1;
border-radius: var(--radius);
}
.contact-form__captcha {
margin-top: var(--space-2);
}
/* Generic button. */
.btn {
display: inline-block;

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 %}