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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user