Files
hamprint/apps/submissions/emails.py

178 lines
7.3 KiB
Python

"""Outgoing email -- plan.md §7 (state-transition side effects).
Five 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.*`.
All five 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})