- 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>
568 lines
18 KiB
Markdown
568 lines
18 KiB
Markdown
# 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
|
|
|
|
1. **Dual access**: Available in both platform admin and business areas
|
|
2. **Both formats**: Support text and HTML email templates
|
|
3. **Visual preview**: Show how the final email will look
|
|
4. **Plugin integration**: Templates attachable via a new template tag type
|
|
5. **Separate templates**: Platform and business templates are completely separate
|
|
6. **Footer enforcement**: Free tier businesses must show "Powered by Smooth Schedule" footer (non-overridable)
|
|
7. **Editor modes**: Both visual builder AND code editor views
|
|
|
|
---
|
|
|
|
## Phase 1: Backend - EmailTemplate Model
|
|
|
|
### 1.1 Create EmailTemplate Model
|
|
|
|
**Location**: `schedule/models.py`
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
# 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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
# 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`
|
|
|
|
```python
|
|
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:
|
|
1. **Subject line editor** - Simple text input with variable insertion
|
|
2. **Content editor** - Tabbed interface:
|
|
- **Visual mode**: WYSIWYG editor (TipTap or similar)
|
|
- **Code mode**: Monaco/CodeMirror for raw HTML
|
|
3. **Plain text editor** - Textarea with variable insertion buttons
|
|
4. **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'`:
|
|
```tsx
|
|
<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
|
|
|
|
```python
|
|
# 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
|
|
|
|
1. **Backend Model & Migration** - EmailTemplate model
|
|
2. **Backend API** - Serializers, ViewSet, URLs
|
|
3. **Frontend List Page** - Basic CRUD
|
|
4. **Frontend Editor** - Dual-mode editor
|
|
5. **Preview Component** - Live preview
|
|
6. **Plugin Integration** - email_template variable type
|
|
7. **Footer Enforcement** - Free tier logic
|
|
8. **Platform Admin** - Platform templates page
|
|
9. **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
|