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