Email COnfigs & setup

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-08 05:41:28 +00:00
parent 7da3334c03
commit 3651ee9ed4
34 changed files with 2418 additions and 77 deletions

View File

@@ -18,4 +18,8 @@ __all__ = [
# New centralized models
'IntegrationProvider',
'AISettings',
# Email models
'EmailSettings',
'EmailTemplate',
'EmailLog',
]

View File

@@ -696,3 +696,6 @@ class SystemAISettingsAdmin(Igny8ModelAdmin):
obj.updated_by = request.user
super().save_model(request, obj, form, change)
# Import Email Admin (EmailSettings, EmailTemplate, EmailLog)
from .email_admin import EmailSettingsAdmin, EmailTemplateAdmin, EmailLogAdmin

View File

@@ -0,0 +1,320 @@
"""
Email Admin Configuration for IGNY8
Provides admin interface for managing:
- Email Settings (global configuration)
- Email Templates (template metadata and testing)
- Email Logs (sent email history)
"""
from django.contrib import admin
from django.utils.html import format_html
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
from django.http import JsonResponse
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from igny8_core.admin.base import Igny8ModelAdmin
from .email_models import EmailSettings, EmailTemplate, EmailLog
@admin.register(EmailSettings)
class EmailSettingsAdmin(Igny8ModelAdmin):
"""
Admin for EmailSettings - Global email configuration (Singleton)
"""
list_display = [
'from_email',
'from_name',
'reply_to_email',
'send_welcome_emails',
'send_billing_emails',
'updated_at',
]
readonly_fields = ['updated_at']
fieldsets = (
('Sender Configuration', {
'fields': ('from_email', 'from_name', 'reply_to_email'),
'description': 'Default sender settings. Email address must be verified in Resend.',
}),
('Company Branding', {
'fields': ('company_name', 'company_address', 'logo_url'),
'description': 'Company information shown in email templates.',
}),
('Support Links', {
'fields': ('support_email', 'support_url', 'unsubscribe_url'),
'classes': ('collapse',),
}),
('Email Types', {
'fields': (
'send_welcome_emails',
'send_billing_emails',
'send_subscription_emails',
'send_low_credit_warnings',
),
'description': 'Enable/disable specific email types globally.',
}),
('Thresholds', {
'fields': ('low_credit_threshold', 'renewal_reminder_days'),
}),
('Metadata', {
'fields': ('updated_by', 'updated_at'),
'classes': ('collapse',),
}),
)
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
return not EmailSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of singleton"""
return False
def save_model(self, request, obj, form, change):
"""Set updated_by to current user"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
@admin.register(EmailTemplate)
class EmailTemplateAdmin(Igny8ModelAdmin):
"""
Admin for EmailTemplate - Manage email templates and testing
"""
list_display = [
'display_name',
'template_type',
'template_name',
'is_active',
'send_count',
'last_sent_at',
'test_email_button',
]
list_filter = ['template_type', 'is_active']
search_fields = ['display_name', 'template_name', 'description']
readonly_fields = ['send_count', 'last_sent_at', 'created_at', 'updated_at']
fieldsets = (
('Template Info', {
'fields': ('template_name', 'template_path', 'display_name', 'description'),
}),
('Email Settings', {
'fields': ('template_type', 'default_subject'),
}),
('Context Configuration', {
'fields': ('required_context', 'sample_context'),
'description': 'Define required variables and sample data for testing.',
'classes': ('collapse',),
}),
('Status', {
'fields': ('is_active',),
}),
('Statistics', {
'fields': ('send_count', 'last_sent_at'),
'classes': ('collapse',),
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',),
}),
)
def test_email_button(self, obj):
"""Add test email button in list view"""
url = reverse('admin:system_emailtemplate_test', args=[obj.pk])
return format_html(
'<a class="button" href="{}" style="padding: 4px 12px; background: #6366f1; color: white; '
'border-radius: 4px; text-decoration: none; font-size: 12px;">Test</a>',
url
)
test_email_button.short_description = 'Test'
test_email_button.allow_tags = True
def get_urls(self):
"""Add custom URL for test email"""
urls = super().get_urls()
custom_urls = [
path(
'<int:template_id>/test/',
self.admin_site.admin_view(self.test_email_view),
name='system_emailtemplate_test'
),
path(
'<int:template_id>/send-test/',
self.admin_site.admin_view(self.send_test_email),
name='system_emailtemplate_send_test'
),
]
return custom_urls + urls
def test_email_view(self, request, template_id):
"""Show test email form"""
template = EmailTemplate.objects.get(pk=template_id)
context = {
**self.admin_site.each_context(request),
'title': f'Test Email: {template.display_name}',
'template': template,
'opts': self.model._meta,
}
return render(request, 'admin/system/emailtemplate/test_email.html', context)
def send_test_email(self, request, template_id):
"""Send test email"""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
import json
from django.utils import timezone
from igny8_core.business.billing.services.email_service import get_email_service
template = EmailTemplate.objects.get(pk=template_id)
to_email = request.POST.get('to_email', request.user.email)
custom_context = request.POST.get('context', '{}')
try:
context = json.loads(custom_context) if custom_context else {}
except json.JSONDecodeError:
context = template.sample_context or {}
# Merge sample context with any custom values
final_context = {**(template.sample_context or {}), **context}
# Add default context values
final_context.setdefault('user_name', 'Test User')
final_context.setdefault('account_name', 'Test Account')
final_context.setdefault('frontend_url', 'https://app.igny8.com')
service = get_email_service()
try:
result = service.send_transactional(
to=to_email,
subject=f'[TEST] {template.default_subject}',
template=template.template_path,
context=final_context,
tags=['test', template.template_type],
)
if result.get('success'):
# Update template stats
template.send_count += 1
template.last_sent_at = timezone.now()
template.save(update_fields=['send_count', 'last_sent_at'])
# Log the email
EmailLog.objects.create(
message_id=result.get('id', ''),
to_email=to_email,
from_email=service.from_email,
subject=f'[TEST] {template.default_subject}',
template_name=template.template_name,
status='sent',
provider=result.get('provider', 'resend'),
tags=['test', template.template_type],
)
messages.success(
request,
f'Test email sent successfully to {to_email}! (ID: {result.get("id", "N/A")})'
)
else:
messages.error(request, f'Failed to send: {result.get("error", "Unknown error")}')
except Exception as e:
messages.error(request, f'Error sending test email: {str(e)}')
return redirect(reverse('admin:system_emailtemplate_changelist'))
@admin.register(EmailLog)
class EmailLogAdmin(Igny8ModelAdmin):
"""
Admin for EmailLog - View sent email history
"""
list_display = [
'sent_at',
'to_email',
'subject_truncated',
'template_name',
'status_badge',
'provider',
'message_id_short',
]
list_filter = ['status', 'provider', 'template_name', 'sent_at']
search_fields = ['to_email', 'subject', 'message_id']
readonly_fields = [
'message_id', 'to_email', 'from_email', 'subject',
'template_name', 'status', 'provider', 'error_message',
'tags', 'sent_at'
]
date_hierarchy = 'sent_at'
fieldsets = (
('Email Details', {
'fields': ('to_email', 'from_email', 'subject'),
}),
('Delivery Info', {
'fields': ('status', 'provider', 'message_id'),
}),
('Template', {
'fields': ('template_name', 'tags'),
}),
('Error Info', {
'fields': ('error_message',),
'classes': ('collapse',),
}),
('Timestamp', {
'fields': ('sent_at',),
}),
)
def has_add_permission(self, request):
"""Logs are created automatically"""
return False
def has_change_permission(self, request, obj=None):
"""Logs are read-only"""
return False
def has_delete_permission(self, request, obj=None):
"""Allow deletion for cleanup"""
return request.user.is_superuser
def subject_truncated(self, obj):
"""Truncate long subjects"""
if len(obj.subject) > 50:
return f'{obj.subject[:50]}...'
return obj.subject
subject_truncated.short_description = 'Subject'
def message_id_short(self, obj):
"""Show truncated message ID"""
if obj.message_id:
return f'{obj.message_id[:20]}...' if len(obj.message_id) > 20 else obj.message_id
return '-'
message_id_short.short_description = 'Message ID'
def status_badge(self, obj):
"""Show status with color badge"""
colors = {
'sent': '#3b82f6',
'delivered': '#22c55e',
'failed': '#ef4444',
'bounced': '#f59e0b',
}
color = colors.get(obj.status, '#6b7280')
return format_html(
'<span style="background: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
color, obj.status.upper()
)
status_badge.short_description = 'Status'
status_badge.allow_tags = True

