Files
smoothschedule/smoothschedule/PLAN_EMAIL_TEMPLATES.md
poduck cfc1b36ada 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>
2025-11-29 18:28:29 -05:00

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