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