feat: complete monorepo structure with frontend and shared resources

- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
This commit is contained in:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

View File

@@ -0,0 +1,39 @@
from django.contrib import admin
from django.contrib.sites.models import Site
from .models import EmailConfiguration
@admin.register(EmailConfiguration)
class EmailConfigurationAdmin(admin.ModelAdmin):
list_display = (
"site",
"from_name",
"from_email",
"reply_to",
"updated_at",
)
list_select_related = ("site",)
search_fields = ("site__domain", "from_name", "from_email", "reply_to")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("site",)}),
(
"Email Settings",
{
"fields": ("api_key", ("from_name", "from_email"), "reply_to"),
"description": 'Configure the email settings. The From field in emails will appear as "From Name <from@email.com>"',
},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related("site")
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "site":
kwargs["queryset"] = Site.objects.all().order_by("domain")
return super().formfield_for_foreignkey(db_field, request, **kwargs)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailServiceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.email_service"

View File

@@ -0,0 +1,102 @@
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address
from .services import EmailService
from .models import EmailConfiguration
class ForwardEmailBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
self.site = kwargs.get("site", None)
def send_messages(self, email_messages):
"""
Send one or more EmailMessage objects and return the number of email
messages sent.
"""
if not email_messages:
return 0
num_sent = 0
for message in email_messages:
try:
sent = self._send(message)
if sent:
num_sent += 1
except Exception:
if not self.fail_silently:
raise
return num_sent
def _send(self, email_message):
"""Send an EmailMessage object."""
if not email_message.recipients():
return False
# Get the first recipient (ForwardEmail API sends to one recipient at a
# time)
to_email = email_message.to[0]
# Get site from connection or instance
if hasattr(email_message, "connection") and hasattr(
email_message.connection, "site"
):
site = email_message.connection.site
else:
site = self.site
if not site:
raise ValueError("Either request or site must be provided")
# Get the site's email configuration
try:
config = EmailConfiguration.objects.get(site=site)
except EmailConfiguration.DoesNotExist:
raise ValueError(
f"Email configuration not found for site: {
site.domain}"
)
# Get the from email, falling back to site's default if not provided
if email_message.from_email:
from_email = sanitize_address(
email_message.from_email, email_message.encoding
)
else:
from_email = config.default_from_email
# Extract clean email address
from_email = EmailService.extract_email(from_email)
# Get reply-to from message headers or use default
reply_to = None
if hasattr(email_message, "reply_to") and email_message.reply_to:
reply_to = email_message.reply_to[0]
elif (
hasattr(email_message, "extra_headers")
and "Reply-To" in email_message.extra_headers
):
reply_to = email_message.extra_headers["Reply-To"]
# Get message content
if email_message.content_subtype == "html":
# If it's HTML content, we'll send it as text for now
# You could extend this to support HTML emails if needed
text = email_message.body
else:
text = email_message.body
try:
EmailService.send_email(
to=to_email,
subject=email_message.subject,
text=text,
from_email=from_email,
reply_to=reply_to,
site=site,
)
return True
except Exception:
if not self.fail_silently:
raise
return False

View File

