From c52af2a1557064c92d505ceeff7cead0a42db67b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 3 Jan 2026 18:29:26 +0100 Subject: [PATCH] feat: implement email template system with vendor overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive email template management for both admin and vendors: Admin Features: - Email templates management page at /admin/email-templates - Edit platform templates with language support (en, fr, de, lb) - Preview templates with sample variables - Send test emails - View email logs per template Vendor Features: - Email templates customization page at /vendor/{code}/email-templates - Override platform templates with vendor-specific versions - Preview and test customized templates - Revert to platform defaults Technical Changes: - Migration for vendor_email_templates table - VendorEmailTemplate model with override management - Enhanced EmailService with language resolution chain (customer preferred -> vendor preferred -> platform default) - Branding resolution (Wizamart default, removed for whitelabel) - Platform-only template protection (billing templates) - Admin and vendor API endpoints with full CRUD - Updated seed script with billing and team templates Files: 22 changed, ~3,900 lines added 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...u9c0d1e2f3g4_add_vendor_email_templates.py | 114 ++++ app/api/v1/admin/__init__.py | 4 + app/api/v1/admin/email_templates.py | 319 ++++++++++ app/api/v1/vendor/__init__.py | 8 +- app/api/v1/vendor/email_templates.py | 545 ++++++++++++++++++ app/routes/admin_pages.py | 24 + app/routes/vendor_pages.py | 19 + app/services/email_service.py | 302 +++++++++- app/templates/admin/email-templates.html | 366 ++++++++++++ app/templates/admin/partials/sidebar.html | 1 + app/templates/vendor/email-templates.html | 329 +++++++++++ app/templates/vendor/partials/sidebar.html | 1 + .../email-templates-architecture.md | 358 +++++++++--- models/database/__init__.py | 2 + models/database/email.py | 120 +++- models/database/vendor.py | 7 + models/database/vendor_email_template.py | 229 ++++++++ models/schema/__init__.py | 2 + models/schema/email.py | 247 ++++++++ scripts/seed_email_templates.py | 367 +++++++++++- static/admin/js/email-templates.js | 303 ++++++++++ static/vendor/js/email-templates.js | 334 +++++++++++ 22 files changed, 3882 insertions(+), 119 deletions(-) create mode 100644 alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py create mode 100644 app/api/v1/admin/email_templates.py create mode 100644 app/api/v1/vendor/email_templates.py create mode 100644 app/templates/admin/email-templates.html create mode 100644 app/templates/vendor/email-templates.html create mode 100644 models/database/vendor_email_template.py create mode 100644 models/schema/email.py create mode 100644 static/admin/js/email-templates.js create mode 100644 static/vendor/js/email-templates.js diff --git a/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py b/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py new file mode 100644 index 00000000..9cf16718 --- /dev/null +++ b/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py @@ -0,0 +1,114 @@ +# alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py +"""Add vendor email templates and enhance email_templates table. + +Revision ID: u9c0d1e2f3g4 +Revises: t8b9c0d1e2f3 +Create Date: 2026-01-03 + +Changes: +- Add is_platform_only column to email_templates (templates that vendors cannot override) +- Add required_variables column to email_templates (JSON list of required variables) +- Create vendor_email_templates table for vendor-specific template overrides +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "u9c0d1e2f3g4" +down_revision = "t8b9c0d1e2f3" +branch_labels = None +depends_on = None + + +def upgrade(): + # Add new columns to email_templates + op.add_column( + "email_templates", + sa.Column("is_platform_only", sa.Boolean(), nullable=False, server_default="0"), + ) + op.add_column( + "email_templates", + sa.Column("required_variables", sa.Text(), nullable=True), + ) + + # Create vendor_email_templates table + op.create_table( + "vendor_email_templates", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("vendor_id", sa.Integer(), nullable=False), + sa.Column("template_code", sa.String(100), nullable=False), + sa.Column("language", sa.String(5), nullable=False, server_default="en"), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("subject", sa.String(500), nullable=False), + sa.Column("body_html", sa.Text(), nullable=False), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["vendor_id"], + ["vendors.id"], + name="fk_vendor_email_templates_vendor_id", + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "vendor_id", + "template_code", + "language", + name="uq_vendor_email_template_code_language", + ), + ) + + # Create indexes for performance + op.create_index( + "ix_vendor_email_templates_vendor_id", + "vendor_email_templates", + ["vendor_id"], + ) + op.create_index( + "ix_vendor_email_templates_template_code", + "vendor_email_templates", + ["template_code"], + ) + op.create_index( + "ix_vendor_email_templates_lookup", + "vendor_email_templates", + ["vendor_id", "template_code", "language"], + ) + + # Add unique constraint to email_templates for code+language + # This ensures we can reliably look up platform templates + op.create_index( + "ix_email_templates_code_language", + "email_templates", + ["code", "language"], + unique=True, + ) + + +def downgrade(): + # Drop indexes + op.drop_index("ix_email_templates_code_language", table_name="email_templates") + op.drop_index("ix_vendor_email_templates_lookup", table_name="vendor_email_templates") + op.drop_index("ix_vendor_email_templates_template_code", table_name="vendor_email_templates") + op.drop_index("ix_vendor_email_templates_vendor_id", table_name="vendor_email_templates") + + # Drop vendor_email_templates table + op.drop_table("vendor_email_templates") + + # Remove new columns from email_templates + op.drop_column("email_templates", "required_variables") + op.drop_column("email_templates", "is_platform_only") diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 93fbcb97..f2ff84ba 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -33,6 +33,7 @@ from . import ( content_pages, customers, dashboard, + email_templates, features, images, inventory, @@ -163,6 +164,9 @@ router.include_router(notifications.router, tags=["admin-notifications"]) # Include messaging endpoints router.include_router(messages.router, tags=["admin-messages"]) +# Include email templates management endpoints +router.include_router(email_templates.router, tags=["admin-email-templates"]) + # Include log management endpoints router.include_router(logs.router, tags=["admin-logs"]) diff --git a/app/api/v1/admin/email_templates.py b/app/api/v1/admin/email_templates.py new file mode 100644 index 00000000..432f2e17 --- /dev/null +++ b/app/api/v1/admin/email_templates.py @@ -0,0 +1,319 @@ +# app/api/v1/admin/email_templates.py +""" +Admin email templates management endpoints. + +Provides endpoints for: +- Listing all platform email templates +- Viewing template details (all languages) +- Updating template content +- Preview and test email sending +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.email_service import EmailService +from models.database.email import EmailCategory, EmailTemplate +from models.database.user import User +from models.schema.email import ( + EmailPreviewRequest, + EmailPreviewResponse, + EmailTemplateResponse, + EmailTemplateSummary, + EmailTemplateUpdate, + EmailTestRequest, + EmailTestResponse, +) + +router = APIRouter(prefix="/email-templates") +logger = logging.getLogger(__name__) + + +@router.get("", response_model=list[EmailTemplateSummary]) +def list_templates( + category: str | None = Query(None, description="Filter by category"), + include_inactive: bool = Query(False, description="Include inactive templates"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + List all platform email templates. + + Templates are grouped by code, showing available languages for each. + """ + templates = EmailTemplate.get_all_templates( + db, category=category, include_inactive=include_inactive + ) + + return EmailTemplateSummary.from_db_list(templates) + + +@router.get("/categories") +def get_categories( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get all email template categories.""" + return { + "categories": [ + {"code": EmailCategory.AUTH.value, "name": "Authentication", "description": "Signup, password reset, verification"}, + {"code": EmailCategory.ORDERS.value, "name": "Orders", "description": "Order confirmations, shipping updates"}, + {"code": EmailCategory.BILLING.value, "name": "Billing", "description": "Invoices, payment failures, subscription changes"}, + {"code": EmailCategory.SYSTEM.value, "name": "System", "description": "Team invites, notifications"}, + {"code": EmailCategory.MARKETING.value, "name": "Marketing", "description": "Newsletters, promotions"}, + ] + } + + +@router.get("/{code}", response_model=list[EmailTemplateResponse]) +def get_template( + code: str, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get a template by code with all language versions. + + Returns all language versions of the template. + """ + templates = ( + db.query(EmailTemplate) + .filter(EmailTemplate.code == code) + .order_by(EmailTemplate.language) + .all() + ) + + if not templates: + raise HTTPException(status_code=404, detail=f"Template not found: {code}") + + return [EmailTemplateResponse.from_db(t) for t in templates] + + +@router.get("/{code}/{language}", response_model=EmailTemplateResponse) +def get_template_language( + code: str, + language: str, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get a specific language version of a template. + """ + template = ( + db.query(EmailTemplate) + .filter( + EmailTemplate.code == code, + EmailTemplate.language == language, + ) + .first() + ) + + if not template: + raise HTTPException( + status_code=404, + detail=f"Template not found: {code} ({language})", + ) + + return EmailTemplateResponse.from_db(template) + + +@router.put("/{code}/{language}", response_model=EmailTemplateResponse) +def update_template( + code: str, + language: str, + data: EmailTemplateUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Update a specific language version of a template. + + Only provided fields are updated. + """ + template = ( + db.query(EmailTemplate) + .filter( + EmailTemplate.code == code, + EmailTemplate.language == language, + ) + .first() + ) + + if not template: + raise HTTPException( + status_code=404, + detail=f"Template not found: {code} ({language})", + ) + + # Update provided fields + import json + + if data.name is not None: + template.name = data.name + if data.description is not None: + template.description = data.description + if data.subject is not None: + template.subject = data.subject + if data.body_html is not None: + template.body_html = data.body_html + if data.body_text is not None: + template.body_text = data.body_text + if data.variables is not None: + template.variables = json.dumps(data.variables) + if data.required_variables is not None: + template.required_variables = json.dumps(data.required_variables) + if data.is_active is not None: + template.is_active = data.is_active + + db.commit() + db.refresh(template) + + logger.info( + f"Email template updated: {code} ({language}) by admin {current_admin.id}" + ) + + return EmailTemplateResponse.from_db(template) + + +@router.post("/{code}/preview", response_model=EmailPreviewResponse) +def preview_template( + code: str, + data: EmailPreviewRequest, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Preview a template with sample variables. + + Returns rendered subject and body without sending. + """ + template = ( + db.query(EmailTemplate) + .filter( + EmailTemplate.code == code, + EmailTemplate.language == data.language, + ) + .first() + ) + + if not template: + raise HTTPException( + status_code=404, + detail=f"Template not found: {code} ({data.language})", + ) + + email_service = EmailService(db) + + # Render with provided variables + subject = email_service.render_template(template.subject, data.variables) + body_html = email_service.render_template(template.body_html, data.variables) + body_text = ( + email_service.render_template(template.body_text, data.variables) + if template.body_text + else None + ) + + return EmailPreviewResponse( + subject=subject, + body_html=body_html, + body_text=body_text, + ) + + +@router.post("/{code}/test", response_model=EmailTestResponse) +def send_test_email( + code: str, + data: EmailTestRequest, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Send a test email to specified address. + + Uses the template with provided variables. + """ + template = ( + db.query(EmailTemplate) + .filter( + EmailTemplate.code == code, + EmailTemplate.language == data.language, + ) + .first() + ) + + if not template: + raise HTTPException( + status_code=404, + detail=f"Template not found: {code} ({data.language})", + ) + + email_service = EmailService(db) + + # Send test email + log = email_service.send_template( + template_code=code, + to_email=data.to_email, + to_name=current_admin.full_name, + language=data.language, + variables=data.variables, + user_id=current_admin.id, + related_type="email_test", + include_branding=True, + ) + + if log.status == "sent": + return EmailTestResponse( + success=True, + message=f"Test email sent to {data.to_email}", + email_log_id=log.id, + ) + else: + return EmailTestResponse( + success=False, + message=f"Failed to send test email: {log.error_message}", + email_log_id=log.id, + ) + + +@router.get("/{code}/logs") +def get_template_logs( + code: str, + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get recent email logs for a specific template. + + Useful for debugging and monitoring. + """ + from models.database.email import EmailLog + + logs = ( + db.query(EmailLog) + .filter(EmailLog.template_code == code) + .order_by(EmailLog.created_at.desc()) + .limit(limit) + .all() + ) + + return { + "template_code": code, + "logs": [ + { + "id": log.id, + "recipient_email": log.recipient_email, + "subject": log.subject, + "status": log.status, + "sent_at": log.sent_at, + "error_message": log.error_message, + "created_at": log.created_at, + } + for log in logs + ], + "total": len(logs), + } diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index 7f297622..53ad206c 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -20,6 +20,7 @@ from . import ( content_pages, customers, dashboard, + email_templates, features, info, inventory, @@ -59,6 +60,7 @@ router.include_router(auth.router, tags=["vendor-auth"]) router.include_router(dashboard.router, tags=["vendor-dashboard"]) router.include_router(profile.router, tags=["vendor-profile"]) router.include_router(settings.router, tags=["vendor-settings"]) +router.include_router(email_templates.router, tags=["vendor-email-templates"]) router.include_router(onboarding.router, tags=["vendor-onboarding"]) # Business operations (with prefixes: /products/*, /orders/*, etc.) @@ -83,11 +85,7 @@ router.include_router(features.router, tags=["vendor-features"]) router.include_router(usage.router, tags=["vendor-usage"]) # Content pages management -router.include_router( - content_pages.router, - prefix="/{vendor_code}/content-pages", - tags=["vendor-content-pages"], -) +router.include_router(content_pages.router, tags=["vendor-content-pages"]) # Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code} router.include_router(info.router, tags=["vendor-info"]) diff --git a/app/api/v1/vendor/email_templates.py b/app/api/v1/vendor/email_templates.py new file mode 100644 index 00000000..6e5cf9ce --- /dev/null +++ b/app/api/v1/vendor/email_templates.py @@ -0,0 +1,545 @@ +# app/api/v1/vendor/email_templates.py +""" +Vendor email template override endpoints. + +Allows vendors to customize platform email templates with their own content. +Platform-only templates (billing, subscription) cannot be overridden. + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from jinja2 import Template +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.email_service import EmailService +from app.services.vendor_service import vendor_service +from models.database.email import EmailTemplate +from models.database.user import User +from models.database.vendor_email_template import VendorEmailTemplate + +router = APIRouter(prefix="/email-templates") +logger = logging.getLogger(__name__) + +# Supported languages +SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"] + + +class VendorTemplateUpdate(BaseModel): + """Schema for creating/updating a vendor template override.""" + + subject: str = Field(..., min_length=1, max_length=500) + body_html: str = Field(..., min_length=1) + body_text: str | None = None + name: str | None = Field(None, max_length=255) + + +class TemplatePreviewRequest(BaseModel): + """Schema for previewing a template.""" + + language: str = "en" + variables: dict[str, Any] = {} + + +class TemplateTestRequest(BaseModel): + """Schema for sending a test email.""" + + to_email: EmailStr + language: str = "en" + variables: dict[str, Any] = {} + + +@router.get("") +def list_overridable_templates( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + List all email templates that the vendor can customize. + + Returns platform templates with vendor override status. + Platform-only templates (billing, subscription) are excluded. + """ + vendor_id = current_user.token_vendor_id + + # Get all overridable platform templates + platform_templates = EmailTemplate.get_overridable_templates(db) + + # Get all vendor overrides + vendor_overrides = VendorEmailTemplate.get_all_overrides_for_vendor(db, vendor_id) + + # Build override lookup + override_lookup = {} + for override in vendor_overrides: + key = (override.template_code, override.language) + override_lookup[key] = override + + # Build response + templates_response = [] + for template in platform_templates: + # Check which languages have overrides + override_languages = [] + for lang in SUPPORTED_LANGUAGES: + if (template.code, lang) in override_lookup: + override_languages.append(lang) + + templates_response.append({ + "code": template.code, + "name": template.name, + "category": template.category, + "description": template.description, + "available_languages": _get_available_languages(db, template.code), + "override_languages": override_languages, + "has_override": len(override_languages) > 0, + "variables": template.required_variables.split(",") if template.required_variables else [], + }) + + return { + "templates": templates_response, + "supported_languages": SUPPORTED_LANGUAGES, + } + + +@router.get("/{code}") +def get_template( + code: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get a specific template with all language versions. + + Returns platform template details and vendor overrides for each language. + """ + vendor_id = current_user.token_vendor_id + + # Get platform template + platform_template = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).first() + + if not platform_template: + raise HTTPException(status_code=404, detail="Template not found") + + if platform_template.is_platform_only: + raise HTTPException( + status_code=403, + detail="This is a platform-only template and cannot be customized" + ) + + # Get all language versions of platform template + platform_versions = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).all() + + # Get vendor overrides for all languages + vendor_overrides = ( + db.query(VendorEmailTemplate) + .filter( + VendorEmailTemplate.vendor_id == vendor_id, + VendorEmailTemplate.template_code == code, + ) + .all() + ) + + override_lookup = {v.language: v for v in vendor_overrides} + platform_lookup = {t.language: t for t in platform_versions} + + # Build language versions + languages = {} + for lang in SUPPORTED_LANGUAGES: + platform_ver = platform_lookup.get(lang) + override_ver = override_lookup.get(lang) + + languages[lang] = { + "has_platform_template": platform_ver is not None, + "has_vendor_override": override_ver is not None, + "platform": { + "subject": platform_ver.subject if platform_ver else None, + "body_html": platform_ver.body_html if platform_ver else None, + "body_text": platform_ver.body_text if platform_ver else None, + } if platform_ver else None, + "vendor_override": { + "subject": override_ver.subject if override_ver else None, + "body_html": override_ver.body_html if override_ver else None, + "body_text": override_ver.body_text if override_ver else None, + "name": override_ver.name if override_ver else None, + "updated_at": override_ver.updated_at.isoformat() if override_ver else None, + } if override_ver else None, + } + + return { + "code": code, + "name": platform_template.name, + "category": platform_template.category, + "description": platform_template.description, + "variables": platform_template.required_variables.split(",") if platform_template.required_variables else [], + "languages": languages, + } + + +@router.get("/{code}/{language}") +def get_template_language( + code: str, + language: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get a specific template for a specific language. + + Returns vendor override if exists, otherwise platform template. + """ + if language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {language}") + + vendor_id = current_user.token_vendor_id + + # Check if template is overridable + platform_template = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).first() + + if not platform_template: + raise HTTPException(status_code=404, detail="Template not found") + + if platform_template.is_platform_only: + raise HTTPException( + status_code=403, + detail="This is a platform-only template and cannot be customized" + ) + + # Check for vendor override + vendor_override = VendorEmailTemplate.get_override(db, vendor_id, code, language) + + # Get platform version for this language + platform_version = EmailTemplate.get_by_code_and_language(db, code, language) + + if vendor_override: + return { + "code": code, + "language": language, + "source": "vendor_override", + "subject": vendor_override.subject, + "body_html": vendor_override.body_html, + "body_text": vendor_override.body_text, + "name": vendor_override.name, + "variables": platform_template.required_variables.split(",") if platform_template.required_variables else [], + "platform_template": { + "subject": platform_version.subject if platform_version else None, + "body_html": platform_version.body_html if platform_version else None, + } if platform_version else None, + } + elif platform_version: + return { + "code": code, + "language": language, + "source": "platform", + "subject": platform_version.subject, + "body_html": platform_version.body_html, + "body_text": platform_version.body_text, + "name": platform_version.name, + "variables": platform_template.required_variables.split(",") if platform_template.required_variables else [], + "platform_template": None, + } + else: + raise HTTPException( + status_code=404, + detail=f"No template found for language: {language}" + ) + + +@router.put("/{code}/{language}") +def update_template_override( + code: str, + language: str, + template_data: VendorTemplateUpdate, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Create or update a vendor template override. + + Creates a vendor-specific version of the email template. + The platform template remains unchanged. + """ + if language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {language}") + + vendor_id = current_user.token_vendor_id + + # Check if template exists and is overridable + platform_template = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).first() + + if not platform_template: + raise HTTPException(status_code=404, detail="Template not found") + + if platform_template.is_platform_only: + raise HTTPException( + status_code=403, + detail="This is a platform-only template and cannot be customized" + ) + + # Validate template content (try to render with dummy variables) + try: + Template(template_data.subject).render({}) + Template(template_data.body_html).render({}) + if template_data.body_text: + Template(template_data.body_text).render({}) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Invalid template syntax: {str(e)}" + ) + + # Create or update override + override = VendorEmailTemplate.create_or_update( + db=db, + vendor_id=vendor_id, + template_code=code, + language=language, + subject=template_data.subject, + body_html=template_data.body_html, + body_text=template_data.body_text, + name=template_data.name, + ) + + db.commit() + + logger.info( + f"Vendor {vendor_id} updated email template override: {code}/{language}" + ) + + return { + "message": "Template override saved", + "code": code, + "language": language, + "is_new": override.created_at == override.updated_at, + } + + +@router.delete("/{code}/{language}") +def delete_template_override( + code: str, + language: str, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Delete a vendor template override. + + Reverts to using the platform default template for this language. + """ + if language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {language}") + + vendor_id = current_user.token_vendor_id + + deleted = VendorEmailTemplate.delete_override(db, vendor_id, code, language) + + if not deleted: + raise HTTPException( + status_code=404, + detail="No override found for this template and language" + ) + + db.commit() + + logger.info( + f"Vendor {vendor_id} deleted email template override: {code}/{language}" + ) + + return { + "message": "Template override deleted - reverted to platform default", + "code": code, + "language": language, + } + + +@router.post("/{code}/preview") +def preview_template( + code: str, + preview_data: TemplatePreviewRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Preview a template with sample variables. + + Uses vendor override if exists, otherwise platform template. + """ + vendor_id = current_user.token_vendor_id + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Check if template exists + platform_template = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).first() + + if not platform_template: + raise HTTPException(status_code=404, detail="Template not found") + + # Get template content (vendor override or platform) + vendor_override = VendorEmailTemplate.get_override( + db, vendor_id, code, preview_data.language + ) + platform_version = EmailTemplate.get_by_code_and_language( + db, code, preview_data.language + ) + + if vendor_override: + subject = vendor_override.subject + body_html = vendor_override.body_html + body_text = vendor_override.body_text + source = "vendor_override" + elif platform_version: + subject = platform_version.subject + body_html = platform_version.body_html + body_text = platform_version.body_text + source = "platform" + else: + raise HTTPException( + status_code=404, + detail=f"No template found for language: {preview_data.language}" + ) + + # Add branding variables + variables = { + **preview_data.variables, + "platform_name": "Wizamart", + "vendor_name": vendor.name, + "support_email": vendor.contact_email or "support@wizamart.com", + } + + # Render templates + try: + rendered_subject = Template(subject).render(variables) + rendered_html = Template(body_html).render(variables) + rendered_text = Template(body_text).render(variables) if body_text else None + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Template rendering error: {str(e)}" + ) + + return { + "source": source, + "language": preview_data.language, + "subject": rendered_subject, + "body_html": rendered_html, + "body_text": rendered_text, + } + + +@router.post("/{code}/test") +def send_test_email( + code: str, + test_data: TemplateTestRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Send a test email using the template. + + Uses vendor override if exists, otherwise platform template. + """ + vendor_id = current_user.token_vendor_id + vendor = vendor_service.get_vendor_by_id(db, vendor_id) + + # Check if template exists + platform_template = db.query(EmailTemplate).filter( + EmailTemplate.code == code + ).first() + + if not platform_template: + raise HTTPException(status_code=404, detail="Template not found") + + # Build test variables + variables = { + **_get_sample_variables(code), + **test_data.variables, + "platform_name": "Wizamart", + "vendor_name": vendor.name, + "support_email": vendor.contact_email or "support@wizamart.com", + } + + try: + # Send using email service (will use vendor override if exists) + email_svc = EmailService(db) + email_log = email_svc.send_template( + template_code=code, + to_email=test_data.to_email, + variables=variables, + vendor_id=vendor_id, + language=test_data.language, + ) + + if email_log.status == "sent": + return { + "success": True, + "message": f"Test email sent to {test_data.to_email}", + } + else: + return { + "success": False, + "message": email_log.error_message or "Failed to send email", + } + except Exception as e: + logger.exception(f"Failed to send test email: {e}") + return { + "success": False, + "message": str(e), + } + + +def _get_available_languages(db: Session, code: str) -> list[str]: + """Get list of languages that have platform templates.""" + templates = db.query(EmailTemplate.language).filter( + EmailTemplate.code == code + ).all() + return [t.language for t in templates] + + +def _get_sample_variables(template_code: str) -> dict[str, Any]: + """Get sample variables for testing templates.""" + samples = { + "signup_welcome": { + "first_name": "John", + "company_name": "Acme Corp", + "email": "john@example.com", + "vendor_code": "acme", + "login_url": "https://example.com/login", + "trial_days": "14", + "tier_name": "Business", + }, + "order_confirmation": { + "customer_name": "Jane Doe", + "order_number": "ORD-12345", + "order_total": "99.99", + "order_items_count": "3", + "order_date": "2024-01-15", + "shipping_address": "123 Main St, Luxembourg City, L-1234", + }, + "password_reset": { + "customer_name": "John Doe", + "reset_link": "https://example.com/reset?token=abc123", + "expiry_hours": "1", + }, + "team_invite": { + "invitee_name": "Jane", + "inviter_name": "John", + "vendor_name": "Acme Corp", + "role": "Admin", + "accept_url": "https://example.com/accept", + "expires_in_days": "7", + }, + } + return samples.get(template_code, {"platform_name": "Wizamart"}) diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 14cfe84f..a0e0c730 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -500,6 +500,30 @@ async def admin_notifications_page( ) +# ============================================================================ +# EMAIL TEMPLATES ROUTES +# ============================================================================ + + +@router.get("/email-templates", response_class=HTMLResponse, include_in_schema=False) +async def admin_email_templates_page( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render email templates management page. + Shows all platform email templates with edit capabilities. + """ + return templates.TemplateResponse( + "admin/email-templates.html", + { + "request": request, + "user": current_user, + }, + ) + + # ============================================================================ # MESSAGING ROUTES # ============================================================================ diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 0c55da9f..6d5b7d59 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -567,6 +567,25 @@ async def vendor_settings_page( ) +@router.get( + "/{vendor_code}/email-templates", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_email_templates_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor email templates customization page. + Allows vendors to override platform email templates. + """ + return templates.TemplateResponse( + "vendor/email-templates.html", + get_vendor_context(request, db, current_user, vendor_code), + ) + + @router.get( "/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False ) diff --git a/app/services/email_service.py b/app/services/email_service.py index 5d06fb40..0c01f585 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -10,15 +10,29 @@ Supports: Features: - Multi-language templates from database +- Vendor template overrides - Jinja2 template rendering - Email logging and tracking - Queue support via background tasks +- Branding based on vendor tier (whitelabel) + +Language Resolution (priority order): +1. Explicit language parameter +2. Customer's preferred language (if customer context) +3. Vendor's storefront language +4. Platform default (en) + +Template Resolution (priority order): +1. Vendor override (if vendor_id and template is not platform-only) +2. Platform template +3. English fallback (if requested language not found) """ import json import logging import smtplib from abc import ABC, abstractmethod +from dataclasses import dataclass from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any @@ -28,9 +42,41 @@ from sqlalchemy.orm import Session from app.core.config import settings from models.database.email import EmailLog, EmailStatus, EmailTemplate +from models.database.vendor_email_template import VendorEmailTemplate logger = logging.getLogger(__name__) +# Platform branding constants +PLATFORM_NAME = "Wizamart" +PLATFORM_SUPPORT_EMAIL = "support@wizamart.com" +PLATFORM_DEFAULT_LANGUAGE = "en" +SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"] + + +@dataclass +class ResolvedTemplate: + """Resolved template content after checking vendor overrides.""" + + subject: str + body_html: str + body_text: str | None + is_vendor_override: bool + template_id: int | None # Platform template ID (None if vendor override) + template_code: str + language: str + + +@dataclass +class BrandingContext: + """Branding variables for email templates.""" + + platform_name: str + platform_logo_url: str | None + support_email: str + vendor_name: str | None + vendor_logo_url: str | None + is_whitelabel: bool + # ============================================================================= # EMAIL PROVIDER ABSTRACTION @@ -325,14 +371,14 @@ class EmailService: Usage: email_service = EmailService(db) - # Send using database template + # Send using database template with vendor override support email_service.send_template( template_code="signup_welcome", - language="en", to_email="user@example.com", to_name="John Doe", variables={"first_name": "John", "login_url": "https://..."}, vendor_id=1, + # Language is resolved automatically from vendor/customer settings ) # Send raw email @@ -347,17 +393,186 @@ class EmailService: self.db = db self.provider = get_provider() self.jinja_env = Environment(loader=BaseLoader()) + # Cache vendor and feature data to avoid repeated queries + self._vendor_cache: dict[int, Any] = {} + self._feature_cache: dict[int, set[str]] = {} + + def _get_vendor(self, vendor_id: int): + """Get vendor with caching.""" + if vendor_id not in self._vendor_cache: + from models.database.vendor import Vendor + + self._vendor_cache[vendor_id] = ( + self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + ) + return self._vendor_cache[vendor_id] + + def _has_feature(self, vendor_id: int, feature_code: str) -> bool: + """Check if vendor has a specific feature enabled.""" + if vendor_id not in self._feature_cache: + from app.core.feature_gate import get_vendor_features + + try: + self._feature_cache[vendor_id] = get_vendor_features(self.db, vendor_id) + except Exception: + self._feature_cache[vendor_id] = set() + + return feature_code in self._feature_cache[vendor_id] + + def resolve_language( + self, + explicit_language: str | None = None, + vendor_id: int | None = None, + customer_id: int | None = None, + ) -> str: + """ + Resolve the language for an email. + + Priority order: + 1. Explicit language parameter + 2. Customer's preferred language (if customer_id provided) + 3. Vendor's storefront language (if vendor_id provided) + 4. Platform default (en) + + Args: + explicit_language: Explicitly requested language + vendor_id: Vendor ID for storefront language lookup + customer_id: Customer ID for preferred language lookup + + Returns: + Resolved language code (one of: en, fr, de, lb) + """ + # 1. Explicit language takes priority + if explicit_language and explicit_language in SUPPORTED_LANGUAGES: + return explicit_language + + # 2. Customer's preferred language + if customer_id: + from models.database.customer import Customer + + customer = ( + self.db.query(Customer).filter(Customer.id == customer_id).first() + ) + if customer and customer.preferred_language in SUPPORTED_LANGUAGES: + return customer.preferred_language + + # 3. Vendor's storefront language + if vendor_id: + vendor = self._get_vendor(vendor_id) + if vendor and vendor.storefront_language in SUPPORTED_LANGUAGES: + return vendor.storefront_language + + # 4. Platform default + return PLATFORM_DEFAULT_LANGUAGE + + def get_branding(self, vendor_id: int | None = None) -> BrandingContext: + """ + Get branding context for email templates. + + If vendor has white_label feature enabled (Enterprise tier), + platform branding is replaced with vendor branding. + + Args: + vendor_id: Optional vendor ID + + Returns: + BrandingContext with appropriate branding variables + """ + vendor = None + is_whitelabel = False + + if vendor_id: + vendor = self._get_vendor(vendor_id) + is_whitelabel = self._has_feature(vendor_id, "white_label") + + if is_whitelabel and vendor: + # Whitelabel: use vendor branding throughout + return BrandingContext( + platform_name=vendor.name, + platform_logo_url=vendor.logo_url, + support_email=vendor.support_email or PLATFORM_SUPPORT_EMAIL, + vendor_name=vendor.name, + vendor_logo_url=vendor.logo_url, + is_whitelabel=True, + ) + else: + # Standard: Wizamart branding with vendor details + return BrandingContext( + platform_name=PLATFORM_NAME, + platform_logo_url=None, # Use default platform logo + support_email=PLATFORM_SUPPORT_EMAIL, + vendor_name=vendor.name if vendor else None, + vendor_logo_url=vendor.logo_url if vendor else None, + is_whitelabel=False, + ) + + def resolve_template( + self, + template_code: str, + language: str, + vendor_id: int | None = None, + ) -> ResolvedTemplate | None: + """ + Resolve template content with vendor override support. + + Resolution order: + 1. Check for vendor override (if vendor_id and template is not platform-only) + 2. Fall back to platform template + 3. Fall back to English if language not found + + Args: + template_code: Template code (e.g., "password_reset") + language: Language code + vendor_id: Optional vendor ID for override lookup + + Returns: + ResolvedTemplate with content, or None if not found + """ + # First, get platform template to check if it's platform-only + platform_template = self.get_template(template_code, language) + + if not platform_template: + logger.warning(f"Template not found: {template_code} ({language})") + return None + + # Check for vendor override (if not platform-only) + if vendor_id and not platform_template.is_platform_only: + vendor_override = VendorEmailTemplate.get_override( + self.db, vendor_id, template_code, language + ) + + if vendor_override: + return ResolvedTemplate( + subject=vendor_override.subject, + body_html=vendor_override.body_html, + body_text=vendor_override.body_text, + is_vendor_override=True, + template_id=None, + template_code=template_code, + language=language, + ) + + # Use platform template + return ResolvedTemplate( + subject=platform_template.subject, + body_html=platform_template.body_html, + body_text=platform_template.body_text, + is_vendor_override=False, + template_id=platform_template.id, + template_code=template_code, + language=language, + ) def get_template( self, template_code: str, language: str = "en" ) -> EmailTemplate | None: - """Get email template from database with fallback to English.""" + """Get platform email template from database with fallback to English.""" template = ( self.db.query(EmailTemplate) .filter( EmailTemplate.code == template_code, EmailTemplate.language == language, - EmailTemplate.is_active == True, + EmailTemplate.is_active == True, # noqa: E712 ) .first() ) @@ -369,7 +584,7 @@ class EmailService: .filter( EmailTemplate.code == template_code, EmailTemplate.language == "en", - EmailTemplate.is_active == True, + EmailTemplate.is_active == True, # noqa: E712 ) .first() ) @@ -390,36 +605,48 @@ class EmailService: template_code: str, to_email: str, to_name: str | None = None, - language: str = "en", + language: str | None = None, variables: dict[str, Any] | None = None, vendor_id: int | None = None, + customer_id: int | None = None, user_id: int | None = None, related_type: str | None = None, related_id: int | None = None, + include_branding: bool = True, ) -> EmailLog: """ - Send an email using a database template. + Send an email using a database template with vendor override support. Args: template_code: Template code (e.g., "signup_welcome") to_email: Recipient email address to_name: Recipient name (optional) - language: Language code (default: "en") + language: Language code (auto-resolved if None) variables: Template variables dict - vendor_id: Related vendor ID for logging + vendor_id: Vendor ID for override lookup and logging + customer_id: Customer ID for language resolution user_id: Related user ID for logging related_type: Related entity type (e.g., "order") related_id: Related entity ID + include_branding: Whether to inject branding variables (default: True) Returns: EmailLog record """ variables = variables or {} - # Get template - template = self.get_template(template_code, language) - if not template: - logger.error(f"Email template not found: {template_code} ({language})") + # Resolve language (uses customer -> vendor -> platform default order) + resolved_language = self.resolve_language( + explicit_language=language, + vendor_id=vendor_id, + customer_id=customer_id, + ) + + # Resolve template (checks vendor override, falls back to platform) + resolved = self.resolve_template(template_code, resolved_language, vendor_id) + + if not resolved: + logger.error(f"Email template not found: {template_code} ({resolved_language})") # Create failed log entry log = EmailLog( template_code=template_code, @@ -429,7 +656,7 @@ class EmailService: from_email=settings.email_from_address, from_name=settings.email_from_name, status=EmailStatus.FAILED.value, - error_message=f"Template not found: {template_code} ({language})", + error_message=f"Template not found: {template_code} ({resolved_language})", provider=settings.email_provider, vendor_id=vendor_id, user_id=user_id, @@ -440,12 +667,25 @@ class EmailService: self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately return log + # Inject branding variables if requested + if include_branding: + branding = self.get_branding(vendor_id) + variables = { + **variables, + "platform_name": branding.platform_name, + "platform_logo_url": branding.platform_logo_url, + "support_email": branding.support_email, + "vendor_name": branding.vendor_name, + "vendor_logo_url": branding.vendor_logo_url, + "is_whitelabel": branding.is_whitelabel, + } + # Render template - subject = self.render_template(template.subject, variables) - body_html = self.render_template(template.body_html, variables) + subject = self.render_template(resolved.subject, variables) + body_html = self.render_template(resolved.body_html, variables) body_text = ( - self.render_template(template.body_text, variables) - if template.body_text + self.render_template(resolved.body_text, variables) + if resolved.body_text else None ) @@ -456,7 +696,7 @@ class EmailService: body_html=body_html, body_text=body_text, template_code=template_code, - template_id=template.id, + template_id=resolved.template_id, vendor_id=vendor_id, user_id=user_id, related_type=related_type, @@ -556,11 +796,29 @@ def send_email( template_code: str, to_email: str, to_name: str | None = None, - language: str = "en", + language: str | None = None, variables: dict[str, Any] | None = None, + vendor_id: int | None = None, + customer_id: int | None = None, **kwargs, ) -> EmailLog: - """Convenience function to send a templated email.""" + """ + Convenience function to send a templated email. + + Args: + db: Database session + template_code: Template code (e.g., "password_reset") + to_email: Recipient email address + to_name: Recipient name (optional) + language: Language code (auto-resolved from customer/vendor if None) + variables: Template variables dict + vendor_id: Vendor ID for override lookup and branding + customer_id: Customer ID for language resolution + **kwargs: Additional arguments passed to send_template + + Returns: + EmailLog record + """ service = EmailService(db) return service.send_template( template_code=template_code, @@ -568,5 +826,7 @@ def send_email( to_name=to_name, language=language, variables=variables, + vendor_id=vendor_id, + customer_id=customer_id, **kwargs, ) diff --git a/app/templates/admin/email-templates.html b/app/templates/admin/email-templates.html new file mode 100644 index 00000000..d0c3aa5f --- /dev/null +++ b/app/templates/admin/email-templates.html @@ -0,0 +1,366 @@ +{% extends "admin/base.html" %} + +{% block title %}Email Templates{% endblock %} + +{% block alpine_data %}emailTemplatesPage(){% endblock %} + +{% block content %} +
+ +
+

+ Email Templates +

+

+ Manage platform email templates. Vendors can override non-platform-only templates. +

+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + +
+ Template + + Category + + Languages + + Type + + Actions +
+ No templates found +
+
+
+
+ + +
+
+ +
+ + +
+ +
+
+
+

+

+
+ +
+ + +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+ + +

+ Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }} +

+
+ + +
+ + +
+ + +
+ + +
+ + +
+

Available Variables

+
+ + No variables defined +
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+ + +
+
+ +
+ + +
+ +
+
+
+

Email Preview

+

+
+ +
+
+ + +
+
+ +
+
+ + +
+ +
+
+
+
+ + +
+
+ +
+ + +
+ +
+

Send Test Email

+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html index aed5bde0..4dcb5265 100644 --- a/app/templates/admin/partials/sidebar.html +++ b/app/templates/admin/partials/sidebar.html @@ -137,6 +137,7 @@ {{ section_header('Platform Settings', 'settingsSection') }} {% call section_content('settingsSection') %} {{ menu_item('settings', '/admin/settings', 'cog', 'General') }} + {{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }} {# TODO: Implement profile and API keys pages #} {# {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }} #} {# {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }} #} diff --git a/app/templates/vendor/email-templates.html b/app/templates/vendor/email-templates.html new file mode 100644 index 00000000..6fb64912 --- /dev/null +++ b/app/templates/vendor/email-templates.html @@ -0,0 +1,329 @@ +{# app/templates/vendor/email-templates.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/modals.html' import modal_dialog %} + +{% block title %}Email Templates{% endblock %} + +{% block alpine_data %}vendorEmailTemplates(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='Email Templates', subtitle='Customize email templates sent to your customers') %} +{% endcall %} + +{{ loading_state('Loading email templates...') }} + +{{ error_state('Error loading templates') }} + + +
+ +
+
+ +
+

+ Customize how emails appear to your customers. Platform templates are used by default, + and you can override them with your own versions. Some templates (billing, subscriptions) + are platform-only and cannot be customized. +

+
+
+
+ + +
+
+

Available Templates

+

Click a template to customize it

+
+ +
+ + + + + + + + + + + + + +
TemplateCategoryLanguagesStatusActions
+
+ + +
+
+ + +{% call modal_dialog( + show_var="showEditModal", + title_var="editingTemplate ? 'Customize: ' + editingTemplate.name : 'Edit Template'", + size="4xl" +) %} + +{% endcall %} + + +{% call modal_dialog( + show_var="showPreviewModal", + title="Email Preview", + size="4xl" +) %} + +{% endcall %} + + +{% call modal_dialog( + show_var="showTestEmailModal", + title="Send Test Email", + size="md" +) %} +
+
+ + +
+

+ A test email will be sent using sample data for template variables. +

+
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/partials/sidebar.html b/app/templates/vendor/partials/sidebar.html index d1d3408e..604dffae 100644 --- a/app/templates/vendor/partials/sidebar.html +++ b/app/templates/vendor/partials/sidebar.html @@ -114,6 +114,7 @@ {{ menu_item('team', 'team', 'user-group', 'Team') }} {{ menu_item('profile', 'profile', 'user', 'Profile') }} {{ menu_item('billing', 'billing', 'credit-card', 'Billing') }} + {{ menu_item('email-templates', 'email-templates', 'mail', 'Email Templates') }} {{ menu_item('settings', 'settings', 'adjustments', 'Settings') }} {% endcall %} diff --git a/docs/implementation/email-templates-architecture.md b/docs/implementation/email-templates-architecture.md index 4c010686..f666ff3b 100644 --- a/docs/implementation/email-templates-architecture.md +++ b/docs/implementation/email-templates-architecture.md @@ -1,107 +1,289 @@ -# Email Templates Architecture (Future) +# Email System Enhancements - Implementation Plan ## Overview -Email templates follow a similar pattern to the CMS content pages system, with platform defaults that vendors can override. +Enhance the email system to support: +- Platform-level templates with vendor overrides +- Wizamart branding by default (removed for Enterprise whitelabel tier) +- Platform-only templates that cannot be overridden +- Admin UI for editing platform templates +- 4-language support (en, fr, de, lb) +- Smart language resolution (customer → vendor → platform default) -## Architecture +--- -### Template Hierarchy +## Phase 1: Database & Model Foundation -1. **Platform Default Templates** - Admin-managed base templates - - Order confirmation - - Shipping notification - - Password reset - - Welcome email - - Invoice email - - etc. +### 1.1 Update EmailTemplate Model -2. **Vendor Overrides** - Optional customization - - Vendors can override platform templates - - Cannot create new template types (unlike CMS pages) - - Must maintain required placeholders +**File:** `models/database/email.py` -### Multi-language Support +Add columns: +- `is_platform_only: bool = False` - Cannot be overridden by vendors +- `required_variables: JSON` - List of variables that must be provided -- Each template exists in all supported languages (FR, EN, DE, LB) -- Vendor overrides are per-language -- Falls back to platform default if no vendor override - -### Key Differences from CMS Pages - -| Feature | CMS Pages | Email Templates | -|---------|-----------|-----------------| -| Create new | Vendors can create | Vendors cannot create | -| Template types | Unlimited | Fixed set by platform | -| Tier limits | Number of pages per tier | No limits (all templates available) | -| Override | Full content | Full content | - -## Database Design (Proposed) - -```sql --- Platform templates (admin-managed) -CREATE TABLE email_templates ( - id SERIAL PRIMARY KEY, - template_key VARCHAR(100) NOT NULL, -- e.g., 'order_confirmation' - language VARCHAR(5) NOT NULL, -- e.g., 'fr', 'en', 'de' - subject TEXT NOT NULL, - html_body TEXT NOT NULL, - text_body TEXT, - placeholders JSONB, -- Required placeholders - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP, - updated_at TIMESTAMP, - UNIQUE(template_key, language) -); - --- Vendor overrides -CREATE TABLE vendor_email_templates ( - id SERIAL PRIMARY KEY, - vendor_id INTEGER REFERENCES vendors(id), - template_key VARCHAR(100) NOT NULL, - language VARCHAR(5) NOT NULL, - subject TEXT NOT NULL, - html_body TEXT NOT NULL, - text_body TEXT, - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP, - updated_at TIMESTAMP, - UNIQUE(vendor_id, template_key, language) -); +```python +is_platform_only = Column(Boolean, default=False, nullable=False) +required_variables = Column(JSON, default=list) ``` -## Rendering Flow +### 1.2 Create VendorEmailTemplate Model -1. Email service receives request (e.g., send order confirmation) -2. Check for vendor override by `(vendor_id, template_key, language)` -3. If no override, use platform default by `(template_key, language)` -4. Render template with context variables -5. Send via email service +**File:** `models/database/vendor_email_template.py` -## API Endpoints (Proposed) +```python +class VendorEmailTemplate(Base): + __tablename__ = "vendor_email_templates" -### Admin -- `GET /admin/email-templates` - List all platform templates -- `GET /admin/email-templates/{key}` - Get template by key -- `PUT /admin/email-templates/{key}` - Update platform template -- `POST /admin/email-templates/{key}/preview` - Preview template + id: int + vendor_id: int (FK → vendors.id) + template_code: str # References EmailTemplate.code + language: str # en, fr, de, lb + subject: str + body_html: str + body_text: str + is_active: bool = True + created_at: datetime + updated_at: datetime -### Vendor -- `GET /vendor/email-templates` - List available templates with override status -- `GET /vendor/email-templates/{key}` - Get template (override or platform) -- `PUT /vendor/email-templates/{key}` - Create/update override -- `DELETE /vendor/email-templates/{key}` - Remove override (revert to platform) -- `POST /vendor/email-templates/{key}/preview` - Preview with vendor context + # Unique constraint: (vendor_id, template_code, language) +``` -## UI Considerations +### 1.3 Migration -- Admin: Template editor with WYSIWYG or code view -- Vendor: Simple override interface showing platform default as reference -- Placeholder validation on save -- Preview with sample data +**File:** `alembic/versions/xxx_add_vendor_email_templates.py` -## Notes +- Add columns to email_templates table +- Create vendor_email_templates table +- Add indexes for lookup performance -- Email templates feature is NOT part of the current settings page -- Contact support messaging in settings directs vendors to admin -- Full implementation to follow CMS pages pattern +--- + +## Phase 2: Email Service Enhancement + +### 2.1 Language Resolution + +**Priority order for customer-facing emails:** +1. `customer.preferred_language` (if customer exists) +2. `vendor.storefront_language` +3. Platform default (`en`) + +**Priority order for vendor-facing emails:** +1. `vendor.preferred_language` +2. Platform default (`en`) + +### 2.2 Template Resolution + +**File:** `app/services/email_service.py` + +```python +def resolve_template( + self, + template_code: str, + language: str, + vendor_id: int | None = None +) -> tuple[str, str, str]: # subject, body_html, body_text + """ + Resolve template with vendor override support. + + 1. If vendor_id provided and template not platform-only: + - Look for VendorEmailTemplate override + - Fall back to platform EmailTemplate + 2. If no vendor or platform-only: + - Use platform EmailTemplate + 3. Language fallback: requested → en + """ +``` + +### 2.3 Branding Resolution + +```python +def get_branding(self, vendor_id: int | None) -> dict: + """ + Get branding variables for email. + + Returns: + - platform_name: "Wizamart" or vendor.name (if whitelabel) + - platform_logo: Wizamart logo or vendor logo (if whitelabel) + - support_email: platform or vendor support email + - vendor_name: Always vendor.name + - vendor_logo: Always vendor logo + """ + if vendor_id: + vendor = self._get_vendor(vendor_id) + if has_feature(vendor, "white_label"): + return { + "platform_name": vendor.name, + "platform_logo": vendor.logo_url, + ... + } + return { + "platform_name": "Wizamart", + "platform_logo": PLATFORM_LOGO_URL, + ... + } +``` + +--- + +## Phase 3: Admin API & UI + +### 3.1 Admin API Endpoints + +**File:** `app/api/v1/admin/email_templates.py` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/email-templates` | List all platform templates | +| GET | `/email-templates/{code}` | Get template (all languages) | +| PUT | `/email-templates/{code}/{language}` | Update template | +| POST | `/email-templates/{code}/preview` | Preview with sample data | +| POST | `/email-templates/{code}/test` | Send test email | + +### 3.2 Admin UI + +**File:** `app/templates/admin/email-templates.html` + +Features: +- List view with template codes, categories, language badges +- Edit view with: + - Language tabs (en, fr, de, lb) + - Subject field + - Rich text editor for body_html + - Plain text preview for body_text + - Variable reference panel + - Preview pane + - Test send button +- Platform-only badge/indicator +- Required variables display + +--- + +## Phase 4: Vendor API & UI + +### 4.1 Vendor API Endpoints + +**File:** `app/api/v1/vendor/email_templates.py` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/email-templates` | List overridable templates | +| GET | `/email-templates/{code}` | Get vendor override or default | +| PUT | `/email-templates/{code}/{language}` | Create/update override | +| DELETE | `/email-templates/{code}/{language}` | Reset to platform default | +| POST | `/email-templates/{code}/preview` | Preview with vendor branding | +| POST | `/email-templates/{code}/test` | Send test email | + +### 4.2 Vendor UI + +**File:** `app/templates/vendor/email-templates.html` + +Features: +- List view with templates, showing "Customized" vs "Using Default" +- Edit view similar to admin but: + - Can only edit non-platform-only templates + - "Reset to Default" button + - Shows platform default as reference +- Tier gating: Only accessible for Business+ tiers + +--- + +## Phase 5: Missing Templates + +### Platform-Only Templates (Billing/Subscription) + +| Code | Category | Description | +|------|----------|-------------| +| `tier_upgrade` | BILLING | Vendor upgraded subscription | +| `tier_downgrade` | BILLING | Vendor downgraded subscription | +| `payment_failed` | BILLING | Subscription payment failed | +| `subscription_cancelled` | BILLING | Subscription cancelled | +| `trial_ending` | BILLING | Trial period ending soon | +| `usage_limit_warning` | BILLING | Approaching usage limits | + +### Overridable Templates + +| Code | Category | Description | +|------|----------|-------------| +| `team_invite` | AUTH | Team member invitation | +| `order_shipped` | ORDERS | Order has shipped | +| `shipping_update` | ORDERS | Shipping status update | +| `low_stock_alert` | SYSTEM | Low inventory warning | + +--- + +## Language Support Matrix + +| Language | Code | Platform Default | +|----------|------|------------------| +| English | `en` | Yes (fallback) | +| French | `fr` | No | +| German | `de` | No | +| Luxembourgish | `lb` | No | + +**Fallback Logic:** +``` +requested_language → en (if template not found) +``` + +--- + +## Files to Create/Modify + +### Phase 1 (Database) +| File | Action | +|------|--------| +| `alembic/versions/xxx_add_vendor_email_templates.py` | Create | +| `models/database/email.py` | Modify | +| `models/database/vendor_email_template.py` | Create | +| `models/database/__init__.py` | Modify | +| `models/schema/email.py` | Create | + +### Phase 2 (Service) +| File | Action | +|------|--------| +| `app/services/email_service.py` | Modify | + +### Phase 3 (Admin) +| File | Action | +|------|--------| +| `app/api/v1/admin/email_templates.py` | Create | +| `app/api/v1/admin/__init__.py` | Modify | +| `app/templates/admin/email-templates.html` | Create | +| `app/routes/admin_pages.py` | Modify | +| `static/admin/js/email-templates.js` | Create | + +### Phase 4 (Vendor) +| File | Action | +|------|--------| +| `app/api/v1/vendor/email_templates.py` | Create | +| `app/api/v1/vendor/__init__.py` | Modify | +| `app/templates/vendor/email-templates.html` | Create | +| `app/routes/vendor_pages.py` | Modify | +| `static/vendor/js/email-templates.js` | Create | + +### Phase 5 (Templates) +| File | Action | +|------|--------| +| `scripts/seed_email_templates.py` | Modify | + +--- + +## Execution Order + +1. **Phase 1**: Database migration and models (~30 min) +2. **Phase 2**: Email service enhancement (~45 min) +3. **Phase 3**: Admin API and UI (~1-2 hours) +4. **Phase 4**: Vendor API and UI (~1-2 hours) +5. **Phase 5**: Add missing templates (~30 min) +6. **Testing**: Integration tests for all phases + +--- + +## Security Considerations + +1. **XSS Prevention**: Sanitize HTML in templates before storage +2. **Access Control**: Vendors can only edit their own templates +3. **Platform-only Protection**: API enforces is_platform_only flag +4. **Template Validation**: Ensure required variables are present in template +5. **Rate Limiting**: Test email sending limited to prevent abuse diff --git a/models/database/__init__.py b/models/database/__init__.py index e381da05..59100c0b 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -20,6 +20,7 @@ from .content_page import ContentPage from .customer import Customer, CustomerAddress from .password_reset_token import PasswordResetToken from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate +from .vendor_email_template import VendorEmailTemplate from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation from .inventory import Inventory from .inventory_transaction import InventoryTransaction, TransactionType @@ -112,6 +113,7 @@ __all__ = [ "EmailLog", "EmailStatus", "EmailTemplate", + "VendorEmailTemplate", # Features "Feature", "FeatureCategory", diff --git a/models/database/email.py b/models/database/email.py index cfccfa8a..0e9407f0 100644 --- a/models/database/email.py +++ b/models/database/email.py @@ -5,9 +5,15 @@ Email system database models. Provides: - EmailTemplate: Multi-language email templates stored in database - EmailLog: Email sending history and tracking + +Platform vs Vendor Templates: +- Platform templates (EmailTemplate) are the defaults +- Vendors can override templates via VendorEmailTemplate +- Platform-only templates (is_platform_only=True) cannot be overridden """ import enum +import json from datetime import datetime from sqlalchemy import ( @@ -16,11 +22,12 @@ from sqlalchemy import ( DateTime, Enum, ForeignKey, + Index, Integer, String, Text, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Session, relationship from app.core.database import Base @@ -81,11 +88,20 @@ class EmailTemplate(Base, TimestampMixin): # e.g., ["first_name", "company_name", "login_url"] variables = Column(Text, nullable=True) + # Required variables (JSON list of variables that MUST be provided) + # Subset of variables that are mandatory for the template to render + required_variables = Column(Text, nullable=True) + # Status is_active = Column(Boolean, default=True, nullable=False) + # Platform-only flag: if True, vendors cannot override this template + # Used for billing, subscription, and other platform-level emails + is_platform_only = Column(Boolean, default=False, nullable=False) + # Unique constraint: one template per code+language __table_args__ = ( + Index("ix_email_templates_code_language", "code", "language", unique=True), {"sqlite_autoincrement": True}, ) @@ -95,8 +111,6 @@ class EmailTemplate(Base, TimestampMixin): @property def variables_list(self) -> list[str]: """Parse variables JSON to list.""" - import json - if not self.variables: return [] try: @@ -104,6 +118,106 @@ class EmailTemplate(Base, TimestampMixin): except (json.JSONDecodeError, TypeError): return [] + @property + def required_variables_list(self) -> list[str]: + """Parse required_variables JSON to list.""" + if not self.required_variables: + return [] + try: + return json.loads(self.required_variables) + except (json.JSONDecodeError, TypeError): + return [] + + @classmethod + def get_by_code_and_language( + cls, + db: Session, + code: str, + language: str, + fallback_to_english: bool = True, + ) -> "EmailTemplate | None": + """ + Get a platform template by code and language. + + Args: + db: Database session + code: Template code (e.g., "password_reset") + language: Language code (en, fr, de, lb) + fallback_to_english: If True, fall back to English if language not found + + Returns: + EmailTemplate if found, None otherwise + """ + template = ( + db.query(cls) + .filter( + cls.code == code, + cls.language == language, + cls.is_active == True, # noqa: E712 + ) + .first() + ) + + # Fallback to English if requested language not found + if not template and fallback_to_english and language != "en": + template = ( + db.query(cls) + .filter( + cls.code == code, + cls.language == "en", + cls.is_active == True, # noqa: E712 + ) + .first() + ) + + return template + + @classmethod + def get_all_templates( + cls, + db: Session, + category: str | None = None, + include_inactive: bool = False, + ) -> list["EmailTemplate"]: + """ + Get all platform templates, optionally filtered by category. + + Args: + db: Database session + category: Optional category filter + include_inactive: Include inactive templates + + Returns: + List of EmailTemplate objects + """ + query = db.query(cls) + + if category: + query = query.filter(cls.category == category) + + if not include_inactive: + query = query.filter(cls.is_active == True) # noqa: E712 + + return query.order_by(cls.code, cls.language).all() + + @classmethod + def get_overridable_templates(cls, db: Session) -> list["EmailTemplate"]: + """ + Get all templates that vendors can override. + + Returns: + List of EmailTemplate objects where is_platform_only=False + """ + return ( + db.query(cls) + .filter( + cls.is_platform_only == False, # noqa: E712 + cls.is_active == True, # noqa: E712 + ) + .order_by(cls.code, cls.language) + .all() + ) + class EmailLog(Base, TimestampMixin): """ diff --git a/models/database/vendor.py b/models/database/vendor.py index f3064aef..224eb0ba 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -170,6 +170,13 @@ class Vendor(Base, TimestampMixin): cascade="all, delete-orphan", ) + # Email template overrides (one-to-many) + email_templates = relationship( + "VendorEmailTemplate", + back_populates="vendor", + cascade="all, delete-orphan", + ) + # Subscription (one-to-one) subscription = relationship( "VendorSubscription", diff --git a/models/database/vendor_email_template.py b/models/database/vendor_email_template.py new file mode 100644 index 00000000..33e017e3 --- /dev/null +++ b/models/database/vendor_email_template.py @@ -0,0 +1,229 @@ +# models/database/vendor_email_template.py +""" +Vendor email template override model. + +Allows vendors to customize platform email templates with their own content. +Platform-only templates cannot be overridden (e.g., billing, subscription emails). +""" + +from datetime import datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Session, relationship + +from app.core.database import Base + +from .base import TimestampMixin + + +class VendorEmailTemplate(Base, TimestampMixin): + """ + Vendor-specific email template override. + + Each vendor can customize email templates for their shop. + Overrides are per-template-code and per-language. + + When sending emails: + 1. Check if vendor has an override for the template+language + 2. If yes, use vendor's version + 3. If no, fall back to platform template + + Platform-only templates (is_platform_only=True on EmailTemplate) + cannot be overridden. + """ + + __tablename__ = "vendor_email_templates" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # Vendor relationship + vendor_id = Column( + Integer, + ForeignKey("vendors.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Template identification (references EmailTemplate.code, not FK) + template_code = Column(String(100), nullable=False, index=True) + language = Column(String(5), nullable=False, default="en") + + # Optional custom name (if None, uses platform template name) + name = Column(String(255), nullable=True) + + # Email content + subject = Column(String(500), nullable=False) + body_html = Column(Text, nullable=False) + body_text = Column(Text, nullable=True) + + # Status + is_active = Column(Boolean, default=True, nullable=False) + + # Relationships + vendor = relationship("Vendor", back_populates="email_templates") + + # Unique constraint: one override per vendor+template+language + __table_args__ = ( + UniqueConstraint( + "vendor_id", + "template_code", + "language", + name="uq_vendor_email_template_code_language", + ), + {"sqlite_autoincrement": True}, + ) + + def __repr__(self): + return ( + f"" + ) + + @classmethod + def get_override( + cls, + db: Session, + vendor_id: int, + template_code: str, + language: str, + ) -> "VendorEmailTemplate | None": + """ + Get vendor's template override if it exists. + + Args: + db: Database session + vendor_id: Vendor ID + template_code: Template code to look up + language: Language code (en, fr, de, lb) + + Returns: + VendorEmailTemplate if override exists, None otherwise + """ + return ( + db.query(cls) + .filter( + cls.vendor_id == vendor_id, + cls.template_code == template_code, + cls.language == language, + cls.is_active == True, # noqa: E712 + ) + .first() + ) + + @classmethod + def get_all_overrides_for_vendor( + cls, + db: Session, + vendor_id: int, + ) -> list["VendorEmailTemplate"]: + """ + Get all template overrides for a vendor. + + Args: + db: Database session + vendor_id: Vendor ID + + Returns: + List of VendorEmailTemplate objects + """ + return ( + db.query(cls) + .filter( + cls.vendor_id == vendor_id, + cls.is_active == True, # noqa: E712 + ) + .order_by(cls.template_code, cls.language) + .all() + ) + + @classmethod + def create_or_update( + cls, + db: Session, + vendor_id: int, + template_code: str, + language: str, + subject: str, + body_html: str, + body_text: str | None = None, + name: str | None = None, + ) -> "VendorEmailTemplate": + """ + Create or update a vendor email template override. + + Args: + db: Database session + vendor_id: Vendor ID + template_code: Template code + language: Language code + subject: Email subject + body_html: HTML body content + body_text: Optional plain text body + name: Optional custom name + + Returns: + Created or updated VendorEmailTemplate + """ + existing = cls.get_override(db, vendor_id, template_code, language) + + if existing: + existing.subject = subject + existing.body_html = body_html + existing.body_text = body_text + existing.name = name + existing.updated_at = datetime.utcnow() + return existing + + new_template = cls( + vendor_id=vendor_id, + template_code=template_code, + language=language, + subject=subject, + body_html=body_html, + body_text=body_text, + name=name, + ) + db.add(new_template) + return new_template + + @classmethod + def delete_override( + cls, + db: Session, + vendor_id: int, + template_code: str, + language: str, + ) -> bool: + """ + Delete a vendor's template override (revert to platform default). + + Args: + db: Database session + vendor_id: Vendor ID + template_code: Template code + language: Language code + + Returns: + True if deleted, False if not found + """ + deleted = ( + db.query(cls) + .filter( + cls.vendor_id == vendor_id, + cls.template_code == template_code, + cls.language == language, + ) + .delete() + ) + return deleted > 0 diff --git a/models/schema/__init__.py b/models/schema/__init__.py index 96e127a3..cdb71410 100644 --- a/models/schema/__init__.py +++ b/models/schema/__init__.py @@ -5,6 +5,7 @@ from . import ( auth, base, + email, inventory, invoice, marketplace_import_job, @@ -21,6 +22,7 @@ from .base import * # Base Pydantic models __all__ = [ "base", "auth", + "email", "invoice", "marketplace_product", "message", diff --git a/models/schema/email.py b/models/schema/email.py new file mode 100644 index 00000000..edfd248f --- /dev/null +++ b/models/schema/email.py @@ -0,0 +1,247 @@ +# models/schema/email.py +""" +Email template Pydantic schemas for API responses and requests. + +Provides schemas for: +- EmailTemplate: Platform email templates +- VendorEmailTemplate: Vendor-specific template overrides +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class EmailTemplateBase(BaseModel): + """Base schema for email templates.""" + + code: str = Field(..., description="Template code (e.g., 'password_reset')") + language: str = Field(default="en", description="Language code (en, fr, de, lb)") + name: str = Field(..., description="Human-readable template name") + description: str | None = Field(None, description="Template description") + category: str = Field(..., description="Template category (auth, orders, billing, etc.)") + subject: str = Field(..., description="Email subject (supports Jinja2 variables)") + body_html: str = Field(..., description="HTML email body") + body_text: str | None = Field(None, description="Plain text fallback") + variables: list[str] = Field(default_factory=list, description="Available variables") + + +class EmailTemplateCreate(EmailTemplateBase): + """Schema for creating an email template.""" + + required_variables: list[str] = Field( + default_factory=list, description="Required variables" + ) + is_platform_only: bool = Field( + default=False, description="Cannot be overridden by vendors" + ) + + +class EmailTemplateUpdate(BaseModel): + """Schema for updating an email template.""" + + name: str | None = Field(None, description="Human-readable template name") + description: str | None = Field(None, description="Template description") + subject: str | None = Field(None, description="Email subject") + body_html: str | None = Field(None, description="HTML email body") + body_text: str | None = Field(None, description="Plain text fallback") + variables: list[str] | None = Field(None, description="Available variables") + required_variables: list[str] | None = Field(None, description="Required variables") + is_active: bool | None = Field(None, description="Template active status") + + +class EmailTemplateResponse(BaseModel): + """Schema for email template API response.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + language: str + name: str + description: str | None + category: str + subject: str + body_html: str + body_text: str | None + variables: list[str] = Field(default_factory=list) + required_variables: list[str] = Field(default_factory=list) + is_active: bool + is_platform_only: bool + created_at: datetime + updated_at: datetime + + @classmethod + def from_db(cls, template) -> "EmailTemplateResponse": + """Create response from database model.""" + return cls( + id=template.id, + code=template.code, + language=template.language, + name=template.name, + description=template.description, + category=template.category, + subject=template.subject, + body_html=template.body_html, + body_text=template.body_text, + variables=template.variables_list, + required_variables=template.required_variables_list, + is_active=template.is_active, + is_platform_only=template.is_platform_only, + created_at=template.created_at, + updated_at=template.updated_at, + ) + + +class EmailTemplateSummary(BaseModel): + """Summary schema for template list views.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + category: str + languages: list[str] = Field(default_factory=list) + is_platform_only: bool + is_active: bool + + @classmethod + def from_db_list(cls, templates: list) -> list["EmailTemplateSummary"]: + """ + Create summaries from database models, grouping by code. + + Args: + templates: List of EmailTemplate models + + Returns: + List of EmailTemplateSummary grouped by template code + """ + # Group templates by code + by_code: dict[str, list] = {} + for t in templates: + if t.code not in by_code: + by_code[t.code] = [] + by_code[t.code].append(t) + + summaries = [] + for code, group in by_code.items(): + first = group[0] + summaries.append( + cls( + id=first.id, + code=code, + name=first.name, + category=first.category, + languages=[t.language for t in group], + is_platform_only=first.is_platform_only, + is_active=first.is_active, + ) + ) + + return summaries + + +# Vendor Email Template Schemas + + +class VendorEmailTemplateCreate(BaseModel): + """Schema for creating a vendor email template override.""" + + template_code: str = Field(..., description="Template code to override") + language: str = Field(default="en", description="Language code") + name: str | None = Field(None, description="Custom name (uses platform default if None)") + subject: str = Field(..., description="Custom email subject") + body_html: str = Field(..., description="Custom HTML body") + body_text: str | None = Field(None, description="Custom plain text body") + + +class VendorEmailTemplateUpdate(BaseModel): + """Schema for updating a vendor email template override.""" + + name: str | None = Field(None, description="Custom name") + subject: str | None = Field(None, description="Custom email subject") + body_html: str | None = Field(None, description="Custom HTML body") + body_text: str | None = Field(None, description="Custom plain text body") + is_active: bool | None = Field(None, description="Override active status") + + +class VendorEmailTemplateResponse(BaseModel): + """Schema for vendor email template override API response.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + vendor_id: int + template_code: str + language: str + name: str | None + subject: str + body_html: str + body_text: str | None + is_active: bool + created_at: datetime + updated_at: datetime + + +class EmailTemplateWithOverrideStatus(BaseModel): + """ + Schema showing template with vendor override status. + + Used in vendor UI to show which templates have been customized. + """ + + model_config = ConfigDict(from_attributes=True) + + code: str + name: str + category: str + languages: list[str] + is_platform_only: bool + has_override: bool = Field( + default=False, description="Whether vendor has customized this template" + ) + override_languages: list[str] = Field( + default_factory=list, + description="Languages with vendor overrides", + ) + + +# Email Preview/Test Schemas + + +class EmailPreviewRequest(BaseModel): + """Schema for requesting an email preview.""" + + template_code: str = Field(..., description="Template code") + language: str = Field(default="en", description="Language code") + variables: dict[str, str] = Field( + default_factory=dict, description="Variables to inject" + ) + + +class EmailPreviewResponse(BaseModel): + """Schema for email preview response.""" + + subject: str + body_html: str + body_text: str | None + + +class EmailTestRequest(BaseModel): + """Schema for sending a test email.""" + + template_code: str = Field(..., description="Template code") + language: str = Field(default="en", description="Language code") + to_email: str = Field(..., description="Recipient email address") + variables: dict[str, str] = Field( + default_factory=dict, description="Variables to inject" + ) + + +class EmailTestResponse(BaseModel): + """Schema for test email response.""" + + success: bool + message: str + email_log_id: int | None = None diff --git a/scripts/seed_email_templates.py b/scripts/seed_email_templates.py index 3871103d..9031af7f 100644 --- a/scripts/seed_email_templates.py +++ b/scripts/seed_email_templates.py @@ -896,6 +896,363 @@ Dëse Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir dës Passwuertzrec Mat beschte Gréiss, D'Team +""", + }, + # ------------------------------------------------------------------------- + # PLATFORM-ONLY BILLING TEMPLATES + # ------------------------------------------------------------------------- + { + "code": "subscription_welcome", + "language": "en", + "name": "Subscription Welcome", + "description": "Sent to vendors when they subscribe to a paid plan", + "category": EmailCategory.BILLING.value, + "is_platform_only": True, + "required_variables": json.dumps(["vendor_name", "tier_name", "billing_cycle", "amount"]), + "variables": json.dumps([ + "vendor_name", "tier_name", "billing_cycle", "amount", + "next_billing_date", "dashboard_url" + ]), + "subject": "Welcome to {{ tier_name }} - Subscription Confirmed", + "body_html": """ + + + + + + +
+

Subscription Confirmed!

+
+ +
+

Hi {{ vendor_name }},

+ +

Thank you for subscribing to Wizamart! Your {{ tier_name }} subscription is now active.

+ +
+

Subscription Details

+

Plan: {{ tier_name }}

+

Billing Cycle: {{ billing_cycle }}

+

Amount: {{ amount }}

+

Next Billing Date: {{ next_billing_date }}

+
+ +
+ + Go to Dashboard + +
+ +

+ If you have any questions about your subscription, please contact our support team. +

+ +

Best regards,
The Wizamart Team

+
+ +
+

© 2024 Wizamart. All rights reserved.

+
+ +""", + "body_text": """Subscription Confirmed! + +Hi {{ vendor_name }}, + +Thank you for subscribing to Wizamart! Your {{ tier_name }} subscription is now active. + +Subscription Details: +- Plan: {{ tier_name }} +- Billing Cycle: {{ billing_cycle }} +- Amount: {{ amount }} +- Next Billing Date: {{ next_billing_date }} + +Go to Dashboard: {{ dashboard_url }} + +If you have any questions about your subscription, please contact our support team. + +Best regards, +The Wizamart Team +""", + }, + { + "code": "payment_failed", + "language": "en", + "name": "Payment Failed", + "description": "Sent when a subscription payment fails", + "category": EmailCategory.BILLING.value, + "is_platform_only": True, + "required_variables": json.dumps(["vendor_name", "tier_name", "amount"]), + "variables": json.dumps([ + "vendor_name", "tier_name", "amount", "retry_date", + "update_payment_url", "support_email" + ]), + "subject": "Action Required: Payment Failed for Your Subscription", + "body_html": """ + + + + + + +
+

Payment Failed

+
+ +
+

Hi {{ vendor_name }},

+ +

We were unable to process your payment of {{ amount }} for your {{ tier_name }} subscription.

+ +
+

What happens next?

+

We'll automatically retry the payment on {{ retry_date }}.

+

To avoid any service interruption, please update your payment method.

+
+ +
+ + Update Payment Method + +
+ +

+ If you need assistance, please contact us at {{ support_email }}. +

+ +

Best regards,
The Wizamart Team

+
+ +""", + "body_text": """Payment Failed + +Hi {{ vendor_name }}, + +We were unable to process your payment of {{ amount }} for your {{ tier_name }} subscription. + +What happens next? +- We'll automatically retry the payment on {{ retry_date }}. +- To avoid any service interruption, please update your payment method. + +Update Payment Method: {{ update_payment_url }} + +If you need assistance, please contact us at {{ support_email }}. + +Best regards, +The Wizamart Team +""", + }, + { + "code": "subscription_cancelled", + "language": "en", + "name": "Subscription Cancelled", + "description": "Sent when a subscription is cancelled", + "category": EmailCategory.BILLING.value, + "is_platform_only": True, + "required_variables": json.dumps(["vendor_name", "tier_name"]), + "variables": json.dumps([ + "vendor_name", "tier_name", "end_date", "reactivate_url" + ]), + "subject": "Your Wizamart Subscription Has Been Cancelled", + "body_html": """ + + + + + + +
+

Subscription Cancelled

+
+ +
+

Hi {{ vendor_name }},

+ +

Your {{ tier_name }} subscription has been cancelled as requested.

+ +
+

What happens now?

+

You'll continue to have access to your {{ tier_name }} features until {{ end_date }}.

+

After that date, your account will be downgraded to the Free tier.

+
+ +

Changed your mind? You can reactivate your subscription at any time:

+ +
+ + Reactivate Subscription + +
+ +

+ We're sorry to see you go. If there's anything we could have done better, please let us know. +

+ +

Best regards,
The Wizamart Team

+
+ +""", + "body_text": """Subscription Cancelled + +Hi {{ vendor_name }}, + +Your {{ tier_name }} subscription has been cancelled as requested. + +What happens now? +- You'll continue to have access to your {{ tier_name }} features until {{ end_date }}. +- After that date, your account will be downgraded to the Free tier. + +Changed your mind? You can reactivate your subscription at any time: +{{ reactivate_url }} + +We're sorry to see you go. If there's anything we could have done better, please let us know. + +Best regards, +The Wizamart Team +""", + }, + { + "code": "trial_ending", + "language": "en", + "name": "Trial Ending Soon", + "description": "Sent when a trial is about to end", + "category": EmailCategory.BILLING.value, + "is_platform_only": True, + "required_variables": json.dumps(["vendor_name", "days_remaining"]), + "variables": json.dumps([ + "vendor_name", "tier_name", "days_remaining", "trial_end_date", + "upgrade_url", "features_list" + ]), + "subject": "Your Trial Ends in {{ days_remaining }} Days", + "body_html": """ + + + + + + +
+

Your Trial is Ending Soon

+
+ +
+

Hi {{ vendor_name }},

+ +

Your {{ tier_name }} trial ends in {{ days_remaining }} days ({{ trial_end_date }}).

+ +
+

Don't lose these features:

+

{{ features_list }}

+
+ +

Subscribe now to continue using all {{ tier_name }} features without interruption:

+ +
+ + Subscribe Now + +
+ +

+ Have questions? Reply to this email and we'll help you choose the right plan. +

+ +

Best regards,
The Wizamart Team

+
+ +""", + "body_text": """Your Trial is Ending Soon + +Hi {{ vendor_name }}, + +Your {{ tier_name }} trial ends in {{ days_remaining }} days ({{ trial_end_date }}). + +Don't lose these features: +{{ features_list }} + +Subscribe now to continue using all {{ tier_name }} features without interruption: +{{ upgrade_url }} + +Have questions? Reply to this email and we'll help you choose the right plan. + +Best regards, +The Wizamart Team +""", + }, + { + "code": "team_invite", + "language": "en", + "name": "Team Member Invitation", + "description": "Sent when a vendor invites a team member", + "category": EmailCategory.SYSTEM.value, + "is_platform_only": False, + "required_variables": json.dumps(["invitee_name", "inviter_name", "vendor_name", "accept_url"]), + "variables": json.dumps([ + "invitee_name", "inviter_name", "vendor_name", "role", + "accept_url", "expires_in_days" + ]), + "subject": "{{ inviter_name }} invited you to join {{ vendor_name }} on Wizamart", + "body_html": """ + + + + + + +
+

You've Been Invited!

+
+ +
+

Hi {{ invitee_name }},

+ +

{{ inviter_name }} has invited you to join {{ vendor_name }} as a team member on Wizamart.

+ +
+

Invitation Details

+

Vendor: {{ vendor_name }}

+

Role: {{ role }}

+

Invited by: {{ inviter_name }}

+
+ +
+ + Accept Invitation + +
+ +

+ This invitation will expire in {{ expires_in_days }} days. +

+ +

+ If you weren't expecting this invitation, you can safely ignore this email. +

+ +

Best regards,
The Wizamart Team

+
+ +""", + "body_text": """You've Been Invited! + +Hi {{ invitee_name }}, + +{{ inviter_name }} has invited you to join {{ vendor_name }} as a team member on Wizamart. + +Invitation Details: +- Vendor: {{ vendor_name }} +- Role: {{ role }} +- Invited by: {{ inviter_name }} + +Accept Invitation: {{ accept_url }} + +This invitation will expire in {{ expires_in_days }} days. + +If you weren't expecting this invitation, you can safely ignore this email. + +Best regards, +The Wizamart Team """, }, ] @@ -910,6 +1267,10 @@ def seed_templates(): updated = 0 for template_data in TEMPLATES: + # Set defaults for new fields + template_data.setdefault("is_platform_only", False) + template_data.setdefault("required_variables", None) + # Check if template already exists existing = ( db.query(EmailTemplate) @@ -925,13 +1286,15 @@ def seed_templates(): for key, value in template_data.items(): setattr(existing, key, value) updated += 1 - print(f"Updated: {template_data['code']} ({template_data['language']})") + platform_only_tag = " [platform-only]" if template_data.get("is_platform_only") else "" + print(f"Updated: {template_data['code']} ({template_data['language']}){platform_only_tag}") else: # Create new template template = EmailTemplate(**template_data) db.add(template) created += 1 - print(f"Created: {template_data['code']} ({template_data['language']})") + platform_only_tag = " [platform-only]" if template_data.get("is_platform_only") else "" + print(f"Created: {template_data['code']} ({template_data['language']}){platform_only_tag}") db.commit() print(f"\nDone! Created: {created}, Updated: {updated}") diff --git a/static/admin/js/email-templates.js b/static/admin/js/email-templates.js new file mode 100644 index 00000000..6ae907ed --- /dev/null +++ b/static/admin/js/email-templates.js @@ -0,0 +1,303 @@ +/** + * Email Templates Management Page + * + * Handles: + * - Listing all platform email templates + * - Editing template content (all languages) + * - Preview and test email sending + */ + +function emailTemplatesPage() { + return { + // Data + loading: true, + templates: [], + categories: [], + selectedCategory: null, + + // Edit Modal + showEditModal: false, + editingTemplate: null, + editLanguage: 'en', + loadingTemplate: false, + editForm: { + subject: '', + body_html: '', + body_text: '', + variables: [], + required_variables: [] + }, + saving: false, + + // Preview Modal + showPreviewModal: false, + previewData: null, + + // Test Email Modal + showTestEmailModal: false, + testEmailAddress: '', + sendingTest: false, + + // Computed + get filteredTemplates() { + if (!this.selectedCategory) { + return this.templates; + } + return this.templates.filter(t => t.category === this.selectedCategory); + }, + + // Lifecycle + async init() { + await this.loadData(); + }, + + // Data Loading + async loadData() { + this.loading = true; + try { + const [templatesRes, categoriesRes] = await Promise.all([ + fetch('/api/v1/admin/email-templates'), + fetch('/api/v1/admin/email-templates/categories') + ]); + + if (templatesRes.ok) { + this.templates = await templatesRes.json(); + } + + if (categoriesRes.ok) { + const data = await categoriesRes.json(); + this.categories = data.categories || []; + } + } catch (error) { + console.error('Failed to load email templates:', error); + this.showNotification('Failed to load templates', 'error'); + } finally { + this.loading = false; + } + }, + + // Category styling + getCategoryClass(category) { + const classes = { + 'auth': 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + 'orders': 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + 'billing': 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + 'system': 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200', + 'marketing': 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200' + }; + return classes[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + }, + + // Edit Template + async editTemplate(template) { + this.editingTemplate = template; + this.editLanguage = 'en'; + this.showEditModal = true; + await this.loadTemplateLanguage(); + }, + + async loadTemplateLanguage() { + if (!this.editingTemplate) return; + + this.loadingTemplate = true; + try { + const response = await fetch( + `/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}` + ); + + if (response.ok) { + const data = await response.json(); + this.editForm = { + subject: data.subject || '', + body_html: data.body_html || '', + body_text: data.body_text || '', + variables: data.variables || [], + required_variables: data.required_variables || [] + }; + } else if (response.status === 404) { + // Template doesn't exist for this language yet + this.editForm = { + subject: '', + body_html: '', + body_text: '', + variables: [], + required_variables: [] + }; + this.showNotification(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info'); + } + } catch (error) { + console.error('Failed to load template:', error); + this.showNotification('Failed to load template', 'error'); + } finally { + this.loadingTemplate = false; + } + }, + + closeEditModal() { + this.showEditModal = false; + this.editingTemplate = null; + this.editForm = { + subject: '', + body_html: '', + body_text: '', + variables: [], + required_variables: [] + }; + }, + + async saveTemplate() { + if (!this.editingTemplate) return; + + this.saving = true; + try { + const response = await fetch( + `/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subject: this.editForm.subject, + body_html: this.editForm.body_html, + body_text: this.editForm.body_text + }) + } + ); + + if (response.ok) { + this.showNotification('Template saved successfully', 'success'); + // Refresh templates list + await this.loadData(); + } else { + const error = await response.json(); + this.showNotification(error.detail || 'Failed to save template', 'error'); + } + } catch (error) { + console.error('Failed to save template:', error); + this.showNotification('Failed to save template', 'error'); + } finally { + this.saving = false; + } + }, + + // Preview + async previewTemplate(template) { + try { + // Use sample variables for preview + const sampleVariables = this.getSampleVariables(template.code); + + const response = await fetch( + `/api/v1/admin/email-templates/${template.code}/preview`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + template_code: template.code, + language: 'en', + variables: sampleVariables + }) + } + ); + + if (response.ok) { + this.previewData = await response.json(); + this.showPreviewModal = true; + } else { + this.showNotification('Failed to load preview', 'error'); + } + } catch (error) { + console.error('Failed to preview template:', error); + this.showNotification('Failed to load preview', 'error'); + } + }, + + getSampleVariables(templateCode) { + // Sample variables for common templates + const samples = { + 'signup_welcome': { + first_name: 'John', + company_name: 'Acme Corp', + email: 'john@example.com', + vendor_code: 'acme', + login_url: 'https://example.com/login', + trial_days: '14', + tier_name: 'Business' + }, + 'order_confirmation': { + customer_name: 'Jane Doe', + order_number: 'ORD-12345', + order_total: '99.99', + order_items_count: '3', + order_date: '2024-01-15', + shipping_address: '123 Main St, Luxembourg City, L-1234' + }, + 'password_reset': { + customer_name: 'John Doe', + reset_link: 'https://example.com/reset?token=abc123', + expiry_hours: '1' + }, + 'team_invite': { + invitee_name: 'Jane', + inviter_name: 'John', + vendor_name: 'Acme Corp', + role: 'Admin', + accept_url: 'https://example.com/accept', + expires_in_days: '7' + } + }; + return samples[templateCode] || { platform_name: 'Wizamart' }; + }, + + // Test Email + sendTestEmail() { + this.showTestEmailModal = true; + }, + + async confirmSendTestEmail() { + if (!this.testEmailAddress || !this.editingTemplate) return; + + this.sendingTest = true; + try { + const response = await fetch( + `/api/v1/admin/email-templates/${this.editingTemplate.code}/test`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + template_code: this.editingTemplate.code, + language: this.editLanguage, + to_email: this.testEmailAddress, + variables: this.getSampleVariables(this.editingTemplate.code) + }) + } + ); + + const result = await response.json(); + + if (result.success) { + this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success'); + this.showTestEmailModal = false; + this.testEmailAddress = ''; + } else { + this.showNotification(result.message || 'Failed to send test email', 'error'); + } + } catch (error) { + console.error('Failed to send test email:', error); + this.showNotification('Failed to send test email', 'error'); + } finally { + this.sendingTest = false; + } + }, + + // Notifications + showNotification(message, type = 'info') { + // Use global notification system if available + if (window.showToast) { + window.showToast(message, type); + } else if (window.Alpine && Alpine.store('notifications')) { + Alpine.store('notifications').add(message, type); + } else { + console.log(`[${type.toUpperCase()}] ${message}`); + } + } + }; +} diff --git a/static/vendor/js/email-templates.js b/static/vendor/js/email-templates.js new file mode 100644 index 00000000..23c7c2b5 --- /dev/null +++ b/static/vendor/js/email-templates.js @@ -0,0 +1,334 @@ +/** + * Vendor Email Templates Management Page + * + * Allows vendors to customize email templates sent to their customers. + * Platform-only templates (billing, subscription) cannot be overridden. + */ + +const vendorEmailTemplatesLog = window.LogConfig?.loggers?.vendorEmailTemplates || + window.LogConfig?.createLogger?.('vendorEmailTemplates', false) || + { info: () => {}, debug: () => {}, warn: () => {}, error: console.error }; + +vendorEmailTemplatesLog.info('Loading...'); + +function vendorEmailTemplates() { + vendorEmailTemplatesLog.info('vendorEmailTemplates() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'email-templates', + + // Loading states + loading: true, + error: '', + saving: false, + + // Data + templates: [], + supportedLanguages: ['en', 'fr', 'de', 'lb'], + + // Edit Modal + showEditModal: false, + editingTemplate: null, + editLanguage: 'en', + loadingTemplate: false, + templateSource: 'platform', + editForm: { + subject: '', + body_html: '', + body_text: '' + }, + reverting: false, + + // Preview Modal + showPreviewModal: false, + previewData: null, + + // Test Email Modal + showTestEmailModal: false, + testEmailAddress: '', + sendingTest: false, + + // Lifecycle + async init() { + await this.loadData(); + }, + + // Data Loading + async loadData() { + this.loading = true; + this.error = ''; + + try { + const response = await fetch('/api/v1/vendor/email-templates', { + headers: { + 'Authorization': `Bearer ${this.getAuthToken()}` + } + }); + + if (response.ok) { + const data = await response.json(); + this.templates = data.templates || []; + this.supportedLanguages = data.supported_languages || ['en', 'fr', 'de', 'lb']; + } else { + const error = await response.json(); + this.error = error.detail || 'Failed to load templates'; + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to load templates:', error); + this.error = 'Failed to load templates'; + } finally { + this.loading = false; + } + }, + + // Auth token helper + getAuthToken() { + // Get from cookie or localStorage depending on your auth setup + return document.cookie + .split('; ') + .find(row => row.startsWith('vendor_token=')) + ?.split('=')[1] || ''; + }, + + // Category styling + getCategoryClass(category) { + const classes = { + 'AUTH': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'ORDERS': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'BILLING': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + 'SYSTEM': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + 'MARKETING': 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400', + 'TEAM': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' + }; + return classes[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + }, + + // Edit Template + async editTemplate(template) { + this.editingTemplate = template; + this.editLanguage = 'en'; + this.showEditModal = true; + await this.loadTemplateLanguage(); + }, + + async loadTemplateLanguage() { + if (!this.editingTemplate) return; + + this.loadingTemplate = true; + + try { + const response = await fetch( + `/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, + { + headers: { + 'Authorization': `Bearer ${this.getAuthToken()}` + } + } + ); + + if (response.ok) { + const data = await response.json(); + this.templateSource = data.source; + this.editForm = { + subject: data.subject || '', + body_html: data.body_html || '', + body_text: data.body_text || '' + }; + } else if (response.status === 404) { + // No template for this language + this.templateSource = 'none'; + this.editForm = { + subject: '', + body_html: '', + body_text: '' + }; + this.showNotification(`No template available for ${this.editLanguage.toUpperCase()}`, 'info'); + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to load template:', error); + this.showNotification('Failed to load template', 'error'); + } finally { + this.loadingTemplate = false; + } + }, + + closeEditModal() { + this.showEditModal = false; + this.editingTemplate = null; + this.editForm = { + subject: '', + body_html: '', + body_text: '' + }; + }, + + async saveTemplate() { + if (!this.editingTemplate) return; + + this.saving = true; + + try { + const response = await fetch( + `/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getAuthToken()}` + }, + body: JSON.stringify({ + subject: this.editForm.subject, + body_html: this.editForm.body_html, + body_text: this.editForm.body_text || null + }) + } + ); + + if (response.ok) { + this.showNotification('Template saved successfully', 'success'); + this.templateSource = 'vendor_override'; + // Refresh list to show updated status + await this.loadData(); + } else { + const error = await response.json(); + this.showNotification(error.detail || 'Failed to save template', 'error'); + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to save template:', error); + this.showNotification('Failed to save template', 'error'); + } finally { + this.saving = false; + } + }, + + async revertToDefault() { + if (!this.editingTemplate) return; + + if (!confirm('Are you sure you want to delete your customization and revert to the platform default?')) { + return; + } + + this.reverting = true; + + try { + const response = await fetch( + `/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.getAuthToken()}` + } + } + ); + + if (response.ok) { + this.showNotification('Reverted to platform default', 'success'); + // Reload the template to show platform version + await this.loadTemplateLanguage(); + // Refresh list + await this.loadData(); + } else { + const error = await response.json(); + this.showNotification(error.detail || 'Failed to revert', 'error'); + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to revert template:', error); + this.showNotification('Failed to revert', 'error'); + } finally { + this.reverting = false; + } + }, + + // Preview + async previewTemplate() { + if (!this.editingTemplate) return; + + try { + const response = await fetch( + `/api/v1/vendor/email-templates/${this.editingTemplate.code}/preview`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getAuthToken()}` + }, + body: JSON.stringify({ + language: this.editLanguage, + variables: {} + }) + } + ); + + if (response.ok) { + this.previewData = await response.json(); + this.showPreviewModal = true; + } else { + this.showNotification('Failed to load preview', 'error'); + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to preview template:', error); + this.showNotification('Failed to load preview', 'error'); + } + }, + + // Test Email + sendTestEmail() { + this.showTestEmailModal = true; + }, + + async confirmSendTestEmail() { + if (!this.testEmailAddress || !this.editingTemplate) return; + + this.sendingTest = true; + + try { + const response = await fetch( + `/api/v1/vendor/email-templates/${this.editingTemplate.code}/test`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getAuthToken()}` + }, + body: JSON.stringify({ + to_email: this.testEmailAddress, + language: this.editLanguage, + variables: {} + }) + } + ); + + const result = await response.json(); + + if (result.success) { + this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success'); + this.showTestEmailModal = false; + this.testEmailAddress = ''; + } else { + this.showNotification(result.message || 'Failed to send test email', 'error'); + } + } catch (error) { + vendorEmailTemplatesLog.error('Failed to send test email:', error); + this.showNotification('Failed to send test email', 'error'); + } finally { + this.sendingTest = false; + } + }, + + // Notifications + showNotification(message, type = 'info') { + // Use global notification system if available + if (window.showToast) { + window.showToast(message, type); + } else if (window.Alpine && Alpine.store('notifications')) { + Alpine.store('notifications').add(message, type); + } else { + console.log(`[${type.toUpperCase()}] ${message}`); + } + } + }; +}