- 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>
18 KiB
Email Template Generator Implementation Plan
Overview
Create an email template system that allows both platform admins and business users to create reusable email templates. Templates can be attached to plugins via a new template variable type that prompts users to select from their email templates.
Requirements Summary
- Dual access: Available in both platform admin and business areas
- Both formats: Support text and HTML email templates
- Visual preview: Show how the final email will look
- Plugin integration: Templates attachable via a new template tag type
- Separate templates: Platform and business templates are completely separate
- Footer enforcement: Free tier businesses must show "Powered by Smooth Schedule" footer (non-overridable)
- Editor modes: Both visual builder AND code editor views
Phase 1: Backend - EmailTemplate Model
1.1 Create EmailTemplate Model
Location: schedule/models.py
class EmailTemplate(models.Model):
"""
Reusable email template for plugins and automations.
Supports both text and HTML content with template variable substitution.
"""
class Scope(models.TextChoices):
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Email structure
subject = models.CharField(max_length=500, help_text="Email subject line - supports template variables")
html_content = models.TextField(blank=True, help_text="HTML email body")
text_content = models.TextField(blank=True, help_text="Plain text email body")
# Scope
scope = models.CharField(
max_length=20,
choices=Scope.choices,
default=Scope.BUSINESS,
)
# Only for PLATFORM scope templates
is_default = models.BooleanField(default=False, help_text="Default template for certain triggers")
# Metadata
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='created_email_templates'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Category for organization
category = models.CharField(
max_length=50,
choices=[
('APPOINTMENT', 'Appointment'),
('REMINDER', 'Reminder'),
('CONFIRMATION', 'Confirmation'),
('MARKETING', 'Marketing'),
('NOTIFICATION', 'Notification'),
('REPORT', 'Report'),
('OTHER', 'Other'),
],
default='OTHER'
)
# Preview data for visual preview
preview_context = models.JSONField(
default=dict,
blank=True,
help_text="Sample data for rendering preview"
)
class Meta:
ordering = ['name']
indexes = [
models.Index(fields=['scope', 'category']),
]
def __str__(self):
return f"{self.name} ({self.get_scope_display()})"
def render(self, context: dict, force_footer: bool = False) -> tuple[str, str, str]:
"""
Render the template with given context.
Args:
context: Dictionary of template variables
force_footer: If True, append "Powered by Smooth Schedule" footer
Returns:
Tuple of (subject, html_content, text_content)
"""
from .template_parser import TemplateVariableParser
subject = TemplateVariableParser.replace_insertion_codes(
self.subject, context
)
html = TemplateVariableParser.replace_insertion_codes(
self.html_content, context
) if self.html_content else ''
text = TemplateVariableParser.replace_insertion_codes(
self.text_content, context
) if self.text_content else ''
# Append footer for free tier if applicable
if force_footer:
html = self._append_html_footer(html)
text = self._append_text_footer(text)
return subject, html, text
def _append_html_footer(self, html: str) -> str:
"""Append Powered by Smooth Schedule footer to HTML"""
footer = '''
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
<p>
Powered by
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
SmoothSchedule
</a>
</p>
</div>
'''
# Insert before </body> if present, otherwise append
if '</body>' in html.lower():
import re
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
return html + footer
def _append_text_footer(self, text: str) -> str:
"""Append Powered by Smooth Schedule footer to plain text"""
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
return text + footer
1.2 Update TemplateVariableParser
Location: schedule/template_parser.py
Add new variable type email_template:
# Add to VARIABLE_PATTERN handling
# Format: {{PROMPT:variable_name|description||email_template}}
# When type == 'email_template', the frontend will:
# 1. Fetch available email templates from /api/email-templates/
# 2. Show a dropdown selector
# 3. Store the selected template ID in config_values
1.3 Create Serializers
Location: schedule/serializers.py
class EmailTemplateSerializer(serializers.ModelSerializer):
"""Full serializer for CRUD operations"""
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True)
class Meta:
model = EmailTemplate
fields = [
'id', 'name', 'description', 'subject',
'html_content', 'text_content', 'scope',
'is_default', 'category', 'preview_context',
'created_by', 'created_by_name',
'created_at', 'updated_at',
]
read_only_fields = ['created_at', 'updated_at', 'created_by']
class EmailTemplateListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for dropdowns"""
class Meta:
model = EmailTemplate
fields = ['id', 'name', 'description', 'category', 'scope']
class EmailTemplatePreviewSerializer(serializers.Serializer):
"""Serializer for preview endpoint"""
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True)
text_content = serializers.CharField(allow_blank=True)
context = serializers.DictField(required=False, default=dict)
1.4 Create ViewSet
Location: schedule/views.py
class EmailTemplateViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing email templates.
- Business users see only BUSINESS scope templates
- Platform users can also see/create PLATFORM scope templates
"""
serializer_class = EmailTemplateSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
user = self.request.user
# Platform users see all templates
if user.is_platform_user:
scope = self.request.query_params.get('scope')
if scope:
return EmailTemplate.objects.filter(scope=scope.upper())
return EmailTemplate.objects.all()
# Business users only see BUSINESS scope templates
return EmailTemplate.objects.filter(scope=EmailTemplate.Scope.BUSINESS)
def perform_create(self, serializer):
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"""
serializer = EmailTemplatePreviewSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
from .template_parser import TemplateVariableParser
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
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)
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context)
# Check if free tier - append footer
force_footer = False
if not request.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:
rendered_html = EmailTemplate._append_html_footer(None, rendered_html)
rendered_text = EmailTemplate._append_text_footer(None, 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"""
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,
)
return Response(EmailTemplateSerializer(new_template).data, status=201)
Phase 2: Plugin Integration
2.1 Update Template Parser for email_template Type
Location: schedule/template_parser.py
# In extract_variables method, when type == 'email_template':
# Return special metadata to indicate dropdown
@classmethod
def _infer_type(cls, var_name: str, description: str) -> str:
# ... existing logic ...
# Check for explicit email_template type
# This is handled in the main extraction logic
pass
2.2 Update Plugin Execution to Handle Email Templates
Location: schedule/tasks.py
def execute_plugin_with_email(plugin_code: str, config_values: dict, context: dict):
"""
Execute plugin code with email template support.
When config_values contains an email_template_id, load and render it.
"""
# Check for email template references in config
for key, value in config_values.items():
if key.endswith('_email_template') and isinstance(value, int):
# Load the email template
try:
template = EmailTemplate.objects.get(id=value)
# Render and make available in context
subject, html, text = template.render(context)
context[f'{key}_subject'] = subject
context[f'{key}_html'] = html
context[f'{key}_text'] = text
except EmailTemplate.DoesNotExist:
pass
# Continue with normal execution
# ...
Phase 3: Frontend - Email Template Editor
3.1 Create EmailTemplateEditor Component
Location: frontend/src/pages/EmailTemplates.tsx
Main page with:
- List of templates with search/filter
- Create/Edit modal with dual-mode editor
- Preview panel
3.2 Create EmailTemplateForm Component
Location: frontend/src/components/EmailTemplateForm.tsx
Features:
- Subject line editor - Simple text input with variable insertion
- Content editor - Tabbed interface:
- Visual mode: WYSIWYG editor (TipTap or similar)
- Code mode: Monaco/CodeMirror for raw HTML
- Plain text editor - Textarea with variable insertion buttons
- Preview panel - Live rendering of HTML email
3.3 Variable Insertion Toolbar
Available variables shown as clickable chips:
{{BUSINESS_NAME}}{{BUSINESS_EMAIL}}{{CUSTOMER_NAME}}{{APPOINTMENT_TIME}}- etc.
3.4 Preview Component
Location: frontend/src/components/EmailPreview.tsx
- Desktop/mobile toggle
- Light/dark mode preview
- Sample data editor
- Footer preview (shown for free tier)
3.5 Update Plugin Config Form
Location: frontend/src/pages/MyPlugins.tsx
When variable.type === 'email_template':
<EmailTemplateSelector
value={configValues[key]}
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
scope="BUSINESS" // or "PLATFORM" for platform admin
/>
3.6 EmailTemplateSelector Component
Location: frontend/src/components/EmailTemplateSelector.tsx
- Dropdown showing available templates
- Category filtering
- Quick preview on hover
- "Create New" link
Phase 4: Footer Enforcement
4.1 Backend Enforcement
In EmailTemplate.render() and preview endpoint:
- Check tenant subscription tier
- If
FREE, always append footer regardless of template content - Footer cannot be removed via template editing
4.2 Frontend Enforcement
In EmailTemplateForm:
- Show permanent footer preview for free tier
- Disable footer editing for free tier
- Show upgrade prompt: "Upgrade to remove footer"
Phase 5: Platform Admin Features
5.1 Platform Email Templates Page
Location: frontend/src/pages/platform/PlatformEmailTemplates.tsx
- Create/manage PLATFORM scope templates
- Set default templates for system events
- Preview with tenant context
5.2 Default Templates
Create seed data for common templates:
- Tenant invitation email (already exists)
- Appointment reminder
- Appointment confirmation
- Password reset
- Welcome email
Database Migrations
# schedule/migrations/XXXX_email_template.py
class Migration(migrations.Migration):
dependencies = [
('schedule', 'previous_migration'),
]
operations = [
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('subject', models.CharField(max_length=500)),
('html_content', models.TextField(blank=True)),
('text_content', models.TextField(blank=True)),
('scope', models.CharField(max_length=20, choices=[
('BUSINESS', 'Business'),
('PLATFORM', 'Platform'),
], default='BUSINESS')),
('is_default', models.BooleanField(default=False)),
('category', models.CharField(max_length=50, choices=[
('APPOINTMENT', 'Appointment'),
('REMINDER', 'Reminder'),
('CONFIRMATION', 'Confirmation'),
('MARKETING', 'Marketing'),
('NOTIFICATION', 'Notification'),
('REPORT', 'Report'),
('OTHER', 'Other'),
], default='OTHER')),
('preview_context', models.JSONField(default=dict, blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='created_email_templates',
to='users.user'
)),
],
options={
'ordering': ['name'],
},
),
migrations.AddIndex(
model_name='emailtemplate',
index=models.Index(fields=['scope', 'category'], name='schedule_em_scope_123456_idx'),
),
]
API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/email-templates/ |
List templates (filtered by scope) |
| 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 |
| POST | /api/email-templates/{id}/duplicate/ |
Duplicate template |
Frontend Routes
| Route | Component | Description |
|---|---|---|
/email-templates |
EmailTemplates | Business email templates |
/email-templates/create |
EmailTemplateForm | Create new template |
/email-templates/:id/edit |
EmailTemplateForm | Edit existing template |
/platform/email-templates |
PlatformEmailTemplates | Platform templates |
Implementation Order
- Backend Model & Migration - EmailTemplate model
- Backend API - Serializers, ViewSet, URLs
- Frontend List Page - Basic CRUD
- Frontend Editor - Dual-mode editor
- Preview Component - Live preview
- Plugin Integration - email_template variable type
- Footer Enforcement - Free tier logic
- Platform Admin - Platform templates page
- Seed Data - Default templates
Testing Checklist
- Create business email template
- Create platform email template (as superuser)
- Preview template with sample data
- Verify footer appears for free tier
- Verify footer hidden for paid tiers
- Verify footer appears in preview for free tier
- Edit template in visual mode
- Edit template in code mode
- Use template in plugin configuration
- Send actual email using template
- Duplicate template
- Delete template