feat: implement email template system with vendor overrides

Add comprehensive email template management for both admin and vendors:

Admin Features:
- Email templates management page at /admin/email-templates
- Edit platform templates with language support (en, fr, de, lb)
- Preview templates with sample variables
- Send test emails
- View email logs per template

Vendor Features:
- Email templates customization page at /vendor/{code}/email-templates
- Override platform templates with vendor-specific versions
- Preview and test customized templates
- Revert to platform defaults

Technical Changes:
- Migration for vendor_email_templates table
- VendorEmailTemplate model with override management
- Enhanced EmailService with language resolution chain
  (customer preferred -> vendor preferred -> platform default)
- Branding resolution (Wizamart default, removed for whitelabel)
- Platform-only template protection (billing templates)
- Admin and vendor API endpoints with full CRUD
- Updated seed script with billing and team templates

Files: 22 changed, ~3,900 lines added

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 18:29:26 +01:00
parent 2e1a2fc9fc
commit c52af2a155
22 changed files with 3882 additions and 119 deletions

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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),
}

View File

@@ -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"])

545
app/api/v1/vendor/email_templates.py vendored Normal file
View File

@@ -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"})

View File

@@ -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
# ============================================================================

View File

@@ -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
)

View File

@@ -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,
)

View File

@@ -0,0 +1,366 @@
{% extends "admin/base.html" %}
{% block title %}Email Templates{% endblock %}
{% block alpine_data %}emailTemplatesPage(){% endblock %}
{% block content %}
<div class="py-6">
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Email Templates
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage platform email templates. Vendors can override non-platform-only templates.
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
</div>
<div x-show="!loading" x-cloak>
<!-- Category Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<button
@click="selectedCategory = null"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === null,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== null
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
All Templates
</button>
<template x-for="cat in categories" :key="cat.code">
<button
@click="selectedCategory = cat.code"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === cat.code,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== cat.code
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
<span x-text="cat.name"></span>
</button>
</template>
</nav>
</div>
<!-- Templates List -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Template
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Category
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Languages
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in filteredTemplates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span x-html="$icon('mail', 'h-5 w-5 text-gray-400 mr-3')"></span>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="template.name"></div>
<div class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full"
:class="getCategoryClass(template.category)"
x-text="template.category"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-1">
<template x-for="lang in template.languages" :key="lang">
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded uppercase"
x-text="lang"></span>
</template>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span x-show="template.is_platform_only"
class="px-2 py-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded-full">
Platform Only
</span>
<span x-show="!template.is_platform_only"
class="px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
Overridable
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button @click="editTemplate(template)"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300 mr-3">
Edit
</button>
<button @click="previewTemplate(template)"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300">
Preview
</button>
</td>
</tr>
</template>
<tr x-show="filteredTemplates.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
No templates found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Edit Template Modal -->
<div x-show="showEditModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="closeEditModal()"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white" x-text="editingTemplate?.name || 'Edit Template'"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="editingTemplate?.code"></p>
</div>
<button @click="closeEditModal()" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
<!-- Language Tabs -->
<div class="mt-4 flex space-x-2">
<template x-for="lang in ['en', 'fr', 'de', 'lb']" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="{
'bg-purple-600 text-white': editLanguage === lang,
'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500': editLanguage !== lang
}"
class="px-3 py-1 text-sm font-medium rounded-md uppercase transition-colors">
<span x-text="lang"></span>
</button>
</template>
</div>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[60vh] overflow-y-auto">
<!-- Loading -->
<div x-show="loadingTemplate" class="flex justify-center py-8">
<span x-html="$icon('spinner', 'h-6 w-6 text-purple-600')"></span>
</div>
<div x-show="!loadingTemplate" class="space-y-4">
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject
</label>
<input type="text"
x-model="editForm.subject"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }}
</p>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Body
</label>
<textarea x-model="editForm.body_html"
rows="12"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text Body
</label>
<textarea x-model="editForm.body_text"
rows="6"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
<!-- Variables Reference -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables</h4>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editForm.variables || []" :key="variable">
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs font-mono"
x-text="'{{ ' + variable + ' }}'"></span>
</template>
<span x-show="!editForm.variables || editForm.variables.length === 0"
class="text-gray-500 dark:text-gray-400 text-sm">No variables defined</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<div>
<button @click="sendTestEmail()"
:disabled="sendingTest"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-html="$icon('mail', '-ml-1 mr-2 h-4 w-4')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test Email'"></span>
</button>
</div>
<div class="flex space-x-3">
<button @click="closeEditModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="saveTemplate()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div x-show="showPreviewModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showPreviewModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Email Preview</h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="previewData?.subject"></p>
</div>
<button @click="showPreviewModal = false" class="text-gray-400 hover:text-gray-500">
<span x-html="$icon('x', 'h-6 w-6')"></span>
</button>
</div>
</div>
<!-- Body -->
<div class="p-6 max-h-[70vh] overflow-y-auto bg-gray-100 dark:bg-gray-900">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<iframe :srcdoc="previewData?.body_html"
class="w-full h-96 border-0"
sandbox="allow-same-origin"></iframe>
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end">
<button @click="showPreviewModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Test Email Modal -->
<div x-show="showTestEmailModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
@click="showTestEmailModal = false"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-md sm:w-full">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Send Test Email</h3>
</div>
<!-- Body -->
<div class="px-6 py-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send to Email
</label>
<input type="email"
x-model="testEmailAddress"
placeholder="your@email.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
</div>
</div>
<!-- Footer -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end space-x-3">
<button @click="showTestEmailModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
Cancel
</button>
<button @click="confirmSendTestEmail()"
:disabled="!testEmailAddress || sendingTest"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="sendingTest ? 'Sending...' : 'Send Test'"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', path='admin/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -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') }} #}

