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:
2026-01-05 22:38:10 +01:00
parent 36603178c3
commit 84a523cd7b
9 changed files with 1765 additions and 85 deletions

View File

@@ -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",