Add proper email notifications

This commit is contained in:
2026-05-14 23:19:50 +03:00
parent fe62575790
commit 9e16b78793
34 changed files with 1313 additions and 83 deletions

129
apps/submissions/emails.py Normal file
View 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})

View File

@@ -0,0 +1,80 @@
"""`python manage.py process_submissions` -- implements plan.md §7.5.
Drains one batch of submissions stuck in `processing`: validates each row's
STL (uploads) or URL (printables / makerworld / thingiverse) and transitions
to `verifying` on success or `rejected` on failure. Runs on a 30-second
loop from the `web` container's entrypoint (plan.md §10) -- one invocation
of this command per tick.
Concurrency: `select_for_update(skip_locked=True)` keeps replicas / stray
cron ticks / a re-entrant restart from grabbing the same row. On SQLite the
locks are no-ops (dev only); Postgres in prod gets the non-blocking lock
semantics the design assumes.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from apps.submissions.emails import send_status_update_email, send_verifying_email
from apps.submissions.models import Submission
from apps.submissions.validation import (
ValidationError,
validate_external_url,
validate_stl_file,
)
BATCH = 50
class Command(BaseCommand):
help = (
"Drain submissions stuck in `processing` -- one batch per invocation. "
"Designed to be called on a loop from the web container entrypoint; "
"safe to also call ad-hoc for debugging."
)
def add_arguments(self, parser) -> None:
parser.add_argument(
"--batch",
type=int,
default=BATCH,
help=f"Max rows to process per invocation (default: {BATCH}).",
)
def handle(self, *args, batch: int, **opts) -> None:
with transaction.atomic():
# `list(...)` materialises the slice inside the transaction so
# subsequent `.save()` calls don't invalidate the queryset.
queue = list(
Submission.objects
.select_for_update(skip_locked=True)
.filter(status=Submission.Status.PROCESSING)
.order_by("updated_at")[:batch]
)
if not queue:
return
for sub in queue:
try:
if sub.source_type == Submission.SourceType.UPLOAD:
validate_stl_file(sub.stl_file.path)
else:
validate_external_url(sub.source_url, sub.source_type)
except ValidationError as exc:
sub.status = Submission.Status.REJECTED
sub.operator_notes = f"Automatic rejection: {exc}"
sub.closed_at = timezone.now()
# closed_by stays NULL -- the validator did the rejecting,
# not an operator (plan.md §5 / §7.3).
sub.save()
send_status_update_email(sub, previous_status="processing")
self.stdout.write(f"rejected {sub.slug}: {exc}")
else:
sub.status = Submission.Status.VERIFYING
sub.save()
send_verifying_email(sub)
self.stdout.write(f"verifying {sub.slug}")

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import uuid
import namesgenerator
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
@@ -178,6 +179,29 @@ class Submission(models.Model):
# dashboard index so post-submit redirects always land somewhere real.
return reverse("dashboard:index")
def save(self, *args, **kwargs):
"""Auto-generate `slug` on first save so any creation path -- admin,
`SubmitView`, fixtures, `objects.create()` -- gets a Docker-style
codename without callers having to remember to set one."""
if not self.slug:
self.slug = self._generate_unique_slug()
super().save(*args, **kwargs)
@classmethod
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
"""`namesgenerator.get_random_name` + collision retries in Python
(rather than a DB-side loop) so the rare collision path stays
observable. If the surrounding transaction still loses a race with
a concurrent insert, the DB's `unique=True` constraint fires an
`IntegrityError` and the caller can retry."""
for _ in range(max_attempts):
candidate = namesgenerator.get_random_name()
if not cls.objects.filter(slug=candidate).exists():
return candidate
# Fall through: extremely unlikely with the size of namesgenerator's
# adjective/surname space.
return namesgenerator.get_random_name()
# --- presentation helpers (consumed by dashboard templates) -------------
SOURCE_LABEL = {
@@ -201,6 +225,20 @@ class Submission(models.Model):
Status.FAILED: "bg-red-100 text-red-800",
}
# Same palette as STATUS_BADGE_CLASS but expressed as hex pairs because
# email clients strip <style> blocks and don't load external CSS, so the
# HTML email templates use inline `background-color` / `color`.
STATUS_EMAIL_COLORS = {
Status.IDENTIFYING: {"bg": "#fef3c7", "fg": "#92400e"}, # amber
Status.PROCESSING: {"bg": "#f1f5f9", "fg": "#334155"}, # slate
Status.VERIFYING: {"bg": "#ede9fe", "fg": "#5b21b6"}, # violet
Status.QUEUED: {"bg": "#dbeafe", "fg": "#1e40af"}, # blue
Status.PRINTING: {"bg": "#ffedd5", "fg": "#9a3412"}, # orange
Status.COMPLETED: {"bg": "#d1fae5", "fg": "#065f46"}, # emerald
Status.REJECTED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
Status.FAILED: {"bg": "#fee2e2", "fg": "#991b1b"}, # red
}
@property
def source_label(self) -> str:
return self.SOURCE_LABEL.get(self.source_type, self.source_type)
@@ -208,3 +246,9 @@ class Submission(models.Model):
@property
def status_badge_class(self) -> str:
return self.STATUS_BADGE_CLASS.get(self.status, "bg-slate-100 text-slate-700")
@property
def status_email_style(self) -> dict:
return self.STATUS_EMAIL_COLORS.get(
self.status, {"bg": "#f1f5f9", "fg": "#334155"}
)

View File

@@ -0,0 +1,190 @@
"""STL + URL validation for the `processing` worker (plan.md §7.5.2 / §7.5.3).
Two public callables:
validate_stl_file(path)
Magic-byte probe + numpy-stl mesh load + bounding-box vs build plate +
triangle-count sanity. Raises `ValidationError` with the user-visible
reason on any rejection.
validate_external_url(url, source_type)
Host allow-list re-check + HEAD reachability (fallback to GET with a
Range header on 405). Raises `ValidationError` likewise.
`numpy-stl` is imported lazily so this module can be imported on a host venv
that hasn't run `pip install -r requirements.txt` yet -- the failure only
fires when an STL is actually validated.
"""
from __future__ import annotations
import struct
from pathlib import Path
from urllib.parse import urlparse
import requests
from django.conf import settings
from .models import Submission
class ValidationError(Exception):
"""Raised when a Submission fails its validation pass. The message
becomes the operator-visible (and user-visible) rejection reason."""
# Host allow-list per source_type. Mirrors `apps.submissions.forms`
# deliberately rather than DRYing through a shared import: the
# `processing` worker shouldn't be coupled to the form layer.
_URL_HOSTS: dict[str, set[str]] = {
Submission.SourceType.PRINTABLES: {"printables.com", "www.printables.com"},
Submission.SourceType.MAKERWORLD: {"makerworld.com", "www.makerworld.com"},
Submission.SourceType.THINGIVERSE: {"thingiverse.com", "www.thingiverse.com"},
}
_MIN_AXIS_MM = 5.0
_MIN_TRIANGLES = 4
_MAX_TRIANGLES = 5_000_000
# Be a polite citizen so anti-bot defenses on the model platforms don't 403 us.
_HTTP_HEADERS = {
"User-Agent": "hamprint-validator/1.0 (+https://hamlab.lt)",
}
def _build_volume() -> tuple[float, float, float]:
"""Read `PRINTER_BUILD_VOLUME_MM` (env-driven, default 235,235,250)."""
raw = getattr(settings, "PRINTER_BUILD_VOLUME_MM", "235,235,250")
parts = [float(x) for x in raw.split(",")]
if len(parts) != 3:
raise RuntimeError(
f"PRINTER_BUILD_VOLUME_MM must be 'x,y,z'; got: {raw!r}"
)
return parts[0], parts[1], parts[2]
def _fmt(value: float) -> str:
"""Render `235.0` as `235`, `235.5` as `235.5`. Just for clean error text."""
return str(int(value)) if value.is_integer() else f"{value:g}"
# ---- STL ---------------------------------------------------------------------
def validate_stl_file(path: str) -> None:
"""Four-pass check; raises `ValidationError` on first failure."""
p = Path(path)
if not p.is_file():
raise ValidationError("STL file is missing on disk")
size = p.stat().st_size
# 1. Magic-byte / format probe.
with p.open("rb") as f:
head_80 = f.read(80)
if head_80.startswith(b"solid "):
# Could be ASCII -- but some binary STLs also start with "solid ".
# Confirm by looking for an ASCII facet marker in the first 4 KB.
with p.open("rb") as f:
first_4k = f.read(4096)
if b"facet normal" not in first_4k:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
# ASCII STL accepted; the mesh load below catches anything subtler.
else:
# Binary STL: 80-byte header + uint32 triangle count + 50 B per tri.
with p.open("rb") as f:
f.seek(80)
tri_bytes = f.read(4)
if len(tri_bytes) != 4:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
triangle_count = struct.unpack("<I", tri_bytes)[0]
if size != 84 + 50 * triangle_count:
raise ValidationError(
"file is not a valid STL: header / size mismatch"
)
# 2. Mesh load. Imported lazily so the module imports without numpy-stl.
try:
from stl import mesh # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover -- only hit on host venv
raise ValidationError(
"numpy-stl is not installed in this environment"
) from exc
try:
m = mesh.Mesh.from_file(str(p))
except Exception: # numpy-stl raises a variety of types; broad on purpose
raise ValidationError("STL could not be parsed")
# 3. Bounding-box check. mesh.vectors has shape (N, 3, 3).
vertices = m.vectors.reshape(-1, 3)
mins = vertices.min(axis=0)
maxs = vertices.max(axis=0)
extents = [float(maxs[i] - mins[i]) for i in range(3)]
bv_x, bv_y, bv_z = _build_volume()
if extents[0] > bv_x or extents[1] > bv_y or extents[2] > bv_z:
raise ValidationError(
f"part is {_fmt(extents[0])}x{_fmt(extents[1])}x{_fmt(extents[2])} mm; "
f"doesn't fit on our {_fmt(bv_x)}x{_fmt(bv_y)}x{_fmt(bv_z)} build plate"
)
if max(extents) < _MIN_AXIS_MM:
raise ValidationError(
f"part is too small to print reliably "
f"(under {_fmt(_MIN_AXIS_MM)} mm on every axis)"
)
# 4. Triangle-count sanity.
tri_count = int(len(m.vectors))
if tri_count < _MIN_TRIANGLES:
raise ValidationError(
f"mesh is degenerate (fewer than {_MIN_TRIANGLES} triangles)"
)
if tri_count > _MAX_TRIANGLES:
raise ValidationError(f"mesh is too dense ({tri_count:,} triangles)")
# ---- URL ---------------------------------------------------------------------
def validate_external_url(source_url: str, source_type: str) -> None:
"""Host check + HEAD reachability; raises `ValidationError` on rejection."""
if not source_url:
raise ValidationError("URL is empty")
# 1. Host re-check. We already did this at form time but the row may have
# been edited via the admin since.
host = (urlparse(source_url).hostname or "").lower()
allowed = _URL_HOSTS.get(source_type, set())
if host not in allowed:
raise ValidationError("URL host doesn't match source type")
# 2. HEAD reachability.
try:
r = requests.head(
source_url,
timeout=5,
allow_redirects=True,
headers=_HTTP_HEADERS,
)
# Some CDNs refuse HEAD with 405; fall back to a tiny ranged GET.
if r.status_code == 405:
r = requests.get(
source_url,
timeout=5,
allow_redirects=True,
headers={**_HTTP_HEADERS, "Range": "bytes=0-1023"},
)
except requests.exceptions.RequestException as exc:
raise ValidationError(
f"URL unreachable ({exc.__class__.__name__})"
) from exc
if r.status_code >= 400:
raise ValidationError(
f"URL returned HTTP {r.status_code}; the model may have been "
f"removed or set to private"
)

View File

@@ -1,45 +1,33 @@
"""Submit form view (plan.md §7.4 + §7).
"""Submit form view (plan.md §7.4).
The view persists a `Submission` and, depending on whether the request is
authenticated, sets the initial state machine state per plan.md §7.3:
OAuth user -> processing (email already verified)
OAuth user -> processing (email already verified)
guest with email -> identifying (waiting for confirmation link click)
Slug generation, email sending, and validation cron are out of scope here and
deferred to plan.md §7.5 (`processor` sidecar) and §7.5 confirmation flow.
For the guest path, the confirmation email is sent **synchronously** after
the DB commit so we know the Mailtrap API outcome before redirecting. The
dashboard then shows a green "check your inbox" notice on success or a red
"couldn't send email -- try Google sign-in" notice on failure.
"""
from __future__ import annotations
import secrets
import namesgenerator
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.html import format_html
from django.views.generic import CreateView
from .emails import send_confirmation_email
from .forms import SubmissionForm
from .models import Submission
def _generate_unique_slug(max_attempts: int = 16) -> str:
"""`namesgenerator.get_random_name` with collision retries.
The slug column is `unique=True`; we retry instead of looping in SQL so
the rare collision path stays observable.
"""
for _ in range(max_attempts):
candidate = namesgenerator.get_random_name()
if not Submission.objects.filter(slug=candidate).exists():
return candidate
# Fall through: extremely unlikely, but caller will see IntegrityError
# if the next save() still collides.
return namesgenerator.get_random_name()
class SubmitView(CreateView):
"""Public submit form. GET renders, POST creates a Submission."""
@@ -52,10 +40,12 @@ class SubmitView(CreateView):
kwargs["user"] = self.request.user
return kwargs
@transaction.atomic
def form_valid(self, form):
submission: Submission = form.save(commit=False)
submission.slug = _generate_unique_slug()
# The slug is auto-generated in `Submission.save()` if blank, so we
# don't need to set one here. The `except IntegrityError` below
# handles the rare race where the just-generated slug collides with
# a concurrent insert.
if self.request.user.is_authenticated:
submission.submitted_by = self.request.user
@@ -70,28 +60,65 @@ class SubmitView(CreateView):
submission.status = Submission.Status.IDENTIFYING
submission.confirmation_token = secrets.token_urlsafe(32)
submission.confirmation_sent_at = timezone.now()
# TODO(plan.md §7.4 step 6): send the confirmation email here.
try:
submission.save()
except IntegrityError:
# Extremely rare slug collision on the unique index; one more try.
submission.slug = _generate_unique_slug()
submission.save()
# Persist inside a tight atomic block so the row is committed BEFORE
# we hit the email transport. That way a slow / failing Mailtrap API
# call can never roll back a saved submission, and we get to look at
# the result and surface it as a user-visible notice.
with transaction.atomic():
try:
submission.save()
except IntegrityError:
# Extremely rare slug collision on the unique index; clearing
# `slug` makes `Submission.save()` regenerate it on retry.
submission.slug = ""
submission.save()
self.object = submission
if submission.status == Submission.Status.IDENTIFYING:
messages.info(
self.request,
f"Submission {submission.slug} created. Check your email "
f"({submission.guest_email}) for a confirmation link -- you "
f"have 24 hours.",
)
self._notify_guest(submission)
else:
messages.success(
self.request,
f"Submission {submission.slug} accepted. We'll start "
f"validating it shortly.",
format_html(
"Submission <strong class=\"font-mono\">{}</strong> accepted. "
"We'll start validating it shortly.",
submission.slug,
),
)
self.object = submission
return super().form_valid(form)
# ---- guest-path notice ---------------------------------------------------
def _notify_guest(self, submission: Submission) -> None:
"""Send the confirmation email and surface success / failure to the
user via the messages framework. Green on 2xx from Mailtrap, red on
any transport failure (with a "sign in with Google instead" hint)."""
sent = send_confirmation_email(submission)
if sent:
messages.success(
self.request,
format_html(
"Submission <strong class=\"font-mono\">{slug}</strong> created. "
"We've sent a confirmation link to <strong>{email}</strong> &mdash; "
"click it within 24 hours to add your print to the queue.",
slug=submission.slug,
email=submission.guest_email,
),
)
else:
messages.error(
self.request,
format_html(
"Submission <strong class=\"font-mono\">{slug}</strong> was saved, "
"but we couldn't send the confirmation email to <strong>{email}</strong>. "
"Try <a href=\"{login_url}\" class=\"underline font-medium\">"
"signing in with Google</a> instead &mdash; it skips email "
"confirmation entirely.",
slug=submission.slug,
email=submission.guest_email,
login_url=reverse("account_login"),
),
)