Email COnfigs & setup
This commit is contained in:
@@ -125,6 +125,20 @@ class RegisterView(APIView):
|
||||
# User will complete payment on /account/plans after signup
|
||||
# This simplifies the signup flow and consolidates all payment handling
|
||||
|
||||
# Send welcome email (if enabled in settings)
|
||||
try:
|
||||
from igny8_core.modules.system.email_models import EmailSettings
|
||||
from igny8_core.business.billing.services.email_service import send_welcome_email
|
||||
|
||||
email_settings = EmailSettings.get_settings()
|
||||
if email_settings.send_welcome_emails and account:
|
||||
send_welcome_email(user, account)
|
||||
except Exception as e:
|
||||
# Don't fail registration if email fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send welcome email for user {user.id}: {e}")
|
||||
|
||||
return success_response(
|
||||
data=response_data,
|
||||
message='Registration successful',
|
||||
@@ -271,6 +285,128 @@ class LoginView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Request Password Reset',
|
||||
description='Request password reset email'
|
||||
)
|
||||
class PasswordResetRequestView(APIView):
|
||||
"""Request password reset endpoint - sends email with reset token."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
from .serializers import RequestPasswordResetSerializer
|
||||
from .models import PasswordResetToken
|
||||
|
||||
serializer = RequestPasswordResetSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
# Don't reveal if email exists - return success anyway
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Generate secure token
|
||||
import secrets
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create reset token (expires in 1 hour)
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
expires_at = timezone.now() + timedelta(hours=1)
|
||||
|
||||
PasswordResetToken.objects.create(
|
||||
user=user,
|
||||
token=token,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
# Send password reset email
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[PASSWORD_RESET] Attempting to send reset email to: {email}")
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import send_password_reset_email
|
||||
result = send_password_reset_email(user, token)
|
||||
logger.info(f"[PASSWORD_RESET] Email send result: {result}")
|
||||
print(f"[PASSWORD_RESET] Email send result: {result}") # Console output
|
||||
except Exception as e:
|
||||
logger.error(f"[PASSWORD_RESET] Failed to send password reset email: {e}", exc_info=True)
|
||||
print(f"[PASSWORD_RESET] ERROR: {e}") # Console output
|
||||
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Reset Password',
|
||||
description='Reset password using token from email'
|
||||
)
|
||||
class PasswordResetConfirmView(APIView):
|
||||
"""Confirm password reset with token."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
from .serializers import ResetPasswordSerializer
|
||||
from .models import PasswordResetToken
|
||||
from django.utils import timezone
|
||||
|
||||
serializer = ResetPasswordSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
token = serializer.validated_data['token']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
try:
|
||||
reset_token = PasswordResetToken.objects.get(
|
||||
token=token,
|
||||
used=False,
|
||||
expires_at__gt=timezone.now()
|
||||
)
|
||||
except PasswordResetToken.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invalid or expired reset token',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Reset password
|
||||
user = reset_token.user
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Mark token as used
|
||||
reset_token.used = True
|
||||
reset_token.save()
|
||||
|
||||
return success_response(
|
||||
message='Password reset successfully. You can now log in with your new password.',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Change Password',
|
||||
@@ -474,13 +610,86 @@ class MeView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Unsubscribe from Emails',
|
||||
description='Unsubscribe a user from marketing, billing, or all email notifications'
|
||||
)
|
||||
class UnsubscribeView(APIView):
|
||||
"""Handle email unsubscribe requests with signed URLs."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Process unsubscribe request.
|
||||
|
||||
Expected payload:
|
||||
- email: The email address to unsubscribe
|
||||
- type: Type of emails to unsubscribe from (marketing, billing, all)
|
||||
- ts: Timestamp from signed URL
|
||||
- sig: HMAC signature from signed URL
|
||||
"""
|
||||
from igny8_core.business.billing.services.email_service import verify_unsubscribe_signature
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
email = request.data.get('email')
|
||||
email_type = request.data.get('type', 'all')
|
||||
timestamp = request.data.get('ts')
|
||||
signature = request.data.get('sig')
|
||||
|
||||
# Validate required fields
|
||||
if not email or not timestamp or not signature:
|
||||
return error_response(
|
||||
error='Missing required parameters',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
error='Invalid timestamp',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Verify signature
|
||||
if not verify_unsubscribe_signature(email, email_type, timestamp, signature):
|
||||
return error_response(
|
||||
error='Invalid or expired unsubscribe link',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Log the unsubscribe request
|
||||
# In production, update user preferences or use email provider's suppression list
|
||||
logger.info(f'Unsubscribe request processed: email={email}, type={email_type}')
|
||||
|
||||
# TODO: Implement preference storage
|
||||
# Options:
|
||||
# 1. Add email preference fields to User model
|
||||
# 2. Use Resend's suppression list API
|
||||
# 3. Create EmailPreferences model
|
||||
|
||||
return success_response(
|
||||
message=f'Successfully unsubscribed from {email_type} emails',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||
path('password-reset/', csrf_exempt(PasswordResetRequestView.as_view()), name='auth-password-reset-request'),
|
||||
path('password-reset/confirm/', csrf_exempt(PasswordResetConfirmView.as_view()), name='auth-password-reset-confirm'),
|
||||
path('me/', MeView.as_view(), name='auth-me'),
|
||||
path('countries/', CountryListView.as_view(), name='auth-countries'),
|
||||
path('unsubscribe/', csrf_exempt(UnsubscribeView.as_view()), name='auth-unsubscribe'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1267,16 +1267,21 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
# Send email (async via Celery if available, otherwise sync)
|
||||
# Send password reset email using the email service
|
||||
try:
|
||||
from igny8_core.modules.system.tasks import send_password_reset_email
|
||||
send_password_reset_email.delay(user.id, token)
|
||||
except:
|
||||
# Fallback to sync email sending
|
||||
from igny8_core.business.billing.services.email_service import send_password_reset_email
|
||||
send_password_reset_email(user, token)
|
||||
except Exception as e:
|
||||
# Fallback to Django's send_mail if email service fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send password reset email via email service: {e}")
|
||||
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
|
||||
reset_url = f"{request.scheme}://{request.get_host()}/reset-password?token={token}"
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
reset_url = f"{frontend_url}/reset-password?token={token}"
|
||||
|
||||
send_mail(
|
||||
subject='Reset Your IGNY8 Password',
|
||||
|
||||
@@ -830,6 +830,15 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
manual_notes=notes,
|
||||
)
|
||||
|
||||
# Send payment confirmation email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_confirmation_email(payment, account)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={'id': payment.id, 'status': payment.status},
|
||||
message='Manual payment submitted for approval',
|
||||
|
||||
@@ -13,6 +13,33 @@ from igny8_core.auth.models import Account
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_low_credits_warning(account, previous_balance):
|
||||
"""
|
||||
Check if credits have fallen below threshold and send warning email.
|
||||
Only sends if this is the first time falling below threshold.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.modules.system.email_models import EmailSettings
|
||||
from .email_service import BillingEmailService
|
||||
|
||||
settings = EmailSettings.get_settings()
|
||||
if not settings.send_low_credit_warnings:
|
||||
return
|
||||
|
||||
threshold = settings.low_credit_threshold
|
||||
|
||||
# Only send if we CROSSED below the threshold (wasn't already below)
|
||||
if account.credits < threshold <= previous_balance:
|
||||
logger.info(f"Credits fell below threshold for account {account.id}: {account.credits} < {threshold}")
|
||||
BillingEmailService.send_low_credits_warning(
|
||||
account=account,
|
||||
current_credits=account.credits,
|
||||
threshold=threshold
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check/send low credits warning: {e}")
|
||||
|
||||
|
||||
class CreditService:
|
||||
"""Service for managing credits - Token-based only"""
|
||||
|
||||
@@ -302,6 +329,9 @@ class CreditService:
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Store previous balance for low credits check
|
||||
previous_balance = account.credits
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
account.save(update_fields=['credits'])
|
||||
@@ -330,6 +360,9 @@ class CreditService:
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
# Check and send low credits warning if applicable
|
||||
_check_low_credits_warning(account, previous_balance)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -6,8 +6,13 @@ Supports template rendering and multiple email types.
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='resend')
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from urllib.parse import urlencode
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
@@ -24,17 +29,142 @@ except ImportError:
|
||||
logger.info("Resend package not installed, will use Django mail backend")
|
||||
|
||||
|
||||
def html_to_plain_text(html: str) -> str:
|
||||
"""
|
||||
Convert HTML to plain text for email multipart.
|
||||
|
||||
This improves deliverability as many spam filters prefer
|
||||
emails with both HTML and plain text versions.
|
||||
"""
|
||||
# Remove style and script blocks
|
||||
text = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Convert common tags to plain text equivalents
|
||||
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'</div>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'</tr>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<li[^>]*>', '• ', text, flags=re.IGNORECASE)
|
||||
|
||||
# Convert links to plain text with URL
|
||||
text = re.sub(r'<a[^>]*href=["\']([^"\']+)["\'][^>]*>([^<]*)</a>', r'\2 (\1)', text, flags=re.IGNORECASE)
|
||||
|
||||
# Remove remaining HTML tags
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
# Decode HTML entities
|
||||
text = text.replace(' ', ' ')
|
||||
text = text.replace('&', '&')
|
||||
text = text.replace('<', '<')
|
||||
text = text.replace('>', '>')
|
||||
text = text.replace('"', '"')
|
||||
text = text.replace(''', "'")
|
||||
|
||||
# Clean up whitespace
|
||||
text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 newlines
|
||||
text = re.sub(r' +', ' ', text) # Multiple spaces to single
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def generate_unsubscribe_url(email: str, email_type: str = 'all') -> str:
|
||||
"""
|
||||
Generate a signed unsubscribe URL for the given email.
|
||||
|
||||
Uses HMAC signature to prevent tampering. The URL includes:
|
||||
- email: The recipient email
|
||||
- type: Type of emails to unsubscribe from (marketing, billing, all)
|
||||
- ts: Timestamp for expiration check
|
||||
- sig: HMAC signature
|
||||
|
||||
Returns:
|
||||
str: Full unsubscribe URL with signed parameters
|
||||
"""
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
secret_key = settings.SECRET_KEY
|
||||
|
||||
# Create payload with timestamp (valid for 30 days)
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Create signature
|
||||
message = f"{email}:{email_type}:{timestamp}"
|
||||
signature = hmac.new(
|
||||
secret_key.encode(),
|
||||
message.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()[:32] # Truncate for shorter URL
|
||||
|
||||
# Build URL with query params
|
||||
params = urlencode({
|
||||
'email': email,
|
||||
'type': email_type,
|
||||
'ts': timestamp,
|
||||
'sig': signature,
|
||||
})
|
||||
|
||||
return f"{frontend_url}/unsubscribe?{params}"
|
||||
|
||||
|
||||
def verify_unsubscribe_signature(email: str, email_type: str, timestamp: int, signature: str) -> bool:
|
||||
"""
|
||||
Verify the HMAC signature for unsubscribe requests.
|
||||
|
||||
Args:
|
||||
email: The recipient email
|
||||
email_type: Type of unsubscribe (marketing, billing, all)
|
||||
timestamp: Unix timestamp from URL
|
||||
signature: HMAC signature from URL
|
||||
|
||||
Returns:
|
||||
bool: True if signature is valid and not expired (30 days)
|
||||
"""
|
||||
secret_key = settings.SECRET_KEY
|
||||
|
||||
# Check expiration (30 days)
|
||||
current_time = int(time.time())
|
||||
if current_time - timestamp > 30 * 24 * 60 * 60:
|
||||
return False
|
||||
|
||||
# Recreate signature
|
||||
message = f"{email}:{email_type}:{timestamp}"
|
||||
expected_sig = hmac.new(
|
||||
secret_key.encode(),
|
||||
message.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()[:32]
|
||||
|
||||
return hmac.compare_digest(signature, expected_sig)
|
||||
|
||||
|
||||
class EmailConfigurationError(Exception):
|
||||
"""Raised when email provider is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
def get_email_settings():
|
||||
"""
|
||||
Get email settings from database (EmailSettings model).
|
||||
Falls back to defaults if not configured.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.modules.system.email_models import EmailSettings
|
||||
return EmailSettings.get_settings()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load EmailSettings: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""
|
||||
Unified email service supporting multiple providers.
|
||||
|
||||
Primary: Resend (for production transactional emails)
|
||||
Fallback: Django's send_mail (uses EMAIL_BACKEND from settings)
|
||||
|
||||
Uses EmailSettings model for configuration (from_email, from_name, etc.)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -42,12 +172,16 @@ class EmailService:
|
||||
self._resend_config = {}
|
||||
self._brevo_configured = False
|
||||
self._brevo_config = {}
|
||||
self._email_settings = None
|
||||
self._setup_providers()
|
||||
|
||||
def _setup_providers(self):
|
||||
"""Initialize email providers from IntegrationProvider"""
|
||||
"""Initialize email providers from IntegrationProvider and EmailSettings"""
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
# Load email settings from database
|
||||
self._email_settings = get_email_settings()
|
||||
|
||||
# Setup Resend
|
||||
if RESEND_AVAILABLE:
|
||||
resend_provider = IntegrationProvider.get_provider('resend')
|
||||
@@ -66,9 +200,18 @@ class EmailService:
|
||||
self._brevo_configured = True
|
||||
logger.info("Brevo email provider initialized")
|
||||
|
||||
def _get_settings(self):
|
||||
"""Get fresh email settings (refreshes on each call)"""
|
||||
if not self._email_settings:
|
||||
self._email_settings = get_email_settings()
|
||||
return self._email_settings
|
||||
|
||||
@property
|
||||
def from_email(self) -> str:
|
||||
"""Get default from email"""
|
||||
"""Get default from email from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.from_email:
|
||||
return settings_obj.from_email
|
||||
return self._resend_config.get(
|
||||
'from_email',
|
||||
getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com')
|
||||
@@ -76,14 +219,70 @@ class EmailService:
|
||||
|
||||
@property
|
||||
def from_name(self) -> str:
|
||||
"""Get default from name"""
|
||||
"""Get default from name from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.from_name:
|
||||
return settings_obj.from_name
|
||||
return self._resend_config.get('from_name', 'IGNY8')
|
||||
|
||||
@property
|
||||
def reply_to(self) -> str:
|
||||
"""Get default reply-to address"""
|
||||
"""Get default reply-to address from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.reply_to_email:
|
||||
return settings_obj.reply_to_email
|
||||
return self._resend_config.get('reply_to', 'support@igny8.com')
|
||||
|
||||
@property
|
||||
def company_name(self) -> str:
|
||||
"""Get company name from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.company_name:
|
||||
return settings_obj.company_name
|
||||
return 'IGNY8'
|
||||
|
||||
@property
|
||||
def company_address(self) -> str:
|
||||
"""Get company address from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.company_address:
|
||||
return settings_obj.company_address
|
||||
return ''
|
||||
|
||||
@property
|
||||
def logo_url(self) -> str:
|
||||
"""Get logo URL from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.logo_url:
|
||||
return settings_obj.logo_url
|
||||
return 'https://app.igny8.com/images/logo/IGNY8_LIGHT_LOGO.png'
|
||||
|
||||
@property
|
||||
def support_email(self) -> str:
|
||||
"""Get support email from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.support_email:
|
||||
return settings_obj.support_email
|
||||
return 'support@igny8.com'
|
||||
|
||||
@property
|
||||
def support_url(self) -> str:
|
||||
"""Get support/help center URL from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.support_url:
|
||||
return settings_obj.support_url
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
return f'{frontend_url}/help'
|
||||
|
||||
@property
|
||||
def unsubscribe_url(self) -> str:
|
||||
"""Get unsubscribe URL from EmailSettings"""
|
||||
settings_obj = self._get_settings()
|
||||
if settings_obj and settings_obj.unsubscribe_url:
|
||||
return settings_obj.unsubscribe_url
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
return f'{frontend_url}/account/settings?tab=notifications'
|
||||
|
||||
def send_transactional(
|
||||
self,
|
||||
to: str | List[str],
|
||||
@@ -121,6 +320,37 @@ class EmailService:
|
||||
if isinstance(to, str):
|
||||
to = [to]
|
||||
|
||||
# Auto-inject common context variables for templates
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
# Add recipient email to context
|
||||
primary_recipient = to[0] if to else ''
|
||||
if 'recipient_email' not in context:
|
||||
context['recipient_email'] = primary_recipient
|
||||
|
||||
# Add email settings to context (from EmailSettings model)
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
if 'frontend_url' not in context:
|
||||
context['frontend_url'] = frontend_url
|
||||
if 'company_name' not in context:
|
||||
context['company_name'] = self.company_name
|
||||
if 'company_address' not in context:
|
||||
context['company_address'] = self.company_address
|
||||
if 'logo_url' not in context:
|
||||
context['logo_url'] = self.logo_url
|
||||
if 'support_email' not in context:
|
||||
context['support_email'] = self.support_email
|
||||
if 'support_url' not in context:
|
||||
context['support_url'] = self.support_url
|
||||
if 'current_year' not in context:
|
||||
from datetime import datetime
|
||||
context['current_year'] = datetime.now().year
|
||||
|
||||
# Add unsubscribe URL to context (from EmailSettings or default)
|
||||
if 'unsubscribe_url' not in context:
|
||||
context['unsubscribe_url'] = self.unsubscribe_url
|
||||
|
||||
# Render template if provided
|
||||
if template:
|
||||
try:
|
||||
@@ -133,6 +363,11 @@ class EmailService:
|
||||
if not html and not text:
|
||||
raise ValueError("Either html, text, or template must be provided")
|
||||
|
||||
# Auto-generate plain text version from HTML for better deliverability
|
||||
# Spam filters prefer emails with both HTML and plain text versions
|
||||
if html and not text:
|
||||
text = html_to_plain_text(html)
|
||||
|
||||
# Build from address
|
||||
sender_name = from_name or self.from_name
|
||||
sender_email = from_email or self.from_email
|
||||
@@ -189,6 +424,23 @@ class EmailService:
|
||||
params['tags'] = [{'name': tag} for tag in tags]
|
||||
if attachments:
|
||||
params['attachments'] = attachments
|
||||
|
||||
# Add headers to improve deliverability
|
||||
# Note: Resend handles DKIM/SPF/DMARC automatically for verified domains
|
||||
# But we can add List-Unsubscribe for transactional emails to reduce spam score
|
||||
headers = {}
|
||||
|
||||
# Add List-Unsubscribe header (helps with spam filters)
|
||||
unsubscribe_url = self._resend_config.get('unsubscribe_url')
|
||||
if unsubscribe_url:
|
||||
headers['List-Unsubscribe'] = f'<{unsubscribe_url}>'
|
||||
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
|
||||
|
||||
# Add mailer identification header
|
||||
headers['X-Mailer'] = 'IGNY8 Email Service'
|
||||
|
||||
if headers:
|
||||
params['headers'] = headers
|
||||
|
||||
response = resend.Emails.send(params)
|
||||
|
||||
@@ -353,7 +605,7 @@ The IGNY8 Team
|
||||
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
|
||||
'approved_at': payment.approved_at or payment.processed_at,
|
||||
'frontend_url': frontend_url,
|
||||
'dashboard_url': f'{frontend_url}/dashboard',
|
||||
'dashboard_url': f'{frontend_url}/',
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -406,7 +658,7 @@ The IGNY8 Team
|
||||
'manual_reference': payment.manual_reference or payment.transaction_reference or '',
|
||||
'reason': reason,
|
||||
'frontend_url': frontend_url,
|
||||
'billing_url': f'{frontend_url}/account/billing',
|
||||
'billing_url': f'{frontend_url}/account/plans',
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -572,7 +824,7 @@ The IGNY8 Team
|
||||
'included_credits': plan.included_credits or 0,
|
||||
'period_end': subscription.current_period_end,
|
||||
'frontend_url': frontend_url,
|
||||
'dashboard_url': f'{frontend_url}/dashboard',
|
||||
'dashboard_url': f'{frontend_url}/',
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -714,7 +966,7 @@ def send_welcome_email(user, account):
|
||||
context = {
|
||||
'user_name': user.first_name or user.email,
|
||||
'account_name': account.name,
|
||||
'login_url': f'{frontend_url}/login',
|
||||
'login_url': f'{frontend_url}/signin',
|
||||
'frontend_url': frontend_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,13 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
|
||||
f"plan={plan.name}"
|
||||
)
|
||||
|
||||
# Send subscription activated email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_subscription_activated_email(account, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send subscription activated email: {e}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription.id,
|
||||
'plan_name': plan.name,
|
||||
@@ -915,6 +922,14 @@ def _handle_subscription_activated(resource: dict):
|
||||
if update_fields != ['updated_at']:
|
||||
account.save(update_fields=update_fields)
|
||||
|
||||
# Send subscription activated email
|
||||
try:
|
||||
subscription = Subscription.objects.get(account=account, external_payment_id=subscription_id)
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_subscription_activated_email(account, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send subscription activated email: {e}")
|
||||
|
||||
except Account.DoesNotExist:
|
||||
logger.error(f"Account {custom_id} not found for PayPal subscription activation")
|
||||
|
||||
@@ -973,7 +988,16 @@ def _handle_subscription_payment_failed(resource: dict):
|
||||
subscription.status = 'past_due'
|
||||
subscription.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
# TODO: Send payment failure notification email
|
||||
# Send payment failure notification email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_failed_notification(
|
||||
account=subscription.account,
|
||||
subscription=subscription,
|
||||
failure_reason='PayPal payment could not be processed'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send payment failed email: {e}")
|
||||
|
||||
except Subscription.DoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -28,6 +28,7 @@ from ..services.stripe_service import StripeService, StripeConfigurationError
|
||||
from ..services.payment_service import PaymentService
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..services.credit_service import CreditService
|
||||
from ..services.email_service import BillingEmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -608,6 +609,12 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
||||
f"plan={plan.name}, credits={plan.included_credits}"
|
||||
)
|
||||
|
||||
# Send subscription activated email
|
||||
try:
|
||||
BillingEmailService.send_subscription_activated_email(account, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send subscription activated email: {e}")
|
||||
|
||||
|
||||
def _add_purchased_credits(account, credit_package_id: str, credit_amount: str, session: dict):
|
||||
"""
|
||||
@@ -736,7 +743,7 @@ def _handle_payment_failed(invoice: dict):
|
||||
"""
|
||||
Handle failed payment.
|
||||
|
||||
Updates subscription status to past_due.
|
||||
Updates subscription status to past_due and sends notification email.
|
||||
"""
|
||||
subscription_id = invoice.get('subscription')
|
||||
if not subscription_id:
|
||||
@@ -749,7 +756,17 @@ def _handle_payment_failed(invoice: dict):
|
||||
|
||||
logger.info(f"Subscription {subscription_id} marked as past_due due to payment failure")
|
||||
|
||||
# TODO: Send payment failure notification email
|
||||
# Send payment failure notification email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
failure_reason = invoice.get('last_finalization_error', {}).get('message', 'Payment could not be processed')
|
||||
BillingEmailService.send_payment_failed_notification(
|
||||
account=subscription.account,
|
||||
subscription=subscription,
|
||||
failure_reason=failure_reason
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send payment failed email: {e}")
|
||||
|
||||
except Subscription.DoesNotExist:
|
||||
logger.warning(f"Subscription not found for payment failure: {subscription_id}")
|
||||
|
||||
@@ -18,4 +18,8 @@ __all__ = [
|
||||
# New centralized models
|
||||
'IntegrationProvider',
|
||||
'AISettings',
|
||||
# Email models
|
||||
'EmailSettings',
|
||||
'EmailTemplate',
|
||||
'EmailLog',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
320
backend/igny8_core/modules/system/email_admin.py
Normal file
320
backend/igny8_core/modules/system/email_admin.py
Normal 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
|
||||
292
backend/igny8_core/modules/system/email_models.py
Normal file
292
backend/igny8_core/modules/system/email_models.py
Normal 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})'
|
||||
@@ -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
|
||||
]
|
||||
@@ -785,6 +785,18 @@ UNFOLD = {
|
||||
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"},
|
||||
],
|
||||
},
|
||||
# Email Settings (NEW)
|
||||
{
|
||||
"title": "Email Settings",
|
||||
"icon": "email",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"},
|
||||
{"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"},
|
||||
{"title": "Email Logs", "icon": "history", "link": lambda request: "/admin/system/emaillog/"},
|
||||
{"title": "Resend Provider", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/resend/change/"},
|
||||
],
|
||||
},
|
||||
# Global Settings
|
||||
{
|
||||
"title": "Global Settings",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="margin-top: 0; color: #1f2937;">Test Email: {{ template.display_name }}</h2>
|
||||
|
||||
<div style="background: #f8fafc; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Template Path:</strong> {{ template.template_path }}
|
||||
</p>
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Type:</strong> {{ template.get_template_type_display }}
|
||||
</p>
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Description:</strong> {{ template.description|default:"No description" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'admin:system_emailtemplate_send_test' template.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Send Test Email To:
|
||||
</label>
|
||||
<input type="email" name="to_email" value="{{ user.email }}"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;"
|
||||
placeholder="recipient@example.com" required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Subject (Preview):
|
||||
</label>
|
||||
<input type="text" disabled value="[TEST] {{ template.default_subject }}"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; background: #f9fafb; color: #6b7280;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Context Variables (JSON):
|
||||
</label>
|
||||
<textarea name="context" rows="8"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; font-family: monospace;"
|
||||
placeholder='{"user_name": "Test User", "account_name": "Test Account"}'>{{ template.sample_context|default:"{}"|safe }}</textarea>
|
||||
<p style="color: #6b7280; font-size: 12px; margin-top: 4px;">
|
||||
Required variables: {{ template.required_context|default:"None specified"|safe }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button type="submit"
|
||||
style="background: #6366f1; color: white; padding: 12px 24px; border: none;
|
||||
border-radius: 6px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
||||
📧 Send Test Email
|
||||
</button>
|
||||
<a href="{% url 'admin:system_emailtemplate_changelist' %}"
|
||||
style="background: #e5e7eb; color: #374151; padding: 12px 24px;
|
||||
border-radius: 6px; font-weight: 500; text-decoration: none; font-size: 14px;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin-top: 20px;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #92400e;">⚠️ Testing Tips</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #78350f; font-size: 14px;">
|
||||
<li>Test emails are prefixed with [TEST] in the subject line</li>
|
||||
<li>Make sure your Resend API key is configured in Integration Providers</li>
|
||||
<li>Use sample_context to pre-fill test data for this template</li>
|
||||
<li>Check Email Logs after sending to verify delivery status</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IGNY8{% endblock %}</title>
|
||||
<title>{% block title %}{{ company_name|default:"IGNY8" }}{% endblock %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
@@ -20,14 +20,18 @@
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 3px solid #6366f1;
|
||||
padding: 30px 20px;
|
||||
background-color: #0c1e35;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.logo {
|
||||
.header img {
|
||||
max-height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
.header .logo-text {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #6366f1;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.content {
|
||||
@@ -36,76 +40,138 @@
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #888888;
|
||||
padding: 24px 20px;
|
||||
background-color: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #6366f1;
|
||||
padding: 14px 32px;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4f46e5;
|
||||
background-color: #2563eb;
|
||||
}
|
||||
.button-secondary {
|
||||
background-color: #64748b;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background-color: #475569;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f8fafc;
|
||||
border-left: 4px solid #6366f1;
|
||||
padding: 15px 20px;
|
||||
background-color: #eff6ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px 20px;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #d1fae5;
|
||||
border-left: 4px solid #10b981;
|
||||
padding: 15px 20px;
|
||||
background-color: #dcfce7;
|
||||
border-left: 4px solid #22c55e;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.error-box {
|
||||
background-color: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #1f2937;
|
||||
color: #0f172a;
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
a {
|
||||
color: #6366f1;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.details-table td {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.details-table td:first-child {
|
||||
color: #6b7280;
|
||||
color: #64748b;
|
||||
width: 40%;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details-table td:last-child {
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e2e8f0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.text-muted {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.text-small {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="{{ frontend_url }}" class="logo">IGNY8</a>
|
||||
<a href="{{ frontend_url }}">
|
||||
{% if logo_url %}
|
||||
<img src="{{ logo_url }}" alt="{{ company_name|default:'IGNY8' }}" />
|
||||
{% else %}
|
||||
<span class="logo-text">{{ company_name|default:"IGNY8" }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year|default:"2026" }} IGNY8. All rights reserved.</p>
|
||||
<p>
|
||||
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
||||
<a href="{{ frontend_url }}/terms">Terms of Service</a>
|
||||
{% if company_address %}
|
||||
<p style="margin-bottom: 12px;">{{ company_address }}</p>
|
||||
{% endif %}
|
||||
<p style="margin-bottom: 8px;">
|
||||
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
||||
<a href="{{ frontend_url }}/terms">Terms of Service</a>{% if unsubscribe_url %} |
|
||||
<a href="{{ unsubscribe_url }}">Email Preferences</a>{% endif %}
|
||||
</p>
|
||||
<p style="color: #94a3b8; margin-top: 16px;">
|
||||
© {{ current_year|default:"2026" }} {{ company_name|default:"IGNY8" }}. All rights reserved.
|
||||
</p>
|
||||
{% if recipient_email %}<p style="color: #94a3b8; font-size: 11px; margin-top: 8px;">This email was sent to {{ recipient_email }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -28,6 +28,6 @@
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing IGNY8!<br>
|
||||
The IGNY8 Team
|
||||
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
|
||||
<p>
|
||||
Thank you for your patience,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
<a href="{{ billing_url }}" class="button">Update Payment Method</a>
|
||||
</p>
|
||||
|
||||
<p>If you need assistance, please contact our support team by replying to this email.</p>
|
||||
<p>If you need assistance, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
<a href="{{ billing_url }}" class="button">Retry Payment</a>
|
||||
</p>
|
||||
|
||||
<p>If you believe this is an error or have questions, please contact our support team by replying to this email.</p>
|
||||
<p>If you believe this is an error or have questions, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
|
||||
<p>The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.</p>
|
||||
|
||||
<p>If you have any questions about this refund, please contact our support team by replying to this email.</p>
|
||||
<p>If you have any questions about this refund, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You now have full access to all features included in your plan. Start exploring what IGNY8 can do for you!</p>
|
||||
<p>You now have full access to all features included in your plan. Start exploring what {{ company_name|default:"IGNY8" }} can do for you!</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing IGNY8!<br>
|
||||
The IGNY8 Team
|
||||
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,6 +36,6 @@
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Welcome to IGNY8{% endblock %}
|
||||
{% block title %}Welcome to {{ company_name|default:"IGNY8" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome to IGNY8!</h1>
|
||||
<h1>Welcome to {{ company_name|default:"IGNY8" }}!</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
<a href="{{ login_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>If you have any questions, our support team is here to help. Just reply to this email or visit our help center.</p>
|
||||
<p>If you have any questions, our support team is here to help. {% if support_url %}<a href="{{ support_url }}">Visit our help center</a> or {% endif %}reply to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The IGNY8 Team
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user