Email COnfigs & setup

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

View File

@@ -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'),
]

View File

@@ -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',

View File

@@ -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',

View File

@@ -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

View File

@@ -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('&nbsp;', ' ')
text = text.replace('&amp;', '&')
text = text.replace('&lt;', '<')
text = text.replace('&gt;', '>')
text = text.replace('&quot;', '"')
text = text.replace('&#39;', "'")
# 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
@@ -190,6 +425,23 @@ class EmailService:
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)
logger.info(f"Email sent via Resend: {subject} to {to}")
@@ -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,
}

View File

@@ -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

View File

@@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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 %}

View File

@@ -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>&copy; {{ 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> &nbsp;|&nbsp;
<a href="{{ frontend_url }}/terms">Terms of Service</a>{% if unsubscribe_url %} &nbsp;|&nbsp;
<a href="{{ unsubscribe_url }}">Email Preferences</a>{% endif %}
</p>
<p style="color: #94a3b8; margin-top: 16px;">
&copy; {{ 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>

View File

@@ -28,6 +28,6 @@
<p>
Best regards,<br>
The IGNY8 Team
The {{ company_name|default:"IGNY8" }} Team
</p>
{% endblock %}

View File

@@ -34,6 +34,6 @@
<p>
Best regards,<br>
The IGNY8 Team
The {{ company_name|default:"IGNY8" }} Team
</p>
{% endblock %}

View File

@@ -26,6 +26,6 @@
<p>
Best regards,<br>
The IGNY8 Team
The {{ company_name|default:"IGNY8" }} Team
</p>
{% endblock %}

View File

@@ -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 %}

View File

@@ -40,6 +40,6 @@
<p>
Thank you for your patience,<br>
The IGNY8 Team
The {{ company_name|default:"IGNY8" }} Team
</p>
{% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -36,6 +36,6 @@
<p>
Best regards,<br>
The IGNY8 Team
The {{ company_name|default:"IGNY8" }} Team
</p>
{% endblock %}

View File

@@ -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 %}

View File

@@ -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='<h1>Test Email</h1><p>If you receive this, Resend is configured correctly!</p>',
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
)
```
---

View File

@@ -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"));
@@ -148,6 +154,12 @@ export default function App() {
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* Auth Flow Pages - Public */}
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
{/* Protected Routes - Require Authentication */}
<Route
element={

View File

@@ -0,0 +1,177 @@
/**
* Forgot Password Page
* Request password reset email
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { fetchAPI } from '../../services/api';
import PageMeta from '../../components/common/PageMeta';
import { ChevronLeftIcon, EnvelopeIcon, CheckCircleIcon } from '../../icons';
type FormState = 'form' | 'success';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formState, setFormState] = useState<FormState>('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 (
<>
<PageMeta
title="Check Your Email - IGNY8"
description="Password reset instructions sent"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Check Your Email
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
If an account exists for <strong>{email}</strong>, you'll receive a password reset link shortly.
</p>
<div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
<p>
<strong>Didn't receive the email?</strong>
</p>
<ul className="list-disc list-inside text-left mt-2 space-y-1">
<li>Check your spam folder</li>
<li>Make sure you entered the correct email</li>
<li>Wait a few minutes and try again</li>
</ul>
</div>
<Link
to="/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"
>
Back to Sign In
</Link>
</div>
</div>
</div>
</>
);
}
// Form State
return (
<>
<PageMeta
title="Forgot Password - IGNY8"
description="Reset your IGNY8 password"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
{/* Form Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<Link
to="/signin"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-6"
>
<ChevronLeftIcon className="w-5 h-5" />
Back to Sign In
</Link>
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mb-6">
<EnvelopeIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Forgot Password?
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
No worries! Enter your email and we'll send you reset instructions.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address
</label>
<input
type="email"
value={email}
onChange={(e) => 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
/>
</div>
<button
type="submit"
disabled={loading || !email}
className="w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 disabled:bg-brand-300 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Sending...
</span>
) : (
'Send Reset Link'
)}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
Remember your password?{' '}
<Link to="/signin" className="text-brand-500 hover:underline font-medium">
Sign In
</Link>
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -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<ResetState>('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 (
<>
<PageMeta
title="Link Expired - IGNY8"
description="Password reset link has expired"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Link Expired
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
This password reset link has expired or is invalid. Please request a new one.
</p>
<Link
to="/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"
>
Back to Sign In
</Link>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}
// Success State
if (resetState === 'success') {
return (
<>
<PageMeta
title="Password Reset Successful - IGNY8"
description="Your password has been reset successfully"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Password Reset!
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
Your password has been successfully reset. You can now sign in with your new password.
</p>
<button
onClick={() => 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
</button>
</div>
</div>
</div>
</>
);
}
// Form State
return (
<>
<PageMeta
title="Reset Password - IGNY8"
description="Create a new password for your IGNY8 account"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
{/* Form Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<Link
to="/signin"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-6"
>
<ChevronLeftIcon className="w-5 h-5" />
Back to Sign In
</Link>
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mb-6">
<LockIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Reset Your Password
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Create a strong password for your account.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
<AlertIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Password Field */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
New Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => 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
/>
<button
type="button"
onClick={() => 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 ? <EyeCloseIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{/* Password Requirements - Always visible for clarity */}
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg space-y-2">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">Password Requirements:</p>
{[
{ 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) => (
<div
key={i}
className={`text-sm flex items-center gap-2 transition-colors ${
password.length === 0
? 'text-gray-400 dark:text-gray-500'
: req.check
? 'text-green-600 dark:text-green-400'
: 'text-red-500 dark:text-red-400'
}`}
>
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
password.length === 0
? 'bg-gray-200 dark:bg-gray-600'
: req.check
? 'bg-green-100 dark:bg-green-900/50'
: 'bg-red-100 dark:bg-red-900/50'
}`}>
{password.length === 0 ? '○' : req.check ? '✓' : '✕'}
</span>
{req.label}
</div>
))}
</div>
</div>
{/* Confirm Password Field */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm Password
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => 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
/>
<button
type="button"
onClick={() => 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 ? <EyeCloseIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{confirmPassword.length > 0 && (
<p className={`mt-2 text-sm flex items-center gap-1.5 ${
passwordsMatch
? 'text-green-600 dark:text-green-400'
: 'text-red-500 dark:text-red-400'
}`}>
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
passwordsMatch
? 'bg-green-100 dark:bg-green-900/50'
: 'bg-red-100 dark:bg-red-900/50'
}`}>
{passwordsMatch ? '✓' : '✕'}
</span>
{passwordsMatch ? 'Passwords match' : 'Passwords do not match'}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading || !isPasswordValid}
className="w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 disabled:bg-brand-300 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Resetting Password...
</span>
) : (
'Reset Password'
)}
</button>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -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 (
<>
<PageMeta
title="Email Preferences - IGNY8"
description="Manage your email preferences"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<EnvelopeIcon className="w-8 h-8 text-brand-600 dark:text-brand-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Manage Email Preferences
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
You can manage your email notification preferences from your account settings.
</p>
<div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6 text-left">
<p className="font-medium mb-2 text-gray-700 dark:text-gray-300">
Important transactional emails cannot be disabled:
</p>
<ul className="list-disc list-inside space-y-1">
<li>Password reset requests</li>
<li>Security alerts</li>
<li>Payment confirmations & invoices</li>
<li>Account status notifications</li>
</ul>
</div>
<Link
to="/account/settings?tab=notifications"
className="inline-flex items-center justify-center gap-2 w-full py-3 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors mb-4"
>
<SettingsIcon className="w-5 h-5" />
Go to Account Settings
</Link>
<Link
to="/"
className="inline-flex items-center justify-center w-full py-3 px-4 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 font-medium rounded-lg transition-colors"
>
Back to Home
</Link>
<p className="mt-6 text-xs text-gray-400 dark:text-gray-500">
Redirecting to account settings in 5 seconds...
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -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<VerifyState>('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 (
<>
<PageMeta
title="Verifying Email - IGNY8"
description="Verifying your email address"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="animate-spin h-8 w-8 text-brand-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Verifying Your Email
</h1>
<p className="text-gray-600 dark:text-gray-300">
Please wait while we verify your email address...
</p>
</div>
</div>
</div>
</>
);
}
// Success State
if (verifyState === 'success') {
return (
<>
<PageMeta
title="Email Verified - IGNY8"
description="Your email has been verified successfully"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Email Verified!
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
Your email has been successfully verified. You can now access all features of your account.
</p>
<button
onClick={() => 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
</button>
</div>
</div>
</div>
</>
);
}
// Expired State
if (verifyState === 'expired') {
return (
<>
<PageMeta
title="Link Expired - IGNY8"
description="Email verification link has expired"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Link Expired
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
{errorMessage || 'This verification link has expired or is invalid.'}
</p>
<Link
to="/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 mb-4"
>
Sign In to Resend
</Link>
<p className="text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}
// Error State
return (
<>
<PageMeta
title="Verification Failed - IGNY8"
description="Email verification failed"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<EnvelopeIcon className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Verification Failed
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
{errorMessage || 'We could not verify your email address. Please try again.'}
</p>
<Link
to="/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 mb-4"
>
Back to Sign In
</Link>
<p className="text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -28,7 +28,7 @@ export default function Privacy() {
<div className="text-center mb-12">
<Link to="/" className="inline-block mb-6">
<img
src="/igny8-logo-trnsp.png"
src="/images/logo/IGNY8_LIGHT_LOGO.png"
alt="IGNY8"
className="h-12 w-auto mx-auto"
/>

View File

@@ -28,7 +28,7 @@ export default function Terms() {
<div className="text-center mb-12">
<Link to="/" className="inline-block mb-6">
<img
src="/igny8-logo-trnsp.png"
src="/images/logo/IGNY8_LIGHT_LOGO.png"
alt="IGNY8"
className="h-12 w-auto mx-auto"
/>