From 36603178c3beddbc539b63307d4fd535ce91be1c Mon Sep 17 00:00:00 2001
From: Samir Boulahtit
Date: Mon, 5 Jan 2026 22:23:47 +0100
Subject: [PATCH] feat: add email settings with database overrides for admin
and vendor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration
Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+
Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers
Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
Makefile | 57 +-
TODO-VENDOR-SECTION | 123 +++
.../v0a1b2c3d4e5_add_vendor_email_settings.py | 102 +++
app/api/v1/admin/settings.py | 416 ++++++++++
app/api/v1/vendor/__init__.py | 2 +
app/api/v1/vendor/content_pages.py | 36 +-
app/api/v1/vendor/email_settings.py | 225 ++++++
app/services/email_service.py | 731 +++++++++++++++++-
app/services/vendor_email_settings_service.py | 444 +++++++++++
app/templates/admin/settings.html | 344 +++++++++
app/templates/shared/macros/feature_gate.html | 35 +
app/templates/shop/account/addresses.html | 1 +
app/templates/shop/account/dashboard.html | 80 +-
app/templates/shop/account/messages.html | 1 +
app/templates/shop/cart.html | 1 +
app/templates/shop/product.html | 1 +
app/templates/vendor/billing.html | 94 +--
app/templates/vendor/customers.html | 222 +++---
app/templates/vendor/dashboard.html | 5 +
app/templates/vendor/email-templates.html | 1 +
app/templates/vendor/inventory.html | 186 ++---
app/templates/vendor/invoices.html | 209 ++---
app/templates/vendor/letzshop.html | 248 +++---
app/templates/vendor/marketplace.html | 180 ++---
app/templates/vendor/messages.html | 118 ++-
app/templates/vendor/notifications.html | 87 +--
app/templates/vendor/onboarding.html | 2 +
app/templates/vendor/orders.html | 197 +++--
app/templates/vendor/settings.html | 394 ++++++++++
app/templates/vendor/team.html | 185 +++--
models/database/__init__.py | 4 +
models/database/vendor.py | 8 +
models/database/vendor_email_settings.py | 255 ++++++
scripts/install.py | 549 +++++++++++++
static/admin/js/settings.js | 229 +++++-
static/vendor/js/analytics.js | 3 +-
static/vendor/js/billing.js | 9 +-
static/vendor/js/customers.js | 9 +-
static/vendor/js/dashboard.js | 9 +-
static/vendor/js/init-alpine.js | 51 +-
static/vendor/js/inventory.js | 3 +-
static/vendor/js/invoices.js | 9 +-
static/vendor/js/letzshop.js | 3 +-
static/vendor/js/marketplace.js | 3 +-
static/vendor/js/messages.js | 9 +-
static/vendor/js/notifications.js | 3 +-
static/vendor/js/order-detail.js | 9 +-
static/vendor/js/orders.js | 9 +-
static/vendor/js/products.js | 6 +-
static/vendor/js/settings.js | 190 ++++-
static/vendor/js/team.js | 3 +-
51 files changed, 4959 insertions(+), 1141 deletions(-)
create mode 100644 TODO-VENDOR-SECTION
create mode 100644 alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
create mode 100644 app/api/v1/vendor/email_settings.py
create mode 100644 app/services/vendor_email_settings_service.py
create mode 100644 models/database/vendor_email_settings.py
create mode 100755 scripts/install.py
diff --git a/Makefile b/Makefile
index cdf38d87..6d43513e 100644
--- a/Makefile
+++ b/Makefile
@@ -97,21 +97,30 @@ migrate-status:
init-prod:
@echo "π§ Initializing production database..."
@echo ""
- @echo "Step 1/4: Creating admin user and platform alerts..."
+ @echo "Step 1/5: Creating admin user and platform settings..."
$(PYTHON) scripts/init_production.py
@echo ""
- @echo "Step 2/4: Initializing log settings..."
+ @echo "Step 2/5: Initializing log settings..."
$(PYTHON) scripts/init_log_settings.py
@echo ""
- @echo "Step 3/4: Creating default CMS content pages..."
+ @echo "Step 3/5: Creating default CMS content pages..."
$(PYTHON) scripts/create_default_content_pages.py
@echo ""
- @echo "Step 4/4: Creating platform pages and landing..."
+ @echo "Step 4/5: Creating platform pages and landing..."
$(PYTHON) scripts/create_platform_pages.py
@echo ""
+ @echo "Step 5/5: Seeding email templates..."
+ $(PYTHON) scripts/seed_email_templates.py
+ @echo ""
@echo "β
Production initialization completed"
@echo "β¨ Platform is ready for production OR development"
+# First-time installation - Complete setup with configuration validation
+install:
+ @echo "π WIZAMART PLATFORM INSTALLATION"
+ @echo "=================================="
+ $(PYTHON) scripts/install.py
+
# Demo data seeding - Cross-platform using Python to set environment
seed-demo:
@echo "πͺ Seeding demo data (normal mode)..."
@@ -423,7 +432,8 @@ help:
@echo " migrate-up - Apply pending migrations"
@echo " migrate-down - Rollback last migration"
@echo " migrate-status - Show migration status"
- @echo " init-prod - Initialize platform (admin, logging, CMS, pages)"
+ @echo " install - First-time setup (validates config + migrate + init)"
+ @echo " init-prod - Initialize platform (admin, CMS, pages, emails)"
@echo " seed-demo - Seed demo data (3 companies + vendors)"
@echo " seed-demo-minimal - Seed minimal demo (1 company + vendor)"
@echo " seed-demo-reset - DELETE ALL demo data and reseed"
@@ -483,13 +493,23 @@ help-db:
@echo " migrate-down - Rollback last migration"
@echo " migrate-status - Show current status and history"
@echo ""
+ @echo "FIRST-TIME INSTALLATION:"
+ @echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
+ @echo " install - Complete installation wizard:"
+ @echo " - Validates .env configuration"
+ @echo " - Checks Stripe, Email, Security settings"
+ @echo " - Runs database migrations"
+ @echo " - Initializes all platform data"
+ @echo " - Provides configuration report"
+ @echo ""
@echo "PLATFORM INITIALIZATION (Production + Development):"
@echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
- @echo " init-prod - Complete platform setup (4 steps):"
- @echo " 1. Create admin user + alerts"
+ @echo " init-prod - Complete platform setup (5 steps):"
+ @echo " 1. Create admin user + settings"
@echo " 2. Initialize log settings"
@echo " 3. Create CMS defaults"
@echo " 4. Create platform pages"
+ @echo " 5. Seed email templates"
@echo ""
@echo "DEMO DATA (Development Only - NEVER in production):"
@echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
@@ -510,17 +530,18 @@ help-db:
@echo ""
@echo "TYPICAL FIRST-TIME SETUP (Development):"
@echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
- @echo " 1. make migrate-up # Apply database schema"
- @echo " 2. make init-prod # Initialize platform (admin, CMS, logging, pages)"
- @echo " 3. make seed-demo # Add demo data (companies, vendors, products)"
- @echo " 4. make dev # Start development server"
- @echo ""
- @echo " OR simply: make db-setup # Does all the above!"
+ @echo " 1. cp .env.example .env # Configure environment"
+ @echo " 2. make install # Validates config + initializes platform"
+ @echo " 3. make seed-demo # Add demo data (optional)"
+ @echo " 4. make dev # Start development server"
@echo ""
@echo "PRODUCTION SETUP:"
@echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
- @echo " 1. Set ENV=production or ENVIRONMENT=production"
- @echo " 2. make migrate-up # Apply database schema"
- @echo " 3. make init-prod # Initialize platform (with .env credentials)"
- @echo " 4. Create companies via admin panel"
- @echo " 5. DO NOT run seed-demo in production!"
\ No newline at end of file
+ @echo " 1. Configure .env with production values:"
+ @echo " - DATABASE_URL (PostgreSQL recommended)"
+ @echo " - JWT_SECRET_KEY (use: openssl rand -hex 32)"
+ @echo " - STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY"
+ @echo " - Email provider settings (SMTP/SendGrid/Mailgun/SES)"
+ @echo " - ADMIN_PASSWORD (strong password)"
+ @echo " 2. make install # Validates + initializes"
+ @echo " 3. DO NOT run seed-demo in production!"
\ No newline at end of file
diff --git a/TODO-VENDOR-SECTION b/TODO-VENDOR-SECTION
new file mode 100644
index 00000000..686040d1
--- /dev/null
+++ b/TODO-VENDOR-SECTION
@@ -0,0 +1,123 @@
+> can you please do a 360 analysis for settings, what else is missing and should be editable / viewable by vendors to properly manage their operations and storefront
+
+β Explore(Analyze vendor settings comprehensively)
+ βΏ Β Done (38 tool uses Β· 66.4k tokens Β· 1m 18s)
+
+β 360Β° Vendor Settings Analysis
+
+ Current Settings Page Structure
+
+ | Section | Status |
+ |---------------|------------------------------|
+ | General | β
Basic (subdomain, status) |
+ | Localization | β
Complete |
+ | Marketplace | β οΈ Partial (only CSV URLs) |
+ | Notifications | β Display-only placeholder |
+
+ ---
+ What's Missing (by Priority)
+
+ Priority 1: Existing Data Not Exposed
+
+ | Category | Fields in DB | Current UI |
+ |------------------------|-----------------------------------------------------------------------|------------|
+ | Business Profile | name, description, contact_email, phone, website, address, tax_number | β None |
+ | Letzshop Feed Settings | default_tax_rate, boost_sort, delivery_method, preorder_days | β None |
+ | Letzshop Sync Settings | auto_sync, sync_interval, test_mode, carriers | β None |
+ | Invoice Settings | company legal info, VAT, bank details, invoice numbering | β None |
+ | Theme/Branding | colors, fonts, logos, layout, custom CSS, social links | β None |
+ | Custom Domains | domain, SSL status, verification | β None |
+
+ Priority 2: Needs Database Models
+
+ | Category | Description |
+ |----------------------|---------------------------------------------|
+ | Shipping/Fulfillment | Zones, rates, carriers, return policies |
+ | Order Preferences | Auto-confirm, auto-ship, order numbering |
+ | SEO/Marketing | Google Analytics, Meta Pixel, schema markup |
+ | Legal/Compliance | Terms, Privacy, GDPR, cookie consent |
+ | Security | 2FA, API key rotation, IP whitelist |
+
+ ---
+ Proposed Settings Page Restructure
+
+ Settings
+ βββ General
+ β βββ Store Name & Description
+ β βββ Subdomain
+ β βββ Store Status (view only)
+ β
+ βββ Business Info
+ β βββ Contact Email, Phone
+ β βββ Website
+ β βββ Business Address
+ β βββ Tax Number
+ β
+ βββ Localization β
(done)
+ β βββ Currency (view only)
+ β βββ Number Format (locale)
+ β βββ Dashboard Language
+ β βββ Content Language
+ β βββ Storefront Languages
+ β
+ βββ Invoice & Billing
+ β βββ Company Legal Name & Address
+ β βββ VAT Registration
+ β βββ Bank Details (IBAN, BIC)
+ β βββ Invoice Numbering
+ β βββ Payment Terms
+ β
+ βββ Branding & Theme
+ β βββ Logo Upload (light/dark)
+ β βββ Favicon & Banner
+ β βββ Color Scheme
+ β βββ Font Selection
+ β βββ Layout Style
+ β βββ Social Links
+ β
+ βββ Letzshop Integration
+ β βββ CSV Feed URLs β
+ β βββ Feed Settings (tax, delivery, preorder)
+ β βββ API Credentials
+ β βββ Sync Settings (auto, interval, test mode)
+ β βββ Carrier Configuration
+ β
+ βββ Domains
+ β βββ Custom Domain Setup
+ β βββ SSL Status
+ β βββ Verification
+ β
+ βββ Notifications
+ β βββ Email Notifications
+ β βββ Order Alerts
+ β βββ Marketing Emails
+ β
+ βββ Security (future)
+ βββ API Keys
+ βββ Two-Factor Auth
+ βββ Session Management
+
+ ---
+ Quick Wins (Can Add Now)
+
+ 1. Business Info section - Fields exist in Vendor model, just need UI
+ 2. Invoice Settings section - VendorInvoiceSettings model exists
+ 3. Letzshop Feed Settings - Fields exist: letzshop_default_tax_rate, letzshop_delivery_method, etc.
+ 4. Theme/Branding - VendorTheme model exists with colors, fonts, logos
+
+ ---
+ Would you like me to implement any of these sections? I'd suggest starting with:
+ 1. Business Info (quick, uses existing fields)
+ 2. Invoice Settings (model exists, high business value)
+ 3. Letzshop Feed Settings (completes marketplace section)
+
+
+
+ -------->
+ > let's tackle all of that this seems great. a few comments from my side:
+1/ vendor business details should be the one at company level unless overriden (please have a look at current architecture)
+2/ email templates is a complex feature because it has to be similar to what we have done at CMS level: admin will have some platform default ones (in multiple languages) and the vendor can override them (but not create any new ones cause it
+won't be supported unlike CMS pages where he can create pretty much anything - btw let s make a note that number of pages should be defined in tiers)
+3/ custom domain setup: admin should be contacted to setup. same for SSL. custom emails. (this should be readonly for now)
+4/ API keys: stripe keys should be there
+5/ sections in settings page are not displayed properly: general , localization etc take 2/3 of the screen size
\ No newline at end of file
diff --git a/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py b/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
new file mode 100644
index 00000000..ac5307a1
--- /dev/null
+++ b/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
@@ -0,0 +1,102 @@
+# alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py
+"""Add vendor email settings table.
+
+Revision ID: v0a1b2c3d4e5
+Revises: u9c0d1e2f3g4
+Create Date: 2026-01-05
+
+Changes:
+- Create vendor_email_settings table for vendor SMTP/email provider configuration
+- Vendors must configure this to send transactional emails
+- Premium providers (SendGrid, Mailgun, SES) are tier-gated (Business+)
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "v0a1b2c3d4e5"
+down_revision = "u9c0d1e2f3g4"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create vendor_email_settings table
+ op.create_table(
+ "vendor_email_settings",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("vendor_id", sa.Integer(), nullable=False),
+ # Sender Identity
+ sa.Column("from_email", sa.String(255), nullable=False),
+ sa.Column("from_name", sa.String(100), nullable=False),
+ sa.Column("reply_to_email", sa.String(255), nullable=True),
+ # Signature/Footer
+ sa.Column("signature_text", sa.Text(), nullable=True),
+ sa.Column("signature_html", sa.Text(), nullable=True),
+ # Provider Configuration
+ sa.Column("provider", sa.String(20), nullable=False, default="smtp"),
+ # SMTP Settings
+ sa.Column("smtp_host", sa.String(255), nullable=True),
+ sa.Column("smtp_port", sa.Integer(), nullable=True, default=587),
+ sa.Column("smtp_username", sa.String(255), nullable=True),
+ sa.Column("smtp_password", sa.String(500), nullable=True),
+ sa.Column("smtp_use_tls", sa.Boolean(), nullable=False, default=True),
+ sa.Column("smtp_use_ssl", sa.Boolean(), nullable=False, default=False),
+ # SendGrid Settings
+ sa.Column("sendgrid_api_key", sa.String(500), nullable=True),
+ # Mailgun Settings
+ sa.Column("mailgun_api_key", sa.String(500), nullable=True),
+ sa.Column("mailgun_domain", sa.String(255), nullable=True),
+ # Amazon SES Settings
+ sa.Column("ses_access_key_id", sa.String(100), nullable=True),
+ sa.Column("ses_secret_access_key", sa.String(500), nullable=True),
+ sa.Column("ses_region", sa.String(50), nullable=True, default="eu-west-1"),
+ # Status & Verification
+ sa.Column("is_configured", sa.Boolean(), nullable=False, default=False),
+ sa.Column("is_verified", sa.Boolean(), nullable=False, default=False),
+ sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("verification_error", sa.Text(), nullable=True),
+ # Timestamps
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
+ # Constraints
+ sa.PrimaryKeyConstraint("id"),
+ sa.ForeignKeyConstraint(
+ ["vendor_id"],
+ ["vendors.id"],
+ name="fk_vendor_email_settings_vendor_id",
+ ondelete="CASCADE",
+ ),
+ sa.UniqueConstraint("vendor_id", name="uq_vendor_email_settings_vendor_id"),
+ )
+
+ # Create indexes
+ op.create_index(
+ "ix_vendor_email_settings_id",
+ "vendor_email_settings",
+ ["id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_vendor_email_settings_vendor_id",
+ "vendor_email_settings",
+ ["vendor_id"],
+ unique=True,
+ )
+ op.create_index(
+ "idx_vendor_email_settings_configured",
+ "vendor_email_settings",
+ ["vendor_id", "is_configured"],
+ )
+
+
+def downgrade() -> None:
+ # Drop indexes
+ op.drop_index("idx_vendor_email_settings_configured", table_name="vendor_email_settings")
+ op.drop_index("ix_vendor_email_settings_vendor_id", table_name="vendor_email_settings")
+ op.drop_index("ix_vendor_email_settings_id", table_name="vendor_email_settings")
+
+ # Drop table
+ op.drop_table("vendor_email_settings")
diff --git a/app/api/v1/admin/settings.py b/app/api/v1/admin/settings.py
index d79594fc..2cdb84c4 100644
--- a/app/api/v1/admin/settings.py
+++ b/app/api/v1/admin/settings.py
@@ -6,14 +6,17 @@ Provides endpoints for:
- Viewing all platform settings
- Creating/updating settings
- Managing configuration by category
+- Email configuration status and testing
"""
import logging
from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
+from app.core.config import settings as app_settings
from app.core.database import get_db
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service
@@ -286,3 +289,416 @@ def delete_setting(
db.commit()
return {"message": message}
+
+
+# ============================================================================
+# EMAIL CONFIGURATION ENDPOINTS
+# ============================================================================
+
+# Email setting keys stored in admin_settings table
+EMAIL_SETTING_KEYS = {
+ "email_provider": "smtp",
+ "email_from_address": "",
+ "email_from_name": "",
+ "email_reply_to": "",
+ "smtp_host": "",
+ "smtp_port": "587",
+ "smtp_user": "",
+ "smtp_password": "",
+ "smtp_use_tls": "true",
+ "smtp_use_ssl": "false",
+ "sendgrid_api_key": "",
+ "mailgun_api_key": "",
+ "mailgun_domain": "",
+ "aws_access_key_id": "",
+ "aws_secret_access_key": "",
+ "aws_region": "eu-west-1",
+ "email_enabled": "true",
+ "email_debug": "false",
+}
+
+
+def get_email_setting(db: Session, key: str) -> str | None:
+ """Get email setting from database, returns None if not set."""
+ setting = admin_settings_service.get_setting_by_key(db, key)
+ return setting.value if setting else None
+
+
+def get_effective_email_config(db: Session) -> dict:
+ """
+ Get effective email configuration.
+
+ Priority: Database settings > Environment variables
+ """
+ config = {}
+
+ # Provider
+ db_provider = get_email_setting(db, "email_provider")
+ config["provider"] = db_provider if db_provider else app_settings.email_provider
+
+ # From settings
+ db_from_email = get_email_setting(db, "email_from_address")
+ config["from_email"] = db_from_email if db_from_email else app_settings.email_from_address
+
+ db_from_name = get_email_setting(db, "email_from_name")
+ config["from_name"] = db_from_name if db_from_name else app_settings.email_from_name
+
+ db_reply_to = get_email_setting(db, "email_reply_to")
+ config["reply_to"] = db_reply_to if db_reply_to else app_settings.email_reply_to
+
+ # SMTP settings
+ db_smtp_host = get_email_setting(db, "smtp_host")
+ config["smtp_host"] = db_smtp_host if db_smtp_host else app_settings.smtp_host
+
+ db_smtp_port = get_email_setting(db, "smtp_port")
+ config["smtp_port"] = int(db_smtp_port) if db_smtp_port else app_settings.smtp_port
+
+ db_smtp_user = get_email_setting(db, "smtp_user")
+ config["smtp_user"] = db_smtp_user if db_smtp_user else app_settings.smtp_user
+
+ db_smtp_password = get_email_setting(db, "smtp_password")
+ config["smtp_password"] = db_smtp_password if db_smtp_password else app_settings.smtp_password
+
+ db_smtp_use_tls = get_email_setting(db, "smtp_use_tls")
+ config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else app_settings.smtp_use_tls
+
+ db_smtp_use_ssl = get_email_setting(db, "smtp_use_ssl")
+ config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else app_settings.smtp_use_ssl
+
+ # SendGrid
+ db_sendgrid_key = get_email_setting(db, "sendgrid_api_key")
+ config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else app_settings.sendgrid_api_key
+
+ # Mailgun
+ db_mailgun_key = get_email_setting(db, "mailgun_api_key")
+ config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else app_settings.mailgun_api_key
+
+ db_mailgun_domain = get_email_setting(db, "mailgun_domain")
+ config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else app_settings.mailgun_domain
+
+ # AWS SES
+ db_aws_key = get_email_setting(db, "aws_access_key_id")
+ config["aws_access_key_id"] = db_aws_key if db_aws_key else app_settings.aws_access_key_id
+
+ db_aws_secret = get_email_setting(db, "aws_secret_access_key")
+ config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else app_settings.aws_secret_access_key
+
+ db_aws_region = get_email_setting(db, "aws_region")
+ config["aws_region"] = db_aws_region if db_aws_region else app_settings.aws_region
+
+ # Behavior
+ db_enabled = get_email_setting(db, "email_enabled")
+ config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else app_settings.email_enabled
+
+ db_debug = get_email_setting(db, "email_debug")
+ config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else app_settings.email_debug
+
+ # Track source for each field (DB override or .env)
+ config["_sources"] = {}
+ for key in ["provider", "from_email", "from_name", "smtp_host", "smtp_port"]:
+ db_key = "email_provider" if key == "provider" else ("email_from_address" if key == "from_email" else ("email_from_name" if key == "from_name" else key))
+ config["_sources"][key] = "database" if get_email_setting(db, db_key) else "env"
+
+ return config
+
+
+class EmailStatusResponse(BaseModel):
+ """Platform email configuration status."""
+
+ provider: str
+ from_email: str
+ from_name: str
+ reply_to: str | None = None
+ smtp_host: str | None = None
+ smtp_port: int | None = None
+ smtp_user: str | None = None
+ mailgun_domain: str | None = None
+ aws_region: str | None = None
+ debug: bool
+ enabled: bool
+ is_configured: bool
+ has_db_overrides: bool = False
+
+
+class EmailSettingsUpdate(BaseModel):
+ """Update email settings."""
+
+ provider: str | None = None
+ from_email: EmailStr | None = None
+ from_name: str | None = None
+ reply_to: EmailStr | None = None
+ # SMTP
+ smtp_host: str | None = None
+ smtp_port: int | None = None
+ smtp_user: str | None = None
+ smtp_password: str | None = None
+ smtp_use_tls: bool | None = None
+ smtp_use_ssl: bool | None = None
+ # SendGrid
+ sendgrid_api_key: str | None = None
+ # Mailgun
+ mailgun_api_key: str | None = None
+ mailgun_domain: str | None = None
+ # AWS SES
+ aws_access_key_id: str | None = None
+ aws_secret_access_key: str | None = None
+ aws_region: str | None = None
+ # Behavior
+ enabled: bool | None = None
+ debug: bool | None = None
+
+
+class TestEmailRequest(BaseModel):
+ """Request body for test email."""
+
+ to_email: EmailStr
+
+
+class TestEmailResponse(BaseModel):
+ """Response for test email."""
+
+ success: bool
+ message: str
+
+
+@router.get("/email/status", response_model=EmailStatusResponse)
+def get_email_status(
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+) -> EmailStatusResponse:
+ """
+ Get platform email configuration status.
+
+ Returns the effective email configuration (DB overrides > .env).
+ Sensitive values (passwords, API keys) are NOT exposed.
+ """
+ config = get_effective_email_config(db)
+ provider = config["provider"].lower()
+
+ # Determine if email is configured based on provider
+ is_configured = False
+ if provider == "smtp":
+ is_configured = bool(config["smtp_host"] and config["smtp_host"] != "localhost")
+ elif provider == "sendgrid":
+ is_configured = bool(config["sendgrid_api_key"])
+ elif provider == "mailgun":
+ is_configured = bool(config["mailgun_api_key"] and config["mailgun_domain"])
+ elif provider == "ses":
+ is_configured = bool(config["aws_access_key_id"] and config["aws_secret_access_key"])
+
+ # Check if any DB overrides exist
+ has_db_overrides = any(v == "database" for v in config["_sources"].values())
+
+ return EmailStatusResponse(
+ provider=provider,
+ from_email=config["from_email"],
+ from_name=config["from_name"],
+ reply_to=config["reply_to"] or None,
+ smtp_host=config["smtp_host"] if provider == "smtp" else None,
+ smtp_port=config["smtp_port"] if provider == "smtp" else None,
+ smtp_user=config["smtp_user"] if provider == "smtp" else None,
+ mailgun_domain=config["mailgun_domain"] if provider == "mailgun" else None,
+ aws_region=config["aws_region"] if provider == "ses" else None,
+ debug=config["debug"],
+ enabled=config["enabled"],
+ is_configured=is_configured,
+ has_db_overrides=has_db_overrides,
+ )
+
+
+@router.put("/email/settings")
+def update_email_settings(
+ settings_update: EmailSettingsUpdate,
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ """
+ Update platform email settings.
+
+ Settings are stored in the database and override .env values.
+ Only non-null values are updated.
+ """
+ from models.schema.admin import AdminSettingCreate
+
+ updated_keys = []
+
+ # Map request fields to database keys
+ field_mappings = {
+ "provider": ("email_provider", "string"),
+ "from_email": ("email_from_address", "string"),
+ "from_name": ("email_from_name", "string"),
+ "reply_to": ("email_reply_to", "string"),
+ "smtp_host": ("smtp_host", "string"),
+ "smtp_port": ("smtp_port", "integer"),
+ "smtp_user": ("smtp_user", "string"),
+ "smtp_password": ("smtp_password", "string"),
+ "smtp_use_tls": ("smtp_use_tls", "boolean"),
+ "smtp_use_ssl": ("smtp_use_ssl", "boolean"),
+ "sendgrid_api_key": ("sendgrid_api_key", "string"),
+ "mailgun_api_key": ("mailgun_api_key", "string"),
+ "mailgun_domain": ("mailgun_domain", "string"),
+ "aws_access_key_id": ("aws_access_key_id", "string"),
+ "aws_secret_access_key": ("aws_secret_access_key", "string"),
+ "aws_region": ("aws_region", "string"),
+ "enabled": ("email_enabled", "boolean"),
+ "debug": ("email_debug", "boolean"),
+ }
+
+ # Sensitive fields that should be marked as encrypted
+ sensitive_keys = {
+ "smtp_password", "sendgrid_api_key", "mailgun_api_key",
+ "aws_access_key_id", "aws_secret_access_key"
+ }
+
+ for field, (db_key, value_type) in field_mappings.items():
+ value = getattr(settings_update, field, None)
+ if value is not None:
+ # Convert value to string for storage
+ if value_type == "boolean":
+ str_value = "true" if value else "false"
+ elif value_type == "integer":
+ str_value = str(value)
+ else:
+ str_value = str(value)
+
+ # Create or update setting
+ setting_data = AdminSettingCreate(
+ key=db_key,
+ value=str_value,
+ value_type=value_type,
+ category="email",
+ description=f"Email setting: {field}",
+ is_encrypted=db_key in sensitive_keys,
+ is_public=False,
+ )
+
+ admin_settings_service.upsert_setting(db, setting_data, current_admin.id)
+ updated_keys.append(field)
+
+ # Log action
+ admin_audit_service.log_action(
+ db=db,
+ admin_user_id=current_admin.id,
+ action="update_email_settings",
+ target_type="email_settings",
+ target_id="platform",
+ details={"updated_keys": updated_keys},
+ )
+ db.commit()
+
+ logger.info(f"Email settings updated by admin {current_admin.id}: {updated_keys}")
+
+ return {
+ "success": True,
+ "message": f"Updated {len(updated_keys)} email setting(s)",
+ "updated_keys": updated_keys,
+ }
+
+
+@router.delete("/email/settings")
+def reset_email_settings(
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ """
+ Reset email settings to use .env values.
+
+ Deletes all email settings from the database, reverting to .env configuration.
+ """
+ deleted_count = 0
+
+ for key in EMAIL_SETTING_KEYS:
+ setting = admin_settings_service.get_setting_by_key(db, key)
+ if setting:
+ db.delete(setting)
+ deleted_count += 1
+
+ # Log action
+ admin_audit_service.log_action(
+ db=db,
+ admin_user_id=current_admin.id,
+ action="reset_email_settings",
+ target_type="email_settings",
+ target_id="platform",
+ details={"deleted_count": deleted_count},
+ )
+ db.commit()
+
+ logger.info(f"Email settings reset by admin {current_admin.id}, deleted {deleted_count} settings")
+
+ return {
+ "success": True,
+ "message": f"Reset {deleted_count} email setting(s) to .env defaults",
+ }
+
+
+@router.post("/email/test", response_model=TestEmailResponse)
+def send_test_email(
+ request: TestEmailRequest,
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+) -> TestEmailResponse:
+ """
+ Send a test email using the platform email configuration.
+
+ This tests the email provider configuration from environment variables.
+ """
+ from app.services.email_service import EmailService
+
+ try:
+ email_service = EmailService(db)
+
+ # Send test email using platform configuration
+ success = email_service.send_raw(
+ to_email=request.to_email,
+ to_name=None,
+ subject="Wizamart Platform - Test Email",
+ body_html="""
+
+
+ Test Email from Wizamart
+ This is a test email to verify your platform email configuration.
+ If you received this email, your email settings are working correctly!
+
+
+ Provider: {provider}
+ From: {from_email}
+
+
+
+ """.format(
+ provider=app_settings.email_provider,
+ from_email=app_settings.email_from_address,
+ ),
+ body_text=f"Test email from Wizamart platform.\n\nProvider: {app_settings.email_provider}\nFrom: {app_settings.email_from_address}",
+ is_platform_email=True,
+ )
+
+ if success:
+ # Log action
+ admin_audit_service.log_action(
+ db=db,
+ admin_user_id=current_admin.id,
+ action="send_test_email",
+ target_type="email",
+ target_id=request.to_email,
+ details={"provider": app_settings.email_provider},
+ )
+ db.commit()
+
+ return TestEmailResponse(
+ success=True,
+ message=f"Test email sent to {request.to_email}",
+ )
+ else:
+ return TestEmailResponse(
+ success=False,
+ message="Failed to send test email. Check server logs for details.",
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send test email: {e}")
+ return TestEmailResponse(
+ success=False,
+ message=f"Error sending test email: {str(e)}",
+ )
diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py
index 53ad206c..b111c372 100644
--- a/app/api/v1/vendor/__init__.py
+++ b/app/api/v1/vendor/__init__.py
@@ -20,6 +20,7 @@ from . import (
content_pages,
customers,
dashboard,
+ email_settings,
email_templates,
features,
info,
@@ -61,6 +62,7 @@ router.include_router(dashboard.router, tags=["vendor-dashboard"])
router.include_router(profile.router, tags=["vendor-profile"])
router.include_router(settings.router, tags=["vendor-settings"])
router.include_router(email_templates.router, tags=["vendor-email-templates"])
+router.include_router(email_settings.router, tags=["vendor-email-settings"])
router.include_router(onboarding.router, tags=["vendor-onboarding"])
# Business operations (with prefixes: /products/*, /orders/*, etc.)
diff --git a/app/api/v1/vendor/content_pages.py b/app/api/v1/vendor/content_pages.py
index 52596aa7..07fe5c43 100644
--- a/app/api/v1/vendor/content_pages.py
+++ b/app/api/v1/vendor/content_pages.py
@@ -2,6 +2,9 @@
"""
Vendor Content Pages API
+Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
+The get_current_vendor_api dependency guarantees token_vendor_id is present.
+
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
@@ -15,11 +18,10 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
-from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service
from models.database.user import User
-router = APIRouter()
+router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__)
@@ -111,11 +113,8 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
pages = content_page_service.list_pages_for_vendor(
- db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
+ db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -132,11 +131,8 @@ def list_vendor_overrides(
Shows what the vendor has customized.
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
pages = content_page_service.list_all_vendor_pages(
- db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
+ db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -154,13 +150,10 @@ def get_page(
Returns vendor override if exists, otherwise platform default.
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
- vendor_id=current_user.vendor_id,
+ vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
@@ -178,15 +171,12 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor.
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
- vendor_id=current_user.vendor_id,
+ vendor_id=current_user.token_vendor_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
@@ -214,14 +204,11 @@ def update_vendor_page(
Can only update pages owned by this vendor.
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
- vendor_id=current_user.vendor_id,
+ vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
@@ -251,9 +238,6 @@ def delete_vendor_page(
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
- if not current_user.vendor_id:
- raise VendorNotAssociatedException()
-
# Delete with ownership check in service layer
- content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)
+ content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()
diff --git a/app/api/v1/vendor/email_settings.py b/app/api/v1/vendor/email_settings.py
new file mode 100644
index 00000000..12c10e7e
--- /dev/null
+++ b/app/api/v1/vendor/email_settings.py
@@ -0,0 +1,225 @@
+# app/api/v1/vendor/email_settings.py
+"""
+Vendor email settings API endpoints.
+
+Allows vendors to configure their email sending settings:
+- SMTP configuration (all tiers)
+- Advanced providers: SendGrid, Mailgun, SES (Business+ tier)
+- Sender identity (from_email, from_name, reply_to)
+- Signature/footer customization
+- Configuration verification via test email
+
+Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+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
+
+router = APIRouter(prefix="/email-settings")
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# SCHEMAS
+# =============================================================================
+
+
+class EmailSettingsUpdate(BaseModel):
+ """Schema for creating/updating email settings."""
+
+ # Sender Identity (Required)
+ from_email: EmailStr = Field(..., description="Sender email address")
+ from_name: str = Field(..., min_length=1, max_length=100, description="Sender name")
+ reply_to_email: EmailStr | None = Field(None, description="Reply-to email address")
+
+ # Signature (Optional)
+ signature_text: str | None = Field(None, description="Plain text signature")
+ signature_html: str | None = Field(None, description="HTML signature/footer")
+
+ # Provider
+ provider: str = Field("smtp", description="Email provider: smtp, sendgrid, mailgun, ses")
+
+ # SMTP Settings
+ smtp_host: str | None = Field(None, description="SMTP server hostname")
+ smtp_port: int | None = Field(587, ge=1, le=65535, description="SMTP server port")
+ smtp_username: str | None = Field(None, description="SMTP username")
+ smtp_password: str | None = Field(None, description="SMTP password")
+ smtp_use_tls: bool = Field(True, description="Use STARTTLS")
+ smtp_use_ssl: bool = Field(False, description="Use SSL/TLS (port 465)")
+
+ # SendGrid
+ sendgrid_api_key: str | None = Field(None, description="SendGrid API key")
+
+ # Mailgun
+ mailgun_api_key: str | None = Field(None, description="Mailgun API key")
+ mailgun_domain: str | None = Field(None, description="Mailgun sending domain")
+
+ # SES
+ ses_access_key_id: str | None = Field(None, description="AWS access key ID")
+ ses_secret_access_key: str | None = Field(None, description="AWS secret access key")
+ ses_region: str | None = Field("eu-west-1", description="AWS region")
+
+
+class VerifyEmailRequest(BaseModel):
+ """Schema for verifying email settings."""
+
+ test_email: EmailStr = Field(..., description="Email address to send test email to")
+
+
+# =============================================================================
+# ENDPOINTS
+# =============================================================================
+
+
+@router.get("")
+def get_email_settings(
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Get current email settings for the vendor.
+
+ Returns settings with sensitive fields masked.
+ """
+ vendor_id = current_user.token_vendor_id
+ service = VendorEmailSettingsService(db)
+
+ 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 {
+ "configured": settings.is_configured,
+ "verified": settings.is_verified,
+ "settings": settings.to_dict(),
+ }
+
+
+@router.get("/status")
+def get_email_status(
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Get email configuration status.
+
+ Used by frontend to show warning banner if not configured.
+ """
+ vendor_id = current_user.token_vendor_id
+ service = VendorEmailSettingsService(db)
+ return service.get_status(vendor_id)
+
+
+@router.get("/providers")
+def get_available_providers(
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Get available email providers for current tier.
+
+ Returns list of providers with availability status.
+ """
+ vendor_id = current_user.token_vendor_id
+ service = VendorEmailSettingsService(db)
+
+ # 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,
+ }
+
+
+@router.put("")
+def update_email_settings(
+ data: EmailSettingsUpdate,
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Create or update email settings.
+
+ Premium providers (SendGrid, Mailgun, SES) require Business+ tier.
+ """
+ vendor_id = current_user.token_vendor_id
+ service = VendorEmailSettingsService(db)
+
+ # 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))
+
+
+@router.post("/verify")
+def verify_email_settings(
+ data: VerifyEmailRequest,
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Verify email settings by sending a test email.
+
+ Sends a test email to the provided address and updates verification status.
+ """
+ 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))
+
+
+@router.delete("")
+def delete_email_settings(
+ current_user: User = Depends(get_current_vendor_api),
+ db: Session = Depends(get_db),
+):
+ """
+ Delete email settings.
+
+ Warning: This will disable email sending for the vendor.
+ """
+ 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")
diff --git a/app/services/email_service.py b/app/services/email_service.py
index 0c01f585..3a0f514e 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -52,6 +52,20 @@ PLATFORM_SUPPORT_EMAIL = "support@wizamart.com"
PLATFORM_DEFAULT_LANGUAGE = "en"
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
+# Tiers that get white-label (no "Powered by Wizamart" footer)
+WHITELABEL_TIERS = {"business", "enterprise"}
+
+# Powered by Wizamart footer (added for Essential/Professional tiers)
+POWERED_BY_FOOTER_HTML = """
+
+"""
+
+POWERED_BY_FOOTER_TEXT = "\n\n---\nPowered by Wizamart - https://wizamart.com"
+
@dataclass
class ResolvedTemplate:
@@ -340,7 +354,582 @@ class DebugProvider(EmailProvider):
# =============================================================================
-# EMAIL SERVICE
+# PLATFORM CONFIG HELPERS (DB overrides .env)
+# =============================================================================
+
+
+def get_platform_email_config(db: Session) -> dict:
+ """
+ Get effective platform email configuration.
+
+ Priority: Database settings > Environment variables (.env)
+
+ Returns:
+ Dictionary with all email configuration values
+ """
+ from models.database.admin import AdminSetting
+
+ def get_db_setting(key: str) -> str | None:
+ setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
+ return setting.value if setting else None
+
+ config = {}
+
+ # Provider
+ db_provider = get_db_setting("email_provider")
+ config["provider"] = db_provider if db_provider else settings.email_provider
+
+ # From settings
+ db_from_email = get_db_setting("email_from_address")
+ config["from_email"] = db_from_email if db_from_email else settings.email_from_address
+
+ db_from_name = get_db_setting("email_from_name")
+ config["from_name"] = db_from_name if db_from_name else settings.email_from_name
+
+ db_reply_to = get_db_setting("email_reply_to")
+ config["reply_to"] = db_reply_to if db_reply_to else settings.email_reply_to
+
+ # SMTP settings
+ db_smtp_host = get_db_setting("smtp_host")
+ config["smtp_host"] = db_smtp_host if db_smtp_host else settings.smtp_host
+
+ db_smtp_port = get_db_setting("smtp_port")
+ config["smtp_port"] = int(db_smtp_port) if db_smtp_port else settings.smtp_port
+
+ db_smtp_user = get_db_setting("smtp_user")
+ config["smtp_user"] = db_smtp_user if db_smtp_user else settings.smtp_user
+
+ db_smtp_password = get_db_setting("smtp_password")
+ config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
+
+ db_smtp_use_tls = get_db_setting("smtp_use_tls")
+ config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
+
+ db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
+ config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
+
+ # SendGrid
+ db_sendgrid_key = get_db_setting("sendgrid_api_key")
+ config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else settings.sendgrid_api_key
+
+ # Mailgun
+ db_mailgun_key = get_db_setting("mailgun_api_key")
+ config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else settings.mailgun_api_key
+
+ db_mailgun_domain = get_db_setting("mailgun_domain")
+ config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else settings.mailgun_domain
+
+ # AWS SES
+ db_aws_key = get_db_setting("aws_access_key_id")
+ config["aws_access_key_id"] = db_aws_key if db_aws_key else settings.aws_access_key_id
+
+ db_aws_secret = get_db_setting("aws_secret_access_key")
+ config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else settings.aws_secret_access_key
+
+ db_aws_region = get_db_setting("aws_region")
+ config["aws_region"] = db_aws_region if db_aws_region else settings.aws_region
+
+ # Behavior
+ db_enabled = get_db_setting("email_enabled")
+ config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
+
+ db_debug = get_db_setting("email_debug")
+ config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
+
+ return config
+
+
+# =============================================================================
+# CONFIGURABLE PLATFORM PROVIDERS (use config dict instead of global settings)
+# =============================================================================
+
+
+class ConfigurableSMTPProvider(EmailProvider):
+ """SMTP provider using config dictionary."""
+
+ def __init__(self, config: dict):
+ self.config = config
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
+ msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
+
+ if reply_to:
+ msg["Reply-To"] = reply_to
+
+ if body_text:
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
+
+ if self.config.get("smtp_use_ssl"):
+ server = smtplib.SMTP_SSL(self.config["smtp_host"], self.config["smtp_port"])
+ else:
+ server = smtplib.SMTP(self.config["smtp_host"], self.config["smtp_port"])
+
+ try:
+ if self.config.get("smtp_use_tls") and not self.config.get("smtp_use_ssl"):
+ server.starttls()
+
+ if self.config.get("smtp_user") and self.config.get("smtp_password"):
+ server.login(self.config["smtp_user"], self.config["smtp_password"])
+
+ server.sendmail(from_email, [to_email], msg.as_string())
+ return True, None, None
+
+ finally:
+ server.quit()
+
+ except Exception as e:
+ logger.error(f"Configurable SMTP send error: {e}")
+ return False, None, str(e)
+
+
+class ConfigurableSendGridProvider(EmailProvider):
+ """SendGrid provider using config dictionary."""
+
+ def __init__(self, config: dict):
+ self.config = config
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ from sendgrid import SendGridAPIClient
+ from sendgrid.helpers.mail import Mail, Email, To, Content
+
+ message = Mail(
+ from_email=Email(from_email, from_name),
+ to_emails=To(to_email, to_name),
+ subject=subject,
+ )
+
+ message.add_content(Content("text/html", body_html))
+ if body_text:
+ message.add_content(Content("text/plain", body_text))
+
+ if reply_to:
+ message.reply_to = Email(reply_to)
+
+ sg = SendGridAPIClient(self.config["sendgrid_api_key"])
+ response = sg.send(message)
+
+ if response.status_code in (200, 201, 202):
+ message_id = response.headers.get("X-Message-Id")
+ return True, message_id, None
+ else:
+ return False, None, f"SendGrid error: {response.status_code}"
+
+ except ImportError:
+ return False, None, "SendGrid library not installed"
+ except Exception as e:
+ logger.error(f"Configurable SendGrid send error: {e}")
+ return False, None, str(e)
+
+
+class ConfigurableMailgunProvider(EmailProvider):
+ """Mailgun provider using config dictionary."""
+
+ def __init__(self, config: dict):
+ self.config = config
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ import requests
+
+ from_str = f"{from_name} <{from_email}>" if from_name else from_email
+ to_str = f"{to_name} <{to_email}>" if to_name else to_email
+
+ data = {
+ "from": from_str,
+ "to": to_str,
+ "subject": subject,
+ "html": body_html,
+ }
+
+ if body_text:
+ data["text"] = body_text
+ if reply_to:
+ data["h:Reply-To"] = reply_to
+
+ response = requests.post(
+ f"https://api.mailgun.net/v3/{self.config['mailgun_domain']}/messages",
+ auth=("api", self.config["mailgun_api_key"]),
+ data=data,
+ timeout=30,
+ )
+
+ if response.status_code == 200:
+ result = response.json()
+ return True, result.get("id"), None
+ else:
+ return False, None, f"Mailgun error: {response.status_code} - {response.text}"
+
+ except Exception as e:
+ logger.error(f"Configurable Mailgun send error: {e}")
+ return False, None, str(e)
+
+
+class ConfigurableSESProvider(EmailProvider):
+ """Amazon SES provider using config dictionary."""
+
+ def __init__(self, config: dict):
+ self.config = config
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ import boto3
+
+ ses = boto3.client(
+ "ses",
+ region_name=self.config["aws_region"],
+ aws_access_key_id=self.config["aws_access_key_id"],
+ aws_secret_access_key=self.config["aws_secret_access_key"],
+ )
+
+ from_str = f"{from_name} <{from_email}>" if from_name else from_email
+
+ body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
+ if body_text:
+ body["Text"] = {"Charset": "UTF-8", "Data": body_text}
+
+ kwargs = {
+ "Source": from_str,
+ "Destination": {"ToAddresses": [to_email]},
+ "Message": {
+ "Subject": {"Charset": "UTF-8", "Data": subject},
+ "Body": body,
+ },
+ }
+
+ if reply_to:
+ kwargs["ReplyToAddresses"] = [reply_to]
+
+ response = ses.send_email(**kwargs)
+ return True, response.get("MessageId"), None
+
+ except ImportError:
+ return False, None, "boto3 library not installed"
+ except Exception as e:
+ logger.error(f"Configurable SES send error: {e}")
+ return False, None, str(e)
+
+
+def get_platform_provider(db: Session) -> EmailProvider:
+ """
+ Get the configured email provider using effective platform config.
+
+ Uses database settings if available, otherwise falls back to .env.
+ """
+ config = get_platform_email_config(db)
+
+ if config.get("debug"):
+ return DebugProvider()
+
+ provider_map = {
+ "smtp": ConfigurableSMTPProvider,
+ "sendgrid": ConfigurableSendGridProvider,
+ "mailgun": ConfigurableMailgunProvider,
+ "ses": ConfigurableSESProvider,
+ }
+
+ provider_name = config.get("provider", "smtp").lower()
+ provider_class = provider_map.get(provider_name)
+
+ if not provider_class:
+ logger.warning(f"Unknown email provider: {provider_name}, using SMTP")
+ return ConfigurableSMTPProvider(config)
+
+ return provider_class(config)
+
+
+# =============================================================================
+# VENDOR EMAIL PROVIDERS
+# =============================================================================
+
+
+class VendorSMTPProvider(EmailProvider):
+ """SMTP provider using vendor-specific settings."""
+
+ def __init__(self, vendor_settings):
+ self.settings = vendor_settings
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
+ msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
+
+ if reply_to:
+ msg["Reply-To"] = reply_to
+
+ if body_text:
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
+
+ # Use vendor's SMTP settings
+ if self.settings.smtp_use_ssl:
+ server = smtplib.SMTP_SSL(self.settings.smtp_host, self.settings.smtp_port)
+ else:
+ server = smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port)
+
+ try:
+ if self.settings.smtp_use_tls and not self.settings.smtp_use_ssl:
+ server.starttls()
+
+ if self.settings.smtp_username and self.settings.smtp_password:
+ server.login(self.settings.smtp_username, self.settings.smtp_password)
+
+ server.sendmail(from_email, [to_email], msg.as_string())
+ return True, None, None
+
+ finally:
+ server.quit()
+
+ except Exception as e:
+ logger.error(f"Vendor SMTP send error: {e}")
+ return False, None, str(e)
+
+
+class VendorSendGridProvider(EmailProvider):
+ """SendGrid provider using vendor-specific API key."""
+
+ def __init__(self, vendor_settings):
+ self.settings = vendor_settings
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ from sendgrid import SendGridAPIClient
+ from sendgrid.helpers.mail import Mail, Email, To, Content
+
+ message = Mail(
+ from_email=Email(from_email, from_name),
+ to_emails=To(to_email, to_name),
+ subject=subject,
+ )
+
+ message.add_content(Content("text/html", body_html))
+ if body_text:
+ message.add_content(Content("text/plain", body_text))
+
+ if reply_to:
+ message.reply_to = Email(reply_to)
+
+ sg = SendGridAPIClient(self.settings.sendgrid_api_key)
+ response = sg.send(message)
+
+ if response.status_code in (200, 201, 202):
+ message_id = response.headers.get("X-Message-Id")
+ return True, message_id, None
+ else:
+ return False, None, f"SendGrid error: {response.status_code}"
+
+ except ImportError:
+ return False, None, "SendGrid library not installed"
+ except Exception as e:
+ logger.error(f"Vendor SendGrid send error: {e}")
+ return False, None, str(e)
+
+
+class VendorMailgunProvider(EmailProvider):
+ """Mailgun provider using vendor-specific settings."""
+
+ def __init__(self, vendor_settings):
+ self.settings = vendor_settings
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ import requests
+
+ from_str = f"{from_name} <{from_email}>" if from_name else from_email
+ to_str = f"{to_name} <{to_email}>" if to_name else to_email
+
+ data = {
+ "from": from_str,
+ "to": to_str,
+ "subject": subject,
+ "html": body_html,
+ }
+
+ if body_text:
+ data["text"] = body_text
+ if reply_to:
+ data["h:Reply-To"] = reply_to
+
+ response = requests.post(
+ f"https://api.mailgun.net/v3/{self.settings.mailgun_domain}/messages",
+ auth=("api", self.settings.mailgun_api_key),
+ data=data,
+ timeout=30,
+ )
+
+ if response.status_code == 200:
+ result = response.json()
+ return True, result.get("id"), None
+ else:
+ return False, None, f"Mailgun error: {response.status_code} - {response.text}"
+
+ except Exception as e:
+ logger.error(f"Vendor Mailgun send error: {e}")
+ return False, None, str(e)
+
+
+class VendorSESProvider(EmailProvider):
+ """Amazon SES provider using vendor-specific credentials."""
+
+ def __init__(self, vendor_settings):
+ self.settings = vendor_settings
+
+ def send(
+ self,
+ to_email: str,
+ to_name: str | None,
+ subject: str,
+ body_html: str,
+ body_text: str | None,
+ from_email: str,
+ from_name: str | None,
+ reply_to: str | None = None,
+ ) -> tuple[bool, str | None, str | None]:
+ try:
+ import boto3
+
+ ses = boto3.client(
+ "ses",
+ region_name=self.settings.ses_region,
+ aws_access_key_id=self.settings.ses_access_key_id,
+ aws_secret_access_key=self.settings.ses_secret_access_key,
+ )
+
+ from_str = f"{from_name} <{from_email}>" if from_name else from_email
+
+ body = {"Html": {"Charset": "UTF-8", "Data": body_html}}
+ if body_text:
+ body["Text"] = {"Charset": "UTF-8", "Data": body_text}
+
+ kwargs = {
+ "Source": from_str,
+ "Destination": {"ToAddresses": [to_email]},
+ "Message": {
+ "Subject": {"Charset": "UTF-8", "Data": subject},
+ "Body": body,
+ },
+ }
+
+ if reply_to:
+ kwargs["ReplyToAddresses"] = [reply_to]
+
+ response = ses.send_email(**kwargs)
+ return True, response.get("MessageId"), None
+
+ except ImportError:
+ return False, None, "boto3 library not installed"
+ except Exception as e:
+ logger.error(f"Vendor SES send error: {e}")
+ return False, None, str(e)
+
+
+def get_vendor_provider(vendor_settings) -> EmailProvider | None:
+ """
+ Create an email provider instance using vendor's settings.
+
+ Args:
+ vendor_settings: VendorEmailSettings model instance
+
+ Returns:
+ EmailProvider instance or None if not configured
+ """
+ if not vendor_settings or not vendor_settings.is_configured:
+ return None
+
+ provider_map = {
+ "smtp": VendorSMTPProvider,
+ "sendgrid": VendorSendGridProvider,
+ "mailgun": VendorMailgunProvider,
+ "ses": VendorSESProvider,
+ }
+
+ provider_class = provider_map.get(vendor_settings.provider)
+ if not provider_class:
+ logger.warning(f"Unknown vendor email provider: {vendor_settings.provider}")
+ return None
+
+ return provider_class(vendor_settings)
+
+
+# =============================================================================
+# PLATFORM EMAIL PROVIDER
# =============================================================================
@@ -387,15 +976,24 @@ class EmailService:
subject="Hello",
body_html="Hello
",
)
+
+ Platform email configuration is loaded from:
+ 1. Database (admin_settings table) - if settings exist
+ 2. Environment variables (.env) - fallback
"""
def __init__(self, db: Session):
self.db = db
- self.provider = get_provider()
+ # Use configurable provider that checks DB first, then .env
+ self.provider = get_platform_provider(db)
+ # Cache the platform config for use in send_raw
+ self._platform_config = get_platform_email_config(db)
self.jinja_env = Environment(loader=BaseLoader())
# Cache vendor and feature data to avoid repeated queries
self._vendor_cache: dict[int, Any] = {}
self._feature_cache: dict[int, set[str]] = {}
+ self._vendor_email_settings_cache: dict[int, Any] = {}
+ self._vendor_tier_cache: dict[int, str | None] = {}
def _get_vendor(self, vendor_id: int):
"""Get vendor with caching."""
@@ -419,6 +1017,76 @@ class EmailService:
return feature_code in self._feature_cache[vendor_id]
+ def _get_vendor_email_settings(self, vendor_id: int):
+ """Get vendor email settings with caching."""
+ if vendor_id not in self._vendor_email_settings_cache:
+ from models.database.vendor_email_settings import VendorEmailSettings
+
+ self._vendor_email_settings_cache[vendor_id] = (
+ self.db.query(VendorEmailSettings)
+ .filter(VendorEmailSettings.vendor_id == vendor_id)
+ .first()
+ )
+ return self._vendor_email_settings_cache[vendor_id]
+
+ def _get_vendor_tier(self, vendor_id: int) -> str | None:
+ """Get vendor's subscription tier with caching."""
+ if vendor_id not in self._vendor_tier_cache:
+ from app.services.subscription_service import subscription_service
+
+ tier = subscription_service.get_current_tier(self.db, vendor_id)
+ self._vendor_tier_cache[vendor_id] = tier.value if tier else None
+ return self._vendor_tier_cache[vendor_id]
+
+ def _should_add_powered_by_footer(self, vendor_id: int | None) -> bool:
+ """
+ Check if "Powered by Wizamart" footer should be added.
+
+ Footer is added for Essential and Professional tiers.
+ Business and Enterprise tiers get white-label (no footer).
+ """
+ if not vendor_id:
+ return False # Platform emails don't get the footer
+
+ tier = self._get_vendor_tier(vendor_id)
+ if not tier:
+ return True # No tier = show footer (shouldn't happen normally)
+
+ return tier.lower() not in WHITELABEL_TIERS
+
+ def _inject_powered_by_footer(
+ self,
+ body_html: str,
+ body_text: str | None,
+ vendor_id: int | None,
+ ) -> tuple[str, str | None]:
+ """
+ Inject "Powered by Wizamart" footer if needed based on tier.
+
+ Returns:
+ Tuple of (modified_html, modified_text)
+ """
+ if not self._should_add_powered_by_footer(vendor_id):
+ return body_html, body_text
+
+ # Inject footer before closing
+ Email Configuration Test
+ This is a test email from Wizamart.
+
+ Your email settings are configured correctly!
+
+
+
+ Provider: SMTP
+ Host: {settings.smtp_host}
+ Sent at: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
+
+
+