feat: implement email template system with vendor overrides

Add comprehensive email template management for both admin and vendors:

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

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

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

Files: 22 changed, ~3,900 lines added

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

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

View File

@@ -20,6 +20,7 @@ from .content_page import ContentPage
from .customer import Customer, CustomerAddress
from .password_reset_token import PasswordResetToken
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
from .vendor_email_template import VendorEmailTemplate
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
from .inventory import Inventory
from .inventory_transaction import InventoryTransaction, TransactionType
@@ -112,6 +113,7 @@ __all__ = [
"EmailLog",
"EmailStatus",
"EmailTemplate",
"VendorEmailTemplate",
# Features
"Feature",
"FeatureCategory",

View File

@@ -5,9 +5,15 @@ Email system database models.
Provides:
- EmailTemplate: Multi-language email templates stored in database
- EmailLog: Email sending history and tracking
Platform vs Vendor Templates:
- Platform templates (EmailTemplate) are the defaults
- Vendors can override templates via VendorEmailTemplate
- Platform-only templates (is_platform_only=True) cannot be overridden
"""
import enum
import json
from datetime import datetime
from sqlalchemy import (
@@ -16,11 +22,12 @@ from sqlalchemy import (
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
@@ -81,11 +88,20 @@ class EmailTemplate(Base, TimestampMixin):
# e.g., ["first_name", "company_name", "login_url"]
variables = Column(Text, nullable=True)
# Required variables (JSON list of variables that MUST be provided)
# Subset of variables that are mandatory for the template to render
required_variables = Column(Text, nullable=True)
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Platform-only flag: if True, vendors cannot override this template
# Used for billing, subscription, and other platform-level emails
is_platform_only = Column(Boolean, default=False, nullable=False)
# Unique constraint: one template per code+language
__table_args__ = (
Index("ix_email_templates_code_language", "code", "language", unique=True),
{"sqlite_autoincrement": True},
)
@@ -95,8 +111,6 @@ class EmailTemplate(Base, TimestampMixin):
@property
def variables_list(self) -> list[str]:
"""Parse variables JSON to list."""
import json
if not self.variables:
return []
try:
@@ -104,6 +118,106 @@ class EmailTemplate(Base, TimestampMixin):
except (json.JSONDecodeError, TypeError):
return []
@property
def required_variables_list(self) -> list[str]:
"""Parse required_variables JSON to list."""
if not self.required_variables:
return []
try:
return json.loads(self.required_variables)
except (json.JSONDecodeError, TypeError):
return []
@classmethod
def get_by_code_and_language(
cls,
db: Session,
code: str,
language: str,
fallback_to_english: bool = True,
) -> "EmailTemplate | None":
"""
Get a platform template by code and language.
Args:
db: Database session
code: Template code (e.g., "password_reset")
language: Language code (en, fr, de, lb)
fallback_to_english: If True, fall back to English if language not found
Returns:
EmailTemplate if found, None otherwise
"""
template = (
db.query(cls)
.filter(
cls.code == code,
cls.language == language,
cls.is_active == True, # noqa: E712
)
.first()
)
# Fallback to English if requested language not found
if not template and fallback_to_english and language != "en":
template = (
db.query(cls)
.filter(
cls.code == code,
cls.language == "en",
cls.is_active == True, # noqa: E712
)
.first()
)
return template
@classmethod
def get_all_templates(
cls,
db: Session,
category: str | None = None,
include_inactive: bool = False,
) -> list["EmailTemplate"]:
"""
Get all platform templates, optionally filtered by category.
Args:
db: Database session
category: Optional category filter
include_inactive: Include inactive templates
Returns:
List of EmailTemplate objects
"""
query = db.query(cls)
if category:
query = query.filter(cls.category == category)
if not include_inactive:
query = query.filter(cls.is_active == True) # noqa: E712
return query.order_by(cls.code, cls.language).all()
@classmethod
def get_overridable_templates(cls, db: Session) -> list["EmailTemplate"]:
"""
Get all templates that vendors can override.
Returns:
List of EmailTemplate objects where is_platform_only=False
"""
return (
db.query(cls)
.filter(
cls.is_platform_only == False, # noqa: E712
cls.is_active == True, # noqa: E712
)
.order_by(cls.code, cls.language)
.all()
)
class EmailLog(Base, TimestampMixin):
"""

View File

@@ -170,6 +170,13 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Email template overrides (one-to-many)
email_templates = relationship(
"VendorEmailTemplate",
back_populates="vendor",
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",

View File

@@ -0,0 +1,229 @@
# models/database/vendor_email_template.py
"""
Vendor email template override model.
Allows vendors to customize platform email templates with their own content.
Platform-only templates cannot be overridden (e.g., billing, subscription emails).
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
from .base import TimestampMixin
class VendorEmailTemplate(Base, TimestampMixin):
"""
Vendor-specific email template override.
Each vendor can customize email templates for their shop.
Overrides are per-template-code and per-language.
When sending emails:
1. Check if vendor has an override for the template+language
2. If yes, use vendor's version
3. If no, fall back to platform template
Platform-only templates (is_platform_only=True on EmailTemplate)
cannot be overridden.
"""
__tablename__ = "vendor_email_templates"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
# Vendor relationship
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Template identification (references EmailTemplate.code, not FK)
template_code = Column(String(100), nullable=False, index=True)
language = Column(String(5), nullable=False, default="en")
# Optional custom name (if None, uses platform template name)
name = Column(String(255), nullable=True)
# Email content
subject = Column(String(500), nullable=False)
body_html = Column(Text, nullable=False)
body_text = Column(Text, nullable=True)
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="email_templates")
# Unique constraint: one override per vendor+template+language
__table_args__ = (
UniqueConstraint(
"vendor_id",
"template_code",
"language",
name="uq_vendor_email_template_code_language",
),
{"sqlite_autoincrement": True},
)
def __repr__(self):
return (
f"<VendorEmailTemplate("
f"vendor_id={self.vendor_id}, "
f"code='{self.template_code}', "
f"language='{self.language}')>"
)
@classmethod
def get_override(
cls,
db: Session,
vendor_id: int,
template_code: str,
language: str,
) -> "VendorEmailTemplate | None":
"""
Get vendor's template override if it exists.
Args:
db: Database session
vendor_id: Vendor ID
template_code: Template code to look up
language: Language code (en, fr, de, lb)
Returns:
VendorEmailTemplate if override exists, None otherwise
"""
return (
db.query(cls)
.filter(
cls.vendor_id == vendor_id,
cls.template_code == template_code,
cls.language == language,
cls.is_active == True, # noqa: E712
)
.first()
)
@classmethod
def get_all_overrides_for_vendor(
cls,
db: Session,
vendor_id: int,
) -> list["VendorEmailTemplate"]:
"""
Get all template overrides for a vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of VendorEmailTemplate objects
"""
return (
db.query(cls)
.filter(
cls.vendor_id == vendor_id,
cls.is_active == True, # noqa: E712
)
.order_by(cls.template_code, cls.language)
.all()
)
@classmethod
def create_or_update(
cls,
db: Session,
vendor_id: int,
template_code: str,
language: str,
subject: str,
body_html: str,
body_text: str | None = None,
name: str | None = None,
) -> "VendorEmailTemplate":
"""
Create or update a vendor email template override.
Args:
db: Database session
vendor_id: Vendor ID
template_code: Template code
language: Language code
subject: Email subject
body_html: HTML body content
body_text: Optional plain text body
name: Optional custom name
Returns:
Created or updated VendorEmailTemplate
"""
existing = cls.get_override(db, vendor_id, template_code, language)
if existing:
existing.subject = subject
existing.body_html = body_html
existing.body_text = body_text
existing.name = name
existing.updated_at = datetime.utcnow()
return existing
new_template = cls(
vendor_id=vendor_id,
template_code=template_code,
language=language,
subject=subject,
body_html=body_html,
body_text=body_text,
name=name,
)
db.add(new_template)
return new_template
@classmethod
def delete_override(
cls,
db: Session,
vendor_id: int,
template_code: str,
language: str,
) -> bool:
"""
Delete a vendor's template override (revert to platform default).
Args:
db: Database session
vendor_id: Vendor ID
template_code: Template code
language: Language code
Returns:
True if deleted, False if not found
"""
deleted = (
db.query(cls)
.filter(
cls.vendor_id == vendor_id,
cls.template_code == template_code,
cls.language == language,
)
.delete()
)
return deleted > 0

View File

@@ -5,6 +5,7 @@
from . import (
auth,
base,
email,
inventory,
invoice,
marketplace_import_job,
@@ -21,6 +22,7 @@ from .base import * # Base Pydantic models
__all__ = [
"base",
"auth",
"email",
"invoice",
"marketplace_product",
"message",

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

@@ -0,0 +1,247 @@
# models/schema/email.py
"""
Email template Pydantic schemas for API responses and requests.
Provides schemas for:
- EmailTemplate: Platform email templates
- VendorEmailTemplate: Vendor-specific template overrides
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class EmailTemplateBase(BaseModel):
"""Base schema for email templates."""
code: str = Field(..., description="Template code (e.g., 'password_reset')")
language: str = Field(default="en", description="Language code (en, fr, de, lb)")
name: str = Field(..., description="Human-readable template name")
description: str | None = Field(None, description="Template description")
category: str = Field(..., description="Template category (auth, orders, billing, etc.)")
subject: str = Field(..., description="Email subject (supports Jinja2 variables)")
body_html: str = Field(..., description="HTML email body")
body_text: str | None = Field(None, description="Plain text fallback")
variables: list[str] = Field(default_factory=list, description="Available variables")
class EmailTemplateCreate(EmailTemplateBase):
"""Schema for creating an email template."""
required_variables: list[str] = Field(
default_factory=list, description="Required variables"
)
is_platform_only: bool = Field(
default=False, description="Cannot be overridden by vendors"
)
class EmailTemplateUpdate(BaseModel):
"""Schema for updating an email template."""
name: str | None = Field(None, description="Human-readable template name")
description: str | None = Field(None, description="Template description")
subject: str | None = Field(None, description="Email subject")
body_html: str | None = Field(None, description="HTML email body")
body_text: str | None = Field(None, description="Plain text fallback")
variables: list[str] | None = Field(None, description="Available variables")
required_variables: list[str] | None = Field(None, description="Required variables")
is_active: bool | None = Field(None, description="Template active status")
class EmailTemplateResponse(BaseModel):
"""Schema for email template API response."""
model_config = ConfigDict(from_attributes=True)
id: int
code: str
language: str
name: str
description: str | None
category: str
subject: str
body_html: str
body_text: str | None
variables: list[str] = Field(default_factory=list)
required_variables: list[str] = Field(default_factory=list)
is_active: bool
is_platform_only: bool
created_at: datetime
updated_at: datetime
@classmethod
def from_db(cls, template) -> "EmailTemplateResponse":
"""Create response from database model."""
return cls(
id=template.id,
code=template.code,
language=template.language,
name=template.name,
description=template.description,
category=template.category,
subject=template.subject,
body_html=template.body_html,
body_text=template.body_text,
variables=template.variables_list,
required_variables=template.required_variables_list,
is_active=template.is_active,
is_platform_only=template.is_platform_only,
created_at=template.created_at,
updated_at=template.updated_at,
)
class EmailTemplateSummary(BaseModel):
"""Summary schema for template list views."""
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
category: str
languages: list[str] = Field(default_factory=list)
is_platform_only: bool
is_active: bool
@classmethod
def from_db_list(cls, templates: list) -> list["EmailTemplateSummary"]:
"""
Create summaries from database models, grouping by code.
Args:
templates: List of EmailTemplate models
Returns:
List of EmailTemplateSummary grouped by template code
"""
# Group templates by code
by_code: dict[str, list] = {}
for t in templates:
if t.code not in by_code:
by_code[t.code] = []
by_code[t.code].append(t)
summaries = []
for code, group in by_code.items():
first = group[0]
summaries.append(
cls(
id=first.id,
code=code,
name=first.name,
category=first.category,
languages=[t.language for t in group],
is_platform_only=first.is_platform_only,
is_active=first.is_active,
)
)
return summaries
# Vendor Email Template Schemas
class VendorEmailTemplateCreate(BaseModel):
"""Schema for creating a vendor email template override."""
template_code: str = Field(..., description="Template code to override")
language: str = Field(default="en", description="Language code")
name: str | None = Field(None, description="Custom name (uses platform default if None)")
subject: str = Field(..., description="Custom email subject")
body_html: str = Field(..., description="Custom HTML body")
body_text: str | None = Field(None, description="Custom plain text body")
class VendorEmailTemplateUpdate(BaseModel):
"""Schema for updating a vendor email template override."""
name: str | None = Field(None, description="Custom name")
subject: str | None = Field(None, description="Custom email subject")
body_html: str | None = Field(None, description="Custom HTML body")
body_text: str | None = Field(None, description="Custom plain text body")
is_active: bool | None = Field(None, description="Override active status")
class VendorEmailTemplateResponse(BaseModel):
"""Schema for vendor email template override API response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
template_code: str
language: str
name: str | None
subject: str
body_html: str
body_text: str | None
is_active: bool
created_at: datetime
updated_at: datetime
class EmailTemplateWithOverrideStatus(BaseModel):
"""
Schema showing template with vendor override status.
Used in vendor UI to show which templates have been customized.
"""
model_config = ConfigDict(from_attributes=True)
code: str
name: str
category: str
languages: list[str]
is_platform_only: bool
has_override: bool = Field(
default=False, description="Whether vendor has customized this template"
)
override_languages: list[str] = Field(
default_factory=list,
description="Languages with vendor overrides",
)
# Email Preview/Test Schemas
class EmailPreviewRequest(BaseModel):
"""Schema for requesting an email preview."""
template_code: str = Field(..., description="Template code")
language: str = Field(default="en", description="Language code")
variables: dict[str, str] = Field(
default_factory=dict, description="Variables to inject"
)
class EmailPreviewResponse(BaseModel):
"""Schema for email preview response."""
subject: str
body_html: str
body_text: str | None
class EmailTestRequest(BaseModel):
"""Schema for sending a test email."""
template_code: str = Field(..., description="Template code")
language: str = Field(default="en", description="Language code")
to_email: str = Field(..., description="Recipient email address")
variables: dict[str, str] = Field(
default_factory=dict, description="Variables to inject"
)
class EmailTestResponse(BaseModel):
"""Schema for test email response."""
success: bool
message: str
email_log_id: int | None = None