View File

@@ -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 %}
<!-- Page Header -->
{% 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') }}
<!-- Main Content -->
<div x-show="!loading && !error" class="space-y-6">
<!-- Info Banner -->
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-3">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<!-- Templates Table -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Available Templates</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Click a template to customize it</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Template</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Languages</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="template in templates" :key="template.code">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-4">
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="template.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></p>
</td>
<td class="px-4 py-4">
<span
:class="getCategoryClass(template.category)"
class="px-2 py-1 text-xs font-medium rounded-full"
x-text="template.category"
></span>
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<span
:class="template.override_languages.includes(lang)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
class="px-2 py-0.5 text-xs font-medium rounded uppercase"
x-text="lang"
></span>
</template>
</div>
</td>
<td class="px-4 py-4">
<template x-if="template.has_override">
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900/30 dark:text-green-400">
<span x-html="$icon('check-circle', 'w-3 h-3')"></span>
Customized
</span>
</template>
<template x-if="!template.has_override">
<span class="px-2 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-400">
Platform Default
</span>
</template>
</td>
<td class="px-4 py-4 text-right">
<button
@click="editTemplate(template)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 hover:text-purple-700 hover:bg-purple-50 rounded-lg dark:text-purple-400 dark:hover:bg-purple-900/20"
>
Customize
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="templates.length === 0">
<div class="p-8 text-center">
<span x-html="$icon('mail', 'w-12 h-12 mx-auto text-gray-400 dark:text-gray-500')"></span>
<p class="mt-2 text-gray-500 dark:text-gray-400">No customizable templates available</p>
</div>
</template>
</div>
</div>
<!-- Edit Template Modal -->
{% call modal_dialog(
show_var="showEditModal",
title_var="editingTemplate ? 'Customize: ' + editingTemplate.name : 'Edit Template'",
size="4xl"
) %}
<template x-if="editingTemplate">
<div class="space-y-6">
<!-- Language Tabs -->
<div class="border-b dark:border-gray-700">
<div class="flex flex-wrap gap-1">
<template x-for="lang in supportedLanguages" :key="lang">
<button
@click="editLanguage = lang; loadTemplateLanguage()"
:class="editLanguage === lang
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="px-4 py-2 text-sm font-medium border-b-2 uppercase"
x-text="lang"
></button>
</template>
</div>
</div>
<!-- Loading -->
<div x-show="loadingTemplate" class="flex items-center justify-center py-8">
<span x-html="$icon('loading', 'w-8 h-8 animate-spin text-purple-600')"></span>
</div>
<!-- Edit Form -->
<div x-show="!loadingTemplate" class="space-y-4">
<!-- Source Indicator -->
<div class="flex items-center gap-2 text-sm">
<template x-if="templateSource === 'vendor_override'">
<span class="text-green-600 dark:text-green-400">Using your customized version</span>
</template>
<template x-if="templateSource === 'platform'">
<span class="text-gray-500 dark:text-gray-400">Using platform default - edit to create your version</span>
</template>
</div>
<!-- Subject -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject Line
</label>
<input
type="text"
x-model="editForm.subject"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Email subject..."
/>
</div>
<!-- Variables Info -->
<div x-show="editingTemplate.variables?.length > 0" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables:</p>
<div class="flex flex-wrap gap-2">
<template x-for="variable in editingTemplate.variables" :key="variable">
<code class="px-2 py-0.5 text-xs bg-white dark:bg-gray-600 rounded border dark:border-gray-500" x-text="'{{ ' + variable + ' }}'"></code>
</template>
</div>
</div>
<!-- HTML Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTML Content
</label>
<textarea
x-model="editForm.body_html"
rows="12"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="<html>...</html>"
></textarea>
</div>
<!-- Plain Text Body -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plain Text (Optional)
</label>
<textarea
x-model="editForm.body_text"
rows="4"
class="w-full px-4 py-2 text-sm font-mono text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Plain text fallback..."
></textarea>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t dark:border-gray-700">
<div>
<!-- Revert to Default Button -->
<template x-if="templateSource === 'vendor_override'">
<button
@click="revertToDefault()"
:disabled="reverting"
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg dark:text-red-400 dark:hover:bg-red-900/20"
>
<span x-show="!reverting">Revert to Platform Default</span>
<span x-show="reverting">Reverting...</span>
</button>
</template>
</div>
<div class="flex items-center gap-3">
<button
@click="previewTemplate()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Preview
</button>
<button
@click="sendTestEmail()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-lg dark:text-gray-400 dark:hover:bg-gray-700"
>
Send Test
</button>
<button
@click="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="saveTemplate()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!saving">Save Override</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
</template>
{% endcall %}
<!-- Preview Modal -->
{% call modal_dialog(
show_var="showPreviewModal",
title="Email Preview",
size="4xl"
) %}
<template x-if="previewData">
<div class="space-y-4">
<div class="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<p class="text-sm"><strong>Subject:</strong> <span x-text="previewData.subject"></span></p>
</div>
<div class="border dark:border-gray-700 rounded-lg overflow-hidden">
<iframe
:srcdoc="previewData.body_html"
class="w-full h-96 bg-white"
sandbox="allow-same-origin"
></iframe>
</div>
<div class="flex justify-end">
<button
@click="showPreviewModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Close
</button>
</div>
</div>
</template>
{% endcall %}
<!-- Test Email Modal -->
{% call modal_dialog(
show_var="showTestEmailModal",
title="Send Test Email",
size="md"
) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Send test email to:
</label>
<input
type="email"
x-model="testEmailAddress"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="your@email.com"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
A test email will be sent using sample data for template variables.
</p>
<div class="flex justify-end gap-3">
<button
@click="showTestEmailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-700 rounded-lg dark:text-gray-400"
>
Cancel
</button>
<button
@click="confirmSendTestEmail()"
:disabled="sendingTest || !testEmailAddress"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!sendingTest">Send Test</span>
<span x-show="sendingTest">Sending...</span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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",

View File

@@ -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):
"""

View File

@@ -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",

View File

@@ -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"<VendorEmailTemplate("
f"vendor_id={self.vendor_id}, "
f"code='{self.template_code}', "
f"language='{self.language}')>"
)
@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

View File

@@ -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",

247
models/schema/email.py Normal file
View File

@@ -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

View File

@@ -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": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Subscription Confirmed!</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ vendor_name }},</p>
<p>Thank you for subscribing to Wizamart! Your {{ tier_name }} subscription is now active.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981;">
<h3 style="margin-top: 0; color: #10b981;">Subscription Details</h3>
<p style="margin: 5px 0;"><strong>Plan:</strong> {{ tier_name }}</p>
<p style="margin: 5px 0;"><strong>Billing Cycle:</strong> {{ billing_cycle }}</p>
<p style="margin: 5px 0;"><strong>Amount:</strong> {{ amount }}</p>
<p style="margin: 5px 0;"><strong>Next Billing Date:</strong> {{ next_billing_date }}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ dashboard_url }}" style="background: #10b981; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Go to Dashboard
</a>
</div>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
If you have any questions about your subscription, please contact our support team.
</p>
<p>Best regards,<br><strong>The Wizamart Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>&copy; 2024 Wizamart. All rights reserved.</p>
</div>
</body>
</html>""",
"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": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Payment Failed</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ vendor_name }},</p>
<p>We were unable to process your payment of <strong>{{ amount }}</strong> for your {{ tier_name }} subscription.</p>
<div style="background: #fef2f2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
<h3 style="margin-top: 0; color: #dc2626;">What happens next?</h3>
<p style="margin: 5px 0;">We'll automatically retry the payment on {{ retry_date }}.</p>
<p style="margin: 5px 0;">To avoid any service interruption, please update your payment method.</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ update_payment_url }}" style="background: #ef4444; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Update Payment Method
</a>
</div>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
If you need assistance, please contact us at {{ support_email }}.
</p>
<p>Best regards,<br><strong>The Wizamart Team</strong></p>
</div>
</body>
</html>""",
"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": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Subscription Cancelled</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ vendor_name }},</p>
<p>Your {{ tier_name }} subscription has been cancelled as requested.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6b7280;">
<h3 style="margin-top: 0; color: #4b5563;">What happens now?</h3>
<p style="margin: 5px 0;">You'll continue to have access to your {{ tier_name }} features until <strong>{{ end_date }}</strong>.</p>
<p style="margin: 5px 0;">After that date, your account will be downgraded to the Free tier.</p>
</div>
<p>Changed your mind? You can reactivate your subscription at any time:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reactivate_url }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reactivate Subscription
</a>
</div>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
We're sorry to see you go. If there's anything we could have done better, please let us know.
</p>
<p>Best regards,<br><strong>The Wizamart Team</strong></p>
</div>
</body>
</html>""",
"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": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Your Trial is Ending Soon</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ vendor_name }},</p>
<p>Your {{ tier_name }} trial ends in <strong>{{ days_remaining }} days</strong> ({{ trial_end_date }}).</p>
<div style="background: #fffbeb; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<h3 style="margin-top: 0; color: #d97706;">Don't lose these features:</h3>
<p style="margin: 5px 0;">{{ features_list }}</p>
</div>
<p>Subscribe now to continue using all {{ tier_name }} features without interruption:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ upgrade_url }}" style="background: #f59e0b; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Subscribe Now
</a>
</div>
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
Have questions? Reply to this email and we'll help you choose the right plan.
</p>
<p>Best regards,<br><strong>The Wizamart Team</strong></p>
</div>
</body>
</html>""",
"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": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">You've Been Invited!</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ invitee_name }},</p>
<p><strong>{{ inviter_name }}</strong> has invited you to join <strong>{{ vendor_name }}</strong> as a team member on Wizamart.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
<h3 style="margin-top: 0; color: #6366f1;">Invitation Details</h3>
<p style="margin: 5px 0;"><strong>Vendor:</strong> {{ vendor_name }}</p>
<p style="margin: 5px 0;"><strong>Role:</strong> {{ role }}</p>
<p style="margin: 5px 0;"><strong>Invited by:</strong> {{ inviter_name }}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ accept_url }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Accept Invitation
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
This invitation will expire in {{ expires_in_days }} days.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
If you weren't expecting this invitation, you can safely ignore this email.
</p>
<p>Best regards,<br><strong>The Wizamart Team</strong></p>
</div>
</body>
</html>""",
"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}")

View File

@@ -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}`);
}
}
};
}

334
static/vendor/js/email-templates.js vendored Normal file
View File

@@ -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}`);
}
}
};
}