Prompt Details
Model
(claude-4-6-opus)
Token size
933
Example input
[USER_STORY]: Add Email Verification β Users must verify their email before logging in. Verification link expires in 24h. Unverified users are blocked from login.
[TECH_STACK]: Python, Django, PostgreSQL, Redis, deployed on AWS ECS
[CODEBASE_CONTEXT]: Monolith, service layer pattern, Celery for async tasks, no custom auth β using Django built-in auth
[DETAIL_LEVEL]: Mid-level
Example output
# Implementation Plan: Email Verification
## 1. Business Objective
Email verification ensures only users with valid, owned email addresses can access the system β reducing fake accounts, improving deliverability for transactional emails, and meeting baseline security expectations. The core risk is friction: a broken or delayed verification email means a lost user. Secondary risk is locking out existing users if migration isn't handled carefully.
## 2. Requirements
**Functional:**
- On registration, send a verification email with a unique, signed link
- Verification link expires after 24 hours
- Unverified users are blocked at login with a clear message
- Users can request a new verification email (INFERRED β without this, expired links create a dead end)
- Clicking a valid link marks the user as verified and redirects to login (INFERRED)
**Non-Functional:**
- Email sending must be async (Celery) β registration response time must not depend on SMTP
- Verification tokens must be tamper-proof (signed, not just random UUIDs in a table)
- Rate-limit resend requests to prevent abuse (INFERRED β 3 per hour per email)
## 3. Architecture Approach
Extend the existing Django auth `User` model via a one-to-one profile or, more cleanly, add a field to whatever user model is in use. Use Django's `TimestampSigner` for tokens rather than storing tokens in the database β this eliminates a table, avoids cleanup jobs, and embeds expiration in the signature itself. The verification flow is: register β Celery task sends email β user clicks link β view validates signature β sets `is_email_verified = True`.
**Tradeoffs:** Signed tokens mean you can't "revoke" a specific token (e.g., invalidate old link when user requests a new one) β but for email verification this is acceptable since verifying twice is a no-op. If you needed single-use tokens, you'd need a database-backed token model with an `is_used` flag, adding a table and a cleanup task. The signer approach buys simplicity and zero DB overhead at the cost of that revocation capability.
## 4. Impacted Layers
| Layer | Components |
|---|---|
| **Model** | `User` model β add `is_email_verified` field, migration |
| **Service** | New `verification_service.py` β token generation, validation, resend logic |
| **Tasks** | New Celery task `send_verification_email` |
| **Views/API** | Modify registration view, new `verify-email` endpoint, new `resend-verification` endpoint, modify login view |
| **Templates** | Verification email template (HTML + plain text), login error messaging |
| **Middleware** | None β enforcement at the login view is sufficient |
## 5. Design Patterns
| Pattern | Where | Why | Tradeoff |
|---|---|---|---|
| Service Layer | `verification_service.py` | Keeps token logic out of views; testable in isolation | Extra indirection for what starts as simple logic |
| Signed Tokens (HMAC) | Token generation/validation | Stateless, no DB table, tamper-proof, expiration built-in | Can't revoke individual tokens |
| Async Task (Celery) | Email dispatch | Registration isn't blocked by SMTP latency | Email delivery is eventual; user might check inbox before it arrives |
| Template Method | Email rendering | Django's `render_to_string` for both HTML and text variants | Must maintain two templates |
The service layer generates signed tokens and validates them; views delegate to the service and handle HTTP concerns only. The Celery task is fired from the service after registration, decoupling email delivery. This keeps the registration view thin β it calls `create_user` then `send_verification_email.delay()`, and returns immediately.
## 6. Data Model Changes
```python
# In your accounts/models.py (or wherever User is defined)
# If using default auth.User, switch to a custom user model or use a profile.
# Assuming you have a custom User model already (AbstractUser):
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
# ... existing fields ...
is_email_verified = models.BooleanField(default=False)
```
**Migration:**
```python
# accounts/migrations/XXXX_add_is_email_verified.py
# Auto-generated, but the critical decision:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', 'XXXX_previous'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_email_verified',
field=models.BooleanField(default=False),
),
]
```
**Backward compatibility:** `default=False` means existing users are unverified. You need a **data migration** to set `is_email_verified=True` for all existing users β otherwise you lock out your entire user base on deploy.
```python
def backfill_existing_users(apps, schema_editor):
User = apps.get_model('accounts', 'User')
User.objects.filter(is_active=True).update(is_email_verified=True)
```
## 7. Implementation Steps
**Step 1 β Add `is_email_verified` field and migrations** `BLOCKER`
```python
# accounts/models.py
class User(AbstractUser):
is_email_verified = models.BooleanField(default=False)
# Then:
# python manage.py makemigrations accounts
# Create a second migration for the backfill:
from django.db import migrations
def backfill(apps, schema_editor):
User = apps.get_model('accounts', 'User')
User.objects.filter(is_active=True).update(is_email_verified=True)
class Migration(migrations.Migration):
dependencies = [
('accounts', 'XXXX_add_is_email_verified'),
]
operations = [
migrations.RunPython(backfill, migrations.RunPython.noop),
]
```
**Step 2 β Build verification service** `BLOCKER`
```python
# accounts/services/verification_service.py
from django.core.signing import TimestampSigner, SignatureExpired, BadSignature
from django.conf import settings
from datetime import timedelta
VERIFICATION_TIMEOUT = timedelta(hours=24)
signer = TimestampSigner(salt='email-verification')
def generate_verification_token(user_id: int) -> str:
return signer.sign(str(user_id))
def verify_token(token: str) -> int:
"""Returns user_id or raises."""
try:
user_id_str = signer.unsign(
token, max_age=VERIFICATION_TIMEOUT.total_seconds()
)
return int(user_id_str)
except SignatureExpired:
raise ValueError("Verification link has expired.")
except BadSignature:
raise ValueError("Invalid verification link.")
def build_verification_url(user_id: int, base_url: str) -> str:
token = generate_verification_token(user_id)
return f"{base_url}/accounts/verify-email/?token={token}"
```
**Step 3 β Create Celery task for sending email**
```python
# accounts/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from accounts.services.verification_service import build_verification_url
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_verification_email(self, user_id: int, email: str, base_url: str):
try:
url = build_verification_url(user_id, base_url)
html = render_to_string('accounts/emails/verify_email.html', {'url': url})
text = render_to_string('accounts/emails/verify_email.txt', {'url': url})
send_mail(
subject='Verify your email address',
message=text,
html_message=html,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[email],
fail_silently=False,
)
except Exception as exc:
raise self.retry(exc=exc)
```
**Step 4 β Modify registration view to trigger verification** `RISK: must get base_url right across environments`
```python
# In your registration view, after user creation:
from accounts.tasks import send_verification_email
def register_view(request):
if request.method == 'POST':
form = RegistrationForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
user.is_email_verified = False
user.save()
base_url = request.build_absolute_uri('/')[:-1] # e.g. https://example.com
send_verification_email.delay(user.id, user.email, base_url)
return render(request, 'accounts/registration_pending.html')
# ... GET handling
```
**Step 5 β Create verify-email endpoint**
```python
# accounts/views.py
from django.shortcuts import redirect, render
from django.contrib.auth import get_user_model
from accounts.services.verification_service import verify_token
from django.contrib import messages
User = get_user_model()
def verify_email_view(request):
token = request.GET.get('token', '')
try:
user_id = verify_token(token)
user = User.objects.get(pk=user_id)
if not user.is_email_verified:
user.is_email_verified = True
user.save(update_fields=['is_email_verified'])
messages.success(request, 'Email verified. You can now log in.')
return redirect('login')
except (ValueError, User.DoesNotExist) as e:
return render(request, 'accounts/verification_failed.html', {
'error': str(e),
}, status=400)
```
**Step 6 β Block unverified users at login**
```python
# accounts/backends.py or directly in login view
# Cleanest approach: custom authentication backend
from django.contrib.auth.backends import ModelBackend
class VerifiedEmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
user = super().authenticate(request, username=username, password=password, **kwargs)
if user and not user.is_email_verified:
return None # Block login
return user
```
```python
# settings.py
AUTHENTICATION_BACKENDS = ['accounts.backends.VerifiedEmailBackend']
```
The tradeoff of returning `None` from the backend: the user sees a generic "invalid credentials" message. To give a specific "please verify your email" message, intercept in the login view instead:
```python
# Alternative: override login view
from django.contrib.auth import authenticate
def login_view(request):
if request.method == 'POST':
user = authenticate(request, username=..., password=...) # use default backend
if user is None:
# invalid credentials
...
elif not user.is_email_verified:
messages.error(request, 'Please verify your email before logging in.')
return render(request, 'accounts/login.html')
else:
login(request, user)
return redirect('home')
```
Pick the login view approach β it gives a better UX. Keep the default `ModelBackend`.
**Step 7 β Add resend-verification endpoint with rate limiting**
```python
# accounts/views.py
from django.core.cache import cache # backed by Redis
def resend_verification_view(request):
if request.method == 'POST':
email = request.POST.get('email', '').lower().strip()
cache_key = f'resend_verification:{email}'
count = cache.get(cache_key, 0)
if count >= 3:
messages.error(request, 'Too many requests. Try again later.')
return render(request, 'accounts/resend_verification.html', status=429)
try:
user = User.objects.get(email=email, is_email_verified=False)
base_url = request.build_absolute_uri('/')[:-1]
send_verification_email.delay(user.id, user.email, base_url)
except User.DoesNotExist:
pass # Don't reveal whether email exists
cache.set(cache_key, count + 1, timeout=3600)
messages.info(request, 'If that email is registered, a verification link has been sent.')
return render(request, 'accounts/resend_verification.html')
return render(request, 'accounts/resend_verification.html')
```
**Step 8 β Wire URLs**
```python
# accounts/urls.py
from accounts.views import verify_email_view, resend_verification_view
urlpatterns = [
# ... existing
path('verify-email/', verify_email_view, name='verify-email'),
path('resend-verification/', resend_verification_view, name='resend-verification'),
]
```
**Step 9 β Create email and page templates**
Four templates: `verify_email.html`, `verify_email.txt`, `registration_pending.html`, `verification_failed.html`. Standard Django template work β no architectural decisions here.
## 8. Effort Estimate
| Step | Size | Uncertainty |
|---|---|---|
| 1. Model + migrations + backfill | S | Low |
| 2. Verification service | S | Low |
| 3. Celery task | S | Low |
| 4. Modify registration view | S | Low |
| 5. Verify-email endpoint | S | Low |
| 6. Login blocking | S | Low |
| 7. Resend with rate limit | M | Low |
| 8. URL wiring | S | Low |
| 9. Templates | M | Low |
| Testing (all) | L | Medium |
| **Total** | **~3β4 days** | |
## 9. Validation and Error Handling
**Validations:** email format (Django handles), token signature integrity (TimestampSigner), rate limit on resend (Redis counter), `update_fields` on save to avoid race conditions.
**User-facing errors:**
| Scenario | Response | Status |
|---|---|---|
| Unverified user tries to login | "Please verify your email before logging in." + resend link | 200 (re-render form) |
| Expired verification link | "Link has expired. Request a new one." + resend link | 400 |
| Tampered/invalid token | "Invalid verification link." | 400 |
| Resend rate limit exceeded | "Too many requests. Try again later." | 429 |
**System errors:**
| Scenario | Response | Status |
|---|---|---|
| SMTP failure | Celery retries 3x, then task fails silently; user can resend | N/A (async) |
| Redis unavailable (rate limit) | Fail open β allow the resend, log error | 200 |
## 10. Testing Strategy
**Critical path (integration test):**
```python
# accounts/tests/test_verification_flow.py
from django.test import TestCase, override_settings
from django.contrib.auth import get_user_model
from accounts.services.verification_service import generate_verification_token
User = get_user_model()
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
class TestVerificationFlow(TestCase):
def test_full_flow(self):
# Register
resp = self.client.post('/accounts/register/', {
'username': 'testuser', 'email': 'test@example.com',
'password1': 'Str0ngP@ss!', 'password2': 'Str0ngP@ss!',
})
user = User.objects.get(username='testuser')
self.assertFalse(user.is_email_verified)
# Login blocked
resp = self.client.post('/accounts/login/', {
'username': 'testuser', 'password': 'Str0ngP@ss!',
})
self.assertContains(resp, 'verify your email')
# Verify
token = generate_verification_token(user.id)
resp = self.client.get(f'/accounts/verify-email/?token={token}')
user.refresh_from_db()
self.assertTrue(user.is_email_verified)
# Login works
self.assertTrue(self.client.login(username='testuser', password='Str0ngP@ss!'))
```
**Additional tests:**
- *Unit:* `verify_token` rejects expired tokens (mock time), rejects tampered tokens, returns correct user_id
- *Unit:* Resend rate limiting β 4th request in same hour returns 429
- *Unit:* Resend for unknown email returns same success message (no enumeration)
- *Integration:* Celery task retries on SMTP failure (`@override_settings` with broken email backend)
- *Edge:* User clicks verification link twice β second click is a no-op, still redirects to login with success
- *Edge:* Backfill migration β existing users have `is_email_verified=True`
## 11. Risks and Mitigation
| Risk | Likelihood | Impact | Mitigation | Step |
|---|---|---|---|---|
| Existing users locked out | High (if backfill missed) | Critical | Data migration backfills all active users to verified; deploy migration before code | 1 |
| Emails land in spam | Medium | High | Use verified SES domain, proper SPF/DKIM; plain text fallback | 3 |
| `base_url` wrong behind ALB/proxy | Medium | High | Use `request.build_absolute_uri` + ensure `SECURE_PROXY_SSL_HEADER` and `USE_X_FORWARDED_HOST` are set | 4 |
| TimestampSigner breaks on SECRET_KEY rotation | Low | High | Document: rotating SECRET_KEY invalidates all outstanding links; users can resend | 2 |
## 12. Refactoring Opportunities
- **Now:** If the project still uses `auth.User` directly (no custom user model), you must switch to a custom user model. This is a significant migration but is a one-time cost that Django strongly recommends doing early.
- **Later:** Extract email sending into a generic `notifications` service if other transactional emails exist or are planned.
- **Later:** Replace `request.build_absolute_uri` with a `SITE_BASE_URL` setting to avoid proxy-related bugs and make Celery tasks independent of request context.
## 13. Deployment Considerations
**Sequence matters:**
1. **Deploy migration first** (can be done independently β adding a boolean column with a default is safe, backfill is idempotent). Run via ECS one-off task or pre-deploy hook.
2. **Deploy code behind a feature flag** (Django Waffle or a simple settings boolean `EMAIL_VERIFICATION_ENABLED`). Login check becomes:
```python
if settings.EMAIL_VERIFICATION_ENABLED and not user.is_email_verified:
# block
```
3. **Phase 1:** Flag off. Deploy everything. Verify Celery task works by registering a test user in staging.
4. **Phase 2:** Flag on. New registrations require verification. Existing users (backfilled as verified) are unaffected.
5. **Phase 3:** Remove flag, clean up conditional logic.
**Configuration:** Ensure `DEFAULT_FROM_EMAIL`, SES credentials, and Redis cache backend are configured in ECS task definitions / environment variables. No new infrastructure β Celery and Redis already exist.
By purchasing this prompt, you agree to our terms of service
CLAUDE-4-6-OPUS
ποΈ Turn any user story into a full implementation plan β for any tech stack.
β
Architecture approach with tradeoffs analysis
β
Step-by-step plan with blockers and risk flags
β
Effort estimates, testing strategy, and deployment rollout
β
Code snippets adapted to your language and framework
π‘ Great for: feature planning, sprint prep, technical design reviews, onboarding senior devs, consulting deliverables, architecture documentation
π Instructions and tips included.
...more
Added 3 weeks ago
