149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""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})
|