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

@@ -52,6 +52,20 @@ PLATFORM_SUPPORT_EMAIL = "support@wizamart.com"
PLATFORM_DEFAULT_LANGUAGE = "en"
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
# Tiers that get white-label (no "Powered by Wizamart" footer)
WHITELABEL_TIERS = {"business", "enterprise"}
# Powered by Wizamart footer (added for Essential/Professional tiers)
POWERED_BY_FOOTER_HTML = """
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Powered by <a href="https://wizamart.com" style="color: #6b46c1; text-decoration: none;">Wizamart</a>
</p>
</div>
"""
POWERED_BY_FOOTER_TEXT = "\n\n---\nPowered by Wizamart - https://wizamart.com"
@dataclass
class ResolvedTemplate:
@@ -340,7 +354,582 @@ class DebugProvider(EmailProvider):
# =============================================================================
# EMAIL SERVICE
# PLATFORM CONFIG HELPERS (DB overrides .env)
# =============================================================================
def get_platform_email_config(db: Session) -> dict:
"""
Get effective platform email configuration.
Priority: Database settings > Environment variables (.env)
Returns:
Dictionary with all email configuration values
"""
from models.database.admin import AdminSetting
def get_db_setting(key: str) -> str | None:
setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
return setting.value if setting else None
config = {}
# Provider
db_provider = get_db_setting("email_provider")
config["provider"] = db_provider if db_provider else settings.email_provider
# From settings
db_from_email = get_db_setting("email_from_address")
config["from_email"] = db_from_email if db_from_email else settings.email_from_address
db_from_name = get_db_setting("email_from_name")
config["from_name"] = db_from_name if db_from_name else settings.email_from_name
db_reply_to = get_db_setting("email_reply_to")
config["reply_to"] = db_reply_to if db_reply_to else settings.email_reply_to
# SMTP settings
db_smtp_host = get_db_setting("smtp_host")
config["smtp_host"] = db_smtp_host if db_smtp_host else settings.smtp_host
db_smtp_port = get_db_setting("smtp_port")
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else settings.smtp_port
db_smtp_user = get_db_setting("smtp_user")
config["smtp_user"] = db_smtp_user if db_smtp_user else settings.smtp_user
db_smtp_password = get_db_setting("smtp_password")
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
db_smtp_use_tls = get_db_setting("smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_db_setting("sendgrid_api_key")
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else settings.sendgrid_api_key
# Mailgun
db_mailgun_key = get_db_setting("mailgun_api_key")
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else settings.mailgun_api_key
db_mailgun_domain = get_db_setting("mailgun_domain")
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else settings.mailgun_domain
# AWS SES
db_aws_key = get_db_setting("aws_access_key_id")
config["aws_access_key_id"] = db_aws_key if db_aws_key else settings.aws_access_key_id
db_aws_secret = get_db_setting("aws_secret_access_key")
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else settings.aws_secret_access_key
db_aws_region = get_db_setting("aws_region")
config["aws_region"] = db_aws_region if db_aws_region else settings.aws_region
# Behavior
db_enabled = get_db_setting("email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
db_debug = get_db_setting("email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
return config
# =============================================================================
# CONFIGURABLE PLATFORM PROVIDERS (use config dict instead of global settings)
# =============================================================================
class ConfigurableSMTPProvider(EmailProvider):
"""SMTP provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
if reply_to:
msg["Reply-To"] = reply_to
if body_text:
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
if self.config.get("smtp_use_ssl"):
server = smtplib.SMTP_SSL(self.config["smtp_host"], self.config["smtp_port"])
else:
server = smtplib.SMTP(self.config["smtp_host"], self.config["smtp_port"])
try:
if self.config.get("smtp_use_tls") and not self.config.get("smtp_use_ssl"):
server.starttls()
if self.config.get("smtp_user") and self.config.get("smtp_password"):
server.login(self.config["smtp_user"], self.config["smtp_password"])
server.sendmail(from_email, [to_email], msg.as_string())
return True, None, None
finally:
server.quit()
except Exception as e:
logger.error(f"Configurable SMTP send error: {e}")
return False, None, str(e)
class ConfigurableSendGridProvider(EmailProvider):
"""SendGrid provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content
message = Mail(
from_email=Email(from_email, from_name),
to_emails=To(to_email, to_name),
subject=subject,
)
message.add_content(Content("text/html", body_html))
if body_text:
message.add_content(Content("text/plain", body_text))
if reply_to:
message.reply_to = Email(reply_to)
sg = SendGridAPIClient(self.config["sendgrid_api_key"])
response = sg.send(message)
if response.status_code in (200, 201, 202):
message_id = response.headers.get("X-Message-Id")
return True, message_id, None
else:
return False, None, f"SendGrid error: {response.status_code}"
except ImportError:
return False, None, "SendGrid library not installed"
except Exception as e:
logger.error(f"Configurable SendGrid send error: {e}")
return False, None, str(e)
class ConfigurableMailgunProvider(EmailProvider):
"""Mailgun provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import requests
from_str = f"{from_name} <{from_email}>" if from_name else from_email
to_str = f"{to_name} <{to_email}>" if to_name else to_email
data = {
"from": from_str,
"to": to_str,
"subject": subject,
"html": body_html,
}
if body_text:
data["text"] = body_text
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{self.config['mailgun_domain']}/messages",
auth=("api", self.config["mailgun_api_key"]),
data=data,
timeout=30,
)
if response.status_code == 200:
result = response.json()
return True, result.get("id"), None
else:
return False, None, f"Mailgun error: {response.status_code} - {response.text}"
except Exception as e:
logger.error(f"Configurable Mailgun send error: {e}")
return False, None, str(e)
class ConfigurableSESProvider(EmailProvider):
"""Amazon SES provider using config dictionary."""
def __init__(self, config: dict):
self.config = config
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import boto3
ses = boto3.client(
"ses",
region_name=self.config["aws_region"],
aws_access_key_id=self.config["aws_access_key_id"],
aws_secret_access_key=self.config["aws_secret_access_key"],
)
from_str = f"{from_name} <{from_email}>" if from_name else from_email
body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
if body_text:
body["Text"] = {"Charset": "UTF-8", "Data": body_text}
kwargs = {
"Source": from_str,
"Destination": {"ToAddresses": [to_email]},
"Message": {
"Subject": {"Charset": "UTF-8", "Data": subject},
"Body": body,
},
}
if reply_to:
kwargs["ReplyToAddresses"] = [reply_to]
response = ses.send_email(**kwargs)
return True, response.get("MessageId"), None
except ImportError:
return False, None, "boto3 library not installed"
except Exception as e:
logger.error(f"Configurable SES send error: {e}")
return False, None, str(e)
def get_platform_provider(db: Session) -> EmailProvider:
"""
Get the configured email provider using effective platform config.
Uses database settings if available, otherwise falls back to .env.
"""
config = get_platform_email_config(db)
if config.get("debug"):
return DebugProvider()
provider_map = {
"smtp": ConfigurableSMTPProvider,
"sendgrid": ConfigurableSendGridProvider,
"mailgun": ConfigurableMailgunProvider,
"ses": ConfigurableSESProvider,
}
provider_name = config.get("provider", "smtp").lower()
provider_class = provider_map.get(provider_name)
if not provider_class:
logger.warning(f"Unknown email provider: {provider_name}, using SMTP")
return ConfigurableSMTPProvider(config)
return provider_class(config)
# =============================================================================
# VENDOR EMAIL PROVIDERS
# =============================================================================
class VendorSMTPProvider(EmailProvider):
"""SMTP provider using vendor-specific settings."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
if reply_to:
msg["Reply-To"] = reply_to
if body_text:
msg.attach(MIMEText(body_text, "plain", "utf-8"))
msg.attach(MIMEText(body_html, "html", "utf-8"))
# Use vendor's SMTP settings
if self.settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(self.settings.smtp_host, self.settings.smtp_port)
else:
server = smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port)
try:
if self.settings.smtp_use_tls and not self.settings.smtp_use_ssl:
server.starttls()
if self.settings.smtp_username and self.settings.smtp_password:
server.login(self.settings.smtp_username, self.settings.smtp_password)
server.sendmail(from_email, [to_email], msg.as_string())
return True, None, None
finally:
server.quit()
except Exception as e:
logger.error(f"Vendor SMTP send error: {e}")
return False, None, str(e)
class VendorSendGridProvider(EmailProvider):
"""SendGrid provider using vendor-specific API key."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content
message = Mail(
from_email=Email(from_email, from_name),
to_emails=To(to_email, to_name),
subject=subject,
)
message.add_content(Content("text/html", body_html))
if body_text:
message.add_content(Content("text/plain", body_text))
if reply_to:
message.reply_to = Email(reply_to)
sg = SendGridAPIClient(self.settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code in (200, 201, 202):
message_id = response.headers.get("X-Message-Id")
return True, message_id, None
else:
return False, None, f"SendGrid error: {response.status_code}"
except ImportError:
return False, None, "SendGrid library not installed"
except Exception as e:
logger.error(f"Vendor SendGrid send error: {e}")
return False, None, str(e)
class VendorMailgunProvider(EmailProvider):
"""Mailgun provider using vendor-specific settings."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import requests
from_str = f"{from_name} <{from_email}>" if from_name else from_email
to_str = f"{to_name} <{to_email}>" if to_name else to_email
data = {
"from": from_str,
"to": to_str,
"subject": subject,
"html": body_html,
}
if body_text:
data["text"] = body_text
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{self.settings.mailgun_domain}/messages",
auth=("api", self.settings.mailgun_api_key),
data=data,
timeout=30,
)
if response.status_code == 200:
result = response.json()
return True, result.get("id"), None
else:
return False, None, f"Mailgun error: {response.status_code} - {response.text}"
except Exception as e:
logger.error(f"Vendor Mailgun send error: {e}")
return False, None, str(e)
class VendorSESProvider(EmailProvider):
"""Amazon SES provider using vendor-specific credentials."""
def __init__(self, vendor_settings):
self.settings = vendor_settings
def send(
self,
to_email: str,
to_name: str | None,
subject: str,
body_html: str,
body_text: str | None,
from_email: str,
from_name: str | None,
reply_to: str | None = None,
) -> tuple[bool, str | None, str | None]:
try:
import boto3
ses = boto3.client(
"ses",
region_name=self.settings.ses_region,
aws_access_key_id=self.settings.ses_access_key_id,
aws_secret_access_key=self.settings.ses_secret_access_key,
)
from_str = f"{from_name} <{from_email}>" if from_name else from_email
body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
if body_text:
body["Text"] = {"Charset": "UTF-8", "Data": body_text}
kwargs = {
"Source": from_str,
"Destination": {"ToAddresses": [to_email]},
"Message": {
"Subject": {"Charset": "UTF-8", "Data": subject},
"Body": body,
},
}
if reply_to:
kwargs["ReplyToAddresses"] = [reply_to]
response = ses.send_email(**kwargs)
return True, response.get("MessageId"), None
except ImportError:
return False, None, "boto3 library not installed"
except Exception as e:
logger.error(f"Vendor SES send error: {e}")
return False, None, str(e)
def get_vendor_provider(vendor_settings) -> EmailProvider | None:
"""
Create an email provider instance using vendor's settings.
Args:
vendor_settings: VendorEmailSettings model instance
Returns:
EmailProvider instance or None if not configured
"""
if not vendor_settings or not vendor_settings.is_configured:
return None
provider_map = {
"smtp": VendorSMTPProvider,
"sendgrid": VendorSendGridProvider,
"mailgun": VendorMailgunProvider,
"ses": VendorSESProvider,
}
provider_class = provider_map.get(vendor_settings.provider)
if not provider_class:
logger.warning(f"Unknown vendor email provider: {vendor_settings.provider}")
return None
return provider_class(vendor_settings)
# =============================================================================
# PLATFORM EMAIL PROVIDER
# =============================================================================
@@ -387,15 +976,24 @@ class EmailService:
subject="Hello",
body_html="<h1>Hello</h1>",
)
Platform email configuration is loaded from:
1. Database (admin_settings table) - if settings exist
2. Environment variables (.env) - fallback
"""
def __init__(self, db: Session):
self.db = db
self.provider = get_provider()
# Use configurable provider that checks DB first, then .env
self.provider = get_platform_provider(db)
# Cache the platform config for use in send_raw
self._platform_config = get_platform_email_config(db)
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]] = {}
self._vendor_email_settings_cache: dict[int, Any] = {}
self._vendor_tier_cache: dict[int, str | None] = {}
def _get_vendor(self, vendor_id: int):
"""Get vendor with caching."""
@@ -419,6 +1017,76 @@ class EmailService:
return feature_code in self._feature_cache[vendor_id]
def _get_vendor_email_settings(self, vendor_id: int):
"""Get vendor email settings with caching."""
if vendor_id not in self._vendor_email_settings_cache:
from models.database.vendor_email_settings import VendorEmailSettings
self._vendor_email_settings_cache[vendor_id] = (
self.db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_id)
.first()
)
return self._vendor_email_settings_cache[vendor_id]
def _get_vendor_tier(self, vendor_id: int) -> str | None:
"""Get vendor's subscription tier with caching."""
if vendor_id not in self._vendor_tier_cache:
from app.services.subscription_service import subscription_service
tier = subscription_service.get_current_tier(self.db, vendor_id)
self._vendor_tier_cache[vendor_id] = tier.value if tier else None
return self._vendor_tier_cache[vendor_id]
def _should_add_powered_by_footer(self, vendor_id: int | None) -> bool:
"""
Check if "Powered by Wizamart" footer should be added.
Footer is added for Essential and Professional tiers.
Business and Enterprise tiers get white-label (no footer).
"""
if not vendor_id:
return False # Platform emails don't get the footer
tier = self._get_vendor_tier(vendor_id)
if not tier:
return True # No tier = show footer (shouldn't happen normally)
return tier.lower() not in WHITELABEL_TIERS
def _inject_powered_by_footer(
self,
body_html: str,
body_text: str | None,
vendor_id: int | None,
) -> tuple[str, str | None]:
"""
Inject "Powered by Wizamart" footer if needed based on tier.
Returns:
Tuple of (modified_html, modified_text)
"""
if not self._should_add_powered_by_footer(vendor_id):
return body_html, body_text
# Inject footer before closing </body> tag if present, otherwise append
if "</body>" in body_html.lower():
# Find </body> case-insensitively and inject before it
import re
body_html = re.sub(
r"(</body>)",
f"{POWERED_BY_FOOTER_HTML}\\1",
body_html,
flags=re.IGNORECASE,
)
else:
body_html += POWERED_BY_FOOTER_HTML
if body_text:
body_text += POWERED_BY_FOOTER_TEXT
return body_html, body_text
def resolve_language(
self,
explicit_language: str | None = None,
@@ -721,16 +1389,55 @@ class EmailService:
related_type: str | None = None,
related_id: int | None = None,
extra_data: str | None = None,
is_platform_email: bool = False,
) -> EmailLog:
"""
Send a raw email without using a template.
For vendor emails (when vendor_id is provided and is_platform_email=False):
- Uses vendor's SMTP/provider settings if configured
- Uses vendor's from_email, from_name, reply_to
- Adds "Powered by Wizamart" footer for Essential/Professional tiers
For platform emails (is_platform_email=True or no vendor_id):
- Uses platform's email settings from config
- No "Powered by Wizamart" footer
Args:
is_platform_email: If True, always use platform settings (for billing, etc.)
Returns:
EmailLog record
"""
from_email = from_email or settings.email_from_address
from_name = from_name or settings.email_from_name
reply_to = reply_to or settings.email_reply_to or None
# Determine which provider and settings to use
vendor_settings = None
vendor_provider = None
provider_name = self._platform_config.get("provider", settings.email_provider)
if vendor_id and not is_platform_email:
vendor_settings = self._get_vendor_email_settings(vendor_id)
if vendor_settings and vendor_settings.is_configured:
vendor_provider = get_vendor_provider(vendor_settings)
if vendor_provider:
# Use vendor's email identity
from_email = from_email or vendor_settings.from_email
from_name = from_name or vendor_settings.from_name
reply_to = reply_to or vendor_settings.reply_to_email
provider_name = f"vendor_{vendor_settings.provider}"
logger.debug(f"Using vendor email provider: {vendor_settings.provider}")
# Fall back to platform settings if no vendor provider
# Uses DB config if available, otherwise .env
if not vendor_provider:
from_email = from_email or self._platform_config.get("from_email", settings.email_from_address)
from_name = from_name or self._platform_config.get("from_name", settings.email_from_name)
reply_to = reply_to or self._platform_config.get("reply_to") or settings.email_reply_to or None
# Inject "Powered by Wizamart" footer for non-whitelabel tiers
if vendor_id and not is_platform_email:
body_html, body_text = self._inject_powered_by_footer(
body_html, body_text, vendor_id
)
# Create log entry
log = EmailLog(
@@ -745,7 +1452,7 @@ class EmailService:
from_name=from_name,
reply_to=reply_to,
status=EmailStatus.PENDING.value,
provider=settings.email_provider,
provider=provider_name,
vendor_id=vendor_id,
user_id=user_id,
related_type=related_type,
@@ -755,16 +1462,20 @@ class EmailService:
self.db.add(log)
self.db.flush()
# Check if emails are disabled
if not settings.email_enabled:
# Check if emails are disabled (uses DB config if available)
email_enabled = self._platform_config.get("enabled", settings.email_enabled)
if not email_enabled:
log.status = EmailStatus.FAILED.value
log.error_message = "Email sending is disabled"
self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately
logger.info(f"Email sending disabled, skipping: {to_email}")
return log
# Use vendor provider if available, otherwise platform provider
provider_to_use = vendor_provider or self.provider
# Send email
success, message_id, error = self.provider.send(
success, message_id, error = provider_to_use.send(
to_email=to_email,
to_name=to_name,
subject=subject,
@@ -777,7 +1488,7 @@ class EmailService:
if success:
log.mark_sent(message_id)
logger.info(f"Email sent to {to_email}: {subject}")
logger.info(f"Email sent to {to_email}: {subject} (via {provider_name})")
else:
log.mark_failed(error or "Unknown error")
logger.error(f"Email failed to {to_email}: {error}")

View File

@@ -0,0 +1,444 @@
# app/services/vendor_email_settings_service.py
"""
Vendor Email Settings Service.
Handles CRUD operations for vendor email configuration:
- SMTP settings
- Advanced providers (SendGrid, Mailgun, SES) - tier-gated
- Sender identity (from_email, from_name, reply_to)
- Signature/footer customization
- Configuration verification via test email
"""
import logging
import smtplib
from datetime import UTC, datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from sqlalchemy.orm import Session
from app.exceptions import NotFoundError, ValidationError, AuthorizationError
from models.database import (
Vendor,
VendorEmailSettings,
EmailProvider,
PREMIUM_EMAIL_PROVIDERS,
VendorSubscription,
TierCode,
)
logger = logging.getLogger(__name__)
# Tiers that allow premium email providers
PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
class VendorEmailSettingsService:
"""Service for managing vendor email settings."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# READ OPERATIONS
# =========================================================================
def get_settings(self, vendor_id: int) -> VendorEmailSettings | None:
"""Get email settings for a vendor."""
return (
self.db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_id)
.first()
)
def get_settings_or_404(self, vendor_id: int) -> VendorEmailSettings:
"""Get email settings or raise 404."""
settings = self.get_settings(vendor_id)
if not settings:
raise NotFoundError(
f"Email settings not found for vendor {vendor_id}. "
"Configure email settings to send emails."
)
return settings
def is_configured(self, vendor_id: int) -> bool:
"""Check if vendor has configured email settings."""
settings = self.get_settings(vendor_id)
return settings is not None and settings.is_configured
def get_status(self, vendor_id: int) -> dict:
"""
Get email configuration status for a vendor.
Returns:
dict with is_configured, is_verified, provider, etc.
"""
settings = self.get_settings(vendor_id)
if not settings:
return {
"is_configured": False,
"is_verified": False,
"provider": None,
"from_email": None,
"from_name": None,
"message": "Email settings not configured. Configure SMTP to send emails.",
}
return {
"is_configured": settings.is_configured,
"is_verified": settings.is_verified,
"provider": settings.provider,
"from_email": settings.from_email,
"from_name": settings.from_name,
"last_verified_at": settings.last_verified_at.isoformat() if settings.last_verified_at else None,
"verification_error": settings.verification_error,
"message": self._get_status_message(settings),
}
def _get_status_message(self, settings: VendorEmailSettings) -> str:
"""Generate a human-readable status message."""
if not settings.is_configured:
return "Complete your email configuration to send emails."
if not settings.is_verified:
return "Email configured but not verified. Send a test email to verify."
return "Email settings configured and verified."
# =========================================================================
# WRITE OPERATIONS
# =========================================================================
def create_or_update(
self,
vendor_id: int,
data: dict,
current_tier: TierCode | None = None,
) -> VendorEmailSettings:
"""
Create or update vendor email settings.
Args:
vendor_id: Vendor ID
data: Settings data (from_email, from_name, smtp_*, etc.)
current_tier: Vendor's current subscription tier (for premium provider validation)
Returns:
Updated VendorEmailSettings
"""
# Validate premium provider access
provider = data.get("provider", "smtp")
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
if current_tier not in PREMIUM_TIERS:
raise AuthorizationError(
f"Provider '{provider}' requires Business or Enterprise tier. "
"Upgrade your plan to use advanced email providers."
)
settings = self.get_settings(vendor_id)
if not settings:
settings = VendorEmailSettings(vendor_id=vendor_id)
self.db.add(settings)
# Update fields
for field in [
"from_email",
"from_name",
"reply_to_email",
"signature_text",
"signature_html",
"provider",
# SMTP
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_use_tls",
"smtp_use_ssl",
# SendGrid
"sendgrid_api_key",
# Mailgun
"mailgun_api_key",
"mailgun_domain",
# SES
"ses_access_key_id",
"ses_secret_access_key",
"ses_region",
]:
if field in data and data[field] is not None:
# Don't overwrite passwords/keys with empty strings
if field.endswith(("_password", "_key", "_access_key")) and data[field] == "":
continue
setattr(settings, field, data[field])
# Update configuration status
settings.update_configuration_status()
# Reset verification if provider/credentials changed
if any(
f in data
for f in ["provider", "smtp_host", "smtp_password", "sendgrid_api_key", "mailgun_api_key", "ses_access_key_id"]
):
settings.is_verified = False
settings.verification_error = None
self.db.commit()
self.db.refresh(settings)
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
return settings
def delete(self, vendor_id: int) -> bool:
"""Delete email settings for a vendor."""
settings = self.get_settings(vendor_id)
if settings:
self.db.delete(settings)
self.db.commit()
logger.info(f"Deleted email settings for vendor {vendor_id}")
return True
return False
# =========================================================================
# VERIFICATION
# =========================================================================
def verify_settings(self, vendor_id: int, test_email: str) -> dict:
"""
Verify email settings by sending a test email.
Args:
vendor_id: Vendor ID
test_email: Email address to send test email to
Returns:
dict with success status and message
"""
settings = self.get_settings_or_404(vendor_id)
if not settings.is_fully_configured():
raise ValidationError("Email settings incomplete. Configure all required fields first.")
try:
# Send test email based on provider
if settings.provider == EmailProvider.SMTP.value:
self._send_smtp_test(settings, test_email)
elif settings.provider == EmailProvider.SENDGRID.value:
self._send_sendgrid_test(settings, test_email)
elif settings.provider == EmailProvider.MAILGUN.value:
self._send_mailgun_test(settings, test_email)
elif settings.provider == EmailProvider.SES.value:
self._send_ses_test(settings, test_email)
else:
raise ValidationError(f"Unknown provider: {settings.provider}")
# Mark as verified
settings.mark_verified()
self.db.commit()
logger.info(f"Email settings verified for vendor {vendor_id}")
return {
"success": True,
"message": f"Test email sent successfully to {test_email}",
}
except Exception as e:
error_msg = str(e)
settings.mark_verification_failed(error_msg)
self.db.commit()
logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}")
return {
"success": False,
"message": f"Failed to send test email: {error_msg}",
}
def _send_smtp_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SMTP."""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Wizamart Email Configuration Test"
msg["From"] = f"{settings.from_name} <{settings.from_email}>"
msg["To"] = to_email
text_content = (
"This is a test email from Wizamart.\n\n"
"Your email settings are configured correctly!\n\n"
f"Provider: SMTP\n"
f"Host: {settings.smtp_host}\n"
)
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your email settings are configured correctly!
</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: SMTP<br>
Host: {settings.smtp_host}<br>
Sent at: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
</p>
</body>
</html>
"""
msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html"))
# Connect and send
if settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port)
else:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port)
if settings.smtp_use_tls:
server.starttls()
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.from_email, to_email, msg.as_string())
server.quit()
def _send_sendgrid_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SendGrid."""
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
except ImportError:
raise ValidationError("SendGrid library not installed. Contact support.")
message = Mail(
from_email=(settings.from_email, settings.from_name),
to_emails=to_email,
subject="Wizamart Email Configuration Test",
html_content=f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your SendGrid settings are configured correctly!
</p>
</body>
</html>
""",
)
sg = SendGridAPIClient(settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code >= 400:
raise Exception(f"SendGrid error: {response.status_code}")
def _send_mailgun_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Mailgun."""
import requests
response = requests.post(
f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages",
auth=("api", settings.mailgun_api_key),
data={
"from": f"{settings.from_name} <{settings.from_email}>",
"to": to_email,
"subject": "Wizamart Email Configuration Test",
"html": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Mailgun settings are configured correctly!
</p>
</body>
</html>
""",
},
timeout=30,
)
if response.status_code >= 400:
raise Exception(f"Mailgun error: {response.text}")
def _send_ses_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Amazon SES."""
try:
import boto3
except ImportError:
raise ValidationError("boto3 library not installed. Contact support.")
client = boto3.client(
"ses",
region_name=settings.ses_region,
aws_access_key_id=settings.ses_access_key_id,
aws_secret_access_key=settings.ses_secret_access_key,
)
client.send_email(
Source=f"{settings.from_name} <{settings.from_email}>",
Destination={"ToAddresses": [to_email]},
Message={
"Subject": {"Data": "Wizamart Email Configuration Test"},
"Body": {
"Html": {
"Data": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Amazon SES settings are configured correctly!
</p>
</body>
</html>
"""
}
},
},
)
# =========================================================================
# TIER HELPERS
# =========================================================================
def get_available_providers(self, tier: TierCode | None) -> list[dict]:
"""
Get list of available email providers for a tier.
Returns list of providers with availability status.
"""
providers = [
{
"code": EmailProvider.SMTP.value,
"name": "SMTP",
"description": "Standard SMTP email server",
"available": True,
"tier_required": None,
},
{
"code": EmailProvider.SENDGRID.value,
"name": "SendGrid",
"description": "SendGrid email delivery platform",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.MAILGUN.value,
"name": "Mailgun",
"description": "Mailgun email API",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.SES.value,
"name": "Amazon SES",
"description": "Amazon Simple Email Service",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
]
return providers
# Module-level service factory
def get_vendor_email_settings_service(db: Session) -> VendorEmailSettingsService:
"""Factory function to get a VendorEmailSettingsService instance."""
return VendorEmailSettingsService(db)