- Fix API-002 in admin/settings.py: use service layer for DB delete - Fix API-001/API-003 in vendor/email_settings.py: add Pydantic response models, remove HTTPException raises - Fix SVC-002/SVC-006 in vendor_email_settings_service.py: use domain exceptions, change db.commit() to db.flush() - Add unit tests for VendorEmailSettingsService - Add integration tests for vendor and admin email settings APIs - Add user guide (docs/guides/email-settings.md) - Add developer guide (docs/implementation/email-settings.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
484 lines
17 KiB
Python
484 lines
17 KiB
Python
# 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 (
|
|
AuthorizationException,
|
|
ResourceNotFoundException,
|
|
ValidationException,
|
|
ExternalServiceException,
|
|
)
|
|
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 ResourceNotFoundException(
|
|
resource_type="vendor_email_settings",
|
|
identifier=str(vendor_id),
|
|
)
|
|
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
|
|
|
|
Raises:
|
|
AuthorizationException: If trying to use premium provider without required tier
|
|
"""
|
|
# 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 AuthorizationException(
|
|
message=f"Provider '{provider}' requires Business or Enterprise tier. "
|
|
"Upgrade your plan to use advanced email providers.",
|
|
required_permission="business_tier",
|
|
)
|
|
|
|
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.flush()
|
|
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
|
|
return settings
|
|
|
|
def delete(self, vendor_id: int) -> None:
|
|
"""
|
|
Delete email settings for a vendor.
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If settings not found
|
|
"""
|
|
settings = self.get_settings(vendor_id)
|
|
if not settings:
|
|
raise ResourceNotFoundException(
|
|
resource_type="vendor_email_settings",
|
|
identifier=str(vendor_id),
|
|
)
|
|
self.db.delete(settings)
|
|
self.db.flush()
|
|
logger.info(f"Deleted email settings for vendor {vendor_id}")
|
|
|
|
# =========================================================================
|
|
# 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
|
|
|
|
Raises:
|
|
ResourceNotFoundException: If settings not found
|
|
ValidationException: If settings incomplete
|
|
"""
|
|
settings = self.get_settings_or_404(vendor_id)
|
|
|
|
if not settings.is_fully_configured():
|
|
raise ValidationException(
|
|
message="Email settings incomplete. Configure all required fields first.",
|
|
field="settings",
|
|
)
|
|
|
|
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 ValidationException(
|
|
message=f"Unknown provider: {settings.provider}",
|
|
field="provider",
|
|
)
|
|
|
|
# Mark as verified
|
|
settings.mark_verified()
|
|
self.db.flush()
|
|
|
|
logger.info(f"Email settings verified for vendor {vendor_id}")
|
|
return {
|
|
"success": True,
|
|
"message": f"Test email sent successfully to {test_email}",
|
|
}
|
|
|
|
except (ValidationException, ExternalServiceException):
|
|
raise # Re-raise domain exceptions
|
|
except Exception as e:
|
|
error_msg = str(e)
|
|
settings.mark_verification_failed(error_msg)
|
|
self.db.flush()
|
|
|
|
logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}")
|
|
# Return error dict instead of raising - verification failure is not a server error
|
|
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 ExternalServiceException(
|
|
service_name="SendGrid",
|
|
message="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 ExternalServiceException(
|
|
service_name="SendGrid",
|
|
message=f"SendGrid error: HTTP {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 ExternalServiceException(
|
|
service_name="Mailgun",
|
|
message=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 ExternalServiceException(
|
|
service_name="Amazon SES",
|
|
message="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)
|