@@ -0,0 +1,196 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.test import RequestFactory, Client
from allauth.account.models import EmailAddress
from apps.accounts.adapters import CustomAccountAdapter
from django.conf import settings
import uuid
User = get_user_model()
class Command(BaseCommand):
help = "Test all email flows in the application"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.factory = RequestFactory()
# Disable CSRF for testing
self.client = Client(enforce_csrf_checks=False)
self.adapter = CustomAccountAdapter()
self.site = Site.objects.get_current()
# Generate unique test data
unique_id = str(uuid.uuid4())[:8]
self.test_username = f"testuser_{unique_id}"
self.test_email = f"test_{unique_id}@thrillwiki.com"
self.test_password = "[PASSWORD-REMOVED]"
self.new_password = "[PASSWORD-REMOVED]"
# Add testserver to ALLOWED_HOSTS
if "testserver" not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.append("testserver")
def handle(self, *args, **options):
self.stdout.write("Starting email flow tests...\n")
# Clean up any existing test users
User.objects.filter(email__endswith="@thrillwiki.com").delete()
# Test registration email
self.test_registration()
# Create a test user for other flows
user = User.objects.create_user(
username=f"testuser2_{str(uuid.uuid4())[:8]}",
email=f"test2_{str(uuid.uuid4())[:8]}@thrillwiki.com",
password=self.test_password,
)
EmailAddress.objects.create(
user=user, email=user.email, primary=True, verified=True
)
# Log in the test user
self.client.force_login(user)
# Test other flows
self.test_password_change(user)
self.test_email_change(user)
self.test_password_reset(user)
# Cleanup
User.objects.filter(email__endswith="@thrillwiki.com").delete()
self.stdout.write(self.style.SUCCESS("All email flow tests completed!\n"))
def test_registration(self):
"""Test registration email flow"""
self.stdout.write("Testing registration email...")
try:
# Use dj-rest-auth registration endpoint
response = self.client.post(
"/api/auth/registration/",
{
"username": self.test_username,
"email": self.test_email,
"password1": self.test_password,
"password2": self.test_password,
},
content_type="application/json",
)
if response.status_code in [200, 201, 204]:
self.stdout.write(
self.style.SUCCESS("Registration email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Registration returned status {
response.status_code}: {
response.content.decode()}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Registration email test failed: {
str(e)}\n"
)
)
def test_password_change(self, user):
"""Test password change using dj-rest-auth"""
self.stdout.write("Testing password change email...")
try:
response = self.client.post(
"/api/auth/password/change/",
{
"old_password": self.test_password,
"new_password1": self.new_password,
"new_password2": self.new_password,
},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Password change email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Password change returned status {
response.status_code}: {
response.content.decode()}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Password change email test failed: {
str(e)}\n"
)
)
def test_email_change(self, user):
"""Test email change verification"""
self.stdout.write("Testing email change verification...")
try:
new_email = f"newemail_{str(uuid.uuid4())[:8]}@thrillwiki.com"
response = self.client.post(
"/api/auth/email/",
{"email": new_email},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Email change verification test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Email change returned status {
response.status_code}: {
response.content.decode()}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Email change verification test failed: {
str(e)}\n"
)
)
def test_password_reset(self, user):
"""Test password reset using dj-rest-auth"""
self.stdout.write("Testing password reset email...")
try:
# Request password reset
response = self.client.post(
"/api/auth/password/reset/",
{"email": user.email},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Password reset email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Password reset returned status {
response.status_code}: {
response.content.decode()}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"Password reset email test failed: {
str(e)}\n"
)
)

View File

