feat: implement email template system with vendor overrides

Add comprehensive email template management for both admin and vendors:

Admin Features:
- Email templates management page at /admin/email-templates
- Edit platform templates with language support (en, fr, de, lb)
- Preview templates with sample variables
- Send test emails
- View email logs per template

Vendor Features:
- Email templates customization page at /vendor/{code}/email-templates
- Override platform templates with vendor-specific versions
- Preview and test customized templates
- Revert to platform defaults

Technical Changes:
- Migration for vendor_email_templates table
- VendorEmailTemplate model with override management
- Enhanced EmailService with language resolution chain
  (customer preferred -> vendor preferred -> platform default)
- Branding resolution (Wizamart default, removed for whitelabel)
- Platform-only template protection (billing templates)
- Admin and vendor API endpoints with full CRUD
- Updated seed script with billing and team templates

Files: 22 changed, ~3,900 lines added

🤖 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-03 18:29:26 +01:00
parent 2e1a2fc9fc
commit c52af2a155
22 changed files with 3882 additions and 119 deletions

View File

@@ -0,0 +1,303 @@
/**
* Email Templates Management Page
*
* Handles:
* - Listing all platform email templates
* - Editing template content (all languages)
* - Preview and test email sending
*/
function emailTemplatesPage() {
return {
// Data
loading: true,
templates: [],
categories: [],
selectedCategory: null,
// Edit Modal
showEditModal: false,
editingTemplate: null,
editLanguage: 'en',
loadingTemplate: false,
editForm: {
subject: '',
body_html: '',
body_text: '',
variables: [],
required_variables: []
},
saving: false,
// Preview Modal
showPreviewModal: false,
previewData: null,
// Test Email Modal
showTestEmailModal: false,
testEmailAddress: '',
sendingTest: false,
// Computed
get filteredTemplates() {
if (!this.selectedCategory) {
return this.templates;
}
return this.templates.filter(t => t.category === this.selectedCategory);
},
// Lifecycle
async init() {
await this.loadData();
},
// Data Loading
async loadData() {
this.loading = true;
try {
const [templatesRes, categoriesRes] = await Promise.all([
fetch('/api/v1/admin/email-templates'),
fetch('/api/v1/admin/email-templates/categories')
]);
if (templatesRes.ok) {
this.templates = await templatesRes.json();
}
if (categoriesRes.ok) {
const data = await categoriesRes.json();
this.categories = data.categories || [];
}
} catch (error) {
console.error('Failed to load email templates:', error);
this.showNotification('Failed to load templates', 'error');
} finally {
this.loading = false;
}
},
// Category styling
getCategoryClass(category) {
const classes = {
'auth': 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
'orders': 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
'billing': 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
'system': 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
'marketing': 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200'
};
return classes[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200';
},
// Edit Template
async editTemplate(template) {
this.editingTemplate = template;
this.editLanguage = 'en';
this.showEditModal = true;
await this.loadTemplateLanguage();
},
async loadTemplateLanguage() {
if (!this.editingTemplate) return;
this.loadingTemplate = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
);
if (response.ok) {
const data = await response.json();
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
variables: data.variables || [],
required_variables: data.required_variables || []
};
} else if (response.status === 404) {
// Template doesn't exist for this language yet
this.editForm = {
subject: '',
body_html: '',
body_text: '',
variables: [],
required_variables: []
};
this.showNotification(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
}
} catch (error) {
console.error('Failed to load template:', error);
this.showNotification('Failed to load template', 'error');
} finally {
this.loadingTemplate = false;
}
},
closeEditModal() {
this.showEditModal = false;
this.editingTemplate = null;
this.editForm = {
subject: '',
body_html: '',
body_text: '',
variables: [],
required_variables: []
};
},
async saveTemplate() {
if (!this.editingTemplate) return;
this.saving = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text
})
}
);
if (response.ok) {
this.showNotification('Template saved successfully', 'success');
// Refresh templates list
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to save template', 'error');
}
} catch (error) {
console.error('Failed to save template:', error);
this.showNotification('Failed to save template', 'error');
} finally {
this.saving = false;
}
},
// Preview
async previewTemplate(template) {
try {
// Use sample variables for preview
const sampleVariables = this.getSampleVariables(template.code);
const response = await fetch(
`/api/v1/admin/email-templates/${template.code}/preview`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_code: template.code,
language: 'en',
variables: sampleVariables
})
}
);
if (response.ok) {
this.previewData = await response.json();
this.showPreviewModal = true;
} else {
this.showNotification('Failed to load preview', 'error');
}
} catch (error) {
console.error('Failed to preview template:', error);
this.showNotification('Failed to load preview', 'error');
}
},
getSampleVariables(templateCode) {
// Sample variables for common templates
const samples = {
'signup_welcome': {
first_name: 'John',
company_name: 'Acme Corp',
email: 'john@example.com',
vendor_code: 'acme',
login_url: 'https://example.com/login',
trial_days: '14',
tier_name: 'Business'
},
'order_confirmation': {
customer_name: 'Jane Doe',
order_number: 'ORD-12345',
order_total: '99.99',
order_items_count: '3',
order_date: '2024-01-15',
shipping_address: '123 Main St, Luxembourg City, L-1234'
},
'password_reset': {
customer_name: 'John Doe',
reset_link: 'https://example.com/reset?token=abc123',
expiry_hours: '1'
},
'team_invite': {
invitee_name: 'Jane',
inviter_name: 'John',
vendor_name: 'Acme Corp',
role: 'Admin',
accept_url: 'https://example.com/accept',
expires_in_days: '7'
}
};
return samples[templateCode] || { platform_name: 'Wizamart' };
},
// Test Email
sendTestEmail() {
this.showTestEmailModal = true;
},
async confirmSendTestEmail() {
if (!this.testEmailAddress || !this.editingTemplate) return;
this.sendingTest = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/test`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_code: this.editingTemplate.code,
language: this.editLanguage,
to_email: this.testEmailAddress,
variables: this.getSampleVariables(this.editingTemplate.code)
})
}
);
const result = await response.json();
if (result.success) {
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
this.showTestEmailModal = false;
this.testEmailAddress = '';
} else {
this.showNotification(result.message || 'Failed to send test email', 'error');
}
} catch (error) {
console.error('Failed to send test email:', error);
this.showNotification('Failed to send test email', 'error');
} finally {
this.sendingTest = false;
}
},
// Notifications
showNotification(message, type = 'info') {
// Use global notification system if available
if (window.showToast) {
window.showToast(message, type);
} else if (window.Alpine && Alpine.store('notifications')) {
Alpine.store('notifications').add(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
};
}

334
static/vendor/js/email-templates.js vendored Normal file
View File

@@ -0,0 +1,334 @@
/**
* Vendor Email Templates Management Page
*
* Allows vendors to customize email templates sent to their customers.
* Platform-only templates (billing, subscription) cannot be overridden.
*/
const vendorEmailTemplatesLog = window.LogConfig?.loggers?.vendorEmailTemplates ||
window.LogConfig?.createLogger?.('vendorEmailTemplates', false) ||
{ info: () => {}, debug: () => {}, warn: () => {}, error: console.error };
vendorEmailTemplatesLog.info('Loading...');
function vendorEmailTemplates() {
vendorEmailTemplatesLog.info('vendorEmailTemplates() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'email-templates',
// Loading states
loading: true,
error: '',
saving: false,
// Data
templates: [],
supportedLanguages: ['en', 'fr', 'de', 'lb'],
// Edit Modal
showEditModal: false,
editingTemplate: null,
editLanguage: 'en',
loadingTemplate: false,
templateSource: 'platform',
editForm: {
subject: '',
body_html: '',
body_text: ''
},
reverting: false,
// Preview Modal
showPreviewModal: false,
previewData: null,
// Test Email Modal
showTestEmailModal: false,
testEmailAddress: '',
sendingTest: false,
// Lifecycle
async init() {
await this.loadData();
},
// Data Loading
async loadData() {
this.loading = true;
this.error = '';
try {
const response = await fetch('/api/v1/vendor/email-templates', {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
});
if (response.ok) {
const data = await response.json();
this.templates = data.templates || [];
this.supportedLanguages = data.supported_languages || ['en', 'fr', 'de', 'lb'];
} else {
const error = await response.json();
this.error = error.detail || 'Failed to load templates';
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to load templates:', error);
this.error = 'Failed to load templates';
} finally {
this.loading = false;
}
},
// Auth token helper
getAuthToken() {
// Get from cookie or localStorage depending on your auth setup
return document.cookie
.split('; ')
.find(row => row.startsWith('vendor_token='))
?.split('=')[1] || '';
},
// Category styling
getCategoryClass(category) {
const classes = {
'AUTH': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'ORDERS': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'BILLING': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
'SYSTEM': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'MARKETING': 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
'TEAM': 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
};
return classes[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
},
// Edit Template
async editTemplate(template) {
this.editingTemplate = template;
this.editLanguage = 'en';
this.showEditModal = true;
await this.loadTemplateLanguage();
},
async loadTemplateLanguage() {
if (!this.editingTemplate) return;
this.loadingTemplate = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
}
);
if (response.ok) {
const data = await response.json();
this.templateSource = data.source;
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || ''
};
} else if (response.status === 404) {
// No template for this language
this.templateSource = 'none';
this.editForm = {
subject: '',
body_html: '',
body_text: ''
};
this.showNotification(`No template available for ${this.editLanguage.toUpperCase()}`, 'info');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to load template:', error);
this.showNotification('Failed to load template', 'error');
} finally {
this.loadingTemplate = false;
}
},
closeEditModal() {
this.showEditModal = false;
this.editingTemplate = null;
this.editForm = {
subject: '',
body_html: '',
body_text: ''
};
},
async saveTemplate() {
if (!this.editingTemplate) return;
this.saving = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text || null
})
}
);
if (response.ok) {
this.showNotification('Template saved successfully', 'success');
this.templateSource = 'vendor_override';
// Refresh list to show updated status
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to save template', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to save template:', error);
this.showNotification('Failed to save template', 'error');
} finally {
this.saving = false;
}
},
async revertToDefault() {
if (!this.editingTemplate) return;
if (!confirm('Are you sure you want to delete your customization and revert to the platform default?')) {
return;
}
this.reverting = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
}
);
if (response.ok) {
this.showNotification('Reverted to platform default', 'success');
// Reload the template to show platform version
await this.loadTemplateLanguage();
// Refresh list
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to revert', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to revert template:', error);
this.showNotification('Failed to revert', 'error');
} finally {
this.reverting = false;
}
},
// Preview
async previewTemplate() {
if (!this.editingTemplate) return;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/preview`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
language: this.editLanguage,
variables: {}
})
}
);
if (response.ok) {
this.previewData = await response.json();
this.showPreviewModal = true;
} else {
this.showNotification('Failed to load preview', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to preview template:', error);
this.showNotification('Failed to load preview', 'error');
}
},
// Test Email
sendTestEmail() {
this.showTestEmailModal = true;
},
async confirmSendTestEmail() {
if (!this.testEmailAddress || !this.editingTemplate) return;
this.sendingTest = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/test`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
to_email: this.testEmailAddress,
language: this.editLanguage,
variables: {}
})
}
);
const result = await response.json();
if (result.success) {
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
this.showTestEmailModal = false;
this.testEmailAddress = '';
} else {
this.showNotification(result.message || 'Failed to send test email', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to send test email:', error);
this.showNotification('Failed to send test email', 'error');
} finally {
this.sendingTest = false;
}
},
// Notifications
showNotification(message, type = 'info') {
// Use global notification system if available
if (window.showToast) {
window.showToast(message, type);
} else if (window.Alpine && Alpine.store('notifications')) {
Alpine.store('notifications').add(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
};
}