From 3651ee9ed4e09b5d4c1261007e98a15c9b19785b Mon Sep 17 00:00:00 2001
From: "IGNY8 VPS (Salman)"
Date: Thu, 8 Jan 2026 05:41:28 +0000
Subject: [PATCH] Email COnfigs & setup
---
backend/igny8_core/auth/urls.py | 209 +++++++++++
backend/igny8_core/auth/views.py | 17 +-
.../business/billing/billing_views.py | 9 +
.../billing/services/credit_service.py | 33 ++
.../billing/services/email_service.py | 268 +++++++++++++-
.../business/billing/views/paypal_views.py | 26 +-
.../business/billing/views/stripe_views.py | 21 +-
backend/igny8_core/modules/system/__init__.py | 4 +
backend/igny8_core/modules/system/admin.py | 3 +
.../igny8_core/modules/system/email_admin.py | 320 ++++++++++++++++
.../igny8_core/modules/system/email_models.py | 292 +++++++++++++++
.../migrations/0020_add_email_models.py | 93 +++++
backend/igny8_core/settings.py | 12 +
.../system/emailtemplate/test_email.html | 78 ++++
backend/igny8_core/templates/emails/base.html | 126 +++++--
.../templates/emails/email_verification.html | 2 +-
.../templates/emails/low_credits.html | 2 +-
.../templates/emails/password_reset.html | 2 +-
.../templates/emails/payment_approved.html | 4 +-
.../emails/payment_confirmation.html | 2 +-
.../templates/emails/payment_failed.html | 4 +-
.../templates/emails/payment_rejected.html | 4 +-
.../templates/emails/refund_notification.html | 4 +-
.../emails/subscription_activated.html | 6 +-
.../emails/subscription_renewal.html | 2 +-
.../igny8_core/templates/emails/welcome.html | 8 +-
.../90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md | 104 +++++-
frontend/src/App.tsx | 12 +
.../src/pages/AuthPages/ForgotPassword.tsx | 177 +++++++++
.../src/pages/AuthPages/ResetPassword.tsx | 344 ++++++++++++++++++
frontend/src/pages/AuthPages/Unsubscribe.tsx | 83 +++++
frontend/src/pages/AuthPages/VerifyEmail.tsx | 220 +++++++++++
frontend/src/pages/legal/Privacy.tsx | 2 +-
frontend/src/pages/legal/Terms.tsx | 2 +-
34 files changed, 2418 insertions(+), 77 deletions(-)
create mode 100644 backend/igny8_core/modules/system/email_admin.py
create mode 100644 backend/igny8_core/modules/system/email_models.py
create mode 100644 backend/igny8_core/modules/system/migrations/0020_add_email_models.py
create mode 100644 backend/igny8_core/templates/admin/system/emailtemplate/test_email.html
create mode 100644 frontend/src/pages/AuthPages/ForgotPassword.tsx
create mode 100644 frontend/src/pages/AuthPages/ResetPassword.tsx
create mode 100644 frontend/src/pages/AuthPages/Unsubscribe.tsx
create mode 100644 frontend/src/pages/AuthPages/VerifyEmail.tsx
diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py
index 705a0f3c..140e4756 100644
--- a/backend/igny8_core/auth/urls.py
+++ b/backend/igny8_core/auth/urls.py
@@ -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'),
]
diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py
index 9b42990f..b6782836 100644
--- a/backend/igny8_core/auth/views.py
+++ b/backend/igny8_core/auth/views.py
@@ -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',
diff --git a/backend/igny8_core/business/billing/billing_views.py b/backend/igny8_core/business/billing/billing_views.py
index 8ffa0e04..0a8cdc4a 100644
--- a/backend/igny8_core/business/billing/billing_views.py
+++ b/backend/igny8_core/business/billing/billing_views.py
@@ -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',
diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py
index 07a347f0..98552ef1 100644
--- a/backend/igny8_core/business/billing/services/credit_service.py
+++ b/backend/igny8_core/business/billing/services/credit_service.py
@@ -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
diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py
index 7b98b634..d7d6d418 100644
--- a/backend/igny8_core/business/billing/services/email_service.py
+++ b/backend/igny8_core/business/billing/services/email_service.py
@@ -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'', '', html, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'', '', text, flags=re.DOTALL | re.IGNORECASE)
+
+ # Convert common tags to plain text equivalents
+ text = re.sub(r' ', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'
', '\n\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r']*>', '• ', text, flags=re.IGNORECASE)
+
+ # Convert links to plain text with URL
+ text = re.sub(r']*href=["\']([^"\']+)["\'][^>]*>([^<]*) ', 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,
}
diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py
index 006b4c28..4a5df942 100644
--- a/backend/igny8_core/business/billing/views/paypal_views.py
+++ b/backend/igny8_core/business/billing/views/paypal_views.py
@@ -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
diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py
index fede16c7..cc06a847 100644
--- a/backend/igny8_core/business/billing/views/stripe_views.py
+++ b/backend/igny8_core/business/billing/views/stripe_views.py
@@ -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}")
diff --git a/backend/igny8_core/modules/system/__init__.py b/backend/igny8_core/modules/system/__init__.py
index 6e9abda4..09f79d2c 100644
--- a/backend/igny8_core/modules/system/__init__.py
+++ b/backend/igny8_core/modules/system/__init__.py
@@ -18,4 +18,8 @@ __all__ = [
# New centralized models
'IntegrationProvider',
'AISettings',
+ # Email models
+ 'EmailSettings',
+ 'EmailTemplate',
+ 'EmailLog',
]
diff --git a/backend/igny8_core/modules/system/admin.py b/backend/igny8_core/modules/system/admin.py
index 1c94223d..b920b289 100644
--- a/backend/igny8_core/modules/system/admin.py
+++ b/backend/igny8_core/modules/system/admin.py
@@ -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
diff --git a/backend/igny8_core/modules/system/email_admin.py b/backend/igny8_core/modules/system/email_admin.py
new file mode 100644
index 00000000..efa4a292
--- /dev/null
+++ b/backend/igny8_core/modules/system/email_admin.py
@@ -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(
+ 'Test ',
+ 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(
+ '/test/',
+ self.admin_site.admin_view(self.test_email_view),
+ name='system_emailtemplate_test'
+ ),
+ path(
+ '/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(
+ '{} ',
+ color, obj.status.upper()
+ )
+ status_badge.short_description = 'Status'
+ status_badge.allow_tags = True
diff --git a/backend/igny8_core/modules/system/email_models.py b/backend/igny8_core/modules/system/email_models.py
new file mode 100644
index 00000000..4325e90f
--- /dev/null
+++ b/backend/igny8_core/modules/system/email_models.py
@@ -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})'
diff --git a/backend/igny8_core/modules/system/migrations/0020_add_email_models.py b/backend/igny8_core/modules/system/migrations/0020_add_email_models.py
new file mode 100644
index 00000000..89e33980
--- /dev/null
+++ b/backend/igny8_core/modules/system/migrations/0020_add_email_models.py
@@ -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
+ ]
diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py
index 19691eed..30b4a9ef 100644
--- a/backend/igny8_core/settings.py
+++ b/backend/igny8_core/settings.py
@@ -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",
diff --git a/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html b/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html
new file mode 100644
index 00000000..d890769c
--- /dev/null
+++ b/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html
@@ -0,0 +1,78 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block content %}
+
+
+
Test Email: {{ template.display_name }}
+
+
+
+ Template Path: {{ template.template_path }}
+
+
+ Type: {{ template.get_template_type_display }}
+
+
+ Description: {{ template.description|default:"No description" }}
+
+
+
+
+
+
+
+
⚠️ Testing Tips
+
+ Test emails are prefixed with [TEST] in the subject line
+ Make sure your Resend API key is configured in Integration Providers
+ Use sample_context to pre-fill test data for this template
+ Check Email Logs after sending to verify delivery status
+
+
+
+{% endblock %}
diff --git a/backend/igny8_core/templates/emails/base.html b/backend/igny8_core/templates/emails/base.html
index bf771356..a5d4058f 100644
--- a/backend/igny8_core/templates/emails/base.html
+++ b/backend/igny8_core/templates/emails/base.html
@@ -3,7 +3,7 @@
- {% block title %}IGNY8{% endblock %}
+ {% block title %}{{ company_name|default:"IGNY8" }}{% endblock %}
{% block content %}{% endblock %}
diff --git a/backend/igny8_core/templates/emails/email_verification.html b/backend/igny8_core/templates/emails/email_verification.html
index 471a34d9..d011afe0 100644
--- a/backend/igny8_core/templates/emails/email_verification.html
+++ b/backend/igny8_core/templates/emails/email_verification.html
@@ -28,6 +28,6 @@
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/low_credits.html b/backend/igny8_core/templates/emails/low_credits.html
index b8b4381e..05276ec1 100644
--- a/backend/igny8_core/templates/emails/low_credits.html
+++ b/backend/igny8_core/templates/emails/low_credits.html
@@ -34,6 +34,6 @@
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/password_reset.html b/backend/igny8_core/templates/emails/password_reset.html
index c6ef2939..e7e80550 100644
--- a/backend/igny8_core/templates/emails/password_reset.html
+++ b/backend/igny8_core/templates/emails/password_reset.html
@@ -26,6 +26,6 @@
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_approved.html b/backend/igny8_core/templates/emails/payment_approved.html
index fcb07e99..eee95b0c 100644
--- a/backend/igny8_core/templates/emails/payment_approved.html
+++ b/backend/igny8_core/templates/emails/payment_approved.html
@@ -41,7 +41,7 @@
-Thank you for choosing IGNY8!
-The IGNY8 Team
+Thank you for choosing {{ company_name|default:"IGNY8" }}!
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_confirmation.html b/backend/igny8_core/templates/emails/payment_confirmation.html
index 3224e9b8..acdc6594 100644
--- a/backend/igny8_core/templates/emails/payment_confirmation.html
+++ b/backend/igny8_core/templates/emails/payment_confirmation.html
@@ -40,6 +40,6 @@
Thank you for your patience,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_failed.html b/backend/igny8_core/templates/emails/payment_failed.html
index 4c00d0b3..5f1e2981 100644
--- a/backend/igny8_core/templates/emails/payment_failed.html
+++ b/backend/igny8_core/templates/emails/payment_failed.html
@@ -30,10 +30,10 @@
Update Payment Method
-If you need assistance, please contact our support team by replying to this email.
+If you need assistance, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/payment_rejected.html b/backend/igny8_core/templates/emails/payment_rejected.html
index 69384885..43f0824f 100644
--- a/backend/igny8_core/templates/emails/payment_rejected.html
+++ b/backend/igny8_core/templates/emails/payment_rejected.html
@@ -40,10 +40,10 @@
Retry Payment
-If you believe this is an error or have questions, please contact our support team by replying to this email.
+If you believe this is an error or have questions, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/refund_notification.html b/backend/igny8_core/templates/emails/refund_notification.html
index 85baef4e..3dc39f48 100644
--- a/backend/igny8_core/templates/emails/refund_notification.html
+++ b/backend/igny8_core/templates/emails/refund_notification.html
@@ -40,10 +40,10 @@
The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.
-If you have any questions about this refund, please contact our support team by replying to this email.
+If you have any questions about this refund, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/subscription_activated.html b/backend/igny8_core/templates/emails/subscription_activated.html
index bdd0fe65..34dc093f 100644
--- a/backend/igny8_core/templates/emails/subscription_activated.html
+++ b/backend/igny8_core/templates/emails/subscription_activated.html
@@ -28,14 +28,14 @@
-You now have full access to all features included in your plan. Start exploring what IGNY8 can do for you!
+You now have full access to all features included in your plan. Start exploring what {{ company_name|default:"IGNY8" }} can do for you!
Go to Dashboard
-Thank you for choosing IGNY8!
-The IGNY8 Team
+Thank you for choosing {{ company_name|default:"IGNY8" }}!
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/subscription_renewal.html b/backend/igny8_core/templates/emails/subscription_renewal.html
index 5352651d..ff6d6aea 100644
--- a/backend/igny8_core/templates/emails/subscription_renewal.html
+++ b/backend/igny8_core/templates/emails/subscription_renewal.html
@@ -36,6 +36,6 @@
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/backend/igny8_core/templates/emails/welcome.html b/backend/igny8_core/templates/emails/welcome.html
index 961aa3a6..e48e2f90 100644
--- a/backend/igny8_core/templates/emails/welcome.html
+++ b/backend/igny8_core/templates/emails/welcome.html
@@ -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 %}
-Welcome to IGNY8!
+Welcome to {{ company_name|default:"IGNY8" }}!
Hi {{ user_name }},
@@ -21,10 +21,10 @@
Go to Dashboard
-If you have any questions, our support team is here to help. Just reply to this email or visit our help center.
+If you have any questions, our support team is here to help. {% if support_url %}Visit our help center or {% endif %}reply to this email.
Best regards,
-The IGNY8 Team
+The {{ company_name|default:"IGNY8" }} Team
{% endblock %}
diff --git a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md
index 04352891..ecff18fa 100644
--- a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md
+++ b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md
@@ -325,25 +325,113 @@ Config (JSON):
4. Click **"Save"**
-### 5.3 Testing Email Delivery
+### 5.3 Email Settings Management
-After configuring Resend, test email delivery:
+IGNY8 provides a dedicated **Email Settings** navigation group in Django Admin:
+
+| Menu Item | URL | Purpose |
+|-----------|-----|---------|
+| Email Configuration | `/admin/system/emailsettings/` | Global email defaults (from, reply-to, feature flags) |
+| Email Templates | `/admin/system/emailtemplate/` | Manage/test email templates |
+| Email Logs | `/admin/system/emaillog/` | View sent email history |
+| Resend Provider | `/admin/system/integrationprovider/resend/change/` | API key & config |
+
+**Email Configuration Settings:**
+- `from_email` - Default sender (must be verified in Resend)
+- `from_name` - Display name for sender
+- `reply_to_email` - Reply-to address
+- `send_welcome_emails` - Toggle welcome emails on/off
+- `send_billing_emails` - Toggle payment/invoice emails
+- `send_subscription_emails` - Toggle renewal reminders
+- `low_credit_threshold` - Credits level to trigger warning email
+
+### 5.4 Testing Email Delivery
+
+**Method 1: Django Admin UI (Recommended)**
+
+1. Go to **Email Settings → Email Templates**
+2. Click the **"Test"** button next to any template
+3. Enter recipient email and customize context JSON
+4. Click **"Send Test Email"**
+5. Check **Email Logs** to verify delivery
+
+**Method 2: Command Line (Docker)**
```bash
-cd /data/app/igny8/backend
-python manage.py shell
-```
-
-```python
+docker exec -it igny8_backend python manage.py shell -c "
from igny8_core.business.billing.services.email_service import get_email_service
service = get_email_service()
-service.send_transactional(
+result = service.send_transactional(
to='your-email@example.com',
subject='Test Email from IGNY8',
html='Test Email If you receive this, Resend is configured correctly!
',
text='Test Email. If you receive this, Resend is configured correctly!'
)
+print('Result:', result)
+"
+```
+
+**Expected successful response:**
+```python
+{'success': True, 'id': '81193754-6f27-4b1a-9c36-d83ae18f6a9a', 'provider': 'resend'}
+```
+
+**Method 3: Test with Template**
+
+```bash
+docker exec -it igny8_backend python manage.py shell -c "
+from igny8_core.business.billing.services.email_service import get_email_service
+
+service = get_email_service()
+result = service.send_transactional(
+ to='your-email@example.com',
+ subject='Welcome Test',
+ template='emails/welcome.html',
+ context={
+ 'user_name': 'Test User',
+ 'account_name': 'Test Account',
+ 'login_url': 'https://app.igny8.com/login',
+ 'frontend_url': 'https://app.igny8.com',
+ },
+ tags=['test']
+)
+print('Result:', result)
+"
+```
+
+### 5.5 Available Email Templates
+
+| Template | Type | Trigger |
+|----------|------|---------|
+| `welcome` | Auth | User registration |
+| `password_reset` | Auth | Password reset request |
+| `email_verification` | Auth | Email verification |
+| `payment_confirmation` | Billing | Manual payment submitted |
+| `payment_approved` | Billing | Payment approved |
+| `payment_rejected` | Billing | Payment declined |
+| `payment_failed` | Billing | Auto-payment failed |
+| `subscription_activated` | Billing | Subscription activated |
+| `subscription_renewal` | Billing | Renewal reminder |
+| `refund_notification` | Billing | Refund processed |
+| `low_credits` | Notification | Credits below threshold |
+
+### 5.6 Email Service API Reference
+
+```python
+send_transactional(
+ to: str | List[str], # Required: recipient email(s)
+ subject: str, # Required: email subject
+ html: str = None, # HTML content
+ text: str = None, # Plain text content
+ template: str = None, # Template path (e.g., 'emails/welcome.html')
+ context: dict = None, # Template context variables
+ from_email: str = None, # Override sender email
+ from_name: str = None, # Override sender name
+ reply_to: str = None, # Reply-to address
+ attachments: List = None, # File attachments
+ tags: List[str] = None # Email tags for tracking
+)
```
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bf11a09e..9f31e0d5 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -23,6 +23,12 @@ import NotFound from "./pages/OtherPage/NotFound";
const Terms = lazy(() => import("./pages/legal/Terms"));
const Privacy = lazy(() => import("./pages/legal/Privacy"));
+// Auth pages - Lazy loaded (password reset, verification, etc.)
+const ForgotPassword = lazy(() => import("./pages/AuthPages/ForgotPassword"));
+const ResetPassword = lazy(() => import("./pages/AuthPages/ResetPassword"));
+const VerifyEmail = lazy(() => import("./pages/AuthPages/VerifyEmail"));
+const Unsubscribe = lazy(() => import("./pages/AuthPages/Unsubscribe"));
+
// Lazy load all other pages - only loads when navigated to
const Home = lazy(() => import("./pages/Dashboard/Home"));
@@ -147,6 +153,12 @@ export default function App() {
{/* Legal Pages - Public */}
} />
} />
+
+ {/* Auth Flow Pages - Public */}
+ } />
+ } />
+ } />
+ } />
{/* Protected Routes - Require Authentication */}
('form');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await fetchAPI('/v1/auth/password-reset/', {
+ method: 'POST',
+ body: JSON.stringify({ email }),
+ });
+ setFormState('success');
+ } catch (err: unknown) {
+ // Always show success to prevent email enumeration
+ setFormState('success');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Success State
+ if (formState === 'success') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Check Your Email
+
+
+ If an account exists for {email} , you'll receive a password reset link shortly.
+
+
+
+ Didn't receive the email?
+
+
+ Check your spam folder
+ Make sure you entered the correct email
+ Wait a few minutes and try again
+
+
+
+ Back to Sign In
+
+
+
+
+ >
+ );
+ }
+
+ // Form State
+ return (
+ <>
+
+
+
+ {/* Logo */}
+
+
+
+
+
+
+
+ {/* Form Card */}
+
+
+
+ Back to Sign In
+
+
+
+
+
+
+
+ Forgot Password?
+
+
+ No worries! Enter your email and we'll send you reset instructions.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Email Address
+
+ setEmail(e.target.value)}
+ className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
+ placeholder="name@example.com"
+ required
+ />
+
+
+
+ {loading ? (
+
+
+
+
+
+ Sending...
+
+ ) : (
+ 'Send Reset Link'
+ )}
+
+
+
+
+ Remember your password?{' '}
+
+ Sign In
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/AuthPages/ResetPassword.tsx b/frontend/src/pages/AuthPages/ResetPassword.tsx
new file mode 100644
index 00000000..a38bd466
--- /dev/null
+++ b/frontend/src/pages/AuthPages/ResetPassword.tsx
@@ -0,0 +1,344 @@
+/**
+ * Reset Password Page
+ * Handles password reset with token from email link
+ */
+
+import { useState, useEffect } from 'react';
+import { Link, useSearchParams, useNavigate } from 'react-router-dom';
+import { fetchAPI } from '../../services/api';
+import PageMeta from '../../components/common/PageMeta';
+import { EyeIcon, EyeCloseIcon, ChevronLeftIcon, CheckCircleIcon, AlertIcon, LockIcon } from '../../icons';
+
+type ResetState = 'form' | 'success' | 'error' | 'expired';
+
+export default function ResetPassword() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get('token');
+
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [resetState, setResetState] = useState('form');
+
+ // Redirect to forgot-password if no token
+ useEffect(() => {
+ if (!token) {
+ // No token - redirect to forgot password page
+ navigate('/forgot-password', { replace: true });
+ }
+ }, [token, navigate]);
+
+ // Password validation checks
+ const passwordChecks = {
+ length: password.length >= 8,
+ uppercase: /[A-Z]/.test(password),
+ lowercase: /[a-z]/.test(password),
+ number: /[0-9]/.test(password),
+ };
+
+ const isPasswordStrong = Object.values(passwordChecks).every(Boolean);
+ const passwordsMatch = password === confirmPassword && confirmPassword.length > 0;
+ const isPasswordValid = isPasswordStrong && passwordsMatch;
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!token) {
+ setResetState('expired');
+ return;
+ }
+
+ if (!isPasswordValid) {
+ setError('Please ensure your password meets all requirements and both passwords match.');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ // Correct API endpoint for password reset confirmation
+ await fetchAPI('/v1/auth/password-reset/confirm/', {
+ method: 'POST',
+ body: JSON.stringify({ token, new_password: password }),
+ });
+ setResetState('success');
+ } catch (err: unknown) {
+ const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } };
+ const message = errorResponse?.response?.data?.message ||
+ errorResponse?.response?.data?.error ||
+ errorResponse?.response?.data?.detail ||
+ 'Failed to reset password. Please try again.';
+
+ if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) {
+ setResetState('expired');
+ } else {
+ setError(message);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Expired/Invalid Token State
+ if (resetState === 'expired') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Link Expired
+
+
+ This password reset link has expired or is invalid. Please request a new one.
+
+
+ Back to Sign In
+
+
+ Need help?{' '}
+
+ Contact Support
+
+
+
+
+
+ >
+ );
+ }
+
+ // Success State
+ if (resetState === 'success') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Password Reset!
+
+
+ Your password has been successfully reset. You can now sign in with your new password.
+
+
navigate('/signin')}
+ className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors"
+ >
+ Sign In
+
+
+
+
+ >
+ );
+ }
+
+ // Form State
+ return (
+ <>
+
+
+
+ {/* Logo */}
+
+
+
+
+
+
+
+ {/* Form Card */}
+
+
+
+ Back to Sign In
+
+
+
+
+
+
+
+ Reset Your Password
+
+
+ Create a strong password for your account.
+
+
+ {error && (
+
+ )}
+
+
+ {/* Password Field */}
+
+
+ New Password
+
+
+ setPassword(e.target.value)}
+ className="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors"
+ placeholder="Enter new password"
+ required
+ />
+ setShowPassword(!showPassword)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
+ >
+ {showPassword ? : }
+
+
+
+ {/* Password Requirements - Always visible for clarity */}
+
+
Password Requirements:
+ {[
+ { check: passwordChecks.length, label: 'At least 8 characters' },
+ { check: passwordChecks.uppercase, label: 'One uppercase letter' },
+ { check: passwordChecks.lowercase, label: 'One lowercase letter' },
+ { check: passwordChecks.number, label: 'One number' },
+ ].map((req, i) => (
+
+
+ {password.length === 0 ? '○' : req.check ? '✓' : '✕'}
+
+ {req.label}
+
+ ))}
+
+
+
+ {/* Confirm Password Field */}
+
+
+ Confirm Password
+
+
+ setConfirmPassword(e.target.value)}
+ className={`w-full px-4 py-3 pr-12 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors ${
+ confirmPassword.length === 0
+ ? 'border-gray-300 dark:border-gray-600'
+ : passwordsMatch
+ ? 'border-green-400 dark:border-green-500'
+ : 'border-red-400 dark:border-red-500'
+ }`}
+ placeholder="Confirm new password"
+ required
+ />
+ setShowConfirmPassword(!showConfirmPassword)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
+ >
+ {showConfirmPassword ? : }
+
+
+ {confirmPassword.length > 0 && (
+
+
+ {passwordsMatch ? '✓' : '✕'}
+
+ {passwordsMatch ? 'Passwords match' : 'Passwords do not match'}
+
+ )}
+
+
+ {/* Submit Button */}
+
+ {loading ? (
+
+
+
+
+
+ Resetting Password...
+
+ ) : (
+ 'Reset Password'
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/AuthPages/Unsubscribe.tsx b/frontend/src/pages/AuthPages/Unsubscribe.tsx
new file mode 100644
index 00000000..77a55f8c
--- /dev/null
+++ b/frontend/src/pages/AuthPages/Unsubscribe.tsx
@@ -0,0 +1,83 @@
+/**
+ * Email Preferences Page
+ * Redirects to account settings for managing email preferences
+ * Transactional emails cannot be unsubscribed - they're always sent
+ */
+
+import { useEffect } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import PageMeta from '../../components/common/PageMeta';
+import { EnvelopeIcon, SettingsIcon } from '../../icons';
+
+export default function Unsubscribe() {
+ const navigate = useNavigate();
+
+ // Auto-redirect to account settings after 3 seconds
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ navigate('/account/settings?tab=notifications');
+ }, 5000);
+ return () => clearTimeout(timer);
+ }, [navigate]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Manage Email Preferences
+
+
+ You can manage your email notification preferences from your account settings.
+
+
+
+
+ Important transactional emails cannot be disabled:
+
+
+ Password reset requests
+ Security alerts
+ Payment confirmations & invoices
+ Account status notifications
+
+
+
+
+
+ Go to Account Settings
+
+
+
+ Back to Home
+
+
+
+ Redirecting to account settings in 5 seconds...
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/AuthPages/VerifyEmail.tsx b/frontend/src/pages/AuthPages/VerifyEmail.tsx
new file mode 100644
index 00000000..f8335f4f
--- /dev/null
+++ b/frontend/src/pages/AuthPages/VerifyEmail.tsx
@@ -0,0 +1,220 @@
+/**
+ * Verify Email Page
+ * Handles email verification with token from email link
+ */
+
+import { useState, useEffect } from 'react';
+import { Link, useSearchParams, useNavigate } from 'react-router-dom';
+import { fetchAPI } from '../../services/api';
+import PageMeta from '../../components/common/PageMeta';
+import { CheckCircleIcon, AlertIcon, EnvelopeIcon } from '../../icons';
+
+type VerifyState = 'verifying' | 'success' | 'error' | 'expired';
+
+export default function VerifyEmail() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get('token');
+
+ const [verifyState, setVerifyState] = useState('verifying');
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ const verifyEmail = async () => {
+ if (!token) {
+ setVerifyState('expired');
+ setErrorMessage('No verification token provided.');
+ return;
+ }
+
+ try {
+ await fetchAPI('/v1/auth/users/verify_email/', {
+ method: 'POST',
+ body: JSON.stringify({ token }),
+ });
+ setVerifyState('success');
+ } catch (err: unknown) {
+ const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } };
+ const message = errorResponse?.response?.data?.error ||
+ errorResponse?.response?.data?.message ||
+ errorResponse?.response?.data?.detail ||
+ 'Failed to verify email';
+
+ if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) {
+ setVerifyState('expired');
+ setErrorMessage('This verification link has expired or is invalid.');
+ } else if (message.toLowerCase().includes('already verified')) {
+ setVerifyState('success');
+ } else {
+ setVerifyState('error');
+ setErrorMessage(message);
+ }
+ }
+ };
+
+ verifyEmail();
+ }, [token]);
+
+ // Verifying State
+ if (verifyState === 'verifying') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Verifying Your Email
+
+
+ Please wait while we verify your email address...
+
+
+
+
+ >
+ );
+ }
+
+ // Success State
+ if (verifyState === 'success') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Email Verified!
+
+
+ Your email has been successfully verified. You can now access all features of your account.
+
+
navigate('/')}
+ className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors"
+ >
+ Go to Dashboard
+
+
+
+
+ >
+ );
+ }
+
+ // Expired State
+ if (verifyState === 'expired') {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Link Expired
+
+
+ {errorMessage || 'This verification link has expired or is invalid.'}
+
+
+ Sign In to Resend
+
+
+ Need help?{' '}
+
+ Contact Support
+
+
+
+
+
+ >
+ );
+ }
+
+ // Error State
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Verification Failed
+
+
+ {errorMessage || 'We could not verify your email address. Please try again.'}
+
+
+ Back to Sign In
+
+
+ Need help?{' '}
+
+ Contact Support
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/legal/Privacy.tsx b/frontend/src/pages/legal/Privacy.tsx
index 466fa381..05329d48 100644
--- a/frontend/src/pages/legal/Privacy.tsx
+++ b/frontend/src/pages/legal/Privacy.tsx
@@ -28,7 +28,7 @@ export default function Privacy() {
diff --git a/frontend/src/pages/legal/Terms.tsx b/frontend/src/pages/legal/Terms.tsx
index 58b43697..62100b1d 100644
--- a/frontend/src/pages/legal/Terms.tsx
+++ b/frontend/src/pages/legal/Terms.tsx
@@ -28,7 +28,7 @@ export default function Terms() {