From 84a523cd7bd7a36e2181bb77f61d0be9d8bc0255 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Mon, 5 Jan 2026 22:38:10 +0100 Subject: [PATCH] fix: resolve email settings architecture violations and add tests/docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/v1/admin/settings.py | 3 +- app/api/v1/vendor/email_settings.py | 167 +++++--- app/services/vendor_email_settings_service.py | 91 ++-- docs/guides/email-settings.md | 254 ++++++++++++ docs/implementation/email-settings.md | 308 ++++++++++++++ mkdocs.yml | 2 + .../api/v1/admin/test_email_settings.py | 290 +++++++++++++ .../api/v1/vendor/test_email_settings.py | 347 ++++++++++++++++ .../test_vendor_email_settings_service.py | 388 ++++++++++++++++++ 9 files changed, 1765 insertions(+), 85 deletions(-) create mode 100644 docs/guides/email-settings.md create mode 100644 docs/implementation/email-settings.md create mode 100644 tests/integration/api/v1/admin/test_email_settings.py create mode 100644 tests/integration/api/v1/vendor/test_email_settings.py create mode 100644 tests/unit/services/test_vendor_email_settings_service.py diff --git a/app/api/v1/admin/settings.py b/app/api/v1/admin/settings.py index 2cdb84c4..12bccbfb 100644 --- a/app/api/v1/admin/settings.py +++ b/app/api/v1/admin/settings.py @@ -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 diff --git a/app/api/v1/vendor/email_settings.py b/app/api/v1/vendor/email_settings.py index 12c10e7e..b818979b 100644 --- a/app/api/v1/vendor/email_settings.py +++ b/app/api/v1/vendor/email_settings.py @@ -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", + ) diff --git a/app/services/vendor_email_settings_service.py b/app/services/vendor_email_settings_service.py index d8afabe6..a62119a3 100644 --- a/app/services/vendor_email_settings_service.py +++ b/app/services/vendor_email_settings_service.py @@ -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", diff --git a/docs/guides/email-settings.md b/docs/guides/email-settings.md new file mode 100644 index 00000000..d1f99ef9 --- /dev/null +++ b/docs/guides/email-settings.md @@ -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 diff --git a/docs/implementation/email-settings.md b/docs/implementation/email-settings.md new file mode 100644 index 00000000..9c871cd7 --- /dev/null +++ b/docs/implementation/email-settings.md @@ -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 = """ +
+

Powered by Wizamart

+
+""" + +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("", f"{POWERED_BY_FOOTER_HTML}") +``` + +## 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 + +{% macro email_settings_warning() %} +
+ Configure email settings to send emails to customers. +
+{% 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 diff --git a/mkdocs.yml b/mkdocs.yml index 090e1b4c..4b151532 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/tests/integration/api/v1/admin/test_email_settings.py b/tests/integration/api/v1/admin/test_email_settings.py new file mode 100644 index 00000000..ab79ac2d --- /dev/null +++ b/tests/integration/api/v1/admin/test_email_settings.py @@ -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 diff --git a/tests/integration/api/v1/vendor/test_email_settings.py b/tests/integration/api/v1/vendor/test_email_settings.py new file mode 100644 index 00000000..836334e4 --- /dev/null +++ b/tests/integration/api/v1/vendor/test_email_settings.py @@ -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 diff --git a/tests/unit/services/test_vendor_email_settings_service.py b/tests/unit/services/test_vendor_email_settings_service.py new file mode 100644 index 00000000..ed21ae6a --- /dev/null +++ b/tests/unit/services/test_vendor_email_settings_service.py @@ -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