first commit

This commit is contained in:
pacnpal
2024-10-28 17:09:57 -04:00
commit 2e1b4d7af7
9993 changed files with 1182741 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
email_service/admin.py Normal file
View File

@@ -0,0 +1,36 @@
from django.contrib import admin
from django.contrib.sites.models import Site
from django.contrib.sites.shortcuts import get_current_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)

6
email_service/apps.py Normal file
View File

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

91
email_service/backends.py Normal file
View File

@@ -0,0 +1,91 @@
from django.core.mail.backends.base import BaseEmailBackend
from django.contrib.sites.shortcuts import get_current_site
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 as e:
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 as e:
if not self.fail_silently:
raise
return False

View File

@@ -0,0 +1,150 @@
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 accounts.adapters import CustomAccountAdapter
from email_service.services import EmailService
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()
self.client = Client(enforce_csrf_checks=False) # Disable CSRF for testing
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-REMOVED]"
self.new_[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,213 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.core.mail import send_mail, get_connection
from django.conf import settings
import requests
import json
import os
from email_service.models import EmailConfiguration
from email_service.services import EmailService
from 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***REMOVED***iron.get('FORWARD_EMAIL_API_KEY')
from_email = os***REMOVED***iron.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(f'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',
}
)
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,33 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('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',
},
),
]

View File

18
email_service/models.py Normal file
View File

@@ -0,0 +1,18 @@
from django.db import models
from django.contrib.sites.models import Site
class EmailConfiguration(models.Model):
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"

92
email_service/services.py Normal file
View File

@@ -0,0 +1,92 @@
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, subject, text, from_email=None, html=None, reply_to=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,
)
# 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)}")

3
email_service/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

6
email_service/urls.py Normal file
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'),
]

45
email_service/views.py Normal file
View File

@@ -0,0 +1,45 @@
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)