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:
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user