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 Wizamart +

+
+""" + +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 tag if present, otherwise append + if "" in body_html.lower(): + # Find case-insensitively and inject before it + import re + body_html = re.sub( + r"()", + f"{POWERED_BY_FOOTER_HTML}\\1", + body_html, + flags=re.IGNORECASE, + ) + else: + body_html += POWERED_BY_FOOTER_HTML + + if body_text: + body_text += POWERED_BY_FOOTER_TEXT + + return body_html, body_text + def resolve_language( self, explicit_language: str | None = None, @@ -721,16 +1389,55 @@ class EmailService: related_type: str | None = None, related_id: int | None = None, extra_data: str | None = None, + is_platform_email: bool = False, ) -> EmailLog: """ Send a raw email without using a template. + For vendor emails (when vendor_id is provided and is_platform_email=False): + - Uses vendor's SMTP/provider settings if configured + - Uses vendor's from_email, from_name, reply_to + - Adds "Powered by Wizamart" footer for Essential/Professional tiers + + For platform emails (is_platform_email=True or no vendor_id): + - Uses platform's email settings from config + - No "Powered by Wizamart" footer + + Args: + is_platform_email: If True, always use platform settings (for billing, etc.) + Returns: EmailLog record """ - from_email = from_email or settings.email_from_address - from_name = from_name or settings.email_from_name - reply_to = reply_to or settings.email_reply_to or None + # Determine which provider and settings to use + vendor_settings = None + vendor_provider = None + provider_name = self._platform_config.get("provider", settings.email_provider) + + if vendor_id and not is_platform_email: + vendor_settings = self._get_vendor_email_settings(vendor_id) + if vendor_settings and vendor_settings.is_configured: + vendor_provider = get_vendor_provider(vendor_settings) + if vendor_provider: + # Use vendor's email identity + from_email = from_email or vendor_settings.from_email + from_name = from_name or vendor_settings.from_name + reply_to = reply_to or vendor_settings.reply_to_email + provider_name = f"vendor_{vendor_settings.provider}" + logger.debug(f"Using vendor email provider: {vendor_settings.provider}") + + # Fall back to platform settings if no vendor provider + # Uses DB config if available, otherwise .env + if not vendor_provider: + from_email = from_email or self._platform_config.get("from_email", settings.email_from_address) + from_name = from_name or self._platform_config.get("from_name", settings.email_from_name) + reply_to = reply_to or self._platform_config.get("reply_to") or settings.email_reply_to or None + + # Inject "Powered by Wizamart" footer for non-whitelabel tiers + if vendor_id and not is_platform_email: + body_html, body_text = self._inject_powered_by_footer( + body_html, body_text, vendor_id + ) # Create log entry log = EmailLog( @@ -745,7 +1452,7 @@ class EmailService: from_name=from_name, reply_to=reply_to, status=EmailStatus.PENDING.value, - provider=settings.email_provider, + provider=provider_name, vendor_id=vendor_id, user_id=user_id, related_type=related_type, @@ -755,16 +1462,20 @@ class EmailService: self.db.add(log) self.db.flush() - # Check if emails are disabled - if not settings.email_enabled: + # Check if emails are disabled (uses DB config if available) + email_enabled = self._platform_config.get("enabled", settings.email_enabled) + if not email_enabled: log.status = EmailStatus.FAILED.value log.error_message = "Email sending is disabled" self.db.commit() # noqa: SVC-006 - Email logs are side effects, commit immediately logger.info(f"Email sending disabled, skipping: {to_email}") return log + # Use vendor provider if available, otherwise platform provider + provider_to_use = vendor_provider or self.provider + # Send email - success, message_id, error = self.provider.send( + success, message_id, error = provider_to_use.send( to_email=to_email, to_name=to_name, subject=subject, @@ -777,7 +1488,7 @@ class EmailService: if success: log.mark_sent(message_id) - logger.info(f"Email sent to {to_email}: {subject}") + logger.info(f"Email sent to {to_email}: {subject} (via {provider_name})") else: log.mark_failed(error or "Unknown error") logger.error(f"Email failed to {to_email}: {error}") diff --git a/app/services/vendor_email_settings_service.py b/app/services/vendor_email_settings_service.py new file mode 100644 index 00000000..d8afabe6 --- /dev/null +++ b/app/services/vendor_email_settings_service.py @@ -0,0 +1,444 @@ +# app/services/vendor_email_settings_service.py +""" +Vendor Email Settings Service. + +Handles CRUD operations for vendor email configuration: +- SMTP settings +- Advanced providers (SendGrid, Mailgun, SES) - tier-gated +- Sender identity (from_email, from_name, reply_to) +- Signature/footer customization +- Configuration verification via test email +""" + +import logging +import smtplib +from datetime import UTC, datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from sqlalchemy.orm import Session + +from app.exceptions import NotFoundError, ValidationError, AuthorizationError +from models.database import ( + Vendor, + VendorEmailSettings, + EmailProvider, + PREMIUM_EMAIL_PROVIDERS, + VendorSubscription, + TierCode, +) + +logger = logging.getLogger(__name__) + + +# Tiers that allow premium email providers +PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE} + + +class VendorEmailSettingsService: + """Service for managing vendor email settings.""" + + def __init__(self, db: Session): + self.db = db + + # ========================================================================= + # READ OPERATIONS + # ========================================================================= + + def get_settings(self, vendor_id: int) -> VendorEmailSettings | None: + """Get email settings for a vendor.""" + return ( + self.db.query(VendorEmailSettings) + .filter(VendorEmailSettings.vendor_id == vendor_id) + .first() + ) + + def get_settings_or_404(self, vendor_id: int) -> VendorEmailSettings: + """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." + ) + return settings + + def is_configured(self, vendor_id: int) -> bool: + """Check if vendor has configured email settings.""" + settings = self.get_settings(vendor_id) + return settings is not None and settings.is_configured + + def get_status(self, vendor_id: int) -> dict: + """ + Get email configuration status for a vendor. + + Returns: + dict with is_configured, is_verified, provider, etc. + """ + settings = self.get_settings(vendor_id) + if not settings: + return { + "is_configured": False, + "is_verified": False, + "provider": None, + "from_email": None, + "from_name": None, + "message": "Email settings not configured. Configure SMTP to send emails.", + } + + return { + "is_configured": settings.is_configured, + "is_verified": settings.is_verified, + "provider": settings.provider, + "from_email": settings.from_email, + "from_name": settings.from_name, + "last_verified_at": settings.last_verified_at.isoformat() if settings.last_verified_at else None, + "verification_error": settings.verification_error, + "message": self._get_status_message(settings), + } + + def _get_status_message(self, settings: VendorEmailSettings) -> str: + """Generate a human-readable status message.""" + if not settings.is_configured: + return "Complete your email configuration to send emails." + if not settings.is_verified: + return "Email configured but not verified. Send a test email to verify." + return "Email settings configured and verified." + + # ========================================================================= + # WRITE OPERATIONS + # ========================================================================= + + def create_or_update( + self, + vendor_id: int, + data: dict, + current_tier: TierCode | None = None, + ) -> VendorEmailSettings: + """ + Create or update vendor email settings. + + Args: + vendor_id: Vendor ID + data: Settings data (from_email, from_name, smtp_*, etc.) + current_tier: Vendor's current subscription tier (for premium provider validation) + + Returns: + Updated VendorEmailSettings + """ + # 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." + ) + + settings = self.get_settings(vendor_id) + if not settings: + settings = VendorEmailSettings(vendor_id=vendor_id) + self.db.add(settings) + + # Update fields + for field in [ + "from_email", + "from_name", + "reply_to_email", + "signature_text", + "signature_html", + "provider", + # SMTP + "smtp_host", + "smtp_port", + "smtp_username", + "smtp_password", + "smtp_use_tls", + "smtp_use_ssl", + # SendGrid + "sendgrid_api_key", + # Mailgun + "mailgun_api_key", + "mailgun_domain", + # SES + "ses_access_key_id", + "ses_secret_access_key", + "ses_region", + ]: + if field in data and data[field] is not None: + # Don't overwrite passwords/keys with empty strings + if field.endswith(("_password", "_key", "_access_key")) and data[field] == "": + continue + setattr(settings, field, data[field]) + + # Update configuration status + settings.update_configuration_status() + + # Reset verification if provider/credentials changed + if any( + f in data + for f in ["provider", "smtp_host", "smtp_password", "sendgrid_api_key", "mailgun_api_key", "ses_access_key_id"] + ): + settings.is_verified = False + settings.verification_error = None + + self.db.commit() + self.db.refresh(settings) + + 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.""" + 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 + + # ========================================================================= + # VERIFICATION + # ========================================================================= + + def verify_settings(self, vendor_id: int, test_email: str) -> dict: + """ + Verify email settings by sending a test email. + + Args: + vendor_id: Vendor ID + test_email: Email address to send test email to + + Returns: + dict with success status and message + """ + settings = self.get_settings_or_404(vendor_id) + + if not settings.is_fully_configured(): + raise ValidationError("Email settings incomplete. Configure all required fields first.") + + try: + # Send test email based on provider + if settings.provider == EmailProvider.SMTP.value: + self._send_smtp_test(settings, test_email) + elif settings.provider == EmailProvider.SENDGRID.value: + self._send_sendgrid_test(settings, test_email) + elif settings.provider == EmailProvider.MAILGUN.value: + self._send_mailgun_test(settings, test_email) + elif settings.provider == EmailProvider.SES.value: + self._send_ses_test(settings, test_email) + else: + raise ValidationError(f"Unknown provider: {settings.provider}") + + # Mark as verified + settings.mark_verified() + self.db.commit() + + logger.info(f"Email settings verified for vendor {vendor_id}") + return { + "success": True, + "message": f"Test email sent successfully to {test_email}", + } + + except Exception as e: + error_msg = str(e) + settings.mark_verification_failed(error_msg) + self.db.commit() + + logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}") + return { + "success": False, + "message": f"Failed to send test email: {error_msg}", + } + + def _send_smtp_test(self, settings: VendorEmailSettings, to_email: str) -> None: + """Send test email via SMTP.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = "Wizamart Email Configuration Test" + msg["From"] = f"{settings.from_name} <{settings.from_email}>" + msg["To"] = to_email + + text_content = ( + "This is a test email from Wizamart.\n\n" + "Your email settings are configured correctly!\n\n" + f"Provider: SMTP\n" + f"Host: {settings.smtp_host}\n" + ) + html_content = f""" + + +

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')} +

