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:
114
alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py
Normal file
114
alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py
Normal 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")
|
||||
@@ -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"])
|
||||
|
||||
|
||||
319
app/api/v1/admin/email_templates.py
Normal file
319
app/api/v1/admin/email_templates.py
Normal 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),
|
||||
}
|
||||
8
app/api/v1/vendor/__init__.py
vendored
8
app/api/v1/vendor/__init__.py
vendored
@@ -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
545
app/api/v1/vendor/email_templates.py
vendored
Normal 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"})
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
366
app/templates/admin/email-templates.html
Normal file
366
app/templates/admin/email-templates.html
Normal 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 %}
|
||||
@@ -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') }} #}
|
||||
|
||||
329
app/templates/vendor/email-templates.html
vendored
Normal file
329
app/templates/vendor/email-templates.html
vendored
Normal 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 %}
|
||||
1
app/templates/vendor/partials/sidebar.html
vendored
1
app/templates/vendor/partials/sidebar.html
vendored
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
229
models/database/vendor_email_template.py
Normal file
229
models/database/vendor_email_template.py
Normal 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
|
||||
@@ -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
247
models/schema/email.py
Normal 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
|
||||
@@ -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>© 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}")
|
||||
|
||||
303
static/admin/js/email-templates.js
Normal file
303
static/admin/js/email-templates.js
Normal 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
334
static/vendor/js/email-templates.js
vendored
Normal 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user