"""Outgoing email -- plan.md §7 (state-transition side effects). Six 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.*`. send_printing_email(submission) Fired on any transition into `status = printing` (operator clicks "Start printing" in admin). Excited tone: "your print is on the bed right now". Template: `emails/printing.*`. send_completed_email(submission) Fired on any transition into `status = completed` (operator clicks "Mark completed"). Pickup-ready announcement; renders `submission.operator_notes` as a "note from the operator" callout when present (typically pickup instructions). Template: `emails/completed.*`. send_staff_verify_email(submission) Operator broadcast fired on any transition into `status = verifying`. One message to every active staff user with a non-empty email (recipients placed in `bcc` so individual addresses stay hidden). Surfaces the submitter's private `notes_for_op` -- not echoed on the public detail page (plan.md §5) -- since the audience is staff only. Template: `emails/staff_verify.*`. All six 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}) def send_printing_email(sub: Submission) -> bool: """Notify the submitter that the print has just started (plan.md §7.3 `queued -> printing` transition). Excited tone -- the operator just clicked "Start printing" in admin and the first layer is going down.""" detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" return _send("printing", sub, {"detail_url": detail_url}) def send_completed_email(sub: Submission) -> bool: """Notify the submitter that the print finished successfully and is ready for pickup (plan.md §7.3 `printing -> completed` transition). `submission.operator_notes` is rendered when present so any pickup-instruction the operator typed in admin reaches the user.""" detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" return _send("completed", sub, {"detail_url": detail_url}) def send_staff_verify_email(sub: Submission) -> bool: """Broadcast a "please verify" notice to every active staff user when a submission lands in `verifying`. Fired by the centralised `Submission.save()` hook so the worker's `processing -> verifying` success branch AND any future admin path that returns a row to `verifying` both trigger it (plan.md §7.3). Recipients are placed in `bcc` so individual staff addresses stay hidden from each other; `to` carries `DEFAULT_FROM_EMAIL` purely to satisfy transports that reject envelopes with no `to`. Returns False (without raising) when there is no active staff with a usable email or when the transport surfaces an exception. """ from django.contrib.auth import get_user_model UserModel = get_user_model() recipients = list( UserModel.objects .filter(is_staff=True, is_active=True) .exclude(email="") .values_list("email", flat=True) ) if not recipients: logger.info( "no active staff with email; skipping staff_verify for %s", sub.slug ) return False admin_url = ( f"{settings.SITE_URL}/admin/submissions/submission/{sub.pk}/change/" ) detail_url = f"{settings.SITE_URL}/p/{sub.slug}/" context = { "submission": sub, "site_url": settings.SITE_URL, "admin_url": admin_url, "detail_url": detail_url, } subject, body_text, body_html = _render("staff_verify", context) msg = EmailMultiAlternatives( subject=subject, body=body_text, from_email=settings.DEFAULT_FROM_EMAIL, to=[settings.DEFAULT_FROM_EMAIL], bcc=recipients, ) msg.attach_alternative(body_html, "text/html") try: msg.send() except Exception: logger.exception("failed to send staff_verify email for %s", sub.slug) return False logger.info( "sent staff_verify email for %s to %d staff", sub.slug, len(recipients) ) return True