+ + + """ + + msg.attach(MIMEText(text_content, "plain")) + msg.attach(MIMEText(html_content, "html")) + + # Connect and send + if settings.smtp_use_ssl: + server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port) + else: + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port) + if settings.smtp_use_tls: + server.starttls() + + server.login(settings.smtp_username, settings.smtp_password) + server.sendmail(settings.from_email, to_email, msg.as_string()) + server.quit() + + def _send_sendgrid_test(self, settings: VendorEmailSettings, to_email: str) -> None: + """Send test email via SendGrid.""" + try: + from sendgrid import SendGridAPIClient + from sendgrid.helpers.mail import Mail + except ImportError: + raise ValidationError("SendGrid library not installed. Contact support.") + + message = Mail( + from_email=(settings.from_email, settings.from_name), + to_emails=to_email, + subject="Wizamart Email Configuration Test", + html_content=f""" + + +

Email Configuration Test

+

This is a test email from Wizamart.

+

+ Your SendGrid settings are configured correctly! +

+ + + """, + ) + + sg = SendGridAPIClient(settings.sendgrid_api_key) + response = sg.send(message) + + if response.status_code >= 400: + raise Exception(f"SendGrid error: {response.status_code}") + + def _send_mailgun_test(self, settings: VendorEmailSettings, to_email: str) -> None: + """Send test email via Mailgun.""" + import requests + + response = requests.post( + f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages", + auth=("api", settings.mailgun_api_key), + data={ + "from": f"{settings.from_name} <{settings.from_email}>", + "to": to_email, + "subject": "Wizamart Email Configuration Test", + "html": f""" + + +

