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

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

  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

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:

  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':

<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

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

  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