"""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, )