feat: add email settings with database overrides for admin and vendor

Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration

Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+

Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers

Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding

🤖 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-05 22:23:47 +01:00
parent ad28a8a9a3
commit 36603178c3
51 changed files with 4959 additions and 1141 deletions

View File

@@ -21,6 +21,7 @@ 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 .vendor_email_settings import EmailProvider, VendorEmailSettings, PREMIUM_EMAIL_PROVIDERS
from .feature import Feature, FeatureCategory, FeatureCode, FeatureUILocation
from .inventory import Inventory
from .inventory_transaction import InventoryTransaction, TransactionType
@@ -114,6 +115,9 @@ __all__ = [
"EmailStatus",
"EmailTemplate",
"VendorEmailTemplate",
"VendorEmailSettings",
"EmailProvider",
"PREMIUM_EMAIL_PROVIDERS",
# Features
"Feature",
"FeatureCategory",

View File

@@ -177,6 +177,14 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan",
)
# Email settings (one-to-one) - vendor SMTP/provider configuration
email_settings = relationship(
"VendorEmailSettings",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
# Subscription (one-to-one)
subscription = relationship(
"VendorSubscription",

View File

@@ -0,0 +1,255 @@
# models/database/vendor_email_settings.py
"""
Vendor Email Settings model for vendor-specific email configuration.
This model stores vendor SMTP/email provider settings, enabling vendors to:
- Send emails from their own domain/email address
- Use their own SMTP server or email provider (tier-gated)
- Customize sender name, reply-to address, and signature
Architecture:
- Vendors MUST configure email settings to send transactional emails
- Platform emails (billing, subscription) still use platform settings
- Advanced providers (SendGrid, Mailgun, SES) are tier-gated (Business+)
- "Powered by Wizamart" footer is added for Essential/Professional tiers
"""
import enum
from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class EmailProvider(str, enum.Enum):
"""Supported email providers."""
SMTP = "smtp" # Standard SMTP (all tiers)
SENDGRID = "sendgrid" # SendGrid API (Business+ tier)
MAILGUN = "mailgun" # Mailgun API (Business+ tier)
SES = "ses" # Amazon SES (Business+ tier)
# Providers that require Business+ tier
PREMIUM_EMAIL_PROVIDERS = {
EmailProvider.SENDGRID,
EmailProvider.MAILGUN,
EmailProvider.SES,
}
class VendorEmailSettings(Base, TimestampMixin):
"""
Vendor email configuration for sending transactional emails.
This is a one-to-one relationship with Vendor.
Vendors must configure this to send emails to their customers.
"""
__tablename__ = "vendor_email_settings"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
# =========================================================================
# Sender Identity (Required)
# =========================================================================
from_email = Column(String(255), nullable=False) # e.g., orders@vendorshop.lu
from_name = Column(String(100), nullable=False) # e.g., "VendorShop"
reply_to_email = Column(String(255), nullable=True) # Optional reply-to address
# =========================================================================
# Email Signature/Footer (Optional)
# =========================================================================
signature_text = Column(Text, nullable=True) # Plain text signature
signature_html = Column(Text, nullable=True) # HTML signature (footer)
# =========================================================================
# Provider Configuration
# =========================================================================
provider = Column(
String(20),
default=EmailProvider.SMTP.value,
nullable=False,
)
# =========================================================================
# SMTP Settings (used when provider=smtp)
# =========================================================================
smtp_host = Column(String(255), nullable=True)
smtp_port = Column(Integer, nullable=True, default=587)
smtp_username = Column(String(255), nullable=True)
smtp_password = Column(String(500), nullable=True) # Encrypted at rest
smtp_use_tls = Column(Boolean, default=True, nullable=False)
smtp_use_ssl = Column(Boolean, default=False, nullable=False) # For port 465
# =========================================================================
# SendGrid Settings (used when provider=sendgrid, Business+ tier)
# =========================================================================
sendgrid_api_key = Column(String(500), nullable=True) # Encrypted at rest
# =========================================================================
# Mailgun Settings (used when provider=mailgun, Business+ tier)
# =========================================================================
mailgun_api_key = Column(String(500), nullable=True) # Encrypted at rest
mailgun_domain = Column(String(255), nullable=True)
# =========================================================================
# Amazon SES Settings (used when provider=ses, Business+ tier)
# =========================================================================
ses_access_key_id = Column(String(100), nullable=True)
ses_secret_access_key = Column(String(500), nullable=True) # Encrypted at rest
ses_region = Column(String(50), nullable=True, default="eu-west-1")
# =========================================================================
# Status & Verification
# =========================================================================
is_configured = Column(Boolean, default=False, nullable=False) # Has complete config
is_verified = Column(Boolean, default=False, nullable=False) # Test email succeeded
last_verified_at = Column(DateTime(timezone=True), nullable=True)
verification_error = Column(Text, nullable=True) # Last verification error message
# =========================================================================
# Relationship
# =========================================================================
vendor = relationship("Vendor", back_populates="email_settings")
# =========================================================================
# Indexes
# =========================================================================
__table_args__ = (
Index("idx_vendor_email_settings_configured", "vendor_id", "is_configured"),
)
def __repr__(self) -> str:
return f"<VendorEmailSettings(vendor_id={self.vendor_id}, provider='{self.provider}', from='{self.from_email}')>"
# =========================================================================
# Helper Methods
# =========================================================================
def is_smtp_configured(self) -> bool:
"""Check if SMTP settings are complete."""
if self.provider != EmailProvider.SMTP.value:
return False
return bool(
self.smtp_host
and self.smtp_port
and self.smtp_username
and self.smtp_password
)
def is_sendgrid_configured(self) -> bool:
"""Check if SendGrid settings are complete."""
if self.provider != EmailProvider.SENDGRID.value:
return False
return bool(self.sendgrid_api_key)
def is_mailgun_configured(self) -> bool:
"""Check if Mailgun settings are complete."""
if self.provider != EmailProvider.MAILGUN.value:
return False
return bool(self.mailgun_api_key and self.mailgun_domain)
def is_ses_configured(self) -> bool:
"""Check if Amazon SES settings are complete."""
if self.provider != EmailProvider.SES.value:
return False
return bool(
self.ses_access_key_id
and self.ses_secret_access_key
and self.ses_region
)
def is_provider_configured(self) -> bool:
"""Check if the current provider is fully configured."""
provider_checks = {
EmailProvider.SMTP.value: self.is_smtp_configured,
EmailProvider.SENDGRID.value: self.is_sendgrid_configured,
EmailProvider.MAILGUN.value: self.is_mailgun_configured,
EmailProvider.SES.value: self.is_ses_configured,
}
check_fn = provider_checks.get(self.provider)
return check_fn() if check_fn else False
def is_fully_configured(self) -> bool:
"""Check if email settings are fully configured (identity + provider)."""
return bool(
self.from_email
and self.from_name
and self.is_provider_configured()
)
def update_configuration_status(self) -> None:
"""Update the is_configured flag based on current settings."""
self.is_configured = self.is_fully_configured()
def mark_verified(self) -> None:
"""Mark settings as verified (test email succeeded)."""
self.is_verified = True
self.last_verified_at = datetime.now(UTC)
self.verification_error = None
def mark_verification_failed(self, error: str) -> None:
"""Mark settings as verification failed."""
self.is_verified = False
self.verification_error = error
def requires_premium_tier(self) -> bool:
"""Check if current provider requires Business+ tier."""
return self.provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]
def to_dict(self) -> dict:
"""Convert to dictionary for API responses (excludes sensitive data)."""
return {
"id": self.id,
"vendor_id": self.vendor_id,
"from_email": self.from_email,
"from_name": self.from_name,
"reply_to_email": self.reply_to_email,
"signature_text": self.signature_text,
"signature_html": self.signature_html,
"provider": self.provider,
# SMTP (mask password)
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"smtp_username": self.smtp_username,
"smtp_password_set": bool(self.smtp_password),
"smtp_use_tls": self.smtp_use_tls,
"smtp_use_ssl": self.smtp_use_ssl,
# SendGrid (mask API key)
"sendgrid_api_key_set": bool(self.sendgrid_api_key),
# Mailgun (mask API key)
"mailgun_api_key_set": bool(self.mailgun_api_key),
"mailgun_domain": self.mailgun_domain,
# SES (mask credentials)
"ses_access_key_id_set": bool(self.ses_access_key_id),
"ses_region": self.ses_region,
# Status
"is_configured": self.is_configured,
"is_verified": self.is_verified,
"last_verified_at": self.last_verified_at.isoformat() if self.last_verified_at else None,
"verification_error": self.verification_error,
"requires_premium_tier": self.requires_premium_tier(),
# Timestamps
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}