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:
@@ -20,6 +20,7 @@ from .content_page import ContentPage
|
||||
from .customer import Customer, CustomerAddress
|
||||
from .password_reset_token import PasswordResetToken
|
||||
from .email import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||
from .vendor_email_template import VendorEmailTemplate
|
||||
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
|
||||
from .inventory import Inventory
|
||||
from .inventory_transaction import InventoryTransaction, TransactionType
|
||||
@@ -112,6 +113,7 @@ __all__ = [
|
||||
"EmailLog",
|
||||
"EmailStatus",
|
||||
"EmailTemplate",
|
||||
"VendorEmailTemplate",
|
||||
# Features
|
||||
"Feature",
|
||||
"FeatureCategory",
|
||||
|
||||
@@ -5,9 +5,15 @@ Email system database models.
|
||||
Provides:
|
||||
- EmailTemplate: Multi-language email templates stored in database
|
||||
- EmailLog: Email sending history and tracking
|
||||
|
||||
Platform vs Vendor Templates:
|
||||
- Platform templates (EmailTemplate) are the defaults
|
||||
- Vendors can override templates via VendorEmailTemplate
|
||||
- Platform-only templates (is_platform_only=True) cannot be overridden
|
||||
"""
|
||||
|
||||
import enum
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -16,11 +22,12 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -81,11 +88,20 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
# e.g., ["first_name", "company_name", "login_url"]
|
||||
variables = Column(Text, nullable=True)
|
||||
|
||||
# Required variables (JSON list of variables that MUST be provided)
|
||||
# Subset of variables that are mandatory for the template to render
|
||||
required_variables = Column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Platform-only flag: if True, vendors cannot override this template
|
||||
# Used for billing, subscription, and other platform-level emails
|
||||
is_platform_only = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Unique constraint: one template per code+language
|
||||
__table_args__ = (
|
||||
Index("ix_email_templates_code_language", "code", "language", unique=True),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
@@ -95,8 +111,6 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
@property
|
||||
def variables_list(self) -> list[str]:
|
||||
"""Parse variables JSON to list."""
|
||||
import json
|
||||
|
||||
if not self.variables:
|
||||
return []
|
||||
try:
|
||||
@@ -104,6 +118,106 @@ class EmailTemplate(Base, TimestampMixin):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
@property
|
||||
def required_variables_list(self) -> list[str]:
|
||||
"""Parse required_variables JSON to list."""
|
||||
if not self.required_variables:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self.required_variables)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_by_code_and_language(
|
||||
cls,
|
||||
db: Session,
|
||||
code: str,
|
||||
language: str,
|
||||
fallback_to_english: bool = True,
|
||||
) -> "EmailTemplate | None":
|
||||
"""
|
||||
Get a platform template by code and language.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
code: Template code (e.g., "password_reset")
|
||||
language: Language code (en, fr, de, lb)
|
||||
fallback_to_english: If True, fall back to English if language not found
|
||||
|
||||
Returns:
|
||||
EmailTemplate if found, None otherwise
|
||||
"""
|
||||
template = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.code == code,
|
||||
cls.language == language,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Fallback to English if requested language not found
|
||||
if not template and fallback_to_english and language != "en":
|
||||
template = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.code == code,
|
||||
cls.language == "en",
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
@classmethod
|
||||
def get_all_templates(
|
||||
cls,
|
||||
db: Session,
|
||||
category: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list["EmailTemplate"]:
|
||||
"""
|
||||
Get all platform templates, optionally filtered by category.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
category: Optional category filter
|
||||
include_inactive: Include inactive templates
|
||||
|
||||
Returns:
|
||||
List of EmailTemplate objects
|
||||
"""
|
||||
query = db.query(cls)
|
||||
|
||||
if category:
|
||||
query = query.filter(cls.category == category)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(cls.is_active == True) # noqa: E712
|
||||
|
||||
return query.order_by(cls.code, cls.language).all()
|
||||
|
||||
@classmethod
|
||||
def get_overridable_templates(cls, db: Session) -> list["EmailTemplate"]:
|
||||
"""
|
||||
Get all templates that vendors can override.
|
||||
|
||||
Returns:
|
||||
List of EmailTemplate objects where is_platform_only=False
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.is_platform_only == False, # noqa: E712
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(cls.code, cls.language)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class EmailLog(Base, TimestampMixin):
|
||||
"""
|
||||
|
||||
@@ -170,6 +170,13 @@ class Vendor(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Email template overrides (one-to-many)
|
||||
email_templates = relationship(
|
||||
"VendorEmailTemplate",
|
||||
back_populates="vendor",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Subscription (one-to-one)
|
||||
subscription = relationship(
|
||||
"VendorSubscription",
|
||||
|
||||
229
models/database/vendor_email_template.py
Normal file
229
models/database/vendor_email_template.py
Normal file
@@ -0,0 +1,229 @@
|
||||
# models/database/vendor_email_template.py
|
||||
"""
|
||||
Vendor email template override model.
|
||||
|
||||
Allows vendors to customize platform email templates with their own content.
|
||||
Platform-only templates cannot be overridden (e.g., billing, subscription emails).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
from .base import TimestampMixin
|
||||
|
||||
|
||||
class VendorEmailTemplate(Base, TimestampMixin):
|
||||
"""
|
||||
Vendor-specific email template override.
|
||||
|
||||
Each vendor can customize email templates for their shop.
|
||||
Overrides are per-template-code and per-language.
|
||||
|
||||
When sending emails:
|
||||
1. Check if vendor has an override for the template+language
|
||||
2. If yes, use vendor's version
|
||||
3. If no, fall back to platform template
|
||||
|
||||
Platform-only templates (is_platform_only=True on EmailTemplate)
|
||||
cannot be overridden.
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_email_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
|
||||
# Vendor relationship
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Template identification (references EmailTemplate.code, not FK)
|
||||
template_code = Column(String(100), nullable=False, index=True)
|
||||
language = Column(String(5), nullable=False, default="en")
|
||||
|
||||
# Optional custom name (if None, uses platform template name)
|
||||
name = Column(String(255), nullable=True)
|
||||
|
||||
# Email content
|
||||
subject = Column(String(500), nullable=False)
|
||||
body_html = Column(Text, nullable=False)
|
||||
body_text = Column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="email_templates")
|
||||
|
||||
# Unique constraint: one override per vendor+template+language
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"vendor_id",
|
||||
"template_code",
|
||||
"language",
|
||||
name="uq_vendor_email_template_code_language",
|
||||
),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<VendorEmailTemplate("
|
||||
f"vendor_id={self.vendor_id}, "
|
||||
f"code='{self.template_code}', "
|
||||
f"language='{self.language}')>"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_override(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
) -> "VendorEmailTemplate | None":
|
||||
"""
|
||||
Get vendor's template override if it exists.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code to look up
|
||||
language: Language code (en, fr, de, lb)
|
||||
|
||||
Returns:
|
||||
VendorEmailTemplate if override exists, None otherwise
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.template_code == template_code,
|
||||
cls.language == language,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all_overrides_for_vendor(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
) -> list["VendorEmailTemplate"]:
|
||||
"""
|
||||
Get all template overrides for a vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
|
||||
Returns:
|
||||
List of VendorEmailTemplate objects
|
||||
"""
|
||||
return (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(cls.template_code, cls.language)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_or_update(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
body_text: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> "VendorEmailTemplate":
|
||||
"""
|
||||
Create or update a vendor email template override.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code
|
||||
language: Language code
|
||||
subject: Email subject
|
||||
body_html: HTML body content
|
||||
body_text: Optional plain text body
|
||||
name: Optional custom name
|
||||
|
||||
Returns:
|
||||
Created or updated VendorEmailTemplate
|
||||
"""
|
||||
existing = cls.get_override(db, vendor_id, template_code, language)
|
||||
|
||||
if existing:
|
||||
existing.subject = subject
|
||||
existing.body_html = body_html
|
||||
existing.body_text = body_text
|
||||
existing.name = name
|
||||
existing.updated_at = datetime.utcnow()
|
||||
return existing
|
||||
|
||||
new_template = cls(
|
||||
vendor_id=vendor_id,
|
||||
template_code=template_code,
|
||||
language=language,
|
||||
subject=subject,
|
||||
body_html=body_html,
|
||||
body_text=body_text,
|
||||
name=name,
|
||||
)
|
||||
db.add(new_template)
|
||||
return new_template
|
||||
|
||||
@classmethod
|
||||
def delete_override(
|
||||
cls,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
template_code: str,
|
||||
language: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a vendor's template override (revert to platform default).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
template_code: Template code
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
deleted = (
|
||||
db.query(cls)
|
||||
.filter(
|
||||
cls.vendor_id == vendor_id,
|
||||
cls.template_code == template_code,
|
||||
cls.language == language,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
return deleted > 0
|
||||
@@ -5,6 +5,7 @@
|
||||
from . import (
|
||||
auth,
|
||||
base,
|
||||
email,
|
||||
inventory,
|
||||
invoice,
|
||||
marketplace_import_job,
|
||||
@@ -21,6 +22,7 @@ from .base import * # Base Pydantic models
|
||||
__all__ = [
|
||||
"base",
|
||||
"auth",
|
||||
"email",
|
||||
"invoice",
|
||||
"marketplace_product",
|
||||
"message",
|
||||
|
||||
247
models/schema/email.py
Normal file
247
models/schema/email.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# models/schema/email.py
|
||||
"""
|
||||
Email template Pydantic schemas for API responses and requests.
|
||||
|
||||
Provides schemas for:
|
||||
- EmailTemplate: Platform email templates
|
||||
- VendorEmailTemplate: Vendor-specific template overrides
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EmailTemplateBase(BaseModel):
|
||||
"""Base schema for email templates."""
|
||||
|
||||
code: str = Field(..., description="Template code (e.g., 'password_reset')")
|
||||
language: str = Field(default="en", description="Language code (en, fr, de, lb)")
|
||||
name: str = Field(..., description="Human-readable template name")
|
||||
description: str | None = Field(None, description="Template description")
|
||||
category: str = Field(..., description="Template category (auth, orders, billing, etc.)")
|
||||
subject: str = Field(..., description="Email subject (supports Jinja2 variables)")
|
||||
body_html: str = Field(..., description="HTML email body")
|
||||
body_text: str | None = Field(None, description="Plain text fallback")
|
||||
variables: list[str] = Field(default_factory=list, description="Available variables")
|
||||
|
||||
|
||||
class EmailTemplateCreate(EmailTemplateBase):
|
||||
"""Schema for creating an email template."""
|
||||
|
||||
required_variables: list[str] = Field(
|
||||
default_factory=list, description="Required variables"
|
||||
)
|
||||
is_platform_only: bool = Field(
|
||||
default=False, description="Cannot be overridden by vendors"
|
||||
)
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
"""Schema for updating an email template."""
|
||||
|
||||
name: str | None = Field(None, description="Human-readable template name")
|
||||
description: str | None = Field(None, description="Template description")
|
||||
subject: str | None = Field(None, description="Email subject")
|
||||
body_html: str | None = Field(None, description="HTML email body")
|
||||
body_text: str | None = Field(None, description="Plain text fallback")
|
||||
variables: list[str] | None = Field(None, description="Available variables")
|
||||
required_variables: list[str] | None = Field(None, description="Required variables")
|
||||
is_active: bool | None = Field(None, description="Template active status")
|
||||
|
||||
|
||||
class EmailTemplateResponse(BaseModel):
|
||||
"""Schema for email template API response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
code: str
|
||||
language: str
|
||||
name: str
|
||||
description: str | None
|
||||
category: str
|
||||
subject: str
|
||||
body_html: str
|
||||
body_text: str | None
|
||||
variables: list[str] = Field(default_factory=list)
|
||||
required_variables: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
is_platform_only: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, template) -> "EmailTemplateResponse":
|
||||
"""Create response from database model."""
|
||||
return cls(
|
||||
id=template.id,
|
||||
code=template.code,
|
||||
language=template.language,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
category=template.category,
|
||||
subject=template.subject,
|
||||
body_html=template.body_html,
|
||||
body_text=template.body_text,
|
||||
variables=template.variables_list,
|
||||
required_variables=template.required_variables_list,
|
||||
is_active=template.is_active,
|
||||
is_platform_only=template.is_platform_only,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class EmailTemplateSummary(BaseModel):
|
||||
"""Summary schema for template list views."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
category: str
|
||||
languages: list[str] = Field(default_factory=list)
|
||||
is_platform_only: bool
|
||||
is_active: bool
|
||||
|
||||
@classmethod
|
||||
def from_db_list(cls, templates: list) -> list["EmailTemplateSummary"]:
|
||||
"""
|
||||
Create summaries from database models, grouping by code.
|
||||
|
||||
Args:
|
||||
templates: List of EmailTemplate models
|
||||
|
||||
Returns:
|
||||
List of EmailTemplateSummary grouped by template code
|
||||
"""
|
||||
# Group templates by code
|
||||
by_code: dict[str, list] = {}
|
||||
for t in templates:
|
||||
if t.code not in by_code:
|
||||
by_code[t.code] = []
|
||||
by_code[t.code].append(t)
|
||||
|
||||
summaries = []
|
||||
for code, group in by_code.items():
|
||||
first = group[0]
|
||||
summaries.append(
|
||||
cls(
|
||||
id=first.id,
|
||||
code=code,
|
||||
name=first.name,
|
||||
category=first.category,
|
||||
languages=[t.language for t in group],
|
||||
is_platform_only=first.is_platform_only,
|
||||
is_active=first.is_active,
|
||||
)
|
||||
)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
# Vendor Email Template Schemas
|
||||
|
||||
|
||||
class VendorEmailTemplateCreate(BaseModel):
|
||||
"""Schema for creating a vendor email template override."""
|
||||
|
||||
template_code: str = Field(..., description="Template code to override")
|
||||
language: str = Field(default="en", description="Language code")
|
||||
name: str | None = Field(None, description="Custom name (uses platform default if None)")
|
||||
subject: str = Field(..., description="Custom email subject")
|
||||
body_html: str = Field(..., description="Custom HTML body")
|
||||
body_text: str | None = Field(None, description="Custom plain text body")
|
||||
|
||||
|
||||
class VendorEmailTemplateUpdate(BaseModel):
|
||||
"""Schema for updating a vendor email template override."""
|
||||
|
||||
name: str | None = Field(None, description="Custom name")
|
||||
subject: str | None = Field(None, description="Custom email subject")
|
||||
body_html: str | None = Field(None, description="Custom HTML body")
|
||||
body_text: str | None = Field(None, description="Custom plain text body")
|
||||
is_active: bool | None = Field(None, description="Override active status")
|
||||
|
||||
|
||||
class VendorEmailTemplateResponse(BaseModel):
|
||||
"""Schema for vendor email template override API response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
template_code: str
|
||||
language: str
|
||||
name: str | None
|
||||
subject: str
|
||||
body_html: str
|
||||
body_text: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EmailTemplateWithOverrideStatus(BaseModel):
|
||||
"""
|
||||
Schema showing template with vendor override status.
|
||||
|
||||
Used in vendor UI to show which templates have been customized.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
code: str
|
||||
name: str
|
||||
category: str
|
||||
languages: list[str]
|
||||
is_platform_only: bool
|
||||
has_override: bool = Field(
|
||||
default=False, description="Whether vendor has customized this template"
|
||||
)
|
||||
override_languages: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Languages with vendor overrides",
|
||||
)
|
||||
|
||||
|
||||
# Email Preview/Test Schemas
|
||||
|
||||
|
||||
class EmailPreviewRequest(BaseModel):
|
||||
"""Schema for requesting an email preview."""
|
||||
|
||||
template_code: str = Field(..., description="Template code")
|
||||
language: str = Field(default="en", description="Language code")
|
||||
variables: dict[str, str] = Field(
|
||||
default_factory=dict, description="Variables to inject"
|
||||
)
|
||||
|
||||
|
||||
class EmailPreviewResponse(BaseModel):
|
||||
"""Schema for email preview response."""
|
||||
|
||||
subject: str
|
||||
body_html: str
|
||||
body_text: str | None
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
"""Schema for sending a test email."""
|
||||
|
||||
template_code: str = Field(..., description="Template code")
|
||||
language: str = Field(default="en", description="Language code")
|
||||
to_email: str = Field(..., description="Recipient email address")
|
||||
variables: dict[str, str] = Field(
|
||||
default_factory=dict, description="Variables to inject"
|
||||
)
|
||||
|
||||
|
||||
class EmailTestResponse(BaseModel):
|
||||
"""Schema for test email response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
email_log_id: int | None = None
|
||||
Reference in New Issue
Block a user