Add proper email notifications
This commit is contained in:
129
apps/submissions/emails.py
Normal file
129
apps/submissions/emails.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Outgoing email -- plan.md §7 (state-transition side effects).
|
||||
|
||||
Two public functions:
|
||||
|
||||
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.
|
||||
|
||||
send_status_update_email(submission, *, previous_status=None)
|
||||
Generic notifier for any state transition the user should know about
|
||||
(queued, rejected, completed, failed). Callers pick when to fire it
|
||||
-- typically operator admin actions and the validation worker.
|
||||
|
||||
Both 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_status_update_email(
|
||||
sub: Submission, *, previous_status: str | None = None
|
||||
) -> bool:
|
||||
"""Notify the submitter that their submission moved to a new state.
|
||||
|
||||
Pass `previous_status` so the email can render "was X, now Y" when
|
||||
useful; omit it for first-time-ever notifications.
|
||||
"""
|
||||
detail_url = f"{settings.SITE_URL}/p/{sub.slug}/"
|
||||
return _send(
|
||||
"status_update",
|
||||
sub,
|
||||
{"detail_url": detail_url, "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})
|
||||
Reference in New Issue
Block a user