"""Outgoing email -- plan.md §7 (state-transition side effects). Three public functions, one per dedicated template: send_confirmation_email(submission) Guest path: token-link emailed immediately after `SubmitView` creates an `identifying` row. The user must click within 24 h (plan.md §7.6) or the row is cleaned up. Template: `emails/confirmation.*`. send_verifying_email(submission) Fired by the `process_submissions` worker on the `processing -> verifying` success branch: "auto-checks cleared, awaiting operator review". Template: `emails/verifying.*`. send_rejection_email(submission, *, previous_status=None) Always fired on any transition into `status = rejected`, whether auto (validator failure from `processing`) or operator-driven (admin "Reject" action from `verifying`). The body renders `submission.operator_notes` verbatim so the user sees the same rejection reason in the email as on the public detail page. Template: `emails/rejected.*`. All three delegate to Django's email machinery. The backend is wired in `hamprint/settings/base.py`: Mailtrap via `django-anymail` when `MAILTRAP_API_TOKEN` is present, console when not. Send failures are caught + logged so a flaky transport never blocks the submission flow. """ from __future__ import annotations import logging from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from .models import Submission logger = logging.getLogger(__name__) def _recipient(sub: Submission) -> str | None: """Resolve the email address for a submission. OAuth users -> `User.email`; guests -> `guest_email`. Returns None if neither is set, which shouldn't happen thanks to the model's `CheckConstraint` but we keep the belt-and-braces check anyway. """ if sub.submitted_by_id and sub.submitted_by.email: return sub.submitted_by.email return sub.guest_email or None def _render(name: str, context: dict) -> tuple[str, str, str]: """Load `templates/emails/{name}.subject.txt` + `.body.txt` + `.body.html` and render each with the same context. The subject is stripped of trailing whitespace so a multi-line subject template (which can happen when an author hits Enter at the end) doesn't accidentally include a newline -- which would make the transport API reject the message as malformed. """ subject = render_to_string(f"emails/{name}.subject.txt", context).strip() body_text = render_to_string(f"emails/{name}.body.txt", context) body_html = render_to_string(f"emails/{name}.body.html", context) return subject, body_text, body_html def _send(name: str, sub: Submission, extra_context: dict) -> bool: """Common send path. Builds a `multipart/alternative` message with both the plain-text and HTML bodies attached so clients that don't render HTML (or where the user has opted out) still see a readable email. Returns True if the message was handed off to the transport backend successfully, False on any failure (we never propagate).""" to = _recipient(sub) if not to: logger.warning("no recipient for submission %s; skipping %s email", sub.slug, name) return False context = { "submission": sub, "site_url": settings.SITE_URL, **extra_context, } subject, body_text, body_html = _render(name, context) msg = EmailMultiAlternatives( subject=subject, body=body_text, # text/plain root part from_email=settings.DEFAULT_FROM_EMAIL, to=[to], ) msg.attach_alternative(body_html, "text/html") # rich version try: msg.send() except Exception: # broad on purpose: transport-level surprises shouldn't crash callers logger.exception("failed to send %s email for %s", name, sub.slug) return False logger.info("sent %s email for %s to %s", name, sub.slug, to) return True def send_confirmation_email(sub: Submission) -> bool: """Send the email-confirmation link to a guest submitter. The URL is built by string concatenation rather than `reverse()` because `dashboard:confirm` is currently scaffolded as a commented stub in `apps/dashboard/urls.py` (plan.md §7). Once that route is wired, switching to `reverse()` is a one-line change. """ confirm_url = ( f"{settings.SITE_URL}/p/{sub.slug}/confirm/{sub.confirmation_token}/" ) return _send("confirmation", sub, {"confirm_url": confirm_url}) def send_rejection_email( sub: Submission, *, previous_status: str | None = None ) -> bool: """Notify the submitter that their submission was rejected. Always fired on any transition into `status = rejected`: - automatic rejection from `processing` (URL/STL validation failure), - operator rejection from `verifying` (admin "Reject" action). The email body renders `submission.operator_notes` verbatim -- that's the same string the auto-validator writes ("Automatic rejection: …") or that an operator types when clicking "Reject" in admin, so the user sees one consistent reason across the email + the public detail page. `previous_status` is the state we left to land in `rejected`. Useful so the email can subtly distinguish "rejected before a human even looked" (from `processing`) vs. "rejected after operator review" (from `verifying`); both render with the same template. """ return _send( "rejected", sub, {"previous_status": previous_status}, ) def send_verifying_email(sub: Submission) -> bool: """Notify the submitter that auto-validation passed (plan.md §7.3 `processing -> verifying` transition). The print is now queued for a manual operator review; if the operator approves, the next email will be the queued / printing one.""" detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" return _send("verifying", sub, {"detail_url": detail_url})