Email COnfigs & setup
This commit is contained in:
@@ -125,6 +125,20 @@ class RegisterView(APIView):
|
|||||||
# User will complete payment on /account/plans after signup
|
# User will complete payment on /account/plans after signup
|
||||||
# This simplifies the signup flow and consolidates all payment handling
|
# 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(
|
return success_response(
|
||||||
data=response_data,
|
data=response_data,
|
||||||
message='Registration successful',
|
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(
|
@extend_schema(
|
||||||
tags=['Authentication'],
|
tags=['Authentication'],
|
||||||
summary='Change Password',
|
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 = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
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('me/', MeView.as_view(), name='auth-me'),
|
||||||
path('countries/', CountryListView.as_view(), name='auth-countries'),
|
path('countries/', CountryListView.as_view(), name='auth-countries'),
|
||||||
|
path('unsubscribe/', csrf_exempt(UnsubscribeView.as_view()), name='auth-unsubscribe'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1267,16 +1267,21 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
expires_at=expires_at
|
expires_at=expires_at
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send email (async via Celery if available, otherwise sync)
|
# Send password reset email using the email service
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.system.tasks import send_password_reset_email
|
from igny8_core.business.billing.services.email_service import send_password_reset_email
|
||||||
send_password_reset_email.delay(user.id, token)
|
send_password_reset_email(user, token)
|
||||||
except:
|
except Exception as e:
|
||||||
# Fallback to sync email sending
|
# 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.core.mail import send_mail
|
||||||
from django.conf import settings
|
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(
|
send_mail(
|
||||||
subject='Reset Your IGNY8 Password',
|
subject='Reset Your IGNY8 Password',
|
||||||
|
|||||||
@@ -830,6 +830,15 @@ class PaymentViewSet(AccountModelViewSet):
|
|||||||
manual_notes=notes,
|
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(
|
return success_response(
|
||||||
data={'id': payment.id, 'status': payment.status},
|
data={'id': payment.id, 'status': payment.status},
|
||||||
message='Manual payment submitted for approval',
|
message='Manual payment submitted for approval',
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ from igny8_core.auth.models import Account
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
class CreditService:
|
||||||
"""Service for managing credits - Token-based only"""
|
"""Service for managing credits - Token-based only"""
|
||||||
|
|
||||||
@@ -302,6 +329,9 @@ class CreditService:
|
|||||||
# Check sufficient credits (legacy: amount is already calculated)
|
# Check sufficient credits (legacy: amount is already calculated)
|
||||||
CreditService.check_credits_legacy(account, amount)
|
CreditService.check_credits_legacy(account, amount)
|
||||||
|
|
||||||
|
# Store previous balance for low credits check
|
||||||
|
previous_balance = account.credits
|
||||||
|
|
||||||
# Deduct from account.credits
|
# Deduct from account.credits
|
||||||
account.credits -= amount
|
account.credits -= amount
|
||||||
account.save(update_fields=['credits'])
|
account.save(update_fields=['credits'])
|
||||||
@@ -330,6 +360,9 @@ class CreditService:
|
|||||||
metadata=metadata or {}
|
metadata=metadata or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check and send low credits warning if applicable
|
||||||
|
_check_low_credits_warning(account, previous_balance)
|
||||||
|
|
||||||
return account.credits
|
return account.credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ Supports template rendering and multiple email types.
|
|||||||
|
|
||||||
Configuration stored in IntegrationProvider model (provider_id='resend')
|
Configuration stored in IntegrationProvider model (provider_id='resend')
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.template.exceptions import TemplateDoesNotExist
|
from django.template.exceptions import TemplateDoesNotExist
|
||||||
@@ -24,17 +29,142 @@ except ImportError:
|
|||||||
logger.info("Resend package not installed, will use Django mail backend")
|
logger.info("Resend package not installed, will use Django mail backend")
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_plain_text(html: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert HTML to plain text for email multipart.
|
||||||
|
|
||||||
|
This improves deliverability as many spam filters prefer
|
||||||
|
emails with both HTML and plain text versions.
|
||||||
|
"""
|
||||||
|
# Remove style and script blocks
|
||||||
|
text = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
|
||||||
|
# Convert common tags to plain text equivalents
|
||||||
|
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</div>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</tr>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<li[^>]*>', '• ', text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Convert links to plain text with URL
|
||||||
|
text = re.sub(r'<a[^>]*href=["\']([^"\']+)["\'][^>]*>([^<]*)</a>', r'\2 (\1)', text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Remove remaining HTML tags
|
||||||
|
text = re.sub(r'<[^>]+>', '', text)
|
||||||
|
|
||||||
|
# Decode HTML entities
|
||||||
|
text = text.replace(' ', ' ')
|
||||||
|
text = text.replace('&', '&')
|
||||||
|
text = text.replace('<', '<')
|
||||||
|
text = text.replace('>', '>')
|
||||||
|
text = text.replace('"', '"')
|
||||||
|
text = text.replace(''', "'")
|
||||||
|
|
||||||
|
# Clean up whitespace
|
||||||
|
text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 newlines
|
||||||
|
text = re.sub(r' +', ' ', text) # Multiple spaces to single
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unsubscribe_url(email: str, email_type: str = 'all') -> str:
|
||||||
|
"""
|
||||||
|
Generate a signed unsubscribe URL for the given email.
|
||||||
|
|
||||||
|
Uses HMAC signature to prevent tampering. The URL includes:
|
||||||
|
- email: The recipient email
|
||||||
|
- type: Type of emails to unsubscribe from (marketing, billing, all)
|
||||||
|
- ts: Timestamp for expiration check
|
||||||
|
- sig: HMAC signature
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Full unsubscribe URL with signed parameters
|
||||||
|
"""
|
||||||
|
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
secret_key = settings.SECRET_KEY
|
||||||
|
|
||||||
|
# Create payload with timestamp (valid for 30 days)
|
||||||
|
timestamp = int(time.time())
|
||||||
|
|
||||||
|
# Create signature
|
||||||
|
message = f"{email}:{email_type}:{timestamp}"
|
||||||
|
signature = hmac.new(
|
||||||
|
secret_key.encode(),
|
||||||
|
message.encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()[:32] # Truncate for shorter URL
|
||||||
|
|
||||||
|
# Build URL with query params
|
||||||
|
params = urlencode({
|
||||||
|
'email': email,
|
||||||
|
'type': email_type,
|
||||||
|
'ts': timestamp,
|
||||||
|
'sig': signature,
|
||||||
|
})
|
||||||
|
|
||||||
|
return f"{frontend_url}/unsubscribe?{params}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_unsubscribe_signature(email: str, email_type: str, timestamp: int, signature: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify the HMAC signature for unsubscribe requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The recipient email
|
||||||
|
email_type: Type of unsubscribe (marketing, billing, all)
|
||||||
|
timestamp: Unix timestamp from URL
|
||||||
|
signature: HMAC signature from URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if signature is valid and not expired (30 days)
|
||||||
|
"""
|
||||||
|
secret_key = settings.SECRET_KEY
|
||||||
|
|
||||||
|
# Check expiration (30 days)
|
||||||
|
current_time = int(time.time())
|
||||||
|
if current_time - timestamp > 30 * 24 * 60 * 60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Recreate signature
|
||||||
|
message = f"{email}:{email_type}:{timestamp}"
|
||||||
|
expected_sig = hmac.new(
|
||||||
|
secret_key.encode(),
|
||||||
|
message.encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()[:32]
|
||||||
|
|
||||||
|
return hmac.compare_digest(signature, expected_sig)
|
||||||
|
|
||||||
|
|
||||||
class EmailConfigurationError(Exception):
|
class EmailConfigurationError(Exception):
|
||||||
"""Raised when email provider is not properly configured"""
|
"""Raised when email provider is not properly configured"""
|
||||||
pass
|
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:
|
class EmailService:
|
||||||
"""
|
"""
|
||||||
Unified email service supporting multiple providers.
|
Unified email service supporting multiple providers.
|
||||||
|
|
||||||
Primary: Resend (for production transactional emails)
|
Primary: Resend (for production transactional emails)
|
||||||
Fallback: Django's send_mail (uses EMAIL_BACKEND from settings)
|
Fallback: Django's send_mail (uses EMAIL_BACKEND from settings)
|
||||||
|
|
||||||
|
Uses EmailSettings model for configuration (from_email, from_name, etc.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -42,12 +172,16 @@ class EmailService:
|
|||||||
self._resend_config = {}
|
self._resend_config = {}
|
||||||
self._brevo_configured = False
|
self._brevo_configured = False
|
||||||
self._brevo_config = {}
|
self._brevo_config = {}
|
||||||
|
self._email_settings = None
|
||||||
self._setup_providers()
|
self._setup_providers()
|
||||||
|
|
||||||
def _setup_providers(self):
|
def _setup_providers(self):
|
||||||
"""Initialize email providers from IntegrationProvider"""
|
"""Initialize email providers from IntegrationProvider and EmailSettings"""
|
||||||
from igny8_core.modules.system.models import IntegrationProvider
|
from igny8_core.modules.system.models import IntegrationProvider
|
||||||
|
|
||||||
|
# Load email settings from database
|
||||||
|
self._email_settings = get_email_settings()
|
||||||
|
|
||||||
# Setup Resend
|
# Setup Resend
|
||||||
if RESEND_AVAILABLE:
|
if RESEND_AVAILABLE:
|
||||||
resend_provider = IntegrationProvider.get_provider('resend')
|
resend_provider = IntegrationProvider.get_provider('resend')
|
||||||
@@ -66,9 +200,18 @@ class EmailService:
|
|||||||
self._brevo_configured = True
|
self._brevo_configured = True
|
||||||
logger.info("Brevo email provider initialized")
|
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
|
@property
|
||||||
def from_email(self) -> str:
|
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(
|
return self._resend_config.get(
|
||||||
'from_email',
|
'from_email',
|
||||||
getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com')
|
getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com')
|
||||||
@@ -76,14 +219,70 @@ class EmailService:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def from_name(self) -> str:
|
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')
|
return self._resend_config.get('from_name', 'IGNY8')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reply_to(self) -> str:
|
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')
|
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(
|
def send_transactional(
|
||||||
self,
|
self,
|
||||||
to: str | List[str],
|
to: str | List[str],
|
||||||
@@ -121,6 +320,37 @@ class EmailService:
|
|||||||
if isinstance(to, str):
|
if isinstance(to, str):
|
||||||
to = [to]
|
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
|
# Render template if provided
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
@@ -133,6 +363,11 @@ class EmailService:
|
|||||||
if not html and not text:
|
if not html and not text:
|
||||||
raise ValueError("Either html, text, or template must be provided")
|
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
|
# Build from address
|
||||||
sender_name = from_name or self.from_name
|
sender_name = from_name or self.from_name
|
||||||
sender_email = from_email or self.from_email
|
sender_email = from_email or self.from_email
|
||||||
@@ -190,6 +425,23 @@ class EmailService:
|
|||||||
if attachments:
|
if attachments:
|
||||||
params['attachments'] = 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)
|
response = resend.Emails.send(params)
|
||||||
|
|
||||||
logger.info(f"Email sent via Resend: {subject} to {to}")
|
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',
|
'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A',
|
||||||
'approved_at': payment.approved_at or payment.processed_at,
|
'approved_at': payment.approved_at or payment.processed_at,
|
||||||
'frontend_url': frontend_url,
|
'frontend_url': frontend_url,
|
||||||
'dashboard_url': f'{frontend_url}/dashboard',
|
'dashboard_url': f'{frontend_url}/',
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -406,7 +658,7 @@ The IGNY8 Team
|
|||||||
'manual_reference': payment.manual_reference or payment.transaction_reference or '',
|
'manual_reference': payment.manual_reference or payment.transaction_reference or '',
|
||||||
'reason': reason,
|
'reason': reason,
|
||||||
'frontend_url': frontend_url,
|
'frontend_url': frontend_url,
|
||||||
'billing_url': f'{frontend_url}/account/billing',
|
'billing_url': f'{frontend_url}/account/plans',
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -572,7 +824,7 @@ The IGNY8 Team
|
|||||||
'included_credits': plan.included_credits or 0,
|
'included_credits': plan.included_credits or 0,
|
||||||
'period_end': subscription.current_period_end,
|
'period_end': subscription.current_period_end,
|
||||||
'frontend_url': frontend_url,
|
'frontend_url': frontend_url,
|
||||||
'dashboard_url': f'{frontend_url}/dashboard',
|
'dashboard_url': f'{frontend_url}/',
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -714,7 +966,7 @@ def send_welcome_email(user, account):
|
|||||||
context = {
|
context = {
|
||||||
'user_name': user.first_name or user.email,
|
'user_name': user.first_name or user.email,
|
||||||
'account_name': account.name,
|
'account_name': account.name,
|
||||||
'login_url': f'{frontend_url}/login',
|
'login_url': f'{frontend_url}/signin',
|
||||||
'frontend_url': frontend_url,
|
'frontend_url': frontend_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -786,6 +786,13 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
|
|||||||
f"plan={plan.name}"
|
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 {
|
return {
|
||||||
'subscription_id': subscription.id,
|
'subscription_id': subscription.id,
|
||||||
'plan_name': plan.name,
|
'plan_name': plan.name,
|
||||||
@@ -915,6 +922,14 @@ def _handle_subscription_activated(resource: dict):
|
|||||||
if update_fields != ['updated_at']:
|
if update_fields != ['updated_at']:
|
||||||
account.save(update_fields=update_fields)
|
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:
|
except Account.DoesNotExist:
|
||||||
logger.error(f"Account {custom_id} not found for PayPal subscription activation")
|
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.status = 'past_due'
|
||||||
subscription.save(update_fields=['status', 'updated_at'])
|
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:
|
except Subscription.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ..services.stripe_service import StripeService, StripeConfigurationError
|
|||||||
from ..services.payment_service import PaymentService
|
from ..services.payment_service import PaymentService
|
||||||
from ..services.invoice_service import InvoiceService
|
from ..services.invoice_service import InvoiceService
|
||||||
from ..services.credit_service import CreditService
|
from ..services.credit_service import CreditService
|
||||||
|
from ..services.email_service import BillingEmailService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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}"
|
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):
|
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.
|
Handle failed payment.
|
||||||
|
|
||||||
Updates subscription status to past_due.
|
Updates subscription status to past_due and sends notification email.
|
||||||
"""
|
"""
|
||||||
subscription_id = invoice.get('subscription')
|
subscription_id = invoice.get('subscription')
|
||||||
if not subscription_id:
|
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")
|
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:
|
except Subscription.DoesNotExist:
|
||||||
logger.warning(f"Subscription not found for payment failure: {subscription_id}")
|
logger.warning(f"Subscription not found for payment failure: {subscription_id}")
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ __all__ = [
|
|||||||
# New centralized models
|
# New centralized models
|
||||||
'IntegrationProvider',
|
'IntegrationProvider',
|
||||||
'AISettings',
|
'AISettings',
|
||||||
|
# Email models
|
||||||
|
'EmailSettings',
|
||||||
|
'EmailTemplate',
|
||||||
|
'EmailLog',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -696,3 +696,6 @@ class SystemAISettingsAdmin(Igny8ModelAdmin):
|
|||||||
obj.updated_by = request.user
|
obj.updated_by = request.user
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
# Import Email Admin (EmailSettings, EmailTemplate, EmailLog)
|
||||||
|
from .email_admin import EmailSettingsAdmin, EmailTemplateAdmin, EmailLogAdmin
|
||||||
|
|||||||
320
backend/igny8_core/modules/system/email_admin.py
Normal file
320
backend/igny8_core/modules/system/email_admin.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Email Admin Configuration for IGNY8
|
||||||
|
|
||||||
|
Provides admin interface for managing:
|
||||||
|
- Email Settings (global configuration)
|
||||||
|
- Email Templates (template metadata and testing)
|
||||||
|
- Email Logs (sent email history)
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
from igny8_core.admin.base import Igny8ModelAdmin
|
||||||
|
from .email_models import EmailSettings, EmailTemplate, EmailLog
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EmailSettings)
|
||||||
|
class EmailSettingsAdmin(Igny8ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin for EmailSettings - Global email configuration (Singleton)
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'from_email',
|
||||||
|
'from_name',
|
||||||
|
'reply_to_email',
|
||||||
|
'send_welcome_emails',
|
||||||
|
'send_billing_emails',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
readonly_fields = ['updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Sender Configuration', {
|
||||||
|
'fields': ('from_email', 'from_name', 'reply_to_email'),
|
||||||
|
'description': 'Default sender settings. Email address must be verified in Resend.',
|
||||||
|
}),
|
||||||
|
('Company Branding', {
|
||||||
|
'fields': ('company_name', 'company_address', 'logo_url'),
|
||||||
|
'description': 'Company information shown in email templates.',
|
||||||
|
}),
|
||||||
|
('Support Links', {
|
||||||
|
'fields': ('support_email', 'support_url', 'unsubscribe_url'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
('Email Types', {
|
||||||
|
'fields': (
|
||||||
|
'send_welcome_emails',
|
||||||
|
'send_billing_emails',
|
||||||
|
'send_subscription_emails',
|
||||||
|
'send_low_credit_warnings',
|
||||||
|
),
|
||||||
|
'description': 'Enable/disable specific email types globally.',
|
||||||
|
}),
|
||||||
|
('Thresholds', {
|
||||||
|
'fields': ('low_credit_threshold', 'renewal_reminder_days'),
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('updated_by', 'updated_at'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
"""Only allow one instance (singleton)"""
|
||||||
|
return not EmailSettings.objects.exists()
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Prevent deletion of singleton"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""Set updated_by to current user"""
|
||||||
|
obj.updated_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EmailTemplate)
|
||||||
|
class EmailTemplateAdmin(Igny8ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin for EmailTemplate - Manage email templates and testing
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'display_name',
|
||||||
|
'template_type',
|
||||||
|
'template_name',
|
||||||
|
'is_active',
|
||||||
|
'send_count',
|
||||||
|
'last_sent_at',
|
||||||
|
'test_email_button',
|
||||||
|
]
|
||||||
|
list_filter = ['template_type', 'is_active']
|
||||||
|
search_fields = ['display_name', 'template_name', 'description']
|
||||||
|
readonly_fields = ['send_count', 'last_sent_at', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Template Info', {
|
||||||
|
'fields': ('template_name', 'template_path', 'display_name', 'description'),
|
||||||
|
}),
|
||||||
|
('Email Settings', {
|
||||||
|
'fields': ('template_type', 'default_subject'),
|
||||||
|
}),
|
||||||
|
('Context Configuration', {
|
||||||
|
'fields': ('required_context', 'sample_context'),
|
||||||
|
'description': 'Define required variables and sample data for testing.',
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': ('is_active',),
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('send_count', 'last_sent_at'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_email_button(self, obj):
|
||||||
|
"""Add test email button in list view"""
|
||||||
|
url = reverse('admin:system_emailtemplate_test', args=[obj.pk])
|
||||||
|
return format_html(
|
||||||
|
'<a class="button" href="{}" style="padding: 4px 12px; background: #6366f1; color: white; '
|
||||||
|
'border-radius: 4px; text-decoration: none; font-size: 12px;">Test</a>',
|
||||||
|
url
|
||||||
|
)
|
||||||
|
test_email_button.short_description = 'Test'
|
||||||
|
test_email_button.allow_tags = True
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
"""Add custom URL for test email"""
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path(
|
||||||
|
'<int:template_id>/test/',
|
||||||
|
self.admin_site.admin_view(self.test_email_view),
|
||||||
|
name='system_emailtemplate_test'
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'<int:template_id>/send-test/',
|
||||||
|
self.admin_site.admin_view(self.send_test_email),
|
||||||
|
name='system_emailtemplate_send_test'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def test_email_view(self, request, template_id):
|
||||||
|
"""Show test email form"""
|
||||||
|
template = EmailTemplate.objects.get(pk=template_id)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
'title': f'Test Email: {template.display_name}',
|
||||||
|
'template': template,
|
||||||
|
'opts': self.model._meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'admin/system/emailtemplate/test_email.html', context)
|
||||||
|
|
||||||
|
def send_test_email(self, request, template_id):
|
||||||
|
"""Send test email"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'POST required'}, status=405)
|
||||||
|
|
||||||
|
import json
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.business.billing.services.email_service import get_email_service
|
||||||
|
|
||||||
|
template = EmailTemplate.objects.get(pk=template_id)
|
||||||
|
|
||||||
|
to_email = request.POST.get('to_email', request.user.email)
|
||||||
|
custom_context = request.POST.get('context', '{}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = json.loads(custom_context) if custom_context else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
context = template.sample_context or {}
|
||||||
|
|
||||||
|
# Merge sample context with any custom values
|
||||||
|
final_context = {**(template.sample_context or {}), **context}
|
||||||
|
|
||||||
|
# Add default context values
|
||||||
|
final_context.setdefault('user_name', 'Test User')
|
||||||
|
final_context.setdefault('account_name', 'Test Account')
|
||||||
|
final_context.setdefault('frontend_url', 'https://app.igny8.com')
|
||||||
|
|
||||||
|
service = get_email_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = service.send_transactional(
|
||||||
|
to=to_email,
|
||||||
|
subject=f'[TEST] {template.default_subject}',
|
||||||
|
template=template.template_path,
|
||||||
|
context=final_context,
|
||||||
|
tags=['test', template.template_type],
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
# Update template stats
|
||||||
|
template.send_count += 1
|
||||||
|
template.last_sent_at = timezone.now()
|
||||||
|
template.save(update_fields=['send_count', 'last_sent_at'])
|
||||||
|
|
||||||
|
# Log the email
|
||||||
|
EmailLog.objects.create(
|
||||||
|
message_id=result.get('id', ''),
|
||||||
|
to_email=to_email,
|
||||||
|
from_email=service.from_email,
|
||||||
|
subject=f'[TEST] {template.default_subject}',
|
||||||
|
template_name=template.template_name,
|
||||||
|
status='sent',
|
||||||
|
provider=result.get('provider', 'resend'),
|
||||||
|
tags=['test', template.template_type],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'Test email sent successfully to {to_email}! (ID: {result.get("id", "N/A")})'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(request, f'Failed to send: {result.get("error", "Unknown error")}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error sending test email: {str(e)}')
|
||||||
|
|
||||||
|
return redirect(reverse('admin:system_emailtemplate_changelist'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EmailLog)
|
||||||
|
class EmailLogAdmin(Igny8ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin for EmailLog - View sent email history
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'sent_at',
|
||||||
|
'to_email',
|
||||||
|
'subject_truncated',
|
||||||
|
'template_name',
|
||||||
|
'status_badge',
|
||||||
|
'provider',
|
||||||
|
'message_id_short',
|
||||||
|
]
|
||||||
|
list_filter = ['status', 'provider', 'template_name', 'sent_at']
|
||||||
|
search_fields = ['to_email', 'subject', 'message_id']
|
||||||
|
readonly_fields = [
|
||||||
|
'message_id', 'to_email', 'from_email', 'subject',
|
||||||
|
'template_name', 'status', 'provider', 'error_message',
|
||||||
|
'tags', 'sent_at'
|
||||||
|
]
|
||||||
|
date_hierarchy = 'sent_at'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Email Details', {
|
||||||
|
'fields': ('to_email', 'from_email', 'subject'),
|
||||||
|
}),
|
||||||
|
('Delivery Info', {
|
||||||
|
'fields': ('status', 'provider', 'message_id'),
|
||||||
|
}),
|
||||||
|
('Template', {
|
||||||
|
'fields': ('template_name', 'tags'),
|
||||||
|
}),
|
||||||
|
('Error Info', {
|
||||||
|
'fields': ('error_message',),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
('Timestamp', {
|
||||||
|
'fields': ('sent_at',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
"""Logs are created automatically"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
"""Logs are read-only"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Allow deletion for cleanup"""
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
def subject_truncated(self, obj):
|
||||||
|
"""Truncate long subjects"""
|
||||||
|
if len(obj.subject) > 50:
|
||||||
|
return f'{obj.subject[:50]}...'
|
||||||
|
return obj.subject
|
||||||
|
subject_truncated.short_description = 'Subject'
|
||||||
|
|
||||||
|
def message_id_short(self, obj):
|
||||||
|
"""Show truncated message ID"""
|
||||||
|
if obj.message_id:
|
||||||
|
return f'{obj.message_id[:20]}...' if len(obj.message_id) > 20 else obj.message_id
|
||||||
|
return '-'
|
||||||
|
message_id_short.short_description = 'Message ID'
|
||||||
|
|
||||||
|
def status_badge(self, obj):
|
||||||
|
"""Show status with color badge"""
|
||||||
|
colors = {
|
||||||
|
'sent': '#3b82f6',
|
||||||
|
'delivered': '#22c55e',
|
||||||
|
'failed': '#ef4444',
|
||||||
|
'bounced': '#f59e0b',
|
||||||
|
}
|
||||||
|
color = colors.get(obj.status, '#6b7280')
|
||||||
|
return format_html(
|
||||||
|
'<span style="background: {}; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">{}</span>',
|
||||||
|
color, obj.status.upper()
|
||||||
|
)
|
||||||
|
status_badge.short_description = 'Status'
|
||||||
|
status_badge.allow_tags = True
|
||||||
292
backend/igny8_core/modules/system/email_models.py
Normal file
292
backend/igny8_core/modules/system/email_models.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Email Configuration Models for IGNY8
|
||||||
|
|
||||||
|
Provides database-driven email settings, template management, and send test functionality.
|
||||||
|
Works with the existing EmailService and IntegrationProvider models.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettings(models.Model):
|
||||||
|
"""
|
||||||
|
Global email settings - singleton model for email configuration.
|
||||||
|
|
||||||
|
Stores default email settings that can be managed through Django admin.
|
||||||
|
These settings work alongside IntegrationProvider (resend) configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Default sender settings
|
||||||
|
from_email = models.EmailField(
|
||||||
|
default='noreply@igny8.com',
|
||||||
|
help_text='Default sender email address (must be verified in Resend)'
|
||||||
|
)
|
||||||
|
from_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
default='IGNY8',
|
||||||
|
help_text='Default sender display name'
|
||||||
|
)
|
||||||
|
reply_to_email = models.EmailField(
|
||||||
|
default='support@igny8.com',
|
||||||
|
help_text='Default reply-to email address'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Company branding for emails
|
||||||
|
company_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
default='IGNY8',
|
||||||
|
help_text='Company name shown in emails'
|
||||||
|
)
|
||||||
|
company_address = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Company address for email footer (CAN-SPAM compliance)'
|
||||||
|
)
|
||||||
|
logo_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text='URL to company logo for emails'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Support links
|
||||||
|
support_email = models.EmailField(
|
||||||
|
default='support@igny8.com',
|
||||||
|
help_text='Support email shown in emails'
|
||||||
|
)
|
||||||
|
support_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Link to support/help center'
|
||||||
|
)
|
||||||
|
unsubscribe_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text='URL for email unsubscribe (for marketing emails)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
send_welcome_emails = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Send welcome email on user registration'
|
||||||
|
)
|
||||||
|
send_billing_emails = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Send payment confirmation, invoice emails'
|
||||||
|
)
|
||||||
|
send_subscription_emails = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Send subscription renewal reminders'
|
||||||
|
)
|
||||||
|
send_low_credit_warnings = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Send low credit warning emails'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credit warning threshold
|
||||||
|
low_credit_threshold = models.IntegerField(
|
||||||
|
default=100,
|
||||||
|
help_text='Send warning when credits fall below this value'
|
||||||
|
)
|
||||||
|
renewal_reminder_days = models.IntegerField(
|
||||||
|
default=7,
|
||||||
|
help_text='Days before subscription renewal to send reminder'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='email_settings_updates'
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'igny8_email_settings'
|
||||||
|
verbose_name = 'Email Settings'
|
||||||
|
verbose_name_plural = 'Email Settings'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Email Settings (from: {self.from_email})'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Ensure only one instance exists (singleton)"""
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_settings(cls):
|
||||||
|
"""Get singleton settings instance, creating if needed"""
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplate(models.Model):
|
||||||
|
"""
|
||||||
|
Email template metadata - tracks available email templates
|
||||||
|
and their usage/configuration.
|
||||||
|
|
||||||
|
Templates are stored as Django templates in templates/emails/.
|
||||||
|
This model provides admin visibility and test sending capability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEMPLATE_TYPE_CHOICES = [
|
||||||
|
('auth', 'Authentication'),
|
||||||
|
('billing', 'Billing'),
|
||||||
|
('notification', 'Notification'),
|
||||||
|
('marketing', 'Marketing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Template identification
|
||||||
|
template_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
help_text='Template file name without extension (e.g., "welcome")'
|
||||||
|
)
|
||||||
|
template_path = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
help_text='Full template path (e.g., "emails/welcome.html")'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display info
|
||||||
|
display_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text='Human-readable template name'
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Description of when this template is used'
|
||||||
|
)
|
||||||
|
template_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=TEMPLATE_TYPE_CHOICES,
|
||||||
|
default='notification'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default subject
|
||||||
|
default_subject = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
help_text='Default email subject line'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Required context variables
|
||||||
|
required_context = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text='List of required context variables for this template'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sample context for testing
|
||||||
|
sample_context = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text='Sample context for test sending (JSON)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='Whether this template is currently in use'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
send_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Number of emails sent using this template'
|
||||||
|
)
|
||||||
|
last_sent_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text='Last time an email was sent with this template'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'igny8_email_templates'
|
||||||
|
verbose_name = 'Email Template'
|
||||||
|
verbose_name_plural = 'Email Templates'
|
||||||
|
ordering = ['template_type', 'display_name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.display_name} ({self.template_type})'
|
||||||
|
|
||||||
|
|
||||||
|
class EmailLog(models.Model):
|
||||||
|
"""
|
||||||
|
Log of sent emails for audit and debugging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('delivered', 'Delivered'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('bounced', 'Bounced'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Email identification
|
||||||
|
message_id = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text='Provider message ID (from Resend)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipients
|
||||||
|
to_email = models.EmailField(
|
||||||
|
help_text='Recipient email'
|
||||||
|
)
|
||||||
|
from_email = models.EmailField(
|
||||||
|
help_text='Sender email'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
subject = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
help_text='Email subject'
|
||||||
|
)
|
||||||
|
template_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text='Template used (if any)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='sent'
|
||||||
|
)
|
||||||
|
provider = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
default='resend',
|
||||||
|
help_text='Email provider used'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error tracking
|
||||||
|
error_message = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Error message if failed'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
tags = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text='Email tags for categorization'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
sent_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'igny8_email_log'
|
||||||
|
verbose_name = 'Email Log'
|
||||||
|
verbose_name_plural = 'Email Logs'
|
||||||
|
ordering = ['-sent_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['to_email', 'sent_at']),
|
||||||
|
models.Index(fields=['status', 'sent_at']),
|
||||||
|
models.Index(fields=['template_name', 'sent_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.subject} → {self.to_email} ({self.status})'
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-08 01:23
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('system', '0019_model_schema_update'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('template_name', models.CharField(help_text='Template file name without extension (e.g., "welcome")', max_length=100, unique=True)),
|
||||||
|
('template_path', models.CharField(help_text='Full template path (e.g., "emails/welcome.html")', max_length=200)),
|
||||||
|
('display_name', models.CharField(help_text='Human-readable template name', max_length=100)),
|
||||||
|
('description', models.TextField(blank=True, help_text='Description of when this template is used')),
|
||||||
|
('template_type', models.CharField(choices=[('auth', 'Authentication'), ('billing', 'Billing'), ('notification', 'Notification'), ('marketing', 'Marketing')], default='notification', max_length=20)),
|
||||||
|
('default_subject', models.CharField(help_text='Default email subject line', max_length=200)),
|
||||||
|
('required_context', models.JSONField(blank=True, default=list, help_text='List of required context variables for this template')),
|
||||||
|
('sample_context', models.JSONField(blank=True, default=dict, help_text='Sample context for test sending (JSON)')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')),
|
||||||
|
('send_count', models.IntegerField(default=0, help_text='Number of emails sent using this template')),
|
||||||
|
('last_sent_at', models.DateTimeField(blank=True, help_text='Last time an email was sent with this template', null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Email Template',
|
||||||
|
'verbose_name_plural': 'Email Templates',
|
||||||
|
'db_table': 'igny8_email_templates',
|
||||||
|
'ordering': ['template_type', 'display_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('message_id', models.CharField(blank=True, help_text='Provider message ID (from Resend)', max_length=200)),
|
||||||
|
('to_email', models.EmailField(help_text='Recipient email', max_length=254)),
|
||||||
|
('from_email', models.EmailField(help_text='Sender email', max_length=254)),
|
||||||
|
('subject', models.CharField(help_text='Email subject', max_length=500)),
|
||||||
|
('template_name', models.CharField(blank=True, help_text='Template used (if any)', max_length=100)),
|
||||||
|
('status', models.CharField(choices=[('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], default='sent', max_length=20)),
|
||||||
|
('provider', models.CharField(default='resend', help_text='Email provider used', max_length=50)),
|
||||||
|
('error_message', models.TextField(blank=True, help_text='Error message if failed')),
|
||||||
|
('tags', models.JSONField(blank=True, default=list, help_text='Email tags for categorization')),
|
||||||
|
('sent_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Email Log',
|
||||||
|
'verbose_name_plural': 'Email Logs',
|
||||||
|
'db_table': 'igny8_email_log',
|
||||||
|
'ordering': ['-sent_at'],
|
||||||
|
'indexes': [models.Index(fields=['to_email', 'sent_at'], name='igny8_email_to_emai_f0efbd_idx'), models.Index(fields=['status', 'sent_at'], name='igny8_email_status_7107f0_idx'), models.Index(fields=['template_name', 'sent_at'], name='igny8_email_templat_e979b9_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('from_email', models.EmailField(default='noreply@igny8.com', help_text='Default sender email address (must be verified in Resend)', max_length=254)),
|
||||||
|
('from_name', models.CharField(default='IGNY8', help_text='Default sender display name', max_length=100)),
|
||||||
|
('reply_to_email', models.EmailField(default='support@igny8.com', help_text='Default reply-to email address', max_length=254)),
|
||||||
|
('company_name', models.CharField(default='IGNY8', help_text='Company name shown in emails', max_length=100)),
|
||||||
|
('company_address', models.TextField(blank=True, help_text='Company address for email footer (CAN-SPAM compliance)')),
|
||||||
|
('logo_url', models.URLField(blank=True, help_text='URL to company logo for emails')),
|
||||||
|
('support_email', models.EmailField(default='support@igny8.com', help_text='Support email shown in emails', max_length=254)),
|
||||||
|
('support_url', models.URLField(blank=True, help_text='Link to support/help center')),
|
||||||
|
('unsubscribe_url', models.URLField(blank=True, help_text='URL for email unsubscribe (for marketing emails)')),
|
||||||
|
('send_welcome_emails', models.BooleanField(default=True, help_text='Send welcome email on user registration')),
|
||||||
|
('send_billing_emails', models.BooleanField(default=True, help_text='Send payment confirmation, invoice emails')),
|
||||||
|
('send_subscription_emails', models.BooleanField(default=True, help_text='Send subscription renewal reminders')),
|
||||||
|
('send_low_credit_warnings', models.BooleanField(default=True, help_text='Send low credit warning emails')),
|
||||||
|
('low_credit_threshold', models.IntegerField(default=100, help_text='Send warning when credits fall below this value')),
|
||||||
|
('renewal_reminder_days', models.IntegerField(default=7, help_text='Days before subscription renewal to send reminder')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_settings_updates', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Email Settings',
|
||||||
|
'verbose_name_plural': 'Email Settings',
|
||||||
|
'db_table': 'igny8_email_settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# Note: AccountIntegrationOverride delete removed - table doesn't exist in DB
|
||||||
|
]
|
||||||
@@ -785,6 +785,18 @@ UNFOLD = {
|
|||||||
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"},
|
{"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
|
# Global Settings
|
||||||
{
|
{
|
||||||
"title": "Global Settings",
|
"title": "Global Settings",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="margin-top: 0; color: #1f2937;">Test Email: {{ template.display_name }}</h2>
|
||||||
|
|
||||||
|
<div style="background: #f8fafc; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||||
|
<strong>Template Path:</strong> {{ template.template_path }}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||||
|
<strong>Type:</strong> {{ template.get_template_type_display }}
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #64748b; font-size: 14px;">
|
||||||
|
<strong>Description:</strong> {{ template.description|default:"No description" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'admin:system_emailtemplate_send_test' template.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||||
|
Send Test Email To:
|
||||||
|
</label>
|
||||||
|
<input type="email" name="to_email" value="{{ user.email }}"
|
||||||
|
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;"
|
||||||
|
placeholder="recipient@example.com" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||||
|
Subject (Preview):
|
||||||
|
</label>
|
||||||
|
<input type="text" disabled value="[TEST] {{ template.default_subject }}"
|
||||||
|
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; background: #f9fafb; color: #6b7280;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||||
|
Context Variables (JSON):
|
||||||
|
</label>
|
||||||
|
<textarea name="context" rows="8"
|
||||||
|
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; font-family: monospace;"
|
||||||
|
placeholder='{"user_name": "Test User", "account_name": "Test Account"}'>{{ template.sample_context|default:"{}"|safe }}</textarea>
|
||||||
|
<p style="color: #6b7280; font-size: 12px; margin-top: 4px;">
|
||||||
|
Required variables: {{ template.required_context|default:"None specified"|safe }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<button type="submit"
|
||||||
|
style="background: #6366f1; color: white; padding: 12px 24px; border: none;
|
||||||
|
border-radius: 6px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
||||||
|
📧 Send Test Email
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'admin:system_emailtemplate_changelist' %}"
|
||||||
|
style="background: #e5e7eb; color: #374151; padding: 12px 24px;
|
||||||
|
border-radius: 6px; font-weight: 500; text-decoration: none; font-size: 14px;">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin-top: 20px;">
|
||||||
|
<h4 style="margin: 0 0 8px 0; color: #92400e;">⚠️ Testing Tips</h4>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #78350f; font-size: 14px;">
|
||||||
|
<li>Test emails are prefixed with [TEST] in the subject line</li>
|
||||||
|
<li>Make sure your Resend API key is configured in Integration Providers</li>
|
||||||
|
<li>Use sample_context to pre-fill test data for this template</li>
|
||||||
|
<li>Check Email Logs after sending to verify delivery status</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
@@ -20,14 +20,18 @@
|
|||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 0;
|
padding: 30px 20px;
|
||||||
background-color: #ffffff;
|
background-color: #0c1e35;
|
||||||
border-bottom: 3px solid #6366f1;
|
border-radius: 12px 12px 0 0;
|
||||||
}
|
}
|
||||||
.logo {
|
.header img {
|
||||||
|
max-height: 40px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.header .logo-text {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #6366f1;
|
color: #ffffff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
@@ -36,76 +40,138 @@
|
|||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 24px 20px;
|
||||||
color: #888888;
|
background-color: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 30px;
|
padding: 14px 32px;
|
||||||
background-color: #6366f1;
|
background-color: #3b82f6;
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background-color: #4f46e5;
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
background-color: #64748b;
|
||||||
|
}
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: #475569;
|
||||||
}
|
}
|
||||||
.info-box {
|
.info-box {
|
||||||
background-color: #f8fafc;
|
background-color: #eff6ff;
|
||||||
border-left: 4px solid #6366f1;
|
border-left: 4px solid #3b82f6;
|
||||||
padding: 15px 20px;
|
padding: 16px 20px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
}
|
}
|
||||||
.warning-box {
|
.warning-box {
|
||||||
background-color: #fef3c7;
|
background-color: #fef3c7;
|
||||||
border-left: 4px solid #f59e0b;
|
border-left: 4px solid #f59e0b;
|
||||||
padding: 15px 20px;
|
padding: 16px 20px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
}
|
}
|
||||||
.success-box {
|
.success-box {
|
||||||
background-color: #d1fae5;
|
background-color: #dcfce7;
|
||||||
border-left: 4px solid #10b981;
|
border-left: 4px solid #22c55e;
|
||||||
padding: 15px 20px;
|
padding: 16px 20px;
|
||||||
margin: 20px 0;
|
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 {
|
h1, h2, h3 {
|
||||||
color: #1f2937;
|
color: #0f172a;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
a {
|
a {
|
||||||
color: #6366f1;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
.details-table {
|
.details-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 15px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
.details-table td {
|
.details-table td {
|
||||||
padding: 8px 0;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
.details-table td:first-child {
|
.details-table td:first-child {
|
||||||
color: #6b7280;
|
color: #64748b;
|
||||||
width: 40%;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<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>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© {{ current_year|default:"2026" }} IGNY8. All rights reserved.</p>
|
{% if company_address %}
|
||||||
<p>
|
<p style="margin-bottom: 12px;">{{ company_address }}</p>
|
||||||
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
{% endif %}
|
||||||
<a href="{{ frontend_url }}/terms">Terms of Service</a>
|
<p style="margin-bottom: 8px;">
|
||||||
|
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
||||||
|
<a href="{{ frontend_url }}/terms">Terms of Service</a>{% if unsubscribe_url %} |
|
||||||
|
<a href="{{ unsubscribe_url }}">Email Preferences</a>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<p style="color: #94a3b8; margin-top: 16px;">
|
||||||
|
© {{ current_year|default:"2026" }} {{ company_name|default:"IGNY8" }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
{% if recipient_email %}<p style="color: #94a3b8; font-size: 11px; margin-top: 8px;">This email was sent to {{ recipient_email }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -28,6 +28,6 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -34,6 +34,6 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,6 +26,6 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Thank you for choosing IGNY8!<br>
|
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,6 +40,6 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Thank you for your patience,<br>
|
Thank you for your patience,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
<a href="{{ billing_url }}" class="button">Update Payment Method</a>
|
<a href="{{ billing_url }}" class="button">Update Payment Method</a>
|
||||||
</p>
|
</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>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,10 +40,10 @@
|
|||||||
<a href="{{ billing_url }}" class="button">Retry Payment</a>
|
<a href="{{ billing_url }}" class="button">Retry Payment</a>
|
||||||
</p>
|
</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>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,10 +40,10 @@
|
|||||||
|
|
||||||
<p>The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.</p>
|
<p>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>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,14 +28,14 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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;">
|
<p style="text-align: center;">
|
||||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Thank you for choosing IGNY8!<br>
|
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -36,6 +36,6 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "emails/base.html" %}
|
{% extends "emails/base.html" %}
|
||||||
{% block title %}Welcome to IGNY8{% endblock %}
|
{% block title %}Welcome to {{ company_name|default:"IGNY8" }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Welcome to IGNY8!</h1>
|
<h1>Welcome to {{ company_name|default:"IGNY8" }}!</h1>
|
||||||
|
|
||||||
<p>Hi {{ user_name }},</p>
|
<p>Hi {{ user_name }},</p>
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
<a href="{{ login_url }}" class="button">Go to Dashboard</a>
|
<a href="{{ login_url }}" class="button">Go to Dashboard</a>
|
||||||
</p>
|
</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>
|
<p>
|
||||||
Best regards,<br>
|
Best regards,<br>
|
||||||
The IGNY8 Team
|
The {{ company_name|default:"IGNY8" }} Team
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -325,25 +325,113 @@ Config (JSON):
|
|||||||
|
|
||||||
4. Click **"Save"**
|
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
|
```bash
|
||||||
cd /data/app/igny8/backend
|
docker exec -it igny8_backend python manage.py shell -c "
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from igny8_core.business.billing.services.email_service import get_email_service
|
from igny8_core.business.billing.services.email_service import get_email_service
|
||||||
|
|
||||||
service = get_email_service()
|
service = get_email_service()
|
||||||
service.send_transactional(
|
result = service.send_transactional(
|
||||||
to='your-email@example.com',
|
to='your-email@example.com',
|
||||||
subject='Test Email from IGNY8',
|
subject='Test Email from IGNY8',
|
||||||
html='<h1>Test Email</h1><p>If you receive this, Resend is configured correctly!</p>',
|
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!'
|
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
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ import NotFound from "./pages/OtherPage/NotFound";
|
|||||||
const Terms = lazy(() => import("./pages/legal/Terms"));
|
const Terms = lazy(() => import("./pages/legal/Terms"));
|
||||||
const Privacy = lazy(() => import("./pages/legal/Privacy"));
|
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
|
// Lazy load all other pages - only loads when navigated to
|
||||||
const Home = lazy(() => import("./pages/Dashboard/Home"));
|
const Home = lazy(() => import("./pages/Dashboard/Home"));
|
||||||
|
|
||||||
@@ -148,6 +154,12 @@ export default function App() {
|
|||||||
<Route path="/terms" element={<Terms />} />
|
<Route path="/terms" element={<Terms />} />
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
<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 */}
|
{/* Protected Routes - Require Authentication */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|||||||
177
frontend/src/pages/AuthPages/ForgotPassword.tsx
Normal file
177
frontend/src/pages/AuthPages/ForgotPassword.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
frontend/src/pages/AuthPages/ResetPassword.tsx
Normal file
344
frontend/src/pages/AuthPages/ResetPassword.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/pages/AuthPages/Unsubscribe.tsx
Normal file
83
frontend/src/pages/AuthPages/Unsubscribe.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
frontend/src/pages/AuthPages/VerifyEmail.tsx
Normal file
220
frontend/src/pages/AuthPages/VerifyEmail.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function Privacy() {
|
|||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<Link to="/" className="inline-block mb-6">
|
<Link to="/" className="inline-block mb-6">
|
||||||
<img
|
<img
|
||||||
src="/igny8-logo-trnsp.png"
|
src="/images/logo/IGNY8_LIGHT_LOGO.png"
|
||||||
alt="IGNY8"
|
alt="IGNY8"
|
||||||
className="h-12 w-auto mx-auto"
|
className="h-12 w-auto mx-auto"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Terms() {
|
|||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<Link to="/" className="inline-block mb-6">
|
<Link to="/" className="inline-block mb-6">
|
||||||
<img
|
<img
|
||||||
src="/igny8-logo-trnsp.png"
|
src="/images/logo/IGNY8_LIGHT_LOGO.png"
|
||||||
alt="IGNY8"
|
alt="IGNY8"
|
||||||
className="h-12 w-auto mx-auto"
|
className="h-12 w-auto mx-auto"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user