feat: Multi-email ticketing system with platform email addresses

- Add PlatformEmailAddress model for managing platform-level email addresses
- Add TicketEmailAddress model for tenant-level email addresses
- Create MailServerService for IMAP integration with mail.talova.net
- Implement PlatformEmailReceiver for processing incoming platform emails
- Add email autoconfiguration for Mozilla, Microsoft, and Apple clients
- Add configurable email polling interval in platform settings
- Add "Check Emails" button on support page for manual refresh
- Add ticket counts to status tabs on support page
- Add platform email addresses management page
- Add Privacy Policy and Terms of Service pages
- Add robots.txt for SEO
- Restrict email addresses to smoothschedule.com domain only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-01 17:49:09 -05:00
parent 65da1c73d0
commit ae74b4c2ed
47 changed files with 6523 additions and 1407 deletions

View File

@@ -5,7 +5,7 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
from rest_framework import serializers
from core.models import Tenant, Domain
from smoothschedule.users.models import User
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
class PlatformSettingsSerializer(serializers.Serializer):
@@ -19,6 +19,7 @@ class PlatformSettingsSerializer(serializers.Serializer):
stripe_validation_error = serializers.CharField(read_only=True)
has_stripe_keys = serializers.SerializerMethodField()
stripe_keys_from_env = serializers.SerializerMethodField()
email_check_interval_minutes = serializers.IntegerField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
def get_stripe_secret_key_masked(self, obj):
@@ -108,8 +109,10 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
'id', 'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'created_at', 'updated_at'
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'is_most_popular', 'show_price',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
@@ -124,8 +127,10 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'create_stripe_product'
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
'is_active', 'is_public', 'is_most_popular', 'show_price',
'create_stripe_product'
]
def create(self, validated_data):
@@ -579,3 +584,255 @@ class TenantInvitationDetailSerializer(TenantInvitationSerializer):
'invited_by': {'read_only': True},
}
class AssignedUserSerializer(serializers.Serializer):
"""Lightweight serializer for assigned user info."""
id = serializers.IntegerField(read_only=True)
email = serializers.EmailField(read_only=True)
first_name = serializers.CharField(read_only=True)
last_name = serializers.CharField(read_only=True)
full_name = serializers.SerializerMethodField()
def get_full_name(self, obj):
return obj.get_full_name() or obj.email
class PlatformEmailAddressListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for listing platform email addresses."""
email_address = serializers.ReadOnlyField()
effective_sender_name = serializers.ReadOnlyField()
assigned_user = AssignedUserSerializer(read_only=True)
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'effective_sender_name',
'local_part', 'domain', 'email_address', 'color',
'assigned_user',
'is_active', 'is_default', 'mail_server_synced',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at'
]
read_only_fields = [
'email_address', 'effective_sender_name', 'mail_server_synced',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at'
]
class PlatformEmailAddressSerializer(serializers.ModelSerializer):
"""Full serializer for platform email addresses."""
email_address = serializers.ReadOnlyField()
effective_sender_name = serializers.ReadOnlyField()
assigned_user = AssignedUserSerializer(read_only=True)
assigned_user_id = serializers.IntegerField(
write_only=True,
required=False,
allow_null=True
)
imap_settings = serializers.SerializerMethodField()
smtp_settings = serializers.SerializerMethodField()
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'effective_sender_name',
'local_part', 'domain', 'email_address', 'color',
'assigned_user', 'assigned_user_id',
'password', 'is_active', 'is_default',
'mail_server_synced', 'last_sync_error', 'last_synced_at',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at',
'imap_settings', 'smtp_settings'
]
read_only_fields = [
'email_address', 'effective_sender_name',
'mail_server_synced', 'last_sync_error', 'last_synced_at',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at',
'imap_settings', 'smtp_settings'
]
extra_kwargs = {
'password': {'write_only': True},
}
def validate_assigned_user_id(self, value):
"""Validate and convert assigned_user_id to User instance."""
if value is None:
return None
from smoothschedule.users.models import User
try:
user = User.objects.get(
pk=value,
role__in=['superuser', 'platform_manager', 'platform_support'],
is_active=True
)
return user
except User.DoesNotExist:
raise serializers.ValidationError("User not found or not a platform user.")
def create(self, validated_data):
assigned_user = validated_data.pop('assigned_user_id', None)
instance = super().create(validated_data)
if assigned_user is not None:
instance.assigned_user = assigned_user
instance.save(update_fields=['assigned_user'])
return instance
def update(self, instance, validated_data):
if 'assigned_user_id' in validated_data:
instance.assigned_user = validated_data.pop('assigned_user_id')
return super().update(instance, validated_data)
def get_imap_settings(self, obj):
"""Return IMAP settings without password."""
settings = obj.get_imap_settings()
settings.pop('password', None)
return settings
def get_smtp_settings(self, obj):
"""Return SMTP settings without password."""
settings = obj.get_smtp_settings()
settings.pop('password', None)
return settings
def validate_local_part(self, value):
"""Validate local part of email address."""
import re
value = value.lower().strip()
# Check format
if not re.match(r'^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$', value):
raise serializers.ValidationError(
"Local part must start and end with a letter or number, "
"and can only contain letters, numbers, dots, underscores, and hyphens"
)
if len(value) > 64:
raise serializers.ValidationError("Local part cannot exceed 64 characters")
return value
def validate_password(self, value):
"""Validate password strength."""
if len(value) < 8:
raise serializers.ValidationError("Password must be at least 8 characters")
return value
def validate(self, attrs):
"""Cross-field validation."""
local_part = attrs.get('local_part', getattr(self.instance, 'local_part', None))
domain = attrs.get('domain', getattr(self.instance, 'domain', None))
if local_part and domain:
# Check uniqueness
qs = PlatformEmailAddress.objects.filter(
local_part=local_part.lower(),
domain=domain
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError({
'local_part': f'Email address {local_part}@{domain} already exists'
})
return attrs
class PlatformEmailAddressCreateSerializer(PlatformEmailAddressSerializer):
"""Serializer for creating platform email addresses with mail server sync."""
def create(self, validated_data):
"""Create the email address and sync to mail server."""
from .mail_server import get_mail_server_service
# Normalize local_part
validated_data['local_part'] = validated_data['local_part'].lower()
# Create the database record first
instance = super().create(validated_data)
# Sync to mail server
service = get_mail_server_service()
success, message = service.sync_account(instance)
if not success:
# Delete the database record if mail server sync failed
instance.delete()
raise serializers.ValidationError({
'mail_server': f'Failed to create email account on mail server: {message}'
})
return instance
class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
"""Serializer for updating platform email addresses."""
email_address = serializers.ReadOnlyField()
assigned_user_id = serializers.IntegerField(
write_only=True,
required=False,
allow_null=True
)
class Meta:
model = PlatformEmailAddress
fields = [
'id', 'display_name', 'sender_name', 'email_address', 'color',
'assigned_user_id',
'password', 'is_active', 'is_default'
]
read_only_fields = ['id', 'email_address']
extra_kwargs = {
'password': {'write_only': True, 'required': False},
'sender_name': {'required': False},
}
def validate_assigned_user_id(self, value):
"""Validate and convert assigned_user_id to User instance."""
if value is None:
return None
from smoothschedule.users.models import User
try:
user = User.objects.get(
pk=value,
role__in=['superuser', 'platform_manager', 'platform_support'],
is_active=True
)
return user
except User.DoesNotExist:
raise serializers.ValidationError("User not found or not a platform user.")
def validate_password(self, value):
"""Validate password strength if provided."""
if value and len(value) < 8:
raise serializers.ValidationError("Password must be at least 8 characters")
return value
def update(self, instance, validated_data):
"""Update the email address and sync password to mail server if changed."""
from .mail_server import get_mail_server_service
password = validated_data.get('password')
password_changed = password and password != instance.password
# Handle assigned_user_id separately
if 'assigned_user_id' in validated_data:
instance.assigned_user = validated_data.pop('assigned_user_id')
# Update the instance
instance = super().update(instance, validated_data)
# Sync to mail server if password changed
if password_changed:
service = get_mail_server_service()
success, message = service.sync_account(instance)
if not success:
raise serializers.ValidationError({
'mail_server': f'Failed to update password on mail server: {message}'
})
return instance