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

@@ -8,7 +8,7 @@ from django.utils.html import format_html
from django.views import View
from django.views.generic import ListView
from apps.submissions.models import Submission
from apps.submissions.models import Submission, VerifiedEmail
class IndexView(ListView):
@@ -119,6 +119,12 @@ class ConfirmEmailView(View):
sub.confirmation_token = ""
sub.save()
# Refresh / record the trust-list entry for this email so subsequent
# guest submissions from the same address (or any +tag variant of
# it) skip the `identifying` step for the next 30 days (plan.md §6).
if sub.guest_email:
VerifiedEmail.record_verification(sub.guest_email)
messages.success(
request,
format_html(

View File

@@ -1,7 +1,15 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import Filament, Submission
from .models import Filament, Submission, VerifiedEmail
@admin.register(VerifiedEmail)
class VerifiedEmailAdmin(admin.ModelAdmin):
list_display = ("email", "validated_at")
search_fields = ("email",)
readonly_fields = ("validated_at",)
ordering = ("-validated_at",)
@admin.register(Filament)

View File

@@ -81,6 +81,9 @@ class SubmissionForm(forms.ModelForm):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Stash for `clean()` -- the per-email cap needs to know whether the
# incoming submission is OAuth (user.email) or guest (guest_email).
self.user = user
# Filament dropdown shows only operator-curated, currently-loaded rows.
self.fields["requested_filament"].queryset = Filament.objects.filter(
@@ -118,6 +121,26 @@ class SubmissionForm(forms.ModelForm):
def clean(self):
cleaned = super().clean()
# Per-email cap (plan.md §6). Run this BEFORE the source-type checks
# so a user already at quota doesn't get a misleading "pick a source"
# error message; the cap is the real reason their submission failed.
owner_email = (
self.user.email
if (self.user and self.user.is_authenticated)
else cleaned.get("guest_email")
)
if owner_email:
active = Submission.active_count_for_email(owner_email)
cap = Submission.MAX_ACTIVE_SUBMISSIONS_PER_EMAIL
if active >= cap:
raise forms.ValidationError(
f"You already have {active} active submission(s) -- "
f"that's the per-email cap of {cap}. Wait for some to "
f"finish printing (or be cleaned up after rejection) "
f"before submitting another."
)
source_type = cleaned.get("source_type")
stl_file = cleaned.get("stl_file")
source_url = cleaned.get("source_url")

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.5 on 2026-05-14 20:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="VerifiedEmail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"email",
models.EmailField(db_index=True, max_length=254, unique=True),
),
("verified_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ("-updated_at",),
},
),
]

View File

@@ -0,0 +1,39 @@
"""Backfill `VerifiedEmail` from any historical submission that already had
`email_confirmed=True` (i.e. the user clicked the confirmation link before
the trust list existed). One-shot; safe to re-run thanks to
`update_or_create`."""
from django.db import migrations
def backfill_verified_emails(apps, schema_editor):
Submission = apps.get_model("submissions", "Submission")
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
# `update_or_create` keeps the migration idempotent.
seen: set[str] = set()
qs = Submission.objects.filter(email_confirmed=True).exclude(
guest_email__isnull=True
).exclude(guest_email="").values_list("guest_email", flat=True)
for email in qs:
email = email.strip().lower()
if not email or email in seen:
continue
seen.add(email)
VerifiedEmail.objects.update_or_create(email=email)
def noop_reverse(apps, schema_editor):
"""We don't try to undo the backfill -- the trust list is a forward-only
derived artefact; rolling back the migration leaves the rows alone."""
class Migration(migrations.Migration):
dependencies = [
("submissions", "0002_verifiedemail"),
]
operations = [
migrations.RunPython(backfill_verified_emails, noop_reverse),
]

View File

@@ -0,0 +1,51 @@
"""Schema changes for the email normalisation / TTL / cap work.
- `Submission.canonical_email`: new indexed column populated by
`Submission.save()`. Used to count active submissions per email for the
10-cap, and to look up the `VerifiedEmail` trust list.
- `VerifiedEmail.verified_at` -> `validated_at`: keeps the data, drops the
`auto_now_add` so `update_or_create` can roll the timestamp forward on
every re-confirmation (rolling 30-day TTL).
- `VerifiedEmail.updated_at`: removed -- `validated_at` IS the most recent
confirmation timestamp now, no need for a second column.
The data backfill (populate canonical_email, re-normalise existing
VerifiedEmail rows) lives in 0005_normalize_existing_data so this
migration stays a clean schema-only change.
"""
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0003_backfill_verified_emails"),
]
operations = [
migrations.AddField(
model_name="submission",
name="canonical_email",
field=models.EmailField(blank=True, db_index=True, max_length=254),
),
migrations.RenameField(
model_name="verifiedemail",
old_name="verified_at",
new_name="validated_at",
),
migrations.RemoveField(
model_name="verifiedemail",
name="updated_at",
),
migrations.AlterField(
model_name="verifiedemail",
name="validated_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterModelOptions(
name="verifiedemail",
options={"ordering": ("-validated_at",)},
),
]

