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>
220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
"""Transactional email sender with a dev-mode log fallback.
|
|
|
|
Thin wrapper around the Resend API (``resend.Emails.send``). Renders
|
|
both HTML and plaintext magic-link bodies from Jinja templates to keep
|
|
copy out of Python code.
|
|
|
|
Security and UX rules
|
|
---------------------
|
|
- **Never raise on missing credentials in development.** A 500 from the
|
|
login POST would expose whether an email is on the allowlist
|
|
(successful sends would succeed, non-allowlisted "sends" would still
|
|
short-circuit) and it would also break local dev. In development we
|
|
log a ``magic_link_dev_fallback`` structured event with the full
|
|
magic-link URL so the developer can copy it.
|
|
- **Production must fail at startup** if Resend credentials are absent;
|
|
that validator lives in :class:`app.config.Settings`, not here.
|
|
- Never log the raw token on its own — only as part of the URL in the
|
|
dev fallback (which is the whole point of the fallback).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
import structlog
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from app.config import Settings
|
|
|
|
|
|
_log = structlog.get_logger(__name__)
|
|
|
|
|
|
class EmailService:
|
|
"""Send magic-link emails via Resend, with a dev-mode log fallback."""
|
|
|
|
def __init__(self, settings: Settings, templates: Jinja2Templates) -> None:
|
|
"""Store dependencies by reference.
|
|
|
|
Parameters
|
|
----------
|
|
settings:
|
|
Application settings; used to pick up ``resend_api_key`` /
|
|
``resend_from`` / ``app_env`` at send time so rotating them
|
|
at runtime (dev) works.
|
|
templates:
|
|
Shared Jinja2 environment. We reuse the app-level one so
|
|
template autoescape defaults and the search path match the
|
|
rest of the site.
|
|
"""
|
|
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,
|
|
*,
|
|
to: str,
|
|
url: str,
|
|
display_name: str,
|
|
ttl_min: int,
|
|
expires_at: datetime,
|
|
) -> None:
|
|
"""Send a magic-link email to ``to`` or log the URL in dev.
|
|
|
|
Behavior
|
|
--------
|
|
- If ``settings.resend_api_key`` is truthy, render both bodies
|
|
and send via Resend.
|
|
- Otherwise (development only — the production config validator
|
|
refuses to boot without a key), emit a structured log event
|
|
``magic_link_dev_fallback`` that includes the full URL.
|
|
|
|
Never raises in the dev fallback path; errors from the Resend
|
|
API surface as logged exceptions so the request-handler layer
|
|
always returns the same response shape regardless of whether
|
|
the email was actually sent (CWE-200 / anti-enumeration).
|
|
"""
|
|
# Build both template bodies first so any rendering error
|
|
# surfaces before we talk to the network.
|
|
ctx = {
|
|
"display_name": display_name,
|
|
"magic_link_url": url,
|
|
"expires_at": expires_at.isoformat(),
|
|
"ttl_min": ttl_min,
|
|
}
|
|
html_body = self._render("emails/magic_link.html", ctx)
|
|
text_body = self._render("emails/magic_link.txt", ctx)
|
|
|
|
api_key: Optional[str] = self._settings.resend_api_key
|
|
sender: Optional[str] = self._settings.resend_from
|
|
|
|
# Dev fallback path: no key configured. Log the URL at INFO so
|
|
# the developer can complete the flow, and return.
|
|
if not api_key or not sender:
|
|
_log.info(
|
|
"magic_link_dev_fallback",
|
|
to=to,
|
|
# Raw token is embedded in the URL; acceptable because
|
|
# this path ONLY runs in local dev (production validator
|
|
# refuses to boot without RESEND_API_KEY).
|
|
magic_link_url=url,
|
|
ttl_min=ttl_min,
|
|
)
|
|
return
|
|
|
|
# Real send. We import here to avoid taking a hard import-time
|
|
# dependency on `resend`'s module-level state during tests that
|
|
# never exercise the send path.
|
|
try:
|
|
import resend # type: ignore[import-untyped]
|
|
|
|
resend.api_key = api_key
|
|
resend.Emails.send(
|
|
{
|
|
"from": sender,
|
|
"to": to,
|
|
"subject": "Your Chicken Babies R Us admin login link",
|
|
"html": html_body,
|
|
"text": text_body,
|
|
}
|
|
)
|
|
_log.info("magic_link_email_sent", to=to)
|
|
except Exception: # noqa: BLE001
|
|
# Do NOT re-raise from the request path — see anti-enumeration
|
|
# note at module top. Log with redacted context.
|
|
_log.exception("magic_link_email_failed", to=to)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internals
|
|
# ------------------------------------------------------------------
|
|
def _render(self, template_name: str, context: dict) -> str:
|
|
"""Render a Jinja template to a string.
|
|
|
|
We use the underlying Jinja environment directly so we get a
|
|
plain string back (``Jinja2Templates.TemplateResponse`` wraps
|
|
the output in an HTTP response, which is not what we want for
|
|
outbound email bodies).
|
|
"""
|
|
template = self._templates.env.get_template(template_name)
|
|
return template.render(**context)
|