Email Configuration Test

+

This is a test email from Wizamart.

+

+ Your Mailgun settings are configured correctly! +

+ + + """, + }, + timeout=30, + ) + + if response.status_code >= 400: + raise Exception(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.") + + client = boto3.client( + "ses", + region_name=settings.ses_region, + aws_access_key_id=settings.ses_access_key_id, + aws_secret_access_key=settings.ses_secret_access_key, + ) + + client.send_email( + Source=f"{settings.from_name} <{settings.from_email}>", + Destination={"ToAddresses": [to_email]}, + Message={ + "Subject": {"Data": "Wizamart Email Configuration Test"}, + "Body": { + "Html": { + "Data": f""" + + +

Email Configuration Test

+

This is a test email from Wizamart.

+

+ Your Amazon SES settings are configured correctly! +

+ + + """ + } + }, + }, + ) + + # ========================================================================= + # TIER HELPERS + # ========================================================================= + + def get_available_providers(self, tier: TierCode | None) -> list[dict]: + """ + Get list of available email providers for a tier. + + Returns list of providers with availability status. + """ + providers = [ + { + "code": EmailProvider.SMTP.value, + "name": "SMTP", + "description": "Standard SMTP email server", + "available": True, + "tier_required": None, + }, + { + "code": EmailProvider.SENDGRID.value, + "name": "SendGrid", + "description": "SendGrid email delivery platform", + "available": tier in PREMIUM_TIERS if tier else False, + "tier_required": "business", + }, + { + "code": EmailProvider.MAILGUN.value, + "name": "Mailgun", + "description": "Mailgun email API", + "available": tier in PREMIUM_TIERS if tier else False, + "tier_required": "business", + }, + { + "code": EmailProvider.SES.value, + "name": "Amazon SES", + "description": "Amazon Simple Email Service", + "available": tier in PREMIUM_TIERS if tier else False, + "tier_required": "business", + }, + ] + return providers + + +# Module-level service factory +def get_vendor_email_settings_service(db: Session) -> VendorEmailSettingsService: + """Factory function to get a VendorEmailSettingsService instance.""" + return VendorEmailSettingsService(db) diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index b7621a31..4a714274 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -20,6 +20,7 @@ {% call tabs_nav() %} {{ tab_button('display', 'Display', icon='view-grid') }} {{ tab_button('logging', 'Logging', icon='document-text') }} + {{ tab_button('email', 'Email', icon='envelope') }} {{ tab_button('shipping', 'Shipping', icon='truck') }} {{ tab_button('system', 'System', icon='cog') }} {{ tab_button('security', 'Security', icon='shield-check') }} @@ -218,6 +219,349 @@ + +
+
+
+

+ Platform Email Configuration +

+ +
+ + +
+
+

+ Configure the platform's email settings for system emails (billing, subscriptions, admin notifications). + Vendor emails use each vendor's own email settings. +

+ + +
+
+
+ + +
+ +
+
+ + + + + + + + +
+

Send Test Email

+
+ + +
+

+ Send a test email to verify the platform email configuration is working. +

+
+
+
+
diff --git a/app/templates/shared/macros/feature_gate.html b/app/templates/shared/macros/feature_gate.html index 59d48000..64ebfbb7 100644 --- a/app/templates/shared/macros/feature_gate.html +++ b/app/templates/shared/macros/feature_gate.html @@ -335,3 +335,38 @@ Upgrade to {% endmacro %} + + +{# ============================================================================= + Email Settings Warning + Shows warning banner when vendor email settings are not configured. + This banner appears at the top of vendor pages until email is configured. + + Usage: + {{ email_settings_warning() }} + ============================================================================= #} +{% macro email_settings_warning() %} +
+
+
+ + + +
+

Email not configured

+

+ Configure your email settings to send order confirmations and customer notifications. +

+
+
+ + Configure Email + +
+
+{% endmacro %} diff --git a/app/templates/shop/account/addresses.html b/app/templates/shop/account/addresses.html index 18ac38e0..70674e2e 100644 --- a/app/templates/shop/account/addresses.html +++ b/app/templates/shop/account/addresses.html @@ -99,6 +99,7 @@
+{# noqa: FE-004 - Complex form modal with dynamic title and extensive form fields not suited for form_modal macro #}
- +{{ confirm_modal( + id='logoutModal', + title='Logout Confirmation', + message="Are you sure you want to logout? You'll need to sign in again to access your account.", + confirm_action='confirmLogout()', + show_var='showLogoutModal', + confirm_text='Logout', + cancel_text='Cancel', + variant='danger' +) }} {% endblock %} {% block extra_scripts %} diff --git a/app/templates/shop/account/messages.html b/app/templates/shop/account/messages.html index bb871cb1..516b77d4 100644 --- a/app/templates/shop/account/messages.html +++ b/app/templates/shop/account/messages.html @@ -114,6 +114,7 @@
+ {# noqa: FE-001 - Custom pagination with currentPage/totalPages vars (not pagination.page/pagination.total) #}