From 9e16b7879361d52faa3b5fa9633821ac1c28a8b7 Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 14 May 2026 23:19:50 +0300 Subject: [PATCH] Add proper email notifications --- .dockerignore | 50 +++++ .env.example | 24 ++- Containerfile | 62 ++++++ apps/accounts/urls.py | 14 ++ apps/accounts/views.py | 69 ++++++- apps/dashboard/urls.py | 2 +- apps/dashboard/views.py | 76 +++++++ apps/submissions/emails.py | 129 ++++++++++++ .../commands/process_submissions.py | 80 ++++++++ apps/submissions/models.py | 44 ++++ apps/submissions/validation.py | 190 ++++++++++++++++++ apps/submissions/views.py | 105 ++++++---- compose.dev.yaml | 55 +++++ compose.yaml | 59 +++--- entrypoint.sh | 38 ++++ hamprint/settings/base.py | 35 +++- hamprint/settings/dev.py | 5 +- hamprint/urls.py | 4 + requirements.txt | 1 + static/css/tailwind.css | 2 +- templates/account/close.html | 44 ++++ templates/account/logout.html | 27 +++ templates/base.html | 20 ++ templates/dashboard/my_prints.html | 7 +- templates/emails/_base.html | 70 +++++++ templates/emails/confirmation.body.html | 39 ++++ templates/emails/confirmation.body.txt | 16 ++ templates/emails/confirmation.subject.txt | 1 + templates/emails/status_update.body.html | 55 +++++ templates/emails/status_update.body.txt | 16 ++ templates/emails/status_update.subject.txt | 1 + templates/emails/verifying.body.html | 37 ++++ templates/emails/verifying.body.txt | 18 ++ templates/emails/verifying.subject.txt | 1 + 34 files changed, 1313 insertions(+), 83 deletions(-) create mode 100644 .dockerignore create mode 100644 Containerfile create mode 100644 apps/accounts/urls.py create mode 100644 apps/submissions/emails.py create mode 100644 apps/submissions/management/commands/process_submissions.py create mode 100644 apps/submissions/validation.py create mode 100644 compose.dev.yaml create mode 100644 entrypoint.sh create mode 100644 templates/account/close.html create mode 100644 templates/account/logout.html create mode 100644 templates/emails/_base.html create mode 100644 templates/emails/confirmation.body.html create mode 100644 templates/emails/confirmation.body.txt create mode 100644 templates/emails/confirmation.subject.txt create mode 100644 templates/emails/status_update.body.html create mode 100644 templates/emails/status_update.body.txt create mode 100644 templates/emails/status_update.subject.txt create mode 100644 templates/emails/verifying.body.html create mode 100644 templates/emails/verifying.body.txt create mode 100644 templates/emails/verifying.subject.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc4d57c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Keep the image lean and reproducible: don't ship anything we'd regenerate +# inside the container anyway, and never ship host-only / secret state. + +# Host venv -- container has its own Python install. +.venv/ + +# VCS metadata. +.git/ +.gitignore +.gitattributes + +# Local databases / dev artefacts. +*.sqlite3 +db.sqlite3* +__pycache__/ +*.py[cod] +*.swp +*.log +*.tmp + +# Secrets / env files: container reads .env via --env-file or compose env_file:. +.env +.env.local + +# Tailwind binary cache: re-downloaded by `manage.py tailwind build` during +# image build, then removed in the same layer. Host copy might be a different +# arch / version, so always skip it. +.django_tailwind_cli/ + +# Output of `collectstatic` -- the Containerfile runs this fresh at build time. +staticfiles/ + +# Documentation, design assets, and prototype HTML that operators don't need +# at runtime. +demo/ +plan.md +plan.pdf +plan.html +CLAUDE.md +CONTRIBUTING.md +README.md +.claude/ + +# IDE / cache directories. +.vscode/ +.idea/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +node_modules/ diff --git a/.env.example b/.env.example index 04ae2cb..b7a9556 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ SECRET_KEY=replace-me-with-a-long-random-string DEBUG=true ALLOWED_HOSTS=localhost,127.0.0.1 +# Public base URL used in outgoing emails (confirmation link, detail-page +# link). Defaults to http://localhost:8000 if unset. In production set to +# e.g. https://print.hamlab.lt -- no trailing slash. +SITE_URL=http://localhost:8000 + # --- PostgreSQL --- # Used by the `db` container and interpolated into DATABASE_URL inside # compose.yaml; the `web` container reads DATABASE_URL via dj-database-url. @@ -16,15 +21,20 @@ POSTGRES_DB=hamprint POSTGRES_USER=hamprint POSTGRES_PASSWORD=changeme -# --- Mailjet (transactional email) --- -# Only used when running with `hamprint.settings.prod`; `hamprint.settings.dev` -# overrides EMAIL_BACKEND to the console backend so emails are printed to -# the `web` container logs. -# Get keys at https://app.mailjet.com/account/apikeys -MAILJET_API_KEY= -MAILJET_API_SECRET= +# --- Mailtrap (transactional email) --- +# When MAILTRAP_API_TOKEN is set, Django sends through Mailtrap's HTTPS API +# via django-anymail. When blank, settings/base.py falls back to the console +# email backend -- handy for local development without burning real quota or +# spamming real addresses. +# Get a token at https://mailtrap.io/api-tokens +MAILTRAP_API_TOKEN= DEFAULT_FROM_EMAIL=hamprint +# Optional: set to a Mailtrap testing-inbox ID to capture outgoing mail in a +# sandbox inbox instead of actually delivering it. Find the ID in the URL +# of the inbox at https://mailtrap.io/inboxes. Leave blank for real sending. +MAILTRAP_TEST_INBOX_ID= + # --- Google OAuth (django-allauth Google provider) --- # Configure at https://console.cloud.google.com/apis/credentials and add a # SocialApp via Django admin (/admin/socialaccount/socialapp/add/). diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..c0a5e07 --- /dev/null +++ b/Containerfile @@ -0,0 +1,62 @@ +# hamprint -- single-container image (plan.md §10). +# +# Runs Gunicorn plus the two periodic jobs (process_submissions every 30 s, +# cleanup_stale every 5 min) in the same process group. No sidecars. +# +# Build: podman build -t hamprint:latest . +# Run: podman run --rm -p 8000:8000 --env-file .env hamprint:latest +# +# In production the host typically mounts a volume at /app/media so uploaded +# STLs survive container restarts; the database connection comes from +# DATABASE_URL (Postgres in prod, SQLite if you really want). + +FROM docker.io/library/python:3.14-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + DJANGO_SETTINGS_MODULE=hamprint.settings.prod + +WORKDIR /app + +# System packages python:3.14-slim doesn't ship: +# tini -- PID 1 for clean signal forwarding to gunicorn + the loops +# libgomp1 -- numpy/numpy-stl runtime on some kernels +# curl -- handy for healthchecks if compose.yaml grows one +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini curl libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# Python deps as their own layer so app-code edits don't invalidate the wheel +# cache. psycopg[binary] is added explicitly because requirements.txt is +# kept Postgres-driver-agnostic for the host-venv (SQLite) path. +COPY requirements.txt . +RUN pip install -r requirements.txt 'psycopg[binary]' + +# Application code (.dockerignore excludes .venv, .git, db.sqlite3, demo/, etc). +COPY . . + +# Build Tailwind CSS, gather everything under STATIC_ROOT for WhiteNoise, then +# drop the ~120 MB Tailwind CLI download so it doesn't ride along. The source +# .css lives at assets/tailwind.source.css (per TAILWIND_CLI_SRC_CSS in +# settings/base.py); only the CLI binary cache is purged. +RUN python manage.py tailwind build --force \ + && python manage.py collectstatic --noinput \ + && rm -rf /app/.django_tailwind_cli + +# Default writable dirs. Mount a volume at /app/media in prod for persistence. +RUN mkdir -p /app/media /app/staticfiles + +# Drop privileges. uid 1000 maps cleanly to the typical host user in rootless +# podman, so a bind-mounted media volume stays writable without extra fuss. +RUN useradd -m -u 1000 app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 + +# tini reaps zombies + forwards SIGTERM to all children, so when the orchestrator +# stops the container both Gunicorn AND the two background loops get the signal. +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["bash", "entrypoint.sh"] diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..e47b07a --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,14 @@ +"""Local additions under `/accounts/`. Mounted in `hamprint/urls.py` BEFORE +`include("allauth.urls")` so any path defined here wins; everything we +don't claim falls through to allauth. +""" + +from django.urls import path + +from . import views + +app_name = "accounts" + +urlpatterns = [ + path("close/", views.close_account, name="close"), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 91ea44a..3e05f60 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -1,3 +1,68 @@ -from django.shortcuts import render +"""Local account views -- the ones allauth doesn't ship. -# Create your views here. +Today: just `close_account` ("permanently delete this user"). Logout is +allauth's; we override its template, not its view. +""" + +from __future__ import annotations + +from django.contrib import messages +from django.contrib.auth import logout +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.http import HttpResponseForbidden +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from apps.submissions.models import Submission + + +@login_required +@require_http_methods(["GET", "POST"]) +def close_account(request): + """Permanently delete the signed-in user and all of their submissions. + + GET -> render a "are you sure?" confirmation page, showing how many of + their submissions will go with the user. + POST -> delete the rows, log the user out, redirect to the dashboard. + + Refuses staff users (`is_staff=True`): they'd be locking themselves + (and possibly the only operator) out of /admin/ with no recourse, so + that path requires another operator to remove the row via + /admin/auth/user/ instead. + + Why delete the submissions too: `Submission.submitted_by` uses + `on_delete=SET_NULL`, but the `CheckConstraint` on the model requires + that EITHER `submitted_by` OR `guest_email` is non-null. OAuth-created + submissions have `guest_email=NULL`, so SET_NULL would violate the + constraint at delete time. Simpler + matches user expectation of + "delete my account": wipe the rows wholesale. The `post_delete` signal + in `apps/submissions/signals.py` unlinks the uploaded STLs at the + same time. + """ + if request.user.is_staff: + return HttpResponseForbidden( + "Staff users cannot close their own account from here. " + "Ask another operator to remove the row via /admin/auth/user/." + ) + + if request.method == "POST": + user = request.user + username = user.get_username() + with transaction.atomic(): + Submission.objects.filter(submitted_by=user).delete() + logout(request) + user.delete() + messages.info( + request, + f"Account {username} and all of your prints have been " + f"permanently deleted.", + ) + return redirect("dashboard:index") + + submission_count = Submission.objects.filter(submitted_by=request.user).count() + return render( + request, + "account/close.html", + {"submission_count": submission_count}, + ) diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py index 1503879..bf790de 100644 --- a/apps/dashboard/urls.py +++ b/apps/dashboard/urls.py @@ -7,9 +7,9 @@ app_name = "dashboard" urlpatterns = [ path("", views.IndexView.as_view(), name="index"), path("my-prints/", views.MyPrintsView.as_view(), name="my_prints"), + path("p//confirm//", views.ConfirmEmailView.as_view(), name="confirm"), # Routes to be added as features land (see plan.md Section 7): # path("p//", views.SubmissionDetailView.as_view(), name="detail"), # path("p//status/", views.SubmissionStatusFragment.as_view(), name="status"), - # path("p//confirm//", views.ConfirmEmailView.as_view(), name="confirm"), # path("p//resend/", views.ResendConfirmationView.as_view(), name="resend"), ] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index e939b10..fe23de1 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -1,5 +1,11 @@ +import secrets + +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Count, Q +from django.shortcuts import get_object_or_404, redirect +from django.utils.html import format_html +from django.views import View from django.views.generic import ListView from apps.submissions.models import Submission @@ -54,6 +60,76 @@ class IndexView(ListView): return ctx +class ConfirmEmailView(View): + """Email-confirmation landing page (plan.md §7.4 step 8). + + The URL `/p//confirm//` is what the welcome email's button + points at. Hitting it does plan.md §7.3's `identifying -> processing` + transition (`email_confirmed = True`, `confirmation_token` cleared), + then bounces the user back to the dashboard with a green notice. The + `processing` worker (plan.md §7.5) picks the row up within ~30 s and + moves it to `verifying`, where it becomes visible on the public list. + + Idempotent: hitting the link twice (or after expiry / approval / rejection) + doesn't crash -- it just surfaces a neutral "already past the + confirmation step" notice and redirects. Constant-time string compare + on the token, so a stranger guessing tokens can't sniff prefix matches + via response-time differences. + """ + + def get(self, request, slug, token): + # 404 if the row doesn't exist -- including the case where + # cleanup_stale has already nuked an unconfirmed submission. + sub = get_object_or_404(Submission, slug=slug) + + if sub.status != Submission.Status.IDENTIFYING: + # Already past the confirmation step -- treat as a no-op rather + # than an error so refreshing / re-clicking is harmless. + messages.info( + request, + format_html( + "Submission {slug} is " + "already past the confirmation step (current state: " + "{status}). Nothing more to do.", + slug=sub.slug, + status=sub.get_status_display(), + ), + ) + return redirect("dashboard:index") + + # Constant-time compare on the token to make timing-side-channel + # attacks against the 32-byte secret impractical. + stored = sub.confirmation_token or "" + if not stored or not secrets.compare_digest(stored, token): + messages.error( + request, + format_html( + "Couldn't confirm submission {slug} — " + "the link is invalid or has expired. If you submitted " + "this print, please " + "submit again.", + slug=sub.slug, + submit_url="/submit/", + ), + ) + return redirect("dashboard:index") + + sub.status = Submission.Status.PROCESSING + sub.email_confirmed = True + sub.confirmation_token = "" + sub.save() + + messages.success( + request, + format_html( + "Submission {slug} confirmed! " + "It'll appear on the dashboard shortly, after validation.", + slug=sub.slug, + ), + ) + return redirect("dashboard:index") + + class MyPrintsView(LoginRequiredMixin, ListView): """Private listing -- every submission the signed-in user has ever made. diff --git a/apps/submissions/emails.py b/apps/submissions/emails.py new file mode 100644 index 0000000..ac6cd39 --- /dev/null +++ b/apps/submissions/emails.py @@ -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}) diff --git a/apps/submissions/management/commands/process_submissions.py b/apps/submissions/management/commands/process_submissions.py new file mode 100644 index 0000000..e23e0f8 --- /dev/null +++ b/apps/submissions/management/commands/process_submissions.py @@ -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}") diff --git a/apps/submissions/models.py b/apps/submissions/models.py index c51cdb3..b83dd6d 100644 --- a/apps/submissions/models.py +++ b/apps/submissions/models.py @@ -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