Email COnfigs & setup

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

View File

@@ -125,6 +125,20 @@ class RegisterView(APIView):
# User will complete payment on /account/plans after signup # 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'),
] ]

View File

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

View File

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

View File

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

View File

@@ -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('&nbsp;', ' ')
text = text.replace('&amp;', '&')
text = text.replace('&lt;', '<')
text = text.replace('&gt;', '>')
text = text.replace('&quot;', '"')
text = text.replace('&#39;', "'")
# Clean up whitespace
text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 newlines
text = re.sub(r' +', ' ', text) # Multiple spaces to single
text = text.strip()
return text
def generate_unsubscribe_url(email: str, email_type: str = 'all') -> str:
"""
Generate a signed unsubscribe URL for the given email.
Uses HMAC signature to prevent tampering. The URL includes:
- email: The recipient email
- type: Type of emails to unsubscribe from (marketing, billing, all)
- ts: Timestamp for expiration check
- sig: HMAC signature
Returns:
str: Full unsubscribe URL with signed parameters
"""
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
secret_key = settings.SECRET_KEY
# Create payload with timestamp (valid for 30 days)
timestamp = int(time.time())
# Create signature
message = f"{email}:{email_type}:{timestamp}"
signature = hmac.new(
secret_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()[:32] # Truncate for shorter URL
# Build URL with query params
params = urlencode({
'email': email,
'type': email_type,
'ts': timestamp,
'sig': signature,
})
return f"{frontend_url}/unsubscribe?{params}"
def verify_unsubscribe_signature(email: str, email_type: str, timestamp: int, signature: str) -> bool:
"""
Verify the HMAC signature for unsubscribe requests.
Args:
email: The recipient email
email_type: Type of unsubscribe (marketing, billing, all)
timestamp: Unix timestamp from URL
signature: HMAC signature from URL
Returns:
bool: True if signature is valid and not expired (30 days)
"""
secret_key = settings.SECRET_KEY
# Check expiration (30 days)
current_time = int(time.time())
if current_time - timestamp > 30 * 24 * 60 * 60:
return False
# Recreate signature
message = f"{email}:{email_type}:{timestamp}"
expected_sig = hmac.new(
secret_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()[:32]
return hmac.compare_digest(signature, expected_sig)
class EmailConfigurationError(Exception): 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,
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -785,6 +785,18 @@ UNFOLD = {
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"}, {"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",

View File

@@ -0,0 +1,78 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
<div style="background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="margin-top: 0; color: #1f2937;">Test Email: {{ template.display_name }}</h2>
<div style="background: #f8fafc; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
<strong>Template Path:</strong> {{ template.template_path }}
</p>
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
<strong>Type:</strong> {{ template.get_template_type_display }}
</p>
<p style="margin: 0; color: #64748b; font-size: 14px;">
<strong>Description:</strong> {{ template.description|default:"No description" }}
</p>
</div>
<form method="post" action="{% url 'admin:system_emailtemplate_send_test' template.pk %}">
{% csrf_token %}
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
Send Test Email To:
</label>
<input type="email" name="to_email" value="{{ user.email }}"
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;"
placeholder="recipient@example.com" required>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
Subject (Preview):
</label>
<input type="text" disabled value="[TEST] {{ template.default_subject }}"
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; background: #f9fafb; color: #6b7280;">
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
Context Variables (JSON):
</label>
<textarea name="context" rows="8"
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; font-family: monospace;"
placeholder='{"user_name": "Test User", "account_name": "Test Account"}'>{{ template.sample_context|default:"{}"|safe }}</textarea>
<p style="color: #6b7280; font-size: 12px; margin-top: 4px;">
Required variables: {{ template.required_context|default:"None specified"|safe }}
</p>
</div>
<div style="display: flex; gap: 12px;">
<button type="submit"
style="background: #6366f1; color: white; padding: 12px 24px; border: none;
border-radius: 6px; font-weight: 500; cursor: pointer; font-size: 14px;">
📧 Send Test Email
</button>
<a href="{% url 'admin:system_emailtemplate_changelist' %}"
style="background: #e5e7eb; color: #374151; padding: 12px 24px;
border-radius: 6px; font-weight: 500; text-decoration: none; font-size: 14px;">
Cancel
</a>
</div>
</form>
</div>
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin-top: 20px;">
<h4 style="margin: 0 0 8px 0; color: #92400e;">⚠️ Testing Tips</h4>
<ul style="margin: 0; padding-left: 20px; color: #78350f; font-size: 14px;">
<li>Test emails are prefixed with [TEST] in the subject line</li>
<li>Make sure your Resend API key is configured in Integration Providers</li>
<li>Use sample_context to pre-fill test data for this template</li>
<li>Check Email Logs after sending to verify delivery status</li>
</ul>
</div>
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<head> <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>&copy; {{ 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> &nbsp;|&nbsp;
<a href="{{ frontend_url }}/terms">Terms of Service</a>{% if unsubscribe_url %} &nbsp;|&nbsp;
<a href="{{ unsubscribe_url }}">Email Preferences</a>{% endif %}
</p> </p>
<p style="color: #94a3b8; margin-top: 16px;">
&copy; {{ current_year|default:"2026" }} {{ company_name|default:"IGNY8" }}. All rights reserved.
</p>
{% if recipient_email %}<p style="color: #94a3b8; font-size: 11px; margin-top: 8px;">This email was sent to {{ recipient_email }}</p>{% endif %}
</div> </div>
</div> </div>
</body> </body>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,10 +40,10 @@
<p>The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.</p> <p>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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,344 @@
/**
* Reset Password Page
* Handles password reset with token from email link
*/
import { useState, useEffect } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { fetchAPI } from '../../services/api';
import PageMeta from '../../components/common/PageMeta';
import { EyeIcon, EyeCloseIcon, ChevronLeftIcon, CheckCircleIcon, AlertIcon, LockIcon } from '../../icons';
type ResetState = 'form' | 'success' | 'error' | 'expired';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resetState, setResetState] = useState<ResetState>('form');
// Redirect to forgot-password if no token
useEffect(() => {
if (!token) {
// No token - redirect to forgot password page
navigate('/forgot-password', { replace: true });
}
}, [token, navigate]);
// Password validation checks
const passwordChecks = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
};
const isPasswordStrong = Object.values(passwordChecks).every(Boolean);
const passwordsMatch = password === confirmPassword && confirmPassword.length > 0;
const isPasswordValid = isPasswordStrong && passwordsMatch;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setResetState('expired');
return;
}
if (!isPasswordValid) {
setError('Please ensure your password meets all requirements and both passwords match.');
return;
}
setLoading(true);
try {
// Correct API endpoint for password reset confirmation
await fetchAPI('/v1/auth/password-reset/confirm/', {
method: 'POST',
body: JSON.stringify({ token, new_password: password }),
});
setResetState('success');
} catch (err: unknown) {
const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } };
const message = errorResponse?.response?.data?.message ||
errorResponse?.response?.data?.error ||
errorResponse?.response?.data?.detail ||
'Failed to reset password. Please try again.';
if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) {
setResetState('expired');
} else {
setError(message);
}
} finally {
setLoading(false);
}
};
// Expired/Invalid Token State
if (resetState === 'expired') {
return (
<>
<PageMeta
title="Link Expired - IGNY8"
description="Password reset link has expired"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Link Expired
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
This password reset link has expired or is invalid. Please request a new one.
</p>
<Link
to="/signin"
className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors"
>
Back to Sign In
</Link>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}
// Success State
if (resetState === 'success') {
return (
<>
<PageMeta
title="Password Reset Successful - IGNY8"
description="Your password has been reset successfully"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Password Reset!
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
Your password has been successfully reset. You can now sign in with your new password.
</p>
<button
onClick={() => navigate('/signin')}
className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors"
>
Sign In
</button>
</div>
</div>
</div>
</>
);
}
// Form State
return (
<>
<PageMeta
title="Reset Password - IGNY8"
description="Create a new password for your IGNY8 account"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
{/* Form Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<Link
to="/signin"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 mb-6"
>
<ChevronLeftIcon className="w-5 h-5" />
Back to Sign In
</Link>
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mb-6">
<LockIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Reset Your Password
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Create a strong password for your account.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
<AlertIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Password Field */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
New Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors"
placeholder="Enter new password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
>
{showPassword ? <EyeCloseIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{/* Password Requirements - Always visible for clarity */}
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg space-y-2">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">Password Requirements:</p>
{[
{ check: passwordChecks.length, label: 'At least 8 characters' },
{ check: passwordChecks.uppercase, label: 'One uppercase letter' },
{ check: passwordChecks.lowercase, label: 'One lowercase letter' },
{ check: passwordChecks.number, label: 'One number' },
].map((req, i) => (
<div
key={i}
className={`text-sm flex items-center gap-2 transition-colors ${
password.length === 0
? 'text-gray-400 dark:text-gray-500'
: req.check
? 'text-green-600 dark:text-green-400'
: 'text-red-500 dark:text-red-400'
}`}
>
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
password.length === 0
? 'bg-gray-200 dark:bg-gray-600'
: req.check
? 'bg-green-100 dark:bg-green-900/50'
: 'bg-red-100 dark:bg-red-900/50'
}`}>
{password.length === 0 ? '○' : req.check ? '✓' : '✕'}
</span>
{req.label}
</div>
))}
</div>
</div>
{/* Confirm Password Field */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm Password
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`w-full px-4 py-3 pr-12 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors ${
confirmPassword.length === 0
? 'border-gray-300 dark:border-gray-600'
: passwordsMatch
? 'border-green-400 dark:border-green-500'
: 'border-red-400 dark:border-red-500'
}`}
placeholder="Confirm new password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
>
{showConfirmPassword ? <EyeCloseIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
</button>
</div>
{confirmPassword.length > 0 && (
<p className={`mt-2 text-sm flex items-center gap-1.5 ${
passwordsMatch
? 'text-green-600 dark:text-green-400'
: 'text-red-500 dark:text-red-400'
}`}>
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
passwordsMatch
? 'bg-green-100 dark:bg-green-900/50'
: 'bg-red-100 dark:bg-red-900/50'
}`}>
{passwordsMatch ? '✓' : '✕'}
</span>
{passwordsMatch ? 'Passwords match' : 'Passwords do not match'}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading || !isPasswordValid}
className="w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 disabled:bg-brand-300 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Resetting Password...
</span>
) : (
'Reset Password'
)}
</button>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,83 @@
/**
* Email Preferences Page
* Redirects to account settings for managing email preferences
* Transactional emails cannot be unsubscribed - they're always sent
*/
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import { EnvelopeIcon, SettingsIcon } from '../../icons';
export default function Unsubscribe() {
const navigate = useNavigate();
// Auto-redirect to account settings after 3 seconds
useEffect(() => {
const timer = setTimeout(() => {
navigate('/account/settings?tab=notifications');
}, 5000);
return () => clearTimeout(timer);
}, [navigate]);
return (
<>
<PageMeta
title="Email Preferences - IGNY8"
description="Manage your email preferences"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<EnvelopeIcon className="w-8 h-8 text-brand-600 dark:text-brand-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Manage Email Preferences
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-6">
You can manage your email notification preferences from your account settings.
</p>
<div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6 text-left">
<p className="font-medium mb-2 text-gray-700 dark:text-gray-300">
Important transactional emails cannot be disabled:
</p>
<ul className="list-disc list-inside space-y-1">
<li>Password reset requests</li>
<li>Security alerts</li>
<li>Payment confirmations & invoices</li>
<li>Account status notifications</li>
</ul>
</div>
<Link
to="/account/settings?tab=notifications"
className="inline-flex items-center justify-center gap-2 w-full py-3 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-lg transition-colors mb-4"
>
<SettingsIcon className="w-5 h-5" />
Go to Account Settings
</Link>
<Link
to="/"
className="inline-flex items-center justify-center w-full py-3 px-4 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 font-medium rounded-lg transition-colors"
>
Back to Home
</Link>
<p className="mt-6 text-xs text-gray-400 dark:text-gray-500">
Redirecting to account settings in 5 seconds...
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,220 @@
/**
* Verify Email Page
* Handles email verification with token from email link
*/
import { useState, useEffect } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { fetchAPI } from '../../services/api';
import PageMeta from '../../components/common/PageMeta';
import { CheckCircleIcon, AlertIcon, EnvelopeIcon } from '../../icons';
type VerifyState = 'verifying' | 'success' | 'error' | 'expired';
export default function VerifyEmail() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [verifyState, setVerifyState] = useState<VerifyState>('verifying');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const verifyEmail = async () => {
if (!token) {
setVerifyState('expired');
setErrorMessage('No verification token provided.');
return;
}
try {
await fetchAPI('/v1/auth/users/verify_email/', {
method: 'POST',
body: JSON.stringify({ token }),
});
setVerifyState('success');
} catch (err: unknown) {
const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } };
const message = errorResponse?.response?.data?.error ||
errorResponse?.response?.data?.message ||
errorResponse?.response?.data?.detail ||
'Failed to verify email';
if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) {
setVerifyState('expired');
setErrorMessage('This verification link has expired or is invalid.');
} else if (message.toLowerCase().includes('already verified')) {
setVerifyState('success');
} else {
setVerifyState('error');
setErrorMessage(message);
}
}
};
verifyEmail();
}, [token]);
// Verifying State
if (verifyState === 'verifying') {
return (
<>
<PageMeta
title="Verifying Email - IGNY8"
description="Verifying your email address"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="animate-spin h-8 w-8 text-brand-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Verifying Your Email
</h1>
<p className="text-gray-600 dark:text-gray-300">
Please wait while we verify your email address...
</p>
</div>
</div>
</div>
</>
);
}
// Success State
if (verifyState === 'success') {
return (
<>
<PageMeta
title="Email Verified - IGNY8"
description="Your email has been verified successfully"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircleIcon className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Email Verified!
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
Your email has been successfully verified. You can now access all features of your account.
</p>
<button
onClick={() => navigate('/')}
className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
</div>
</>
);
}
// Expired State
if (verifyState === 'expired') {
return (
<>
<PageMeta
title="Link Expired - IGNY8"
description="Email verification link has expired"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Link Expired
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
{errorMessage || 'This verification link has expired or is invalid.'}
</p>
<Link
to="/signin"
className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors mb-4"
>
Sign In to Resend
</Link>
<p className="text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}
// Error State
return (
<>
<PageMeta
title="Verification Failed - IGNY8"
description="Email verification failed"
/>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link to="/">
<img src="/images/logo/IGNY8_LIGHT_LOGO.png" alt="IGNY8" className="h-10 mx-auto dark:hidden" />
<img src="/images/logo/IGNY8_DARK_LOGO.png" alt="IGNY8" className="h-10 mx-auto hidden dark:block" />
</Link>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<EnvelopeIcon className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Verification Failed
</h1>
<p className="text-gray-600 dark:text-gray-300 mb-8">
{errorMessage || 'We could not verify your email address. Please try again.'}
</p>
<Link
to="/signin"
className="inline-flex items-center justify-center w-full py-3 px-4 bg-brand-500 hover:bg-brand-600 text-white font-medium rounded-lg transition-colors mb-4"
>
Back to Sign In
</Link>
<p className="text-sm text-gray-500 dark:text-gray-400">
Need help?{' '}
<a href="mailto:support@igny8.com" className="text-brand-500 hover:underline">
Contact Support
</a>
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -28,7 +28,7 @@ export default function Privacy() {
<div className="text-center mb-12"> <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"
/> />

View File

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