diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py
index fe23de1..7a4f22d 100644
--- a/apps/dashboard/views.py
+++ b/apps/dashboard/views.py
@@ -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(
diff --git a/apps/submissions/admin.py b/apps/submissions/admin.py
index 2db0ad4..d14f5b3 100644
--- a/apps/submissions/admin.py
+++ b/apps/submissions/admin.py
@@ -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)
diff --git a/apps/submissions/forms.py b/apps/submissions/forms.py
index 7667334..8e5f3a7 100644
--- a/apps/submissions/forms.py
+++ b/apps/submissions/forms.py
@@ -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")
diff --git a/apps/submissions/migrations/0002_verifiedemail.py b/apps/submissions/migrations/0002_verifiedemail.py
new file mode 100644
index 0000000..1d4e9bf
--- /dev/null
+++ b/apps/submissions/migrations/0002_verifiedemail.py
@@ -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",),
+ },
+ ),
+ ]
diff --git a/apps/submissions/migrations/0003_backfill_verified_emails.py b/apps/submissions/migrations/0003_backfill_verified_emails.py
new file mode 100644
index 0000000..3ade651
--- /dev/null
+++ b/apps/submissions/migrations/0003_backfill_verified_emails.py
@@ -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),
+ ]
diff --git a/apps/submissions/migrations/0004_email_normalization.py b/apps/submissions/migrations/0004_email_normalization.py
new file mode 100644
index 0000000..9ee2cb0
--- /dev/null
+++ b/apps/submissions/migrations/0004_email_normalization.py
@@ -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",)},
+ ),
+ ]
diff --git a/apps/submissions/migrations/0005_normalize_existing_data.py b/apps/submissions/migrations/0005_normalize_existing_data.py
new file mode 100644
index 0000000..c4ecd4c
--- /dev/null
+++ b/apps/submissions/migrations/0005_normalize_existing_data.py
@@ -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),
+ ]
diff --git a/apps/submissions/models.py b/apps/submissions/models.py
index b83dd6d..19eaeeb 100644
--- a/apps/submissions/models.py
+++ b/apps/submissions/models.py
@@ -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
diff --git a/apps/submissions/views.py b/apps/submissions/views.py
index 14209ca..d8d2e3e 100644
--- a/apps/submissions/views.py
+++ b/apps/submissions/views.py
@@ -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 {slug} created. "
- "We've sent a confirmation link to {email} — "
+ "We've sent you a confirmation link — check your inbox and "
"click it within 24 hours to add your print to the queue.",
slug=submission.slug,
- email=submission.guest_email,
),
)
else: