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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All Templates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Template
+
+
+ Category
+
+
+ Languages
+
+
+ Type
+
+
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Platform Only
+
+
+ Overridable
+
+
+
+
+ Edit
+
+
+ Preview
+
+
+
+
+
+
+
+ No templates found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Subject
+
+
+
+ Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }}
+
+
+
+
+
+
+ HTML Body
+
+
+
+
+
+
+
+ Plain Text Body
+
+
+
+
+
+
+
Available Variables
+
+
+
+
+ No variables defined
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Send Test Email
+
+
+
+
+
+
+ Send to Email
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+{% 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
+
+
+
+
+
+
+ Template
+ Category
+ Languages
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Customized
+
+
+
+
+ Platform Default
+
+
+
+
+
+ Customize
+
+
+
+
+
+
+
+
+
+
+
+
No customizable templates available
+
+
+
+
+
+
+{% call modal_dialog(
+ show_var="showEditModal",
+ title_var="editingTemplate ? 'Customize: ' + editingTemplate.name : 'Edit Template'",
+ size="4xl"
+) %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Using your customized version
+
+
+ Using platform default - edit to create your version
+
+
+
+
+
+
+ Subject Line
+
+
+
+
+
+
+
Available Variables:
+
+
+
+
+
+
+
+
+
+
+ HTML Content
+
+
+
+
+
+
+
+ Plain Text (Optional)
+
+
+
+
+
+
+
+
+
+
+
+ Revert to Platform Default
+ Reverting...
+
+
+
+
+
+ Preview
+
+
+ Send Test
+
+
+ Cancel
+
+
+ Save Override
+ Saving...
+
+
+
+
+
+{% endcall %}
+
+
+{% call modal_dialog(
+ show_var="showPreviewModal",
+ title="Email Preview",
+ size="4xl"
+) %}
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+{% endcall %}
+
+
+{% call modal_dialog(
+ show_var="showTestEmailModal",
+ title="Send Test Email",
+ size="md"
+) %}
+
+
+
+ Send test email to:
+
+
+
+
+ A test email will be sent using sample data for template variables.
+
+
+
+ Cancel
+
+
+ Send Test
+ Sending...
+
+
+
+{% 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 }}
+
+
+
+
+
+ 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.
+
+
+
+
+
+ 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:
+
+
+
+
+ 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:
+
+
+
+
+ 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 }}
+
+
+
+
+
+ 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}`);
+ }
+ }
+ };
+}