feat: Add SMTP settings and collapsible email configuration UI

- Add SMTP fields to TicketEmailSettings model (host, port, TLS/SSL, credentials, from email/name)
- Update serializers with SMTP fields and is_smtp_configured flag
- Add TicketEmailTestSmtpView for testing SMTP connections
- Update frontend API types and hooks for SMTP settings
- Add collapsible IMAP and SMTP configuration sections with "Configured" badges
- Fix TypeScript errors in mockData.ts (missing required fields, type mismatches)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-29 18:28:29 -05:00
parent 0c7d76e264
commit cfc1b36ada
94 changed files with 13419 additions and 1121 deletions

View File

@@ -8,13 +8,14 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.decorators import action
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
EventPluginSerializer, GlobalEventPluginSerializer
EventPluginSerializer, GlobalEventPluginSerializer,
EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer
)
from .models import Service
from core.permissions import HasQuota
@@ -1240,3 +1241,193 @@ class GlobalEventPluginViewSet(viewsets.ModelViewSet):
{'value': 60, 'label': '1 hour'},
],
})
class EmailTemplateViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing email templates.
Email templates can be used by plugins to send customized emails.
Templates support variable substitution for dynamic content.
Access Control:
- Business users see only BUSINESS scope templates (their own tenant's)
- Platform users can also see/create PLATFORM scope templates (shared)
Endpoints:
- GET /api/email-templates/ - List templates (filtered by scope/category)
- POST /api/email-templates/ - Create template
- GET /api/email-templates/{id}/ - Get template details
- PATCH /api/email-templates/{id}/ - Update template
- DELETE /api/email-templates/{id}/ - Delete template
- POST /api/email-templates/preview/ - Render preview with sample data
- POST /api/email-templates/{id}/duplicate/ - Create a copy
- GET /api/email-templates/variables/ - Get available template variables
"""
queryset = EmailTemplate.objects.all()
serializer_class = EmailTemplateSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Filter templates based on user type and query params"""
user = self.request.user
queryset = super().get_queryset()
# Platform users see all templates
if hasattr(user, 'is_platform_user') and user.is_platform_user:
scope = self.request.query_params.get('scope')
if scope:
queryset = queryset.filter(scope=scope.upper())
else:
# Business users only see BUSINESS scope templates
queryset = queryset.filter(scope=EmailTemplate.Scope.BUSINESS)
# Filter by category if specified
category = self.request.query_params.get('category')
if category:
queryset = queryset.filter(category=category.upper())
return queryset.order_by('name')
def get_serializer_class(self):
"""Use lightweight serializer for list view"""
if self.action == 'list':
return EmailTemplateListSerializer
return EmailTemplateSerializer
def perform_create(self, serializer):
"""Set created_by from request user"""
serializer.save(created_by=self.request.user)
@action(detail=False, methods=['post'])
def preview(self, request):
"""
Render a preview of the template with sample data.
Request body:
{
"subject": "Hello {{CUSTOMER_NAME}}",
"html_content": "<p>Your appointment is on {{APPOINTMENT_DATE}}</p>",
"text_content": "Your appointment is on {{APPOINTMENT_DATE}}",
"context": {"CUSTOMER_NAME": "John"} // optional overrides
}
Response includes rendered content with force_footer flag for free tier.
"""
serializer = EmailTemplatePreviewSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
from .template_parser import TemplateVariableParser
from datetime import datetime
context = serializer.validated_data.get('context', {})
subject = serializer.validated_data['subject']
html = serializer.validated_data.get('html_content', '')
text = serializer.validated_data.get('text_content', '')
# Add default sample values for preview
default_context = {
'BUSINESS_NAME': 'Demo Business',
'BUSINESS_EMAIL': 'contact@demo.com',
'BUSINESS_PHONE': '(555) 123-4567',
'CUSTOMER_NAME': 'John Doe',
'CUSTOMER_EMAIL': 'john@example.com',
'APPOINTMENT_TIME': 'Monday, January 15, 2025 at 2:00 PM',
'APPOINTMENT_DATE': 'January 15, 2025',
'APPOINTMENT_SERVICE': 'Consultation',
'TODAY': datetime.now().strftime('%B %d, %Y'),
'NOW': datetime.now().strftime('%B %d, %Y at %I:%M %p'),
}
default_context.update(context)
rendered_subject = TemplateVariableParser.replace_insertion_codes(subject, default_context)
rendered_html = TemplateVariableParser.replace_insertion_codes(html, default_context) if html else ''
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context) if text else ''
# Check if free tier - append footer
force_footer = False
user = request.user
if hasattr(user, 'is_platform_user') and not user.is_platform_user:
from django.db import connection
if hasattr(connection, 'tenant') and connection.tenant.subscription_tier == 'FREE':
force_footer = True
if force_footer:
# Create a temporary instance just to use the footer methods
temp = EmailTemplate()
rendered_html = temp._append_html_footer(rendered_html)
rendered_text = temp._append_text_footer(rendered_text)
return Response({
'subject': rendered_subject,
'html_content': rendered_html,
'text_content': rendered_text,
'force_footer': force_footer,
})
@action(detail=True, methods=['post'])
def duplicate(self, request, pk=None):
"""
Create a copy of an existing template.
The copy will have "(Copy)" appended to its name.
"""
template = self.get_object()
new_template = EmailTemplate.objects.create(
name=f"{template.name} (Copy)",
description=template.description,
subject=template.subject,
html_content=template.html_content,
text_content=template.text_content,
scope=template.scope,
category=template.category,
preview_context=template.preview_context,
created_by=request.user,
)
serializer = EmailTemplateSerializer(new_template)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['get'])
def variables(self, request):
"""
Get available template variables for the email template editor.
Returns variables grouped by category with descriptions.
"""
return Response({
'variables': [
{
'category': 'Business',
'items': [
{'code': '{{BUSINESS_NAME}}', 'description': 'Business name'},
{'code': '{{BUSINESS_EMAIL}}', 'description': 'Business contact email'},
{'code': '{{BUSINESS_PHONE}}', 'description': 'Business phone number'},
]
},
{
'category': 'Customer',
'items': [
{'code': '{{CUSTOMER_NAME}}', 'description': 'Customer full name'},
{'code': '{{CUSTOMER_EMAIL}}', 'description': 'Customer email address'},
]
},
{
'category': 'Appointment',
'items': [
{'code': '{{APPOINTMENT_TIME}}', 'description': 'Full date and time'},
{'code': '{{APPOINTMENT_DATE}}', 'description': 'Date only'},
{'code': '{{APPOINTMENT_SERVICE}}', 'description': 'Service name'},
]
},
{
'category': 'Date/Time',
'items': [
{'code': '{{TODAY}}', 'description': 'Current date'},
{'code': '{{NOW}}', 'description': 'Current date and time'},
]
},
],
'categories': [choice[0] for choice in EmailTemplate.Category.choices],
})