View File

@@ -0,0 +1,92 @@
"""One-shot data backfill for the schema change in 0004:
1. Populate `Submission.canonical_email` for every existing row by deriving
it from `submitted_by.email` (OAuth) or `guest_email` (guest) and
running through the same normaliser the live `save()` uses.
2. Re-normalise every `VerifiedEmail.email` already in the table. Rows that
collapse to the same canonical form are deduped: we keep the row with
the most recent `validated_at` and delete the others.
Defensive: both passes use `update_fields=` and `update_or_create`-style
logic so re-running the migration is a no-op once it's been applied.
"""
from django.db import migrations
def _normalize_email(email):
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}"
def forward(apps, schema_editor):
Submission = apps.get_model("submissions", "Submission")
VerifiedEmail = apps.get_model("submissions", "VerifiedEmail")
User = apps.get_model("auth", "User")
# ---- Submission.canonical_email -----------------------------------------
# Pull all related users up front so we don't do an O(N) round-trip
# per submission.
user_emails = dict(
User.objects.exclude(email="").values_list("pk", "email")
)
to_update = []
for sub in Submission.objects.all().only(
"pk", "submitted_by_id", "guest_email", "canonical_email"
):
owner_email = ""
if sub.submitted_by_id and user_emails.get(sub.submitted_by_id):
owner_email = user_emails[sub.submitted_by_id]
elif sub.guest_email:
owner_email = sub.guest_email
new = _normalize_email(owner_email)
if new != sub.canonical_email:
sub.canonical_email = new
to_update.append(sub)
if to_update:
Submission.objects.bulk_update(to_update, ["canonical_email"], batch_size=500)
# ---- VerifiedEmail re-normalisation + dedup ----------------------------
# First pass: pick the surviving row per normalised form (most recent
# validated_at wins). Delete the losers.
survivors: dict[str, tuple[int, object]] = {} # norm -> (pk, validated_at)
for row in VerifiedEmail.objects.all().only("pk", "email", "validated_at"):
norm = _normalize_email(row.email)
if not norm:
row.delete()
continue
prev = survivors.get(norm)
if prev is None:
survivors[norm] = (row.pk, row.validated_at)
else:
prev_pk, prev_at = prev
if row.validated_at > prev_at:
VerifiedEmail.objects.filter(pk=prev_pk).delete()
survivors[norm] = (row.pk, row.validated_at)
else:
row.delete()
# Second pass: rewrite the surviving row's email to its normalised form
# (no-op when already normalised; safe because all duplicates are gone).
for norm, (pk, _at) in survivors.items():
VerifiedEmail.objects.filter(pk=pk).update(email=norm)
def reverse(apps, schema_editor):
"""The forward pass is a derived backfill; there's nothing meaningful
to undo. Leaving rows alone is the right thing on rollback."""
class Migration(migrations.Migration):
dependencies = [
("submissions", "0004_email_normalization"),
]
operations = [
migrations.RunPython(forward, reverse),
]

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

View File

@@ -25,7 +25,7 @@ from django.views.generic import CreateView
from .emails import send_confirmation_email
from .forms import SubmissionForm
from .models import Submission
from .models import Submission, VerifiedEmail
class SubmitView(CreateView):
@@ -52,6 +52,16 @@ class SubmitView(CreateView):
submission.guest_email = None
submission.email_confirmed = True
submission.status = Submission.Status.PROCESSING
elif submission.guest_email and VerifiedEmail.is_trusted(submission.guest_email):
# Returning guest: their email is on the trust list AND the
# 30-day TTL hasn't lapsed (plan.md §6). Skip `identifying` and
# the welcome email entirely -- straight to `processing` like
# an OAuth submitter. Email normalisation happens inside
# `is_trusted`, so `user+a@gmail.com` and `user@gmail.com`
# collapse to the same lookup.
submission.submitted_by = None
submission.email_confirmed = True
submission.status = Submission.Status.PROCESSING
else:
submission.submitted_by = None
# guest_email is already on the form's cleaned_data, ModelForm
@@ -102,10 +112,9 @@ class SubmitView(CreateView):
self.request,
format_html(
"Submission <strong class=\"font-mono\">{slug}</strong> created. "
"We've sent a confirmation link to <strong>{email}</strong> &mdash; "
"We've sent you a confirmation link &mdash; check your inbox and "
"click it within 24 hours to add your print to the queue.",
slug=submission.slug,
email=submission.guest_email,
),
)
else: