Add better email verification

This commit is contained in:
2026-05-14 23:49:54 +03:00
parent 569d57e144
commit 46fc07a1ae
9 changed files with 394 additions and 6 deletions

View File

@@ -7,12 +7,38 @@ are documented in plan.md §7.
from __future__ import annotations
import uuid
from datetime import timedelta
import namesgenerator
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
def normalize_email(email: str) -> str:
"""Canonicalise an email for per-email accounting (the trust list AND
the active-submission cap).
- Lowercase the entire address.
- Strip Gmail-style `+tag` from the local part:
`user+anything@host.com` -> `user@host.com`.
Same return value for `User@Gmail.com`, `user+a@gmail.com`,
`user+b@gmail.com`. Returns `""` for falsy / malformed input.
"""
if not email or "@" not in email:
return (email or "").lower()
local, _, domain = email.lower().rpartition("@")
if "+" in local:
local = local.split("+", 1)[0]
return f"{local}@{domain}"
# Email trust lasts 30 days from `validated_at`; after that the user has to
# click a fresh confirmation link to re-prove inbox ownership.
EMAIL_TRUST_TTL = timedelta(days=30)
def _validate_stl_size(uploaded_file) -> None:
@@ -27,6 +53,61 @@ def _validate_stl_size(uploaded_file) -> None:
)
class VerifiedEmail(models.Model):
"""An email address that has confirmed at least one hamprint submission.
Stored **normalised** (lowercased, `+tag` stripped) so
`user@gmail.com`, `user+a@gmail.com`, and `user+b@gmail.com` all
collapse to a single row.
Each entry carries a single `validated_at` timestamp. Trust expires
after `EMAIL_TRUST_TTL` (30 days); after that the next guest submission
from the same address falls back to `identifying` and gets a fresh
confirmation link, which on success bumps `validated_at` back to now
via `record_verification()`.
Trade-off worth knowing: anyone who knows a verified address can use
it to bypass confirmation for the next 30 days. We accept that for
hamlab's small-scale, operator-moderated workflow -- the operator
still has to manually approve every submission before it prints.
Operators can also revoke an entry via the admin to force the next
submission back through the welcome-email flow.
"""
email = models.EmailField(unique=True, db_index=True)
validated_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ("-validated_at",)
def __str__(self) -> str:
return self.email
@classmethod
def is_trusted(cls, email: str) -> bool:
"""True if `email` (after normalisation) has a fresh trust entry --
i.e., `validated_at` is within `EMAIL_TRUST_TTL` of now."""
norm = normalize_email(email)
if not norm:
return False
cutoff = timezone.now() - EMAIL_TRUST_TTL
return cls.objects.filter(email=norm, validated_at__gte=cutoff).exists()
@classmethod
def record_verification(cls, email: str) -> "VerifiedEmail | None":
"""Mark `email` as freshly verified. Normalises first, then
`update_or_create`-s with a current `validated_at`. Returns the
row (or None if `email` was falsy)."""
norm = normalize_email(email)
if not norm:
return None
obj, _ = cls.objects.update_or_create(
email=norm,
defaults={"validated_at": timezone.now()},
)
return obj
class Filament(models.Model):
"""Operator-curated filament inventory (plan.md §5).
@@ -98,6 +179,15 @@ class Submission(models.Model):
)
TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED)
# Per-email rate limit. A single email address (after normalisation --
# see `normalize_email`) is allowed at most this many submissions
# whose status is NOT in `STATUSES_EXCLUDED_FROM_LIMIT`. The exclusion
# is intentional: `printing` jobs are short-lived and operator-driven,
# and `rejected` jobs are already cleaned up after 24 h, so neither
# should count against the user's quota.
MAX_ACTIVE_SUBMISSIONS_PER_EMAIL = 10
STATUSES_EXCLUDED_FROM_LIMIT = (Status.PRINTING, Status.REJECTED)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.CharField(max_length=64, unique=True, db_index=True)
@@ -159,6 +249,13 @@ class Submission(models.Model):
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
# Normalised email (lowercased, +tag stripped) of whoever owns this row
# -- the OAuth user if `submitted_by` is set, otherwise the guest. Used
# for the active-submissions cap + the `VerifiedEmail` trust lookup,
# both of which need to treat `user+a@gmail.com` and `user@gmail.com`
# as the same mailbox. Populated in `save()`.
canonical_email = models.EmailField(blank=True, db_index=True)
class Meta:
ordering = ("-created_at",)
constraints = [
@@ -182,11 +279,38 @@ class Submission(models.Model):
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."""
codename without callers having to remember to set one. Also keeps
`canonical_email` in sync with whichever side (OAuth user / guest)
currently owns the row, so the per-email cap and trust list don't
depend on the caller remembering to set it."""
if not self.slug:
self.slug = self._generate_unique_slug()
# Re-derive canonical_email every save: cheap, and survives an
# operator flipping `submitted_by` / `guest_email` in admin.
owner_email = ""
if self.submitted_by_id and self.submitted_by and self.submitted_by.email:
owner_email = self.submitted_by.email
elif self.guest_email:
owner_email = self.guest_email
self.canonical_email = normalize_email(owner_email)
super().save(*args, **kwargs)
@classmethod
def active_count_for_email(cls, email: str) -> int:
"""Return how many of this email's submissions count against the
`MAX_ACTIVE_SUBMISSIONS_PER_EMAIL` cap.
Submissions in `STATUSES_EXCLUDED_FROM_LIMIT` (`printing`,
`rejected`) are excluded -- they're transient or already-cleaned
states that shouldn't pin the user's quota.
"""
norm = normalize_email(email)
if not norm:
return 0
return cls.objects.filter(canonical_email=norm).exclude(
status__in=cls.STATUSES_EXCLUDED_FROM_LIMIT
).count()
@classmethod
def _generate_unique_slug(cls, max_attempts: int = 16) -> str:
"""`namesgenerator.get_random_name` + collision retries in Python