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:
@@ -610,7 +610,8 @@ def reset_email_settings(
|
|||||||
for key in EMAIL_SETTING_KEYS:
|
for key in EMAIL_SETTING_KEYS:
|
||||||
setting = admin_settings_service.get_setting_by_key(db, key)
|
setting = admin_settings_service.get_setting_by_key(db, key)
|
||||||
if setting:
|
if setting:
|
||||||
db.delete(setting)
|
# Use service method for deletion (API-002 compliance)
|
||||||
|
admin_settings_service.delete_setting(db, key, current_admin.id)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
|
|
||||||
# Log action
|
# Log action
|
||||||
|
|||||||
167
app/api/v1/vendor/email_settings.py
vendored
167
app/api/v1/vendor/email_settings.py
vendored
@@ -14,13 +14,12 @@ Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pa
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.exceptions import NotFoundError, ValidationError, AuthorizationError
|
|
||||||
from app.services.vendor_email_settings_service import VendorEmailSettingsService
|
from app.services.vendor_email_settings_service import VendorEmailSettingsService
|
||||||
from app.services.subscription_service import subscription_service
|
from app.services.subscription_service import subscription_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
@@ -76,16 +75,62 @@ class VerifyEmailRequest(BaseModel):
|
|||||||
test_email: EmailStr = Field(..., description="Email address to send test email to")
|
test_email: EmailStr = Field(..., description="Email address to send test email to")
|
||||||
|
|
||||||
|
|
||||||
|
# Response models for API-001 compliance
|
||||||
|
class EmailSettingsResponse(BaseModel):
|
||||||
|
"""Response for email settings."""
|
||||||
|
|
||||||
|
configured: bool
|
||||||
|
verified: bool | None = None
|
||||||
|
settings: dict | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailStatusResponse(BaseModel):
|
||||||
|
"""Response for email status check."""
|
||||||
|
|
||||||
|
is_configured: bool
|
||||||
|
is_verified: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersResponse(BaseModel):
|
||||||
|
"""Response for available providers."""
|
||||||
|
|
||||||
|
providers: list[dict]
|
||||||
|
current_tier: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailUpdateResponse(BaseModel):
|
||||||
|
"""Response for email settings update."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
settings: dict
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerifyResponse(BaseModel):
|
||||||
|
"""Response for email verification."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDeleteResponse(BaseModel):
|
||||||
|
"""Response for email settings deletion."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ENDPOINTS
|
# ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("", response_model=EmailSettingsResponse)
|
||||||
def get_email_settings(
|
def get_email_settings(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> EmailSettingsResponse:
|
||||||
"""
|
"""
|
||||||
Get current email settings for the vendor.
|
Get current email settings for the vendor.
|
||||||
|
|
||||||
@@ -96,24 +141,24 @@ def get_email_settings(
|
|||||||
|
|
||||||
settings = service.get_settings(vendor_id)
|
settings = service.get_settings(vendor_id)
|
||||||
if not settings:
|
if not settings:
|
||||||
return {
|
return EmailSettingsResponse(
|
||||||
"configured": False,
|
configured=False,
|
||||||
"settings": None,
|
settings=None,
|
||||||
"message": "Email settings not configured. Configure SMTP to send emails to customers.",
|
message="Email settings not configured. Configure SMTP to send emails to customers.",
|
||||||
}
|
)
|
||||||
|
|
||||||
return {
|
return EmailSettingsResponse(
|
||||||
"configured": settings.is_configured,
|
configured=settings.is_configured,
|
||||||
"verified": settings.is_verified,
|
verified=settings.is_verified,
|
||||||
"settings": settings.to_dict(),
|
settings=settings.to_dict(),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status", response_model=EmailStatusResponse)
|
||||||
def get_email_status(
|
def get_email_status(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> EmailStatusResponse:
|
||||||
"""
|
"""
|
||||||
Get email configuration status.
|
Get email configuration status.
|
||||||
|
|
||||||
@@ -121,14 +166,15 @@ def get_email_status(
|
|||||||
"""
|
"""
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
service = VendorEmailSettingsService(db)
|
service = VendorEmailSettingsService(db)
|
||||||
return service.get_status(vendor_id)
|
status = service.get_status(vendor_id)
|
||||||
|
return EmailStatusResponse(**status)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers")
|
@router.get("/providers", response_model=ProvidersResponse)
|
||||||
def get_available_providers(
|
def get_available_providers(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> ProvidersResponse:
|
||||||
"""
|
"""
|
||||||
Get available email providers for current tier.
|
Get available email providers for current tier.
|
||||||
|
|
||||||
@@ -140,22 +186,24 @@ def get_available_providers(
|
|||||||
# Get vendor's current tier
|
# Get vendor's current tier
|
||||||
tier = subscription_service.get_current_tier(db, vendor_id)
|
tier = subscription_service.get_current_tier(db, vendor_id)
|
||||||
|
|
||||||
return {
|
return ProvidersResponse(
|
||||||
"providers": service.get_available_providers(tier),
|
providers=service.get_available_providers(tier),
|
||||||
"current_tier": tier.value if tier else None,
|
current_tier=tier.value if tier else None,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("", response_model=EmailUpdateResponse)
|
||||||
def update_email_settings(
|
def update_email_settings(
|
||||||
data: EmailSettingsUpdate,
|
data: EmailSettingsUpdate,
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> EmailUpdateResponse:
|
||||||
"""
|
"""
|
||||||
Create or update email settings.
|
Create or update email settings.
|
||||||
|
|
||||||
Premium providers (SendGrid, Mailgun, SES) require Business+ tier.
|
Premium providers (SendGrid, Mailgun, SES) require Business+ tier.
|
||||||
|
Raises AuthorizationException if tier is insufficient.
|
||||||
|
Raises ValidationException if data is invalid.
|
||||||
"""
|
"""
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
service = VendorEmailSettingsService(db)
|
service = VendorEmailSettingsService(db)
|
||||||
@@ -163,63 +211,66 @@ def update_email_settings(
|
|||||||
# Get vendor's current tier for validation
|
# Get vendor's current tier for validation
|
||||||
tier = subscription_service.get_current_tier(db, vendor_id)
|
tier = subscription_service.get_current_tier(db, vendor_id)
|
||||||
|
|
||||||
try:
|
# Service raises appropriate exceptions (API-003 compliance)
|
||||||
settings = service.create_or_update(
|
settings = service.create_or_update(
|
||||||
vendor_id=vendor_id,
|
vendor_id=vendor_id,
|
||||||
data=data.model_dump(exclude_unset=True),
|
data=data.model_dump(exclude_unset=True),
|
||||||
current_tier=tier,
|
current_tier=tier,
|
||||||
)
|
)
|
||||||
return {
|
db.commit()
|
||||||
"success": True,
|
|
||||||
"message": "Email settings updated successfully",
|
return EmailUpdateResponse(
|
||||||
"settings": settings.to_dict(),
|
success=True,
|
||||||
}
|
message="Email settings updated successfully",
|
||||||
except AuthorizationError as e:
|
settings=settings.to_dict(),
|
||||||
raise HTTPException(status_code=403, detail=str(e))
|
)
|
||||||
except ValidationError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify")
|
@router.post("/verify", response_model=EmailVerifyResponse)
|
||||||
def verify_email_settings(
|
def verify_email_settings(
|
||||||
data: VerifyEmailRequest,
|
data: VerifyEmailRequest,
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> EmailVerifyResponse:
|
||||||
"""
|
"""
|
||||||
Verify email settings by sending a test email.
|
Verify email settings by sending a test email.
|
||||||
|
|
||||||
Sends a test email to the provided address and updates verification status.
|
Sends a test email to the provided address and updates verification status.
|
||||||
|
Raises ResourceNotFoundException if settings not configured.
|
||||||
|
Raises ValidationException if verification fails.
|
||||||
"""
|
"""
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
service = VendorEmailSettingsService(db)
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
try:
|
# Service raises appropriate exceptions (API-003 compliance)
|
||||||
result = service.verify_settings(vendor_id, data.test_email)
|
result = service.verify_settings(vendor_id, data.test_email)
|
||||||
if result["success"]:
|
db.commit()
|
||||||
return result
|
|
||||||
else:
|
return EmailVerifyResponse(
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
success=result["success"],
|
||||||
except NotFoundError as e:
|
message=result["message"],
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
)
|
||||||
except ValidationError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("")
|
@router.delete("", response_model=EmailDeleteResponse)
|
||||||
def delete_email_settings(
|
def delete_email_settings(
|
||||||
current_user: User = Depends(get_current_vendor_api),
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> EmailDeleteResponse:
|
||||||
"""
|
"""
|
||||||
Delete email settings.
|
Delete email settings.
|
||||||
|
|
||||||
Warning: This will disable email sending for the vendor.
|
Warning: This will disable email sending for the vendor.
|
||||||
|
Raises ResourceNotFoundException if settings not found.
|
||||||
"""
|
"""
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
service = VendorEmailSettingsService(db)
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
if service.delete(vendor_id):
|
# Service raises ResourceNotFoundException if not found (API-003 compliance)
|
||||||
return {"success": True, "message": "Email settings deleted"}
|
service.delete(vendor_id)
|
||||||
else:
|
db.commit()
|
||||||
raise HTTPException(status_code=404, detail="Email settings not found")
|
|
||||||
|
return EmailDeleteResponse(
|
||||||
|
success=True,
|
||||||
|
message="Email settings deleted",
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from email.mime.text import MIMEText
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.exceptions import NotFoundError, ValidationError, AuthorizationError
|
from app.exceptions import (
|
||||||
|
AuthorizationException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
ExternalServiceException,
|
||||||
|
)
|
||||||
from models.database import (
|
from models.database import (
|
||||||
Vendor,
|
Vendor,
|
||||||
VendorEmailSettings,
|
VendorEmailSettings,
|
||||||
@@ -57,9 +62,9 @@ class VendorEmailSettingsService:
|
|||||||
"""Get email settings or raise 404."""
|
"""Get email settings or raise 404."""
|
||||||
settings = self.get_settings(vendor_id)
|
settings = self.get_settings(vendor_id)
|
||||||
if not settings:
|
if not settings:
|
||||||
raise NotFoundError(
|
raise ResourceNotFoundException(
|
||||||
f"Email settings not found for vendor {vendor_id}. "
|
resource_type="vendor_email_settings",
|
||||||
"Configure email settings to send emails."
|
identifier=str(vendor_id),
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -125,14 +130,18 @@ class VendorEmailSettingsService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated VendorEmailSettings
|
Updated VendorEmailSettings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthorizationException: If trying to use premium provider without required tier
|
||||||
"""
|
"""
|
||||||
# Validate premium provider access
|
# Validate premium provider access
|
||||||
provider = data.get("provider", "smtp")
|
provider = data.get("provider", "smtp")
|
||||||
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
|
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
|
||||||
if current_tier not in PREMIUM_TIERS:
|
if current_tier not in PREMIUM_TIERS:
|
||||||
raise AuthorizationError(
|
raise AuthorizationException(
|
||||||
f"Provider '{provider}' requires Business or Enterprise tier. "
|
message=f"Provider '{provider}' requires Business or Enterprise tier. "
|
||||||
"Upgrade your plan to use advanced email providers."
|
"Upgrade your plan to use advanced email providers.",
|
||||||
|
required_permission="business_tier",
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = self.get_settings(vendor_id)
|
settings = self.get_settings(vendor_id)
|
||||||
@@ -182,21 +191,26 @@ class VendorEmailSettingsService:
|
|||||||
settings.is_verified = False
|
settings.is_verified = False
|
||||||
settings.verification_error = None
|
settings.verification_error = None
|
||||||
|
|
||||||
self.db.commit()
|
self.db.flush()
|
||||||
self.db.refresh(settings)
|
|
||||||
|
|
||||||
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
|
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
def delete(self, vendor_id: int) -> bool:
|
def delete(self, vendor_id: int) -> None:
|
||||||
"""Delete email settings for a vendor."""
|
"""
|
||||||
|
Delete email settings for a vendor.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If settings not found
|
||||||
|
"""
|
||||||
settings = self.get_settings(vendor_id)
|
settings = self.get_settings(vendor_id)
|
||||||
if settings:
|
if not settings:
|
||||||
self.db.delete(settings)
|
raise ResourceNotFoundException(
|
||||||
self.db.commit()
|
resource_type="vendor_email_settings",
|
||||||
logger.info(f"Deleted email settings for vendor {vendor_id}")
|
identifier=str(vendor_id),
|
||||||
return True
|
)
|
||||||
return False
|
self.db.delete(settings)
|
||||||
|
self.db.flush()
|
||||||
|
logger.info(f"Deleted email settings for vendor {vendor_id}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# VERIFICATION
|
# VERIFICATION
|
||||||
@@ -212,11 +226,18 @@ class VendorEmailSettingsService:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with success status and message
|
dict with success status and message
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ResourceNotFoundException: If settings not found
|
||||||
|
ValidationException: If settings incomplete
|
||||||
"""
|
"""
|
||||||
settings = self.get_settings_or_404(vendor_id)
|
settings = self.get_settings_or_404(vendor_id)
|
||||||
|
|
||||||
if not settings.is_fully_configured():
|
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:
|
try:
|
||||||
# Send test email based on provider
|
# Send test email based on provider
|
||||||
@@ -229,11 +250,14 @@ class VendorEmailSettingsService:
|
|||||||
elif settings.provider == EmailProvider.SES.value:
|
elif settings.provider == EmailProvider.SES.value:
|
||||||
self._send_ses_test(settings, test_email)
|
self._send_ses_test(settings, test_email)
|
||||||
else:
|
else:
|
||||||
raise ValidationError(f"Unknown provider: {settings.provider}")
|
raise ValidationException(
|
||||||
|
message=f"Unknown provider: {settings.provider}",
|
||||||
|
field="provider",
|
||||||
|
)
|
||||||
|
|
||||||
# Mark as verified
|
# Mark as verified
|
||||||
settings.mark_verified()
|
settings.mark_verified()
|
||||||
self.db.commit()
|
self.db.flush()
|
||||||
|
|
||||||
logger.info(f"Email settings verified for vendor {vendor_id}")
|
logger.info(f"Email settings verified for vendor {vendor_id}")
|
||||||
return {
|
return {
|
||||||
@@ -241,12 +265,15 @@ class VendorEmailSettingsService:
|
|||||||
"message": f"Test email sent successfully to {test_email}",
|
"message": f"Test email sent successfully to {test_email}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except (ValidationException, ExternalServiceException):
|
||||||
|
raise # Re-raise domain exceptions
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
settings.mark_verification_failed(error_msg)
|
settings.mark_verification_failed(error_msg)
|
||||||
self.db.commit()
|
self.db.flush()
|
||||||
|
|
||||||
logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}")
|
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 {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Failed to send test email: {error_msg}",
|
"message": f"Failed to send test email: {error_msg}",
|
||||||
@@ -304,7 +331,10 @@ class VendorEmailSettingsService:
|
|||||||
from sendgrid import SendGridAPIClient
|
from sendgrid import SendGridAPIClient
|
||||||
from sendgrid.helpers.mail import Mail
|
from sendgrid.helpers.mail import Mail
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ValidationError("SendGrid library not installed. Contact support.")
|
raise ExternalServiceException(
|
||||||
|
service_name="SendGrid",
|
||||||
|
message="SendGrid library not installed. Contact support.",
|
||||||
|
)
|
||||||
|
|
||||||
message = Mail(
|
message = Mail(
|
||||||
from_email=(settings.from_email, settings.from_name),
|
from_email=(settings.from_email, settings.from_name),
|
||||||
@@ -327,7 +357,10 @@ class VendorEmailSettingsService:
|
|||||||
response = sg.send(message)
|
response = sg.send(message)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
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:
|
def _send_mailgun_test(self, settings: VendorEmailSettings, to_email: str) -> None:
|
||||||
"""Send test email via Mailgun."""
|
"""Send test email via Mailgun."""
|
||||||
@@ -356,14 +389,20 @@ class VendorEmailSettingsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code >= 400:
|
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:
|
def _send_ses_test(self, settings: VendorEmailSettings, to_email: str) -> None:
|
||||||
"""Send test email via Amazon SES."""
|
"""Send test email via Amazon SES."""
|
||||||
try:
|
try:
|
||||||
import boto3
|
import boto3
|
||||||
except ImportError:
|
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(
|
client = boto3.client(
|
||||||
"ses",
|
"ses",
|
||||||
|
|||||||
254
docs/guides/email-settings.md
Normal file
254
docs/guides/email-settings.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Email Settings Guide
|
||||||
|
|
||||||
|
This guide covers email configuration for both **vendors** and **platform administrators**. The Wizamart platform uses a layered email system where vendors manage their own email sending while the platform handles system-level communications.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The email system has two distinct configurations:
|
||||||
|
|
||||||
|
| Aspect | Platform (Admin) | Vendor |
|
||||||
|
|--------|-----------------|--------|
|
||||||
|
| Purpose | System emails (billing, admin notifications) | Customer-facing emails (orders, marketing) |
|
||||||
|
| Configuration | Environment variables (.env) + Database overrides | Database (per-vendor) |
|
||||||
|
| Cost | Platform owner pays | Vendor pays |
|
||||||
|
| Providers | SMTP, SendGrid, Mailgun, SES | SMTP (all tiers), Premium providers (Business+) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vendor Email Settings
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
As a vendor, you need to configure email settings to send emails to your customers. This includes order confirmations, shipping updates, and marketing emails.
|
||||||
|
|
||||||
|
#### Accessing Email Settings
|
||||||
|
|
||||||
|
1. Log in to your Vendor Dashboard
|
||||||
|
2. Navigate to **Settings** from the sidebar
|
||||||
|
3. Click on the **Email** tab
|
||||||
|
|
||||||
|
### Available Providers
|
||||||
|
|
||||||
|
| Provider | Tier Required | Best For |
|
||||||
|
|----------|---------------|----------|
|
||||||
|
| SMTP | All tiers | Standard email servers, most common |
|
||||||
|
| SendGrid | Business+ | High-volume transactional emails |
|
||||||
|
| Mailgun | Business+ | Developer-friendly API |
|
||||||
|
| Amazon SES | Business+ | AWS ecosystem, cost-effective |
|
||||||
|
|
||||||
|
### Configuring SMTP
|
||||||
|
|
||||||
|
SMTP is available for all subscription tiers. Common SMTP providers include:
|
||||||
|
- Gmail (smtp.gmail.com:587)
|
||||||
|
- Microsoft 365 (smtp.office365.com:587)
|
||||||
|
- Your hosting provider's SMTP server
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- **From Email**: The sender email address (e.g., orders@yourstore.com)
|
||||||
|
- **From Name**: The sender display name (e.g., "Your Store")
|
||||||
|
- **SMTP Host**: Your SMTP server address
|
||||||
|
- **SMTP Port**: Usually 587 (TLS) or 465 (SSL)
|
||||||
|
- **SMTP Username**: Your login username
|
||||||
|
- **SMTP Password**: Your login password
|
||||||
|
- **Use TLS**: Enable for port 587 (recommended)
|
||||||
|
- **Use SSL**: Enable for port 465
|
||||||
|
|
||||||
|
### Configuring Premium Providers (Business+)
|
||||||
|
|
||||||
|
If you have a Business or Enterprise subscription, you can use premium email providers:
|
||||||
|
|
||||||
|
#### SendGrid
|
||||||
|
1. Create a SendGrid account at [sendgrid.com](https://sendgrid.com)
|
||||||
|
2. Generate an API key
|
||||||
|
3. Enter the API key in your vendor settings
|
||||||
|
|
||||||
|
#### Mailgun
|
||||||
|
1. Create a Mailgun account at [mailgun.com](https://mailgun.com)
|
||||||
|
2. Add and verify your domain
|
||||||
|
3. Get your API key from the dashboard
|
||||||
|
4. Enter the API key and domain in your settings
|
||||||
|
|
||||||
|
#### Amazon SES
|
||||||
|
1. Set up SES in your AWS account
|
||||||
|
2. Verify your sender domain/email
|
||||||
|
3. Create IAM credentials with SES permissions
|
||||||
|
4. Enter the access key, secret key, and region
|
||||||
|
|
||||||
|
### Verifying Your Configuration
|
||||||
|
|
||||||
|
After configuring your email settings:
|
||||||
|
|
||||||
|
1. Click **Save Settings**
|
||||||
|
2. Enter a test email address in the **Test Email** field
|
||||||
|
3. Click **Send Test**
|
||||||
|
4. Check your inbox for the test email
|
||||||
|
|
||||||
|
If the test fails, check:
|
||||||
|
- Your credentials are correct
|
||||||
|
- Your IP/domain is not blocked
|
||||||
|
- For Gmail: Allow "less secure apps" or use an app password
|
||||||
|
|
||||||
|
### Email Warning Banner
|
||||||
|
|
||||||
|
Until you configure and verify your email settings, you'll see a warning banner at the top of your dashboard. This ensures you don't forget to set up email before your store goes live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Admin Email Settings
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Platform administrators can configure system-wide email settings for platform communications like:
|
||||||
|
- Subscription billing notifications
|
||||||
|
- Admin alerts
|
||||||
|
- Platform-wide announcements
|
||||||
|
|
||||||
|
### Configuration Sources
|
||||||
|
|
||||||
|
Admin email settings support two configuration sources:
|
||||||
|
|
||||||
|
1. **Environment Variables (.env)** - Default configuration
|
||||||
|
2. **Database Overrides** - Override .env via the admin UI
|
||||||
|
|
||||||
|
Database settings take priority over .env values.
|
||||||
|
|
||||||
|
### Accessing Admin Email Settings
|
||||||
|
|
||||||
|
1. Log in to the Admin Panel
|
||||||
|
2. Navigate to **Settings**
|
||||||
|
3. Click on the **Email** tab
|
||||||
|
|
||||||
|
### Viewing Current Configuration
|
||||||
|
|
||||||
|
The Email tab shows:
|
||||||
|
- **Provider**: Current email provider (SMTP, SendGrid, etc.)
|
||||||
|
- **From Email**: Sender email address
|
||||||
|
- **From Name**: Sender display name
|
||||||
|
- **Status**: Whether email is configured and enabled
|
||||||
|
- **DB Overrides**: Whether database overrides are active
|
||||||
|
|
||||||
|
### Editing Settings
|
||||||
|
|
||||||
|
Click **Edit Settings** to modify the email configuration:
|
||||||
|
|
||||||
|
1. Select the email provider
|
||||||
|
2. Enter the required credentials
|
||||||
|
3. Configure enabled/debug flags
|
||||||
|
4. Click **Save Email Settings**
|
||||||
|
|
||||||
|
### Resetting to .env Defaults
|
||||||
|
|
||||||
|
If you've made database overrides and want to revert to .env configuration:
|
||||||
|
|
||||||
|
1. Click **Reset to .env Defaults**
|
||||||
|
2. Confirm the action
|
||||||
|
|
||||||
|
This removes all email settings from the database, reverting to .env values.
|
||||||
|
|
||||||
|
### Testing Configuration
|
||||||
|
|
||||||
|
1. Enter a test email address
|
||||||
|
2. Click **Send Test**
|
||||||
|
3. Check your inbox
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
For platform configuration via .env:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Provider: smtp, sendgrid, mailgun, ses
|
||||||
|
EMAIL_PROVIDER=smtp
|
||||||
|
|
||||||
|
# Sender identity
|
||||||
|
EMAIL_FROM_ADDRESS=noreply@yourplatform.com
|
||||||
|
EMAIL_FROM_NAME=Your Platform
|
||||||
|
EMAIL_REPLY_TO=support@yourplatform.com
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
EMAIL_ENABLED=true
|
||||||
|
EMAIL_DEBUG=false
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-username
|
||||||
|
SMTP_PASSWORD=your-password
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
|
||||||
|
# SendGrid
|
||||||
|
SENDGRID_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# Mailgun
|
||||||
|
MAILGUN_API_KEY=your-api-key
|
||||||
|
MAILGUN_DOMAIN=mg.yourdomain.com
|
||||||
|
|
||||||
|
# Amazon SES
|
||||||
|
AWS_ACCESS_KEY_ID=your-access-key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
AWS_REGION=eu-west-1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier-Based Branding
|
||||||
|
|
||||||
|
The email system includes tier-based branding for vendor emails:
|
||||||
|
|
||||||
|
| Tier | Branding |
|
||||||
|
|------|----------|
|
||||||
|
| Essential | "Powered by Wizamart" footer |
|
||||||
|
| Professional | "Powered by Wizamart" footer |
|
||||||
|
| Business | No branding (white-label) |
|
||||||
|
| Enterprise | No branding (white-label) |
|
||||||
|
|
||||||
|
Business and Enterprise tier vendors get completely white-labeled emails with no Wizamart branding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**"Email sending is disabled"**
|
||||||
|
- Check that `EMAIL_ENABLED=true` in .env
|
||||||
|
- Or enable it in the admin settings
|
||||||
|
|
||||||
|
**"Connection refused" on SMTP**
|
||||||
|
- Verify SMTP host and port
|
||||||
|
- Check firewall rules
|
||||||
|
- Ensure TLS/SSL settings match your server
|
||||||
|
|
||||||
|
**"Authentication failed"**
|
||||||
|
- Double-check username/password
|
||||||
|
- For Gmail, use an App Password
|
||||||
|
- For Microsoft 365, check MFA requirements
|
||||||
|
|
||||||
|
**"SendGrid error: 403"**
|
||||||
|
- Verify your API key has Mail Send permissions
|
||||||
|
- Check sender identity is verified
|
||||||
|
|
||||||
|
**Premium provider not available**
|
||||||
|
- Upgrade to Business or Enterprise tier
|
||||||
|
- Contact support if you have the right tier but can't access
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug mode to log emails instead of sending them:
|
||||||
|
- Set `EMAIL_DEBUG=true` in .env
|
||||||
|
- Or enable "Debug mode" in admin settings
|
||||||
|
|
||||||
|
Debug mode logs the email content to the server logs without actually sending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never share API keys or passwords** in logs or frontend
|
||||||
|
2. **Use environment variables** for sensitive credentials
|
||||||
|
3. **Enable TLS** for SMTP connections
|
||||||
|
4. **Verify sender domains** with your email provider
|
||||||
|
5. **Monitor email logs** for delivery issues
|
||||||
|
6. **Rotate credentials** periodically
|
||||||
308
docs/implementation/email-settings.md
Normal file
308
docs/implementation/email-settings.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Email Settings Implementation
|
||||||
|
|
||||||
|
This document describes the technical implementation of the email settings system for both vendor and platform (admin) configurations.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Email System Architecture │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Platform Email │ │ Vendor Email │ │
|
||||||
|
│ │ (Admin/Billing)│ │ (Customer-facing) │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ get_platform_ │ │ get_vendor_ │ │
|
||||||
|
│ │ email_config(db) │ │ provider() │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ AdminSettings DB │ │VendorEmailSettings│ │
|
||||||
|
│ │ (.env fallback)│ │ (per vendor) │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────┬───────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ EmailService │ │
|
||||||
|
│ │ send_raw() │ │
|
||||||
|
│ └────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ Email Providers │ │
|
||||||
|
│ │ SMTP/SG/MG/SES │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Models
|
||||||
|
|
||||||
|
### VendorEmailSettings
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/database/vendor_email_settings.py
|
||||||
|
|
||||||
|
class VendorEmailSettings(Base):
|
||||||
|
__tablename__ = "vendor_email_settings"
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int # FK to vendors.id (one-to-one)
|
||||||
|
|
||||||
|
# Sender Identity
|
||||||
|
from_email: str
|
||||||
|
from_name: str
|
||||||
|
reply_to_email: str | None
|
||||||
|
|
||||||
|
# Signature
|
||||||
|
signature_text: str | None
|
||||||
|
signature_html: str | None
|
||||||
|
|
||||||
|
# Provider
|
||||||
|
provider: str = "smtp" # smtp, sendgrid, mailgun, ses
|
||||||
|
|
||||||
|
# SMTP Settings
|
||||||
|
smtp_host: str | None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None
|
||||||
|
smtp_password: str | None
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
smtp_use_ssl: bool = False
|
||||||
|
|
||||||
|
# SendGrid
|
||||||
|
sendgrid_api_key: str | None
|
||||||
|
|
||||||
|
# Mailgun
|
||||||
|
mailgun_api_key: str | None
|
||||||
|
mailgun_domain: str | None
|
||||||
|
|
||||||
|
# SES
|
||||||
|
ses_access_key_id: str | None
|
||||||
|
ses_secret_access_key: str | None
|
||||||
|
ses_region: str = "eu-west-1"
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_configured: bool = False
|
||||||
|
is_verified: bool = False
|
||||||
|
last_verified_at: datetime | None
|
||||||
|
verification_error: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Settings (Platform Email)
|
||||||
|
|
||||||
|
Platform email settings are stored in the generic `admin_settings` table with category="email":
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Keys stored in admin_settings table
|
||||||
|
EMAIL_SETTING_KEYS = {
|
||||||
|
"email_provider",
|
||||||
|
"email_from_address",
|
||||||
|
"email_from_name",
|
||||||
|
"email_reply_to",
|
||||||
|
"smtp_host",
|
||||||
|
"smtp_port",
|
||||||
|
"smtp_user",
|
||||||
|
"smtp_password",
|
||||||
|
"smtp_use_tls",
|
||||||
|
"smtp_use_ssl",
|
||||||
|
"sendgrid_api_key",
|
||||||
|
"mailgun_api_key",
|
||||||
|
"mailgun_domain",
|
||||||
|
"aws_access_key_id",
|
||||||
|
"aws_secret_access_key",
|
||||||
|
"aws_region",
|
||||||
|
"email_enabled",
|
||||||
|
"email_debug",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Vendor Email Settings
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/v1/vendor/email-settings` | GET | Get current email settings |
|
||||||
|
| `/api/v1/vendor/email-settings` | PUT | Create/update email settings |
|
||||||
|
| `/api/v1/vendor/email-settings` | DELETE | Delete email settings |
|
||||||
|
| `/api/v1/vendor/email-settings/status` | GET | Get configuration status |
|
||||||
|
| `/api/v1/vendor/email-settings/providers` | GET | Get available providers for tier |
|
||||||
|
| `/api/v1/vendor/email-settings/verify` | POST | Send test email |
|
||||||
|
|
||||||
|
### Admin Email Settings
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/v1/admin/settings/email/status` | GET | Get effective email config |
|
||||||
|
| `/api/v1/admin/settings/email/settings` | PUT | Update email settings in DB |
|
||||||
|
| `/api/v1/admin/settings/email/settings` | DELETE | Reset to .env defaults |
|
||||||
|
| `/api/v1/admin/settings/email/test` | POST | Send test email |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### VendorEmailSettingsService
|
||||||
|
|
||||||
|
Location: `app/services/vendor_email_settings_service.py`
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `get_settings(vendor_id)` - Get settings for a vendor
|
||||||
|
- `create_or_update(vendor_id, data, current_tier)` - Create/update settings
|
||||||
|
- `delete(vendor_id)` - Delete settings
|
||||||
|
- `verify_settings(vendor_id, test_email)` - Send test email
|
||||||
|
- `get_available_providers(tier)` - Get providers for subscription tier
|
||||||
|
|
||||||
|
### EmailService Integration
|
||||||
|
|
||||||
|
The EmailService (`app/services/email_service.py`) uses:
|
||||||
|
|
||||||
|
1. **Platform Config**: `get_platform_email_config(db)` checks database first, then .env
|
||||||
|
2. **Vendor Config**: `get_vendor_provider(settings)` creates provider from VendorEmailSettings
|
||||||
|
3. **Provider Selection**: `send_raw()` uses vendor provider when `vendor_id` provided and `is_platform_email=False`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# EmailService.send_raw() flow
|
||||||
|
def send_raw(self, to_email, subject, body_html, vendor_id=None, is_platform_email=False):
|
||||||
|
if vendor_id and not is_platform_email:
|
||||||
|
# Use vendor's email provider
|
||||||
|
vendor_settings = self._get_vendor_email_settings(vendor_id)
|
||||||
|
if vendor_settings and vendor_settings.is_configured:
|
||||||
|
provider = get_vendor_provider(vendor_settings)
|
||||||
|
else:
|
||||||
|
# Use platform provider (DB config > .env)
|
||||||
|
provider = self.provider # Set in __init__ via get_platform_provider(db)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier-Based Features
|
||||||
|
|
||||||
|
### Premium Provider Gating
|
||||||
|
|
||||||
|
Premium providers (SendGrid, Mailgun, SES) are gated to Business+ tiers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PREMIUM_EMAIL_PROVIDERS = {EmailProvider.SENDGRID, EmailProvider.MAILGUN, EmailProvider.SES}
|
||||||
|
PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
|
||||||
|
|
||||||
|
def create_or_update(self, vendor_id, data, current_tier):
|
||||||
|
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(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### White-Label Branding
|
||||||
|
|
||||||
|
Emails include "Powered by Wizamart" footer for non-whitelabel tiers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
WHITELABEL_TIERS = {"business", "enterprise"}
|
||||||
|
|
||||||
|
POWERED_BY_FOOTER_HTML = """
|
||||||
|
<div style="margin-top: 30px; ...">
|
||||||
|
<p>Powered by <a href="https://wizamart.com">Wizamart</a></p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _inject_powered_by_footer(self, body_html, vendor_id):
|
||||||
|
tier = self._get_vendor_tier(vendor_id)
|
||||||
|
if tier and tier.lower() in WHITELABEL_TIERS:
|
||||||
|
return body_html # No footer for business/enterprise
|
||||||
|
return body_html.replace("</body>", f"{POWERED_BY_FOOTER_HTML}</body>")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Priority
|
||||||
|
|
||||||
|
### Platform Email
|
||||||
|
|
||||||
|
1. **Database** (admin_settings table) - Highest priority
|
||||||
|
2. **Environment Variables** (.env) - Fallback
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_platform_email_config(db: Session) -> dict:
|
||||||
|
def get_db_setting(key: str) -> str | None:
|
||||||
|
setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
|
||||||
|
return setting.value if setting else None
|
||||||
|
|
||||||
|
# Check DB first, fallback to .env
|
||||||
|
db_provider = get_db_setting("email_provider")
|
||||||
|
config["provider"] = db_provider if db_provider else settings.email_provider
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vendor Email
|
||||||
|
|
||||||
|
Vendors have their own dedicated settings table with no fallback - they must configure their own email.
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### Vendor Settings Page
|
||||||
|
|
||||||
|
- **Location**: `app/templates/vendor/settings.html`, `static/vendor/js/settings.js`
|
||||||
|
- **Alpine.js State**: `emailSettings`, `emailForm`, `hasEmailChanges`
|
||||||
|
- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `sendTestEmail()`
|
||||||
|
|
||||||
|
### Admin Settings Page
|
||||||
|
|
||||||
|
- **Location**: `app/templates/admin/settings.html`, `static/admin/js/settings.js`
|
||||||
|
- **Alpine.js State**: `emailSettings`, `emailForm`, `emailEditMode`
|
||||||
|
- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `resetEmailSettings()`, `sendTestEmail()`
|
||||||
|
|
||||||
|
### Warning Banner
|
||||||
|
|
||||||
|
Shows until email is configured:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- app/templates/shared/macros/feature_gate.html -->
|
||||||
|
{% macro email_settings_warning() %}
|
||||||
|
<div x-data="emailSettingsWarning()" x-show="showWarning">
|
||||||
|
Configure email settings to send emails to customers.
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Location: `tests/unit/services/test_vendor_email_settings_service.py`
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Read operations (get_settings, get_status, is_configured)
|
||||||
|
- Write operations (create_or_update, delete)
|
||||||
|
- Tier validation (premium providers)
|
||||||
|
- Verification (mock SMTP)
|
||||||
|
- Provider availability
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Locations:
|
||||||
|
- `tests/integration/api/v1/vendor/test_email_settings.py`
|
||||||
|
- `tests/integration/api/v1/admin/test_email_settings.py`
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- CRUD operations via API
|
||||||
|
- Authentication/authorization
|
||||||
|
- Validation errors
|
||||||
|
- Status endpoints
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `models/database/vendor_email_settings.py` - Model
|
||||||
|
- `alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py` - Migration
|
||||||
|
- `app/services/vendor_email_settings_service.py` - Service
|
||||||
|
- `app/api/v1/vendor/email_settings.py` - API endpoints
|
||||||
|
- `scripts/install.py` - Installation wizard
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `app/services/email_service.py` - Added platform config, vendor providers
|
||||||
|
- `app/api/v1/admin/settings.py` - Added email endpoints
|
||||||
|
- `app/templates/admin/settings.html` - Email tab
|
||||||
|
- `app/templates/vendor/settings.html` - Email tab
|
||||||
|
- `static/admin/js/settings.js` - Email JS
|
||||||
|
- `static/vendor/js/settings.js` - Email JS
|
||||||
|
- `static/vendor/js/init-alpine.js` - Warning banner component
|
||||||
@@ -171,6 +171,7 @@ nav:
|
|||||||
- Stock Management Integration: implementation/stock-management-integration.md
|
- Stock Management Integration: implementation/stock-management-integration.md
|
||||||
- Email Templates Architecture: implementation/email-templates-architecture.md
|
- Email Templates Architecture: implementation/email-templates-architecture.md
|
||||||
- Password Reset: implementation/password-reset-implementation.md
|
- Password Reset: implementation/password-reset-implementation.md
|
||||||
|
- Email Settings: implementation/email-settings.md
|
||||||
|
|
||||||
# --- Testing ---
|
# --- Testing ---
|
||||||
- Testing:
|
- Testing:
|
||||||
@@ -218,6 +219,7 @@ nav:
|
|||||||
- Product Management: guides/product-management.md
|
- Product Management: guides/product-management.md
|
||||||
- Inventory Management: guides/inventory-management.md
|
- Inventory Management: guides/inventory-management.md
|
||||||
- Subscription Tier Management: guides/subscription-tier-management.md
|
- Subscription Tier Management: guides/subscription-tier-management.md
|
||||||
|
- Email Settings: guides/email-settings.md
|
||||||
- Email Templates: guides/email-templates.md
|
- Email Templates: guides/email-templates.md
|
||||||
- Shop Setup: guides/shop-setup.md
|
- Shop Setup: guides/shop-setup.md
|
||||||
- CSV Import: guides/csv-import.md
|
- CSV Import: guides/csv-import.md
|
||||||
|
|||||||
290
tests/integration/api/v1/admin/test_email_settings.py
Normal file
290
tests/integration/api/v1/admin/test_email_settings.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# tests/integration/api/v1/admin/test_email_settings.py
|
||||||
|
"""Integration tests for admin email settings API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models.database.admin import AdminSetting
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET EMAIL STATUS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestGetAdminEmailStatus:
|
||||||
|
"""Test suite for GET /admin/settings/email/status endpoint."""
|
||||||
|
|
||||||
|
def test_get_status_unauthenticated(self, client):
|
||||||
|
"""Test getting status without auth fails."""
|
||||||
|
response = client.get("/api/v1/admin/settings/email/status")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_status_non_admin(self, client, auth_headers):
|
||||||
|
"""Test getting status as non-admin fails."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/settings/email/status",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_get_status_admin(self, client, admin_headers):
|
||||||
|
"""Test getting status as admin succeeds."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/settings/email/status",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "provider" in data
|
||||||
|
assert "from_email" in data
|
||||||
|
assert "enabled" in data
|
||||||
|
assert "is_configured" in data
|
||||||
|
|
||||||
|
def test_get_status_has_db_overrides_flag(self, client, admin_headers):
|
||||||
|
"""Test status includes has_db_overrides flag."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/settings/email/status",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "has_db_overrides" in data
|
||||||
|
# Initially should be False (no DB settings)
|
||||||
|
assert data["has_db_overrides"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UPDATE EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestUpdateAdminEmailSettings:
|
||||||
|
"""Test suite for PUT /admin/settings/email/settings endpoint."""
|
||||||
|
|
||||||
|
def test_update_unauthenticated(self, client):
|
||||||
|
"""Test updating settings without auth fails."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
json={"from_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_update_non_admin(self, client, auth_headers):
|
||||||
|
"""Test updating settings as non-admin fails."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"from_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_update_settings_admin(self, client, admin_headers, db):
|
||||||
|
"""Test updating settings as admin succeeds."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "platform@example.com",
|
||||||
|
"from_name": "Test Platform",
|
||||||
|
"provider": "smtp",
|
||||||
|
"smtp_host": "smtp.test.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "updated_keys" in data
|
||||||
|
assert "from_email" in data["updated_keys"]
|
||||||
|
|
||||||
|
# Verify settings were stored in DB
|
||||||
|
setting = (
|
||||||
|
db.query(AdminSetting)
|
||||||
|
.filter(AdminSetting.key == "email_from_address")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert setting is not None
|
||||||
|
assert setting.value == "platform@example.com"
|
||||||
|
|
||||||
|
def test_update_partial_settings(self, client, admin_headers):
|
||||||
|
"""Test updating only some settings."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"enabled": False,
|
||||||
|
"debug": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "enabled" in data["updated_keys"]
|
||||||
|
assert "debug" in data["updated_keys"]
|
||||||
|
|
||||||
|
def test_status_shows_db_overrides(self, client, admin_headers):
|
||||||
|
"""Test that status shows DB overrides after update."""
|
||||||
|
# First, set a DB override
|
||||||
|
client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"from_email": "override@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/settings/email/status",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_db_overrides"] is True
|
||||||
|
assert data["from_email"] == "override@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RESET EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestResetAdminEmailSettings:
|
||||||
|
"""Test suite for DELETE /admin/settings/email/settings endpoint."""
|
||||||
|
|
||||||
|
def test_reset_unauthenticated(self, client):
|
||||||
|
"""Test resetting settings without auth fails."""
|
||||||
|
response = client.delete("/api/v1/admin/settings/email/settings")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_reset_non_admin(self, client, auth_headers):
|
||||||
|
"""Test resetting settings as non-admin fails."""
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_reset_settings_admin(self, client, admin_headers, db):
|
||||||
|
"""Test resetting settings as admin."""
|
||||||
|
# First, create some DB overrides
|
||||||
|
client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "tobereset@example.com",
|
||||||
|
"provider": "sendgrid",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify they exist
|
||||||
|
setting = (
|
||||||
|
db.query(AdminSetting)
|
||||||
|
.filter(AdminSetting.key == "email_from_address")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert setting is not None
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
|
||||||
|
# Verify they're gone
|
||||||
|
db.expire_all()
|
||||||
|
setting = (
|
||||||
|
db.query(AdminSetting)
|
||||||
|
.filter(AdminSetting.key == "email_from_address")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert setting is None
|
||||||
|
|
||||||
|
def test_status_after_reset(self, client, admin_headers):
|
||||||
|
"""Test status after reset shows no DB overrides."""
|
||||||
|
# Set an override
|
||||||
|
client.put(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"from_email": "override@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
client.delete(
|
||||||
|
"/api/v1/admin/settings/email/settings",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/admin/settings/email/status",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_db_overrides"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TEST EMAIL TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestSendAdminTestEmail:
|
||||||
|
"""Test suite for POST /admin/settings/email/test endpoint."""
|
||||||
|
|
||||||
|
def test_send_test_unauthenticated(self, client):
|
||||||
|
"""Test sending test email without auth fails."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/admin/settings/email/test",
|
||||||
|
json={"to_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_send_test_non_admin(self, client, auth_headers):
|
||||||
|
"""Test sending test email as non-admin fails."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/admin/settings/email/test",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"to_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_send_test_invalid_email(self, client, admin_headers):
|
||||||
|
"""Test sending to invalid email format fails."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/admin/settings/email/test",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"to_email": "not-an-email"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
def test_send_test_admin(self, client, admin_headers):
|
||||||
|
"""Test sending test email as admin."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/admin/settings/email/test",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"to_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# May fail if email not configured, but should not be 401/403
|
||||||
|
assert response.status_code in (200, 500)
|
||||||
|
data = response.json()
|
||||||
|
assert "success" in data
|
||||||
|
assert "message" in data
|
||||||
347
tests/integration/api/v1/vendor/test_email_settings.py
vendored
Normal file
347
tests/integration/api/v1/vendor/test_email_settings.py
vendored
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# tests/integration/api/v1/vendor/test_email_settings.py
|
||||||
|
"""Integration tests for vendor email settings API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from models.database import VendorEmailSettings
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FIXTURES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vendor_email_settings(db, test_vendor_with_vendor_user):
|
||||||
|
"""Create email settings for vendor owned by test vendor user."""
|
||||||
|
settings = VendorEmailSettings(
|
||||||
|
vendor_id=test_vendor_with_vendor_user.id,
|
||||||
|
from_email="vendor@example.com",
|
||||||
|
from_name="Vendor Test",
|
||||||
|
provider="smtp",
|
||||||
|
smtp_host="smtp.example.com",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="vendoruser",
|
||||||
|
smtp_password="vendorpass",
|
||||||
|
smtp_use_tls=True,
|
||||||
|
is_configured=True,
|
||||||
|
is_verified=False,
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def verified_vendor_email_settings(db, test_vendor_with_vendor_user):
|
||||||
|
"""Create verified email settings."""
|
||||||
|
settings = VendorEmailSettings(
|
||||||
|
vendor_id=test_vendor_with_vendor_user.id,
|
||||||
|
from_email="verified@example.com",
|
||||||
|
from_name="Verified Sender",
|
||||||
|
provider="smtp",
|
||||||
|
smtp_host="smtp.example.com",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="testuser",
|
||||||
|
smtp_password="testpass",
|
||||||
|
smtp_use_tls=True,
|
||||||
|
is_configured=True,
|
||||||
|
is_verified=True,
|
||||||
|
last_verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestGetEmailSettings:
|
||||||
|
"""Test suite for GET /email-settings endpoint."""
|
||||||
|
|
||||||
|
def test_get_settings_not_configured(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test getting settings when not configured."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["configured"] is False
|
||||||
|
assert data["settings"] is None
|
||||||
|
|
||||||
|
def test_get_settings_configured(
|
||||||
|
self, client, vendor_auth_headers, vendor_email_settings
|
||||||
|
):
|
||||||
|
"""Test getting configured settings."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["configured"] is True
|
||||||
|
assert data["settings"]["from_email"] == "vendor@example.com"
|
||||||
|
# Password should be masked
|
||||||
|
assert "vendorpass" not in str(data)
|
||||||
|
|
||||||
|
def test_get_settings_unauthenticated(self, client):
|
||||||
|
"""Test getting settings without auth fails."""
|
||||||
|
response = client.get("/api/v1/vendor/email-settings")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET STATUS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestGetEmailStatus:
|
||||||
|
"""Test suite for GET /email-settings/status endpoint."""
|
||||||
|
|
||||||
|
def test_get_status_not_configured(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test status when not configured."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings/status",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_configured"] is False
|
||||||
|
assert data["is_verified"] is False
|
||||||
|
|
||||||
|
def test_get_status_configured_unverified(
|
||||||
|
self, client, vendor_auth_headers, vendor_email_settings
|
||||||
|
):
|
||||||
|
"""Test status when configured but not verified."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings/status",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_configured"] is True
|
||||||
|
assert data["is_verified"] is False
|
||||||
|
|
||||||
|
def test_get_status_verified(
|
||||||
|
self, client, vendor_auth_headers, verified_vendor_email_settings
|
||||||
|
):
|
||||||
|
"""Test status when verified."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings/status",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_configured"] is True
|
||||||
|
assert data["is_verified"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET PROVIDERS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestGetProviders:
|
||||||
|
"""Test suite for GET /email-settings/providers endpoint."""
|
||||||
|
|
||||||
|
def test_get_providers(self, client, vendor_auth_headers):
|
||||||
|
"""Test getting available providers."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/vendor/email-settings/providers",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "providers" in data
|
||||||
|
assert len(data["providers"]) >= 1
|
||||||
|
|
||||||
|
# SMTP should always be available
|
||||||
|
smtp = next((p for p in data["providers"] if p["code"] == "smtp"), None)
|
||||||
|
assert smtp is not None
|
||||||
|
assert smtp["available"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UPDATE EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestUpdateEmailSettings:
|
||||||
|
"""Test suite for PUT /email-settings endpoint."""
|
||||||
|
|
||||||
|
def test_create_settings(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test creating new email settings."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "new@example.com",
|
||||||
|
"from_name": "New Vendor",
|
||||||
|
"provider": "smtp",
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_username": "user",
|
||||||
|
"smtp_password": "pass",
|
||||||
|
"smtp_use_tls": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["settings"]["from_email"] == "new@example.com"
|
||||||
|
|
||||||
|
def test_update_existing_settings(
|
||||||
|
self, client, vendor_auth_headers, vendor_email_settings
|
||||||
|
):
|
||||||
|
"""Test updating existing settings."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "updated@example.com",
|
||||||
|
"from_name": "Updated Name",
|
||||||
|
"provider": "smtp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["settings"]["from_email"] == "updated@example.com"
|
||||||
|
assert data["settings"]["from_name"] == "Updated Name"
|
||||||
|
|
||||||
|
def test_premium_provider_rejected_for_basic_tier(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test premium provider rejected without Business tier."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "test@example.com",
|
||||||
|
"from_name": "Test",
|
||||||
|
"provider": "sendgrid",
|
||||||
|
"sendgrid_api_key": "test-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should fail with 403 (AuthorizationException)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_invalid_email_rejected(self, client, vendor_auth_headers):
|
||||||
|
"""Test invalid email format rejected."""
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={
|
||||||
|
"from_email": "not-an-email",
|
||||||
|
"from_name": "Test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DELETE EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestDeleteEmailSettings:
|
||||||
|
"""Test suite for DELETE /email-settings endpoint."""
|
||||||
|
|
||||||
|
def test_delete_settings(
|
||||||
|
self, client, vendor_auth_headers, vendor_email_settings, db
|
||||||
|
):
|
||||||
|
"""Test deleting email settings."""
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
|
||||||
|
# Verify deletion
|
||||||
|
settings = (
|
||||||
|
db.query(VendorEmailSettings)
|
||||||
|
.filter(VendorEmailSettings.vendor_id == vendor_email_settings.vendor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert settings is None
|
||||||
|
|
||||||
|
def test_delete_settings_not_found(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test deleting non-existent settings returns 404."""
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/vendor/email-settings",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VERIFY EMAIL SETTINGS TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestVerifyEmailSettings:
|
||||||
|
"""Test suite for POST /email-settings/verify endpoint."""
|
||||||
|
|
||||||
|
def test_verify_not_configured(
|
||||||
|
self, client, vendor_auth_headers, test_vendor_with_vendor_user
|
||||||
|
):
|
||||||
|
"""Test verification fails when settings not configured."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/vendor/email-settings/verify",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={"test_email": "test@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_verify_invalid_email(
|
||||||
|
self, client, vendor_auth_headers, vendor_email_settings
|
||||||
|
):
|
||||||
|
"""Test verification with invalid email address."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/vendor/email-settings/verify",
|
||||||
|
headers=vendor_auth_headers,
|
||||||
|
json={"test_email": "not-an-email"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422 # Validation error
|
||||||
388
tests/unit/services/test_vendor_email_settings_service.py
Normal file
388
tests/unit/services/test_vendor_email_settings_service.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# tests/unit/services/test_vendor_email_settings_service.py
|
||||||
|
"""Unit tests for VendorEmailSettingsService."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
AuthorizationException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
from app.services.vendor_email_settings_service import VendorEmailSettingsService
|
||||||
|
from models.database import VendorEmailSettings, TierCode
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FIXTURES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_email_settings(db, test_vendor):
|
||||||
|
"""Create test email settings for a vendor."""
|
||||||
|
settings = VendorEmailSettings(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
from_email="test@example.com",
|
||||||
|
from_name="Test Sender",
|
||||||
|
provider="smtp",
|
||||||
|
smtp_host="smtp.example.com",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="testuser",
|
||||||
|
smtp_password="testpass",
|
||||||
|
smtp_use_tls=True,
|
||||||
|
smtp_use_ssl=False,
|
||||||
|
is_configured=True,
|
||||||
|
is_verified=False,
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_verified_email_settings(db, test_vendor):
|
||||||
|
"""Create verified email settings."""
|
||||||
|
settings = VendorEmailSettings(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
from_email="verified@example.com",
|
||||||
|
from_name="Verified Sender",
|
||||||
|
provider="smtp",
|
||||||
|
smtp_host="smtp.example.com",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="testuser",
|
||||||
|
smtp_password="testpass",
|
||||||
|
smtp_use_tls=True,
|
||||||
|
is_configured=True,
|
||||||
|
is_verified=True,
|
||||||
|
last_verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# READ OPERATION TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestVendorEmailSettingsRead:
|
||||||
|
"""Test suite for reading email settings."""
|
||||||
|
|
||||||
|
def test_get_settings_exists(self, db, test_email_settings):
|
||||||
|
"""Test getting settings when they exist."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
settings = service.get_settings(test_email_settings.vendor_id)
|
||||||
|
|
||||||
|
assert settings is not None
|
||||||
|
assert settings.from_email == "test@example.com"
|
||||||
|
assert settings.provider == "smtp"
|
||||||
|
|
||||||
|
def test_get_settings_not_exists(self, db, test_vendor):
|
||||||
|
"""Test getting settings when they don't exist."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
settings = service.get_settings(test_vendor.id)
|
||||||
|
|
||||||
|
assert settings is None
|
||||||
|
|
||||||
|
def test_get_settings_or_404_exists(self, db, test_email_settings):
|
||||||
|
"""Test get_settings_or_404 when settings exist."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
settings = service.get_settings_or_404(test_email_settings.vendor_id)
|
||||||
|
|
||||||
|
assert settings is not None
|
||||||
|
assert settings.id == test_email_settings.id
|
||||||
|
|
||||||
|
def test_get_settings_or_404_not_exists(self, db, test_vendor):
|
||||||
|
"""Test get_settings_or_404 raises exception when not found."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ResourceNotFoundException) as exc:
|
||||||
|
service.get_settings_or_404(test_vendor.id)
|
||||||
|
|
||||||
|
assert "vendor_email_settings" in str(exc.value)
|
||||||
|
|
||||||
|
def test_is_configured_true(self, db, test_email_settings):
|
||||||
|
"""Test is_configured returns True for configured settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
result = service.is_configured(test_email_settings.vendor_id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_is_configured_false_not_exists(self, db, test_vendor):
|
||||||
|
"""Test is_configured returns False when settings don't exist."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
result = service.is_configured(test_vendor.id)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_status_configured(self, db, test_email_settings):
|
||||||
|
"""Test get_status for configured settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
status = service.get_status(test_email_settings.vendor_id)
|
||||||
|
|
||||||
|
assert status["is_configured"] is True
|
||||||
|
assert status["is_verified"] is False
|
||||||
|
assert status["provider"] == "smtp"
|
||||||
|
assert status["from_email"] == "test@example.com"
|
||||||
|
|
||||||
|
def test_get_status_not_configured(self, db, test_vendor):
|
||||||
|
"""Test get_status when settings don't exist."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
status = service.get_status(test_vendor.id)
|
||||||
|
|
||||||
|
assert status["is_configured"] is False
|
||||||
|
assert status["is_verified"] is False
|
||||||
|
assert status["provider"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WRITE OPERATION TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestVendorEmailSettingsWrite:
|
||||||
|
"""Test suite for writing email settings."""
|
||||||
|
|
||||||
|
def test_create_settings(self, db, test_vendor):
|
||||||
|
"""Test creating new email settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"from_email": "new@example.com",
|
||||||
|
"from_name": "New Sender",
|
||||||
|
"provider": "smtp",
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_username": "user",
|
||||||
|
"smtp_password": "pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = service.create_or_update(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
data=data,
|
||||||
|
current_tier=TierCode.ESSENTIAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.from_email == "new@example.com"
|
||||||
|
assert settings.provider == "smtp"
|
||||||
|
assert settings.smtp_host == "smtp.example.com"
|
||||||
|
|
||||||
|
def test_update_existing_settings(self, db, test_email_settings):
|
||||||
|
"""Test updating existing settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"from_email": "updated@example.com",
|
||||||
|
"from_name": "Updated Sender",
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = service.create_or_update(
|
||||||
|
vendor_id=test_email_settings.vendor_id,
|
||||||
|
data=data,
|
||||||
|
current_tier=TierCode.ESSENTIAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.from_email == "updated@example.com"
|
||||||
|
assert settings.from_name == "Updated Sender"
|
||||||
|
# Other fields should remain unchanged
|
||||||
|
assert settings.smtp_host == "smtp.example.com"
|
||||||
|
|
||||||
|
def test_premium_provider_requires_business_tier(self, db, test_vendor):
|
||||||
|
"""Test that premium providers require Business tier."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"from_email": "test@example.com",
|
||||||
|
"from_name": "Test",
|
||||||
|
"provider": "sendgrid",
|
||||||
|
"sendgrid_api_key": "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(AuthorizationException) as exc:
|
||||||
|
service.create_or_update(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
data=data,
|
||||||
|
current_tier=TierCode.ESSENTIAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Business or Enterprise" in str(exc.value)
|
||||||
|
|
||||||
|
def test_premium_provider_allowed_for_business(self, db, test_vendor):
|
||||||
|
"""Test that premium providers work with Business tier."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"from_email": "test@example.com",
|
||||||
|
"from_name": "Test",
|
||||||
|
"provider": "sendgrid",
|
||||||
|
"sendgrid_api_key": "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = service.create_or_update(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
data=data,
|
||||||
|
current_tier=TierCode.BUSINESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.provider == "sendgrid"
|
||||||
|
|
||||||
|
def test_provider_change_resets_verification(self, db, test_verified_email_settings):
|
||||||
|
"""Test that changing provider resets verification status."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
assert test_verified_email_settings.is_verified is True
|
||||||
|
|
||||||
|
data = {"smtp_host": "new-smtp.example.com"}
|
||||||
|
|
||||||
|
settings = service.create_or_update(
|
||||||
|
vendor_id=test_verified_email_settings.vendor_id,
|
||||||
|
data=data,
|
||||||
|
current_tier=TierCode.ESSENTIAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.is_verified is False
|
||||||
|
|
||||||
|
def test_delete_settings(self, db, test_email_settings):
|
||||||
|
"""Test deleting email settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
vendor_id = test_email_settings.vendor_id
|
||||||
|
|
||||||
|
service.delete(vendor_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Verify deletion
|
||||||
|
settings = service.get_settings(vendor_id)
|
||||||
|
assert settings is None
|
||||||
|
|
||||||
|
def test_delete_settings_not_found(self, db, test_vendor):
|
||||||
|
"""Test deleting non-existent settings raises exception."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ResourceNotFoundException):
|
||||||
|
service.delete(test_vendor.id)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VERIFICATION TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestVendorEmailSettingsVerification:
|
||||||
|
"""Test suite for email verification."""
|
||||||
|
|
||||||
|
def test_verify_settings_not_configured(self, db, test_vendor):
|
||||||
|
"""Test verification fails for non-existent settings."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ResourceNotFoundException):
|
||||||
|
service.verify_settings(test_vendor.id, "test@example.com")
|
||||||
|
|
||||||
|
def test_verify_settings_incomplete(self, db, test_vendor):
|
||||||
|
"""Test verification fails for incomplete settings."""
|
||||||
|
# Create incomplete settings
|
||||||
|
settings = VendorEmailSettings(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
from_email="test@example.com",
|
||||||
|
from_name="Test",
|
||||||
|
provider="smtp",
|
||||||
|
# Missing SMTP config
|
||||||
|
is_configured=False,
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationException) as exc:
|
||||||
|
service.verify_settings(test_vendor.id, "test@example.com")
|
||||||
|
|
||||||
|
assert "incomplete" in str(exc.value).lower()
|
||||||
|
|
||||||
|
@patch("smtplib.SMTP")
|
||||||
|
def test_verify_smtp_success(self, mock_smtp, db, test_email_settings):
|
||||||
|
"""Test successful SMTP verification."""
|
||||||
|
# Mock SMTP connection
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_smtp.return_value = mock_server
|
||||||
|
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
result = service.verify_settings(
|
||||||
|
test_email_settings.vendor_id,
|
||||||
|
"recipient@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "successfully" in result["message"].lower()
|
||||||
|
|
||||||
|
@patch("smtplib.SMTP")
|
||||||
|
def test_verify_smtp_failure(self, mock_smtp, db, test_email_settings):
|
||||||
|
"""Test SMTP verification failure."""
|
||||||
|
# Mock SMTP error
|
||||||
|
mock_smtp.side_effect = Exception("Connection refused")
|
||||||
|
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
result = service.verify_settings(
|
||||||
|
test_email_settings.vendor_id,
|
||||||
|
"recipient@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "failed" in result["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROVIDER AVAILABILITY TESTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.email
|
||||||
|
class TestVendorEmailProvidersAvailability:
|
||||||
|
"""Test suite for provider availability checking."""
|
||||||
|
|
||||||
|
def test_get_providers_essential_tier(self, db):
|
||||||
|
"""Test available providers for Essential tier."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
providers = service.get_available_providers(TierCode.ESSENTIAL)
|
||||||
|
|
||||||
|
# Find SMTP provider
|
||||||
|
smtp = next((p for p in providers if p["code"] == "smtp"), None)
|
||||||
|
assert smtp is not None
|
||||||
|
assert smtp["available"] is True
|
||||||
|
|
||||||
|
# Find SendGrid provider
|
||||||
|
sendgrid = next((p for p in providers if p["code"] == "sendgrid"), None)
|
||||||
|
assert sendgrid is not None
|
||||||
|
assert sendgrid["available"] is False
|
||||||
|
|
||||||
|
def test_get_providers_business_tier(self, db):
|
||||||
|
"""Test available providers for Business tier."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
providers = service.get_available_providers(TierCode.BUSINESS)
|
||||||
|
|
||||||
|
# All providers should be available
|
||||||
|
for provider in providers:
|
||||||
|
assert provider["available"] is True
|
||||||
|
|
||||||
|
def test_get_providers_no_tier(self, db):
|
||||||
|
"""Test available providers with no subscription."""
|
||||||
|
service = VendorEmailSettingsService(db)
|
||||||
|
providers = service.get_available_providers(None)
|
||||||
|
|
||||||
|
# Only SMTP should be available
|
||||||
|
smtp = next((p for p in providers if p["code"] == "smtp"), None)
|
||||||
|
assert smtp["available"] is True
|
||||||
|
|
||||||
|
sendgrid = next((p for p in providers if p["code"] == "sendgrid"), None)
|
||||||
|
assert sendgrid["available"] is False
|
||||||
Reference in New Issue
Block a user