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

@@ -610,7 +610,8 @@ def reset_email_settings(
for key in EMAIL_SETTING_KEYS:
setting = admin_settings_service.get_setting_by_key(db, key)
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
# Log action

View File

@@ -14,13 +14,12 @@ Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pa
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
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.subscription_service import subscription_service
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")
# 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
# =============================================================================
@router.get("")
@router.get("", response_model=EmailSettingsResponse)
def get_email_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailSettingsResponse:
"""
Get current email settings for the vendor.
@@ -96,24 +141,24 @@ def get_email_settings(
settings = service.get_settings(vendor_id)
if not settings:
return {
"configured": False,
"settings": None,
"message": "Email settings not configured. Configure SMTP to send emails to customers.",
}
return EmailSettingsResponse(
configured=False,
settings=None,
message="Email settings not configured. Configure SMTP to send emails to customers.",
)
return {
"configured": settings.is_configured,
"verified": settings.is_verified,
"settings": settings.to_dict(),
}
return EmailSettingsResponse(
configured=settings.is_configured,
verified=settings.is_verified,
settings=settings.to_dict(),
)
@router.get("/status")
@router.get("/status", response_model=EmailStatusResponse)
def get_email_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailStatusResponse:
"""
Get email configuration status.
@@ -121,14 +166,15 @@ def get_email_status(
"""
vendor_id = current_user.token_vendor_id
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(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> ProvidersResponse:
"""
Get available email providers for current tier.
@@ -140,22 +186,24 @@ def get_available_providers(
# Get vendor's current tier
tier = subscription_service.get_current_tier(db, vendor_id)
return {
"providers": service.get_available_providers(tier),
"current_tier": tier.value if tier else None,
}
return ProvidersResponse(
providers=service.get_available_providers(tier),
current_tier=tier.value if tier else None,
)
@router.put("")
@router.put("", response_model=EmailUpdateResponse)
def update_email_settings(
data: EmailSettingsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailUpdateResponse:
"""
Create or update email settings.
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
service = VendorEmailSettingsService(db)
@@ -163,63 +211,66 @@ def update_email_settings(
# Get vendor's current tier for validation
tier = subscription_service.get_current_tier(db, vendor_id)
try:
settings = service.create_or_update(
vendor_id=vendor_id,
data=data.model_dump(exclude_unset=True),
current_tier=tier,
)
return {
"success": True,
"message": "Email settings updated successfully",
"settings": settings.to_dict(),
}
except AuthorizationError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
# Service raises appropriate exceptions (API-003 compliance)
settings = service.create_or_update(
vendor_id=vendor_id,
data=data.model_dump(exclude_unset=True),
current_tier=tier,
)
db.commit()
return EmailUpdateResponse(
success=True,
message="Email settings updated successfully",
settings=settings.to_dict(),
)
@router.post("/verify")
@router.post("/verify", response_model=EmailVerifyResponse)
def verify_email_settings(
data: VerifyEmailRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailVerifyResponse:
"""
Verify email settings by sending a test email.
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
service = VendorEmailSettingsService(db)
try:
result = service.verify_settings(vendor_id, data.test_email)
if result["success"]:
return result
else:
raise HTTPException(status_code=400, detail=result["message"])
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
# Service raises appropriate exceptions (API-003 compliance)
result = service.verify_settings(vendor_id, data.test_email)
db.commit()
return EmailVerifyResponse(
success=result["success"],
message=result["message"],
)
@router.delete("")
@router.delete("", response_model=EmailDeleteResponse)
def delete_email_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailDeleteResponse:
"""
Delete email settings.
Warning: This will disable email sending for the vendor.
Raises ResourceNotFoundException if settings not found.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
if service.delete(vendor_id):
return {"success": True, "message": "Email settings deleted"}
else:
raise HTTPException(status_code=404, detail="Email settings not found")
# Service raises ResourceNotFoundException if not found (API-003 compliance)
service.delete(vendor_id)
db.commit()
return EmailDeleteResponse(
success=True,
message="Email settings deleted",
)

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

View 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

View 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

View File

@@ -171,6 +171,7 @@ nav:
- Stock Management Integration: implementation/stock-management-integration.md
- Email Templates Architecture: implementation/email-templates-architecture.md
- Password Reset: implementation/password-reset-implementation.md
- Email Settings: implementation/email-settings.md
# --- Testing ---
- Testing:
@@ -218,6 +219,7 @@ nav:
- Product Management: guides/product-management.md
- Inventory Management: guides/inventory-management.md
- Subscription Tier Management: guides/subscription-tier-management.md
- Email Settings: guides/email-settings.md
- Email Templates: guides/email-templates.md
- Shop Setup: guides/shop-setup.md
- CSV Import: guides/csv-import.md

View 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

View 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

View 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