@@ -0,0 +1,244 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.conf import settings
import requests
import os
from apps.email_service.models import EmailConfiguration
from apps.email_service.services import EmailService
from apps.email_service.backends import ForwardEmailBackend
class Command(BaseCommand):
help = "Test the email service functionality"
def add_arguments(self, parser):
parser.add_argument(
"--to",
type=str,
help="Recipient email address (optional, defaults to current user's email)",
)
parser.add_argument(
"--api-key",
type=str,
help="ForwardEmail API key (optional, will use configured value)",
)
parser.add_argument(
"--from-email",
type=str,
help="Sender email address (optional, will use configured value)",
)
def get_config(self):
"""Get email configuration from database or environment"""
try:
site = Site.objects.get(id=settings.SITE_ID)
config = EmailConfiguration.objects.get(site=site)
return {
"api_key": config.api_key,
"from_email": config.default_from_email,
"site": site,
}
except (Site.DoesNotExist, EmailConfiguration.DoesNotExist):
# Try environment variables
api_key = os.environ.get("FORWARD_EMAIL_API_KEY")
from_email = os.environ.get("FORWARD_EMAIL_FROM")
if not api_key or not from_email:
self.stdout.write(
self.style.WARNING(
"No configuration found in database or environment variables.\n"
"Please either:\n"
"1. Configure email settings in Django admin, or\n"
"2. Set environment variables FORWARD_EMAIL_API_KEY and FORWARD_EMAIL_FROM, or\n"
"3. Provide --api-key and --from-email arguments"
)
)
return None
return {
"api_key": api_key,
"from_email": from_email,
"site": Site.objects.get(id=settings.SITE_ID),
}
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting email service tests..."))
# Get configuration
config = self.get_config()
if not config and not (options["api_key"] and options["from_email"]):
self.stdout.write(
self.style.ERROR("No email configuration available. Tests aborted.")
)
return
# Use provided values or fall back to config
api_key = options["api_key"] or config["api_key"]
from_email = options["from_email"] or config["from_email"]
site = config["site"]
# If no recipient specified, use the from_email address for testing
to_email = options["to"] or "test@thrillwiki.com"
self.stdout.write(self.style.SUCCESS("Using configuration:"))
self.stdout.write(f" From: {from_email}")
self.stdout.write(f" To: {to_email}")
self.stdout.write(f' API Key: {"*" * len(api_key)}')
self.stdout.write(f" Site: {site.domain}")
try:
# 1. Test site configuration
config = self.test_site_configuration(api_key, from_email)
# 2. Test direct service
self.test_email_service_directly(to_email, config.site)
# 3. Test API endpoint
self.test_api_endpoint(to_email)
# 4. Test Django email backend
self.test_email_backend(to_email, config.site)
self.stdout.write(
self.style.SUCCESS("\nAll tests completed successfully! 🎉")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"\nTests failed: {str(e)}"))
def test_site_configuration(self, api_key, from_email):
"""Test creating and retrieving site configuration"""
self.stdout.write("\nTesting site configuration...")
try:
# Get or create default site
site = Site.objects.get_or_create(
id=settings.SITE_ID,
defaults={"domain": "example.com", "name": "example.com"},
)[0]
# Create or update email configuration
config, created = EmailConfiguration.objects.update_or_create(
site=site,
defaults={
"api_key": api_key,
"default_from_email": from_email,
},
)
action = "Created new" if created else "Updated existing"
self.stdout.write(self.style.SUCCESS(f"{action} site configuration"))
return config
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"✗ Site configuration failed: {
str(e)}"
)
)
raise
def test_api_endpoint(self, to_email):
"""Test sending email via the API endpoint"""
self.stdout.write("\nTesting API endpoint...")
try:
# Make request to the API endpoint
response = requests.post(
"http://127.0.0.1:8000/api/email/send-email/",
json={
"to": to_email,
"subject": "Test Email via API",
"text": "This is a test email sent via the API endpoint.",
},
headers={
"Content-Type": "application/json",
},
timeout=60,
)
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS("✓ API endpoint test successful"))
else:
self.stdout.write(
self.style.ERROR(
f"✗ API endpoint test failed with status {
response.status_code}: {
response.text}"
)
)
raise Exception(f"API test failed: {response.text}")
except requests.exceptions.ConnectionError:
self.stdout.write(
self.style.ERROR(
"✗ API endpoint test failed: Could not connect to server. "
"Make sure the Django development server is running."
)
)
raise Exception("Could not connect to Django server")
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"✗ API endpoint test failed: {
str(e)}"
)
)
raise
def test_email_backend(self, to_email, site):
"""Test sending email via Django's email backend"""
self.stdout.write("\nTesting Django email backend...")
try:
# Create a connection with site context
backend = ForwardEmailBackend(fail_silently=False, site=site)
# Debug output
self.stdout.write(
f" Debug: Using from_email: {
site.email_config.default_from_email}"
)
self.stdout.write(f" Debug: Using to_email: {to_email}")
send_mail(
subject="Test Email via Backend",
message="This is a test email sent via the Django email backend.",
from_email=site.email_config.default_from_email, # Explicitly set from_email
recipient_list=[to_email],
fail_silently=False,
connection=backend,
)
self.stdout.write(self.style.SUCCESS("✓ Email backend test successful"))
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"✗ Email backend test failed: {
str(e)}"
)
)
raise
def test_email_service_directly(self, to_email, site):
"""Test sending email directly via EmailService"""
self.stdout.write("\nTesting EmailService directly...")
try:
response = EmailService.send_email(
to=to_email,
subject="Test Email via Service",
text="This is a test email sent directly via the EmailService.",
site=site,
)
self.stdout.write(
self.style.SUCCESS("✓ Direct EmailService test successful")
)
return response
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"✗ Direct EmailService test failed: {
str(e)}"
)
)
raise

View File