View File

@@ -0,0 +1,292 @@
"""
Email Configuration Models for IGNY8
Provides database-driven email settings, template management, and send test functionality.
Works with the existing EmailService and IntegrationProvider models.
"""
from django.db import models
from django.conf import settings
class EmailSettings(models.Model):
"""
Global email settings - singleton model for email configuration.
Stores default email settings that can be managed through Django admin.
These settings work alongside IntegrationProvider (resend) configuration.
"""
# Default sender settings
from_email = models.EmailField(
default='noreply@igny8.com',
help_text='Default sender email address (must be verified in Resend)'
)
from_name = models.CharField(
max_length=100,
default='IGNY8',
help_text='Default sender display name'
)
reply_to_email = models.EmailField(
default='support@igny8.com',
help_text='Default reply-to email address'
)
# Company branding for emails
company_name = models.CharField(
max_length=100,
default='IGNY8',
help_text='Company name shown in emails'
)
company_address = models.TextField(
blank=True,
help_text='Company address for email footer (CAN-SPAM compliance)'
)
logo_url = models.URLField(
blank=True,
help_text='URL to company logo for emails'
)
# Support links
support_email = models.EmailField(
default='support@igny8.com',
help_text='Support email shown in emails'
)
support_url = models.URLField(
blank=True,
help_text='Link to support/help center'
)
unsubscribe_url = models.URLField(
blank=True,
help_text='URL for email unsubscribe (for marketing emails)'
)
# Feature flags
send_welcome_emails = models.BooleanField(
default=True,
help_text='Send welcome email on user registration'
)
send_billing_emails = models.BooleanField(
default=True,
help_text='Send payment confirmation, invoice emails'
)
send_subscription_emails = models.BooleanField(
default=True,
help_text='Send subscription renewal reminders'
)
send_low_credit_warnings = models.BooleanField(
default=True,
help_text='Send low credit warning emails'
)
# Credit warning threshold
low_credit_threshold = models.IntegerField(
default=100,
help_text='Send warning when credits fall below this value'
)
renewal_reminder_days = models.IntegerField(
default=7,
help_text='Days before subscription renewal to send reminder'
)
# Audit
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='email_settings_updates'
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_email_settings'
verbose_name = 'Email Settings'
verbose_name_plural = 'Email Settings'
def __str__(self):
return f'Email Settings (from: {self.from_email})'
def save(self, *args, **kwargs):
"""Ensure only one instance exists (singleton)"""
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_settings(cls):
"""Get singleton settings instance, creating if needed"""
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class EmailTemplate(models.Model):
"""
Email template metadata - tracks available email templates
and their usage/configuration.
Templates are stored as Django templates in templates/emails/.
This model provides admin visibility and test sending capability.
"""
TEMPLATE_TYPE_CHOICES = [
('auth', 'Authentication'),
('billing', 'Billing'),
('notification', 'Notification'),
('marketing', 'Marketing'),
]
# Template identification
template_name = models.CharField(
max_length=100,
unique=True,
help_text='Template file name without extension (e.g., "welcome")'
)
template_path = models.CharField(
max_length=200,
help_text='Full template path (e.g., "emails/welcome.html")'
)
# Display info
display_name = models.CharField(
max_length=100,
help_text='Human-readable template name'
)
description = models.TextField(
blank=True,
help_text='Description of when this template is used'
)
template_type = models.CharField(
max_length=20,
choices=TEMPLATE_TYPE_CHOICES,
default='notification'
)
# Default subject
default_subject = models.CharField(
max_length=200,
help_text='Default email subject line'
)
# Required context variables
required_context = models.JSONField(
default=list,
blank=True,
help_text='List of required context variables for this template'
)
# Sample context for testing
sample_context = models.JSONField(
default=dict,
blank=True,
help_text='Sample context for test sending (JSON)'
)
# Status
is_active = models.BooleanField(
default=True,
help_text='Whether this template is currently in use'
)
# Stats
send_count = models.IntegerField(
default=0,
help_text='Number of emails sent using this template'
)
last_sent_at = models.DateTimeField(
null=True,
blank=True,
help_text='Last time an email was sent with this template'
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_email_templates'
verbose_name = 'Email Template'
verbose_name_plural = 'Email Templates'
ordering = ['template_type', 'display_name']
def __str__(self):
return f'{self.display_name} ({self.template_type})'
class EmailLog(models.Model):
"""
Log of sent emails for audit and debugging.
"""
STATUS_CHOICES = [
('sent', 'Sent'),
('delivered', 'Delivered'),
('failed', 'Failed'),
('bounced', 'Bounced'),
]
# Email identification
message_id = models.CharField(
max_length=200,
blank=True,
help_text='Provider message ID (from Resend)'
)
# Recipients
to_email = models.EmailField(
help_text='Recipient email'
)
from_email = models.EmailField(
help_text='Sender email'
)
# Content
subject = models.CharField(
max_length=500,
help_text='Email subject'
)
template_name = models.CharField(
max_length=100,
blank=True,
help_text='Template used (if any)'
)
# Status
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='sent'
)
provider = models.CharField(
max_length=50,
default='resend',
help_text='Email provider used'
)
# Error tracking
error_message = models.TextField(
blank=True,
help_text='Error message if failed'
)
# Metadata
tags = models.JSONField(
default=list,
blank=True,
help_text='Email tags for categorization'
)
# Timestamps
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'igny8_email_log'
verbose_name = 'Email Log'
verbose_name_plural = 'Email Logs'
ordering = ['-sent_at']
indexes = [
models.Index(fields=['to_email', 'sent_at']),
models.Index(fields=['status', 'sent_at']),
models.Index(fields=['template_name', 'sent_at']),
]
def __str__(self):
return f'{self.subject}{self.to_email} ({self.status})'

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.2.10 on 2026-01-08 01:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0019_model_schema_update'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('template_name', models.CharField(help_text='Template file name without extension (e.g., "welcome")', max_length=100, unique=True)),
('template_path', models.CharField(help_text='Full template path (e.g., "emails/welcome.html")', max_length=200)),
('display_name', models.CharField(help_text='Human-readable template name', max_length=100)),
('description', models.TextField(blank=True, help_text='Description of when this template is used')),
('template_type', models.CharField(choices=[('auth', 'Authentication'), ('billing', 'Billing'), ('notification', 'Notification'), ('marketing', 'Marketing')], default='notification', max_length=20)),
('default_subject', models.CharField(help_text='Default email subject line', max_length=200)),
('required_context', models.JSONField(blank=True, default=list, help_text='List of required context variables for this template')),
('sample_context', models.JSONField(blank=True, default=dict, help_text='Sample context for test sending (JSON)')),
('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')),
('send_count', models.IntegerField(default=0, help_text='Number of emails sent using this template')),
('last_sent_at', models.DateTimeField(blank=True, help_text='Last time an email was sent with this template', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Email Template',
'verbose_name_plural': 'Email Templates',
'db_table': 'igny8_email_templates',
'ordering': ['template_type', 'display_name'],
},
),
migrations.CreateModel(
name='EmailLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message_id', models.CharField(blank=True, help_text='Provider message ID (from Resend)', max_length=200)),
('to_email', models.EmailField(help_text='Recipient email', max_length=254)),
('from_email', models.EmailField(help_text='Sender email', max_length=254)),
('subject', models.CharField(help_text='Email subject', max_length=500)),
('template_name', models.CharField(blank=True, help_text='Template used (if any)', max_length=100)),
('status', models.CharField(choices=[('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], default='sent', max_length=20)),
('provider', models.CharField(default='resend', help_text='Email provider used', max_length=50)),
('error_message', models.TextField(blank=True, help_text='Error message if failed')),
('tags', models.JSONField(blank=True, default=list, help_text='Email tags for categorization')),
('sent_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Email Log',
'verbose_name_plural': 'Email Logs',
'db_table': 'igny8_email_log',
'ordering': ['-sent_at'],
'indexes': [models.Index(fields=['to_email', 'sent_at'], name='igny8_email_to_emai_f0efbd_idx'), models.Index(fields=['status', 'sent_at'], name='igny8_email_status_7107f0_idx'), models.Index(fields=['template_name', 'sent_at'], name='igny8_email_templat_e979b9_idx')],
},
),
migrations.CreateModel(
name='EmailSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('from_email', models.EmailField(default='noreply@igny8.com', help_text='Default sender email address (must be verified in Resend)', max_length=254)),
('from_name', models.CharField(default='IGNY8', help_text='Default sender display name', max_length=100)),
('reply_to_email', models.EmailField(default='support@igny8.com', help_text='Default reply-to email address', max_length=254)),
('company_name', models.CharField(default='IGNY8', help_text='Company name shown in emails', max_length=100)),
('company_address', models.TextField(blank=True, help_text='Company address for email footer (CAN-SPAM compliance)')),
('logo_url', models.URLField(blank=True, help_text='URL to company logo for emails')),
('support_email', models.EmailField(default='support@igny8.com', help_text='Support email shown in emails', max_length=254)),
('support_url', models.URLField(blank=True, help_text='Link to support/help center')),
('unsubscribe_url', models.URLField(blank=True, help_text='URL for email unsubscribe (for marketing emails)')),
('send_welcome_emails', models.BooleanField(default=True, help_text='Send welcome email on user registration')),
('send_billing_emails', models.BooleanField(default=True, help_text='Send payment confirmation, invoice emails')),
('send_subscription_emails', models.BooleanField(default=True, help_text='Send subscription renewal reminders')),
('send_low_credit_warnings', models.BooleanField(default=True, help_text='Send low credit warning emails')),
('low_credit_threshold', models.IntegerField(default=100, help_text='Send warning when credits fall below this value')),
('renewal_reminder_days', models.IntegerField(default=7, help_text='Days before subscription renewal to send reminder')),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_settings_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Email Settings',
'verbose_name_plural': 'Email Settings',
'db_table': 'igny8_email_settings',
},
),
# Note: AccountIntegrationOverride delete removed - table doesn't exist in DB
]