fix: resolve email settings architecture violations and add tests/docs
- 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>
This commit is contained in:
@@ -18,7 +18,12 @@ from email.mime.text import MIMEText
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import NotFoundError, ValidationError, AuthorizationError
|
||||
from app.exceptions import (
|
||||
AuthorizationException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
ExternalServiceException,
|
||||
)
|
||||
from models.database import (
|
||||
Vendor,
|
||||
VendorEmailSettings,
|
||||
@@ -57,9 +62,9 @@ class VendorEmailSettingsService:
|
||||
"""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."
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="vendor_email_settings",
|
||||
identifier=str(vendor_id),
|
||||
)
|
||||
return settings
|
||||
|
||||
@@ -125,14 +130,18 @@ class VendorEmailSettingsService:
|
||||
|
||||
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 AuthorizationError(
|
||||
f"Provider '{provider}' requires Business or Enterprise tier. "
|
||||
"Upgrade your plan to use advanced email providers."
|
||||
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)
|
||||
@@ -182,21 +191,26 @@ class VendorEmailSettingsService:
|
||||
settings.is_verified = False
|
||||
settings.verification_error = None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(settings)
|
||||
|
||||
self.db.flush()
|
||||
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."""
|
||||
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 settings:
|
||||
self.db.delete(settings)
|
||||
self.db.commit()
|
||||
logger.info(f"Deleted email settings for vendor {vendor_id}")
|
||||
return True
|
||||
return False
|
||||
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
|
||||
@@ -212,11 +226,18 @@ class VendorEmailSettingsService:
|
||||
|
||||
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 ValidationError("Email settings incomplete. Configure all required fields first.")
|
||||
raise ValidationException(
|
||||
message="Email settings incomplete. Configure all required fields first.",
|
||||
field="settings",
|
||||
)
|
||||
|
||||
try:
|
||||
# Send test email based on provider
|
||||
@@ -229,11 +250,14 @@ class VendorEmailSettingsService:
|
||||
elif settings.provider == EmailProvider.SES.value:
|
||||
self._send_ses_test(settings, test_email)
|
||||
else:
|
||||
raise ValidationError(f"Unknown provider: {settings.provider}")
|
||||
raise ValidationException(
|
||||
message=f"Unknown provider: {settings.provider}",
|
||||
field="provider",
|
||||
)
|
||||
|
||||
# Mark as verified
|
||||
settings.mark_verified()
|
||||
self.db.commit()
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Email settings verified for vendor {vendor_id}")
|
||||
return {
|
||||
@@ -241,12 +265,15 @@ class VendorEmailSettingsService:
|
||||
"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.commit()
|
||||
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}",
|
||||
@@ -304,7 +331,10 @@ class VendorEmailSettingsService:
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
except ImportError:
|
||||
raise ValidationError("SendGrid library not installed. Contact support.")
|
||||
raise ExternalServiceException(
|
||||
service_name="SendGrid",
|
||||
message="SendGrid library not installed. Contact support.",
|
||||
)
|
||||
|
||||
message = Mail(
|
||||
from_email=(settings.from_email, settings.from_name),
|
||||
@@ -327,7 +357,10 @@ class VendorEmailSettingsService:
|
||||
response = sg.send(message)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise Exception(f"SendGrid error: {response.status_code}")
|
||||
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."""
|
||||
@@ -356,14 +389,20 @@ class VendorEmailSettingsService:
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise Exception(f"Mailgun error: {response.text}")
|
||||
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 ValidationError("boto3 library not installed. Contact support.")
|
||||
raise ExternalServiceException(
|
||||
service_name="Amazon SES",
|
||||
message="boto3 library not installed. Contact support.",
|
||||
)
|
||||
|
||||
client = boto3.client(
|
||||
"ses",
|
||||
|
||||
Reference in New Issue
Block a user