@@ -0,0 +1,141 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("sites", "0002_alter_domain_unique"),
]
operations = [
migrations.CreateModel(
name="EmailConfiguration",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="sites.site",
),
),
],
options={
"verbose_name": "Email Configuration",
"verbose_name_plural": "Email Configurations",
},
),
migrations.CreateModel(
name="EmailConfigurationEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="email_service.emailconfiguration",
),
),
(
"site",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="sites.site",
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,25 @@
from django.db import models
from django.contrib.sites.models import Site
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class EmailConfiguration(TrackedModel):
api_key = models.CharField(max_length=255)
from_email = models.EmailField()
from_name = models.CharField(
max_length=255,
help_text="The name that will appear in the From field of emails",
)
reply_to = models.EmailField()
site = models.ForeignKey(Site, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.from_name} <{self.from_email}>"
class Meta:
verbose_name = "Email Configuration"
verbose_name_plural = "Email Configurations"

View File

@@ -0,0 +1,112 @@
import requests
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.message import sanitize_address
from .models import EmailConfiguration
import json
import base64
class EmailService:
@staticmethod
def send_email(
*,
to: str,
subject: str,
text: str,
from_email: str = None,
html: str = None,
reply_to: str = None,
request=None,
site=None,
):
# Get the site configuration
if site is None and request is not None:
site = get_current_site(request)
elif site is None:
raise ImproperlyConfigured("Either request or site must be provided")
try:
# Fetch the email configuration for the current site
email_config = EmailConfiguration.objects.get(site=site)
api_key = email_config.api_key
# Use provided from_email or construct from config
if not from_email:
from_email = f"{
email_config.from_name} <{
email_config.from_email}>"
elif "<" not in from_email:
# If from_email is provided but doesn't include a name, add the
# configured name
from_email = f"{email_config.from_name} <{from_email}>"
# Use provided reply_to or fall back to config
if not reply_to:
reply_to = email_config.reply_to
except EmailConfiguration.DoesNotExist:
raise ImproperlyConfigured(
f"Email configuration is missing for site: {site.domain}"
)
# Ensure the reply_to address is clean
reply_to = sanitize_address(reply_to, "utf-8")
# Format data for the API
data = {
"from": from_email, # Now includes the name in format "Name <email@domain.com>"
"to": to,
"subject": subject,
"text": text,
"replyTo": reply_to,
}
# Add HTML version if provided
if html:
data["html"] = html
# Debug output
print("\nEmail Service Debug:")
print(f"From: {from_email}")
print(f"To: {to}")
print(f"Reply-To: {reply_to}")
print(f"API Key: {api_key}")
print(f"Site: {site.domain}")
print(f"Request URL: {settings.FORWARD_EMAIL_BASE_URL}/v1/emails")
print(f"Request Data: {json.dumps(data, indent=2)}")
# Create Basic auth header with API key as username and empty password
auth_header = base64.b64encode(f"{api_key}:".encode()).decode()
headers = {
"Authorization": f"Basic {auth_header}",
"Accept": "application/json",
"Content-Type": "application/json",
}
try:
response = requests.post(
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
json=data,
headers=headers,
timeout=60,
)
# Debug output
print(f"Response Status: {response.status_code}")
print(f"Response Headers: {dict(response.headers)}")
print(f"Response Body: {response.text}")
if response.status_code != 200:
error_message = response.text if response.text else "Unknown error"
raise Exception(
f"Failed to send email (Status {
response.status_code}): {error_message}"
)
return response.json()
except requests.RequestException as e:
raise Exception(f"Failed to send email: {str(e)}")
except Exception as e:
raise Exception(f"Failed to send email: {str(e)}")

View File

@@ -0,0 +1 @@
# Create your tests here.

View File

@@ -0,0 +1,6 @@
from django.urls import path
from .views import SendEmailView
urlpatterns = [
path("send-email/", SendEmailView.as_view(), name="send-email"),
]

View File

@@ -0,0 +1,49 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.contrib.sites.shortcuts import get_current_site
from .services import EmailService
class SendEmailView(APIView):
permission_classes = [AllowAny] # Allow unauthenticated access
def post(self, request):
data = request.data
to = data.get("to")
subject = data.get("subject")
text = data.get("text")
from_email = data.get("from_email") # Optional
if not all([to, subject, text]):
return Response(
{
"error": "Missing required fields",
"required_fields": ["to", "subject", "text"],
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Get the current site
site = get_current_site(request)
# Send email using the site's configuration
response = EmailService.send_email(
to=to,
subject=subject,
text=text,
from_email=from_email, # Will use site's default if None
site=site,
)
return Response(
{"message": "Email sent successfully", "response": response},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)