feat: add email settings with database overrides for admin and vendor

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 22:23:47 +01:00
parent ad28a8a9a3
commit 36603178c3
51 changed files with 4959 additions and 1141 deletions

View File

@@ -27,6 +27,7 @@
{{ tab_button('marketplace', 'Marketplace', tab_var='activeSection', icon='shopping-cart') }}
{{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }}
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
{{ tab_button('email', 'Email', tab_var='activeSection', icon='envelope') }}
{{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
@@ -591,6 +592,7 @@
</div>
<!-- Boost Sort -->
{# noqa: FE-008 - Decimal input with 0.1 step and custom @input handler, not suited for number_stepper #}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Boost Sort Priority
@@ -803,6 +805,398 @@
</div>
</div>
<!-- Email Settings -->
<div x-show="activeSection === 'email'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Email Settings</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Configure your email sending settings for customer communications</p>
</div>
<div class="p-4">
<!-- Loading state for email settings -->
<div x-show="emailSettingsLoading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
<div x-show="!emailSettingsLoading" class="space-y-6">
<!-- Configuration Status Banner -->
<template x-if="!emailSettings?.is_configured">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-start gap-3">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="font-medium text-yellow-800 dark:text-yellow-300">Email not configured</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
Configure your SMTP settings to send emails to your customers (order confirmations, shipping updates, etc.)
</p>
</div>
</div>
</div>
</template>
<template x-if="emailSettings?.is_configured && !emailSettings?.is_verified">
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-3">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="font-medium text-blue-800 dark:text-blue-300">Email configured but not verified</p>
<p class="text-sm text-blue-700 dark:text-blue-400 mt-1">
Send a test email to verify your settings are working correctly.
</p>
</div>
</div>
</div>
</template>
<template x-if="emailSettings?.is_configured && emailSettings?.is_verified">
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div class="flex items-start gap-3">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="font-medium text-green-800 dark:text-green-300">Email configured and verified</p>
<p class="text-sm text-green-700 dark:text-green-400 mt-1">
Your email settings are ready. Emails will be sent from <span class="font-medium" x-text="emailForm.from_email"></span>
</p>
</div>
</div>
</div>
</template>
<!-- Sender Identity -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Sender Identity</h4>
<div class="space-y-4">
<!-- From Email -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
From Email <span class="text-red-500">*</span>
</label>
<input
type="email"
x-model="emailForm.from_email"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="orders@yourstore.com"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Email address that customers will see in their inbox
</p>
</div>
<!-- From Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
From Name <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.from_name"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Your Store Name"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Name that appears as the sender (e.g., "Your Store Name")
</p>
</div>
<!-- Reply-To Email -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reply-To Email
</label>
<input
type="email"
x-model="emailForm.reply_to_email"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="support@yourstore.com"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Optional: Where replies should go (defaults to From Email)
</p>
</div>
</div>
</div>
<!-- Email Provider -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Email Provider</h4>
<div class="space-y-4">
<!-- Provider Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Provider
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<template x-for="provider in emailProviders" :key="provider.code">
<button
type="button"
@click="provider.available ? (emailForm.provider = provider.code, markEmailChanged()) : null"
:class="emailForm.provider === provider.code
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: provider.available
? 'border-gray-200 dark:border-gray-600 hover:border-purple-300'
: 'border-gray-200 dark:border-gray-600 opacity-50 cursor-not-allowed'"
class="relative p-3 border-2 rounded-lg transition-colors"
>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="provider.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="provider.description"></div>
<template x-if="!provider.available">
<div class="absolute top-1 right-1">
<span class="px-1.5 py-0.5 text-xs font-medium text-purple-600 bg-purple-100 rounded dark:bg-purple-900/50 dark:text-purple-400">
Business+
</span>
</div>
</template>
<template x-if="emailForm.provider === provider.code">
<div class="absolute top-1 right-1">
<span x-html="$icon('check-circle', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
</div>
</template>
</button>
</template>
</div>
</div>
<!-- SMTP Settings -->
<template x-if="emailForm.provider === 'smtp'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- SMTP Host -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Host <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.smtp_host"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="smtp.yourprovider.com"
/>
</div>
<!-- SMTP Port -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Port <span class="text-red-500">*</span>
</label>
<input
type="number"
x-model.number="emailForm.smtp_port"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="587"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- SMTP Username -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Username <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.smtp_username"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="your-username"
/>
</div>
<!-- SMTP Password -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SMTP Password <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.smtp_password"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
:placeholder="emailSettings?.smtp_password_set ? '••••••••' : 'Enter password'"
/>
</div>
</div>
<!-- TLS/SSL Options -->
<div class="flex items-center gap-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="emailForm.smtp_use_tls"
@change="markEmailChanged()"
class="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use TLS (STARTTLS)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="emailForm.smtp_use_ssl"
@change="markEmailChanged()"
class="w-4 h-4 text-purple-600 bg-gray-100 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use SSL (port 465)</span>
</label>
</div>
</div>
</template>
<!-- SendGrid Settings -->
<template x-if="emailForm.provider === 'sendgrid'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SendGrid API Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.sendgrid_api_key"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
:placeholder="emailSettings?.sendgrid_api_key_set ? '••••••••' : 'SG.xxxxx'"
/>
</div>
</div>
</template>
<!-- Mailgun Settings -->
<template x-if="emailForm.provider === 'mailgun'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mailgun API Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.mailgun_api_key"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
:placeholder="emailSettings?.mailgun_api_key_set ? '••••••••' : 'key-xxxxx'"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mailgun Domain <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.mailgun_domain"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="mg.yourdomain.com"
/>
</div>
</div>
</div>
</template>
<!-- SES Settings -->
<template x-if="emailForm.provider === 'ses'">
<div class="space-y-4 pt-4 border-t dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Access Key ID <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="emailForm.ses_access_key_id"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="AKIAIOSFODNN7EXAMPLE"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Secret Access Key <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="emailForm.ses_secret_access_key"
@input="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
:placeholder="emailSettings?.ses_access_key_id_set ? '••••••••' : 'Enter secret'"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AWS Region
</label>
<select
x-model="emailForm.ses_region"
@change="markEmailChanged()"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
</select>
</div>
</div>
</template>
</div>
</div>
<!-- Signature (Optional) -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-4">Email Signature (Optional)</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Plain Text Signature
</label>
<textarea
x-model="emailForm.signature_text"
@input="markEmailChanged()"
rows="3"
class="w-full px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="Best regards,&#10;The Your Store Team"
></textarea>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row justify-between gap-4 pt-4 border-t dark:border-gray-600">
<!-- Test Email -->
<div class="flex items-center gap-2">
<input
type="email"
x-model="testEmailAddress"
class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-800 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
placeholder="test@example.com"
/>
<button
@click="sendTestEmail()"
:disabled="!emailSettings?.is_configured || sendingTestEmail || !testEmailAddress"
class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 disabled:opacity-50 dark:bg-purple-900/30 dark:text-purple-400"
>
<span x-show="!sendingTestEmail">Send Test</span>
<span x-show="sendingTestEmail">Sending...</span>
</button>
</div>
<!-- Save Button -->
<button
@click="saveEmailSettings()"
:disabled="saving || !hasEmailChanges"
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!saving">Save Email Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
</div>
<!-- Domains Settings -->
<div x-show="activeSection === 'domains'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">