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>
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""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,
|
|
)
|