diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index 705a0f3c..140e4756 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -125,6 +125,20 @@ class RegisterView(APIView): # User will complete payment on /account/plans after signup # This simplifies the signup flow and consolidates all payment handling + # Send welcome email (if enabled in settings) + try: + from igny8_core.modules.system.email_models import EmailSettings + from igny8_core.business.billing.services.email_service import send_welcome_email + + email_settings = EmailSettings.get_settings() + if email_settings.send_welcome_emails and account: + send_welcome_email(user, account) + except Exception as e: + # Don't fail registration if email fails + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send welcome email for user {user.id}: {e}") + return success_response( data=response_data, message='Registration successful', @@ -271,6 +285,128 @@ class LoginView(APIView): ) +@extend_schema( + tags=['Authentication'], + summary='Request Password Reset', + description='Request password reset email' +) +class PasswordResetRequestView(APIView): + """Request password reset endpoint - sends email with reset token.""" + permission_classes = [permissions.AllowAny] + + def post(self, request): + from .serializers import RequestPasswordResetSerializer + from .models import PasswordResetToken + + serializer = RequestPasswordResetSerializer(data=request.data) + if not serializer.is_valid(): + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + email = serializer.validated_data['email'] + + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + # Don't reveal if email exists - return success anyway + return success_response( + message='If an account with that email exists, a password reset link has been sent.', + request=request + ) + + # Generate secure token + import secrets + token = secrets.token_urlsafe(32) + + # Create reset token (expires in 1 hour) + from django.utils import timezone + from datetime import timedelta + expires_at = timezone.now() + timedelta(hours=1) + + PasswordResetToken.objects.create( + user=user, + token=token, + expires_at=expires_at + ) + + # Send password reset email + import logging + logger = logging.getLogger(__name__) + logger.info(f"[PASSWORD_RESET] Attempting to send reset email to: {email}") + + try: + from igny8_core.business.billing.services.email_service import send_password_reset_email + result = send_password_reset_email(user, token) + logger.info(f"[PASSWORD_RESET] Email send result: {result}") + print(f"[PASSWORD_RESET] Email send result: {result}") # Console output + except Exception as e: + logger.error(f"[PASSWORD_RESET] Failed to send password reset email: {e}", exc_info=True) + print(f"[PASSWORD_RESET] ERROR: {e}") # Console output + + return success_response( + message='If an account with that email exists, a password reset link has been sent.', + request=request + ) + + +@extend_schema( + tags=['Authentication'], + summary='Reset Password', + description='Reset password using token from email' +) +class PasswordResetConfirmView(APIView): + """Confirm password reset with token.""" + permission_classes = [permissions.AllowAny] + + def post(self, request): + from .serializers import ResetPasswordSerializer + from .models import PasswordResetToken + from django.utils import timezone + + serializer = ResetPasswordSerializer(data=request.data) + if not serializer.is_valid(): + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + token = serializer.validated_data['token'] + new_password = serializer.validated_data['new_password'] + + try: + reset_token = PasswordResetToken.objects.get( + token=token, + used=False, + expires_at__gt=timezone.now() + ) + except PasswordResetToken.DoesNotExist: + return error_response( + error='Invalid or expired reset token', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Reset password + user = reset_token.user + user.set_password(new_password) + user.save() + + # Mark token as used + reset_token.used = True + reset_token.save() + + return success_response( + message='Password reset successfully. You can now log in with your new password.', + request=request + ) + + @extend_schema( tags=['Authentication'], summary='Change Password', @@ -474,13 +610,86 @@ class MeView(APIView): ) +@extend_schema( + tags=['Authentication'], + summary='Unsubscribe from Emails', + description='Unsubscribe a user from marketing, billing, or all email notifications' +) +class UnsubscribeView(APIView): + """Handle email unsubscribe requests with signed URLs.""" + permission_classes = [permissions.AllowAny] + + def post(self, request): + """ + Process unsubscribe request. + + Expected payload: + - email: The email address to unsubscribe + - type: Type of emails to unsubscribe from (marketing, billing, all) + - ts: Timestamp from signed URL + - sig: HMAC signature from signed URL + """ + from igny8_core.business.billing.services.email_service import verify_unsubscribe_signature + import logging + + logger = logging.getLogger(__name__) + + email = request.data.get('email') + email_type = request.data.get('type', 'all') + timestamp = request.data.get('ts') + signature = request.data.get('sig') + + # Validate required fields + if not email or not timestamp or not signature: + return error_response( + error='Missing required parameters', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + timestamp = int(timestamp) + except (ValueError, TypeError): + return error_response( + error='Invalid timestamp', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Verify signature + if not verify_unsubscribe_signature(email, email_type, timestamp, signature): + return error_response( + error='Invalid or expired unsubscribe link', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Log the unsubscribe request + # In production, update user preferences or use email provider's suppression list + logger.info(f'Unsubscribe request processed: email={email}, type={email_type}') + + # TODO: Implement preference storage + # Options: + # 1. Add email preference fields to User model + # 2. Use Resend's suppression list API + # 3. Create EmailPreferences model + + return success_response( + message=f'Successfully unsubscribed from {email_type} emails', + request=request + ) + + urlpatterns = [ path('', include(router.urls)), path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'), path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'), path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'), path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'), + path('password-reset/', csrf_exempt(PasswordResetRequestView.as_view()), name='auth-password-reset-request'), + path('password-reset/confirm/', csrf_exempt(PasswordResetConfirmView.as_view()), name='auth-password-reset-confirm'), path('me/', MeView.as_view(), name='auth-me'), path('countries/', CountryListView.as_view(), name='auth-countries'), + path('unsubscribe/', csrf_exempt(UnsubscribeView.as_view()), name='auth-unsubscribe'), ] diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 9b42990f..b6782836 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -1267,16 +1267,21 @@ class AuthViewSet(viewsets.GenericViewSet): expires_at=expires_at ) - # Send email (async via Celery if available, otherwise sync) + # Send password reset email using the email service try: - from igny8_core.modules.system.tasks import send_password_reset_email - send_password_reset_email.delay(user.id, token) - except: - # Fallback to sync email sending + from igny8_core.business.billing.services.email_service import send_password_reset_email + send_password_reset_email(user, token) + except Exception as e: + # Fallback to Django's send_mail if email service fails + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to send password reset email via email service: {e}") + from django.core.mail import send_mail from django.conf import settings - reset_url = f"{request.scheme}://{request.get_host()}/reset-password?token={token}" + frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') + reset_url = f"{frontend_url}/reset-password?token={token}" send_mail( subject='Reset Your IGNY8 Password', diff --git a/backend/igny8_core/business/billing/billing_views.py b/backend/igny8_core/business/billing/billing_views.py index 8ffa0e04..0a8cdc4a 100644 --- a/backend/igny8_core/business/billing/billing_views.py +++ b/backend/igny8_core/business/billing/billing_views.py @@ -830,6 +830,15 @@ class PaymentViewSet(AccountModelViewSet): manual_notes=notes, ) + # Send payment confirmation email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_payment_confirmation_email(payment, account) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f'Failed to send payment confirmation email: {str(e)}') + return success_response( data={'id': payment.id, 'status': payment.status}, message='Manual payment submitted for approval', diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py index 07a347f0..98552ef1 100644 --- a/backend/igny8_core/business/billing/services/credit_service.py +++ b/backend/igny8_core/business/billing/services/credit_service.py @@ -13,6 +13,33 @@ from igny8_core.auth.models import Account logger = logging.getLogger(__name__) +def _check_low_credits_warning(account, previous_balance): + """ + Check if credits have fallen below threshold and send warning email. + Only sends if this is the first time falling below threshold. + """ + try: + from igny8_core.modules.system.email_models import EmailSettings + from .email_service import BillingEmailService + + settings = EmailSettings.get_settings() + if not settings.send_low_credit_warnings: + return + + threshold = settings.low_credit_threshold + + # Only send if we CROSSED below the threshold (wasn't already below) + if account.credits < threshold <= previous_balance: + logger.info(f"Credits fell below threshold for account {account.id}: {account.credits} < {threshold}") + BillingEmailService.send_low_credits_warning( + account=account, + current_credits=account.credits, + threshold=threshold + ) + except Exception as e: + logger.error(f"Failed to check/send low credits warning: {e}") + + class CreditService: """Service for managing credits - Token-based only""" @@ -302,6 +329,9 @@ class CreditService: # Check sufficient credits (legacy: amount is already calculated) CreditService.check_credits_legacy(account, amount) + # Store previous balance for low credits check + previous_balance = account.credits + # Deduct from account.credits account.credits -= amount account.save(update_fields=['credits']) @@ -330,6 +360,9 @@ class CreditService: metadata=metadata or {} ) + # Check and send low credits warning if applicable + _check_low_credits_warning(account, previous_balance) + return account.credits @staticmethod diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py index 7b98b634..d7d6d418 100644 --- a/backend/igny8_core/business/billing/services/email_service.py +++ b/backend/igny8_core/business/billing/services/email_service.py @@ -6,8 +6,13 @@ Supports template rendering and multiple email types. Configuration stored in IntegrationProvider model (provider_id='resend') """ +import hashlib +import hmac import logging +import re +import time from typing import Optional, List, Dict, Any +from urllib.parse import urlencode from django.core.mail import send_mail from django.template.loader import render_to_string from django.template.exceptions import TemplateDoesNotExist @@ -24,17 +29,142 @@ except ImportError: logger.info("Resend package not installed, will use Django mail backend") +def html_to_plain_text(html: str) -> str: + """ + Convert HTML to plain text for email multipart. + + This improves deliverability as many spam filters prefer + emails with both HTML and plain text versions. + """ + # Remove style and script blocks + text = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + + # Convert common tags to plain text equivalents + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'

', '\n\n', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r']*>', '• ', text, flags=re.IGNORECASE) + + # Convert links to plain text with URL + text = re.sub(r']*href=["\']([^"\']+)["\'][^>]*>([^<]*)', r'\2 (\1)', text, flags=re.IGNORECASE) + + # Remove remaining HTML tags + text = re.sub(r'<[^>]+>', '', text) + + # Decode HTML entities + text = text.replace(' ', ' ') + text = text.replace('&', '&') + text = text.replace('<', '<') + text = text.replace('>', '>') + text = text.replace('"', '"') + text = text.replace(''', "'") + + # Clean up whitespace + text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 newlines + text = re.sub(r' +', ' ', text) # Multiple spaces to single + text = text.strip() + + return text + + +def generate_unsubscribe_url(email: str, email_type: str = 'all') -> str: + """ + Generate a signed unsubscribe URL for the given email. + + Uses HMAC signature to prevent tampering. The URL includes: + - email: The recipient email + - type: Type of emails to unsubscribe from (marketing, billing, all) + - ts: Timestamp for expiration check + - sig: HMAC signature + + Returns: + str: Full unsubscribe URL with signed parameters + """ + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000') + secret_key = settings.SECRET_KEY + + # Create payload with timestamp (valid for 30 days) + timestamp = int(time.time()) + + # Create signature + message = f"{email}:{email_type}:{timestamp}" + signature = hmac.new( + secret_key.encode(), + message.encode(), + hashlib.sha256 + ).hexdigest()[:32] # Truncate for shorter URL + + # Build URL with query params + params = urlencode({ + 'email': email, + 'type': email_type, + 'ts': timestamp, + 'sig': signature, + }) + + return f"{frontend_url}/unsubscribe?{params}" + + +def verify_unsubscribe_signature(email: str, email_type: str, timestamp: int, signature: str) -> bool: + """ + Verify the HMAC signature for unsubscribe requests. + + Args: + email: The recipient email + email_type: Type of unsubscribe (marketing, billing, all) + timestamp: Unix timestamp from URL + signature: HMAC signature from URL + + Returns: + bool: True if signature is valid and not expired (30 days) + """ + secret_key = settings.SECRET_KEY + + # Check expiration (30 days) + current_time = int(time.time()) + if current_time - timestamp > 30 * 24 * 60 * 60: + return False + + # Recreate signature + message = f"{email}:{email_type}:{timestamp}" + expected_sig = hmac.new( + secret_key.encode(), + message.encode(), + hashlib.sha256 + ).hexdigest()[:32] + + return hmac.compare_digest(signature, expected_sig) + + class EmailConfigurationError(Exception): """Raised when email provider is not properly configured""" pass +def get_email_settings(): + """ + Get email settings from database (EmailSettings model). + Falls back to defaults if not configured. + """ + try: + from igny8_core.modules.system.email_models import EmailSettings + return EmailSettings.get_settings() + except Exception as e: + logger.warning(f"Could not load EmailSettings: {e}") + return None + + class EmailService: """ Unified email service supporting multiple providers. Primary: Resend (for production transactional emails) Fallback: Django's send_mail (uses EMAIL_BACKEND from settings) + + Uses EmailSettings model for configuration (from_email, from_name, etc.) """ def __init__(self): @@ -42,12 +172,16 @@ class EmailService: self._resend_config = {} self._brevo_configured = False self._brevo_config = {} + self._email_settings = None self._setup_providers() def _setup_providers(self): - """Initialize email providers from IntegrationProvider""" + """Initialize email providers from IntegrationProvider and EmailSettings""" from igny8_core.modules.system.models import IntegrationProvider + # Load email settings from database + self._email_settings = get_email_settings() + # Setup Resend if RESEND_AVAILABLE: resend_provider = IntegrationProvider.get_provider('resend') @@ -66,9 +200,18 @@ class EmailService: self._brevo_configured = True logger.info("Brevo email provider initialized") + def _get_settings(self): + """Get fresh email settings (refreshes on each call)""" + if not self._email_settings: + self._email_settings = get_email_settings() + return self._email_settings + @property def from_email(self) -> str: - """Get default from email""" + """Get default from email from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.from_email: + return settings_obj.from_email return self._resend_config.get( 'from_email', getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com') @@ -76,14 +219,70 @@ class EmailService: @property def from_name(self) -> str: - """Get default from name""" + """Get default from name from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.from_name: + return settings_obj.from_name return self._resend_config.get('from_name', 'IGNY8') @property def reply_to(self) -> str: - """Get default reply-to address""" + """Get default reply-to address from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.reply_to_email: + return settings_obj.reply_to_email return self._resend_config.get('reply_to', 'support@igny8.com') + @property + def company_name(self) -> str: + """Get company name from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.company_name: + return settings_obj.company_name + return 'IGNY8' + + @property + def company_address(self) -> str: + """Get company address from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.company_address: + return settings_obj.company_address + return '' + + @property + def logo_url(self) -> str: + """Get logo URL from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.logo_url: + return settings_obj.logo_url + return 'https://app.igny8.com/images/logo/IGNY8_LIGHT_LOGO.png' + + @property + def support_email(self) -> str: + """Get support email from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.support_email: + return settings_obj.support_email + return 'support@igny8.com' + + @property + def support_url(self) -> str: + """Get support/help center URL from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.support_url: + return settings_obj.support_url + frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') + return f'{frontend_url}/help' + + @property + def unsubscribe_url(self) -> str: + """Get unsubscribe URL from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.unsubscribe_url: + return settings_obj.unsubscribe_url + frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') + return f'{frontend_url}/account/settings?tab=notifications' + def send_transactional( self, to: str | List[str], @@ -121,6 +320,37 @@ class EmailService: if isinstance(to, str): to = [to] + # Auto-inject common context variables for templates + if context is None: + context = {} + + # Add recipient email to context + primary_recipient = to[0] if to else '' + if 'recipient_email' not in context: + context['recipient_email'] = primary_recipient + + # Add email settings to context (from EmailSettings model) + frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') + if 'frontend_url' not in context: + context['frontend_url'] = frontend_url + if 'company_name' not in context: + context['company_name'] = self.company_name + if 'company_address' not in context: + context['company_address'] = self.company_address + if 'logo_url' not in context: + context['logo_url'] = self.logo_url + if 'support_email' not in context: + context['support_email'] = self.support_email + if 'support_url' not in context: + context['support_url'] = self.support_url + if 'current_year' not in context: + from datetime import datetime + context['current_year'] = datetime.now().year + + # Add unsubscribe URL to context (from EmailSettings or default) + if 'unsubscribe_url' not in context: + context['unsubscribe_url'] = self.unsubscribe_url + # Render template if provided if template: try: @@ -133,6 +363,11 @@ class EmailService: if not html and not text: raise ValueError("Either html, text, or template must be provided") + # Auto-generate plain text version from HTML for better deliverability + # Spam filters prefer emails with both HTML and plain text versions + if html and not text: + text = html_to_plain_text(html) + # Build from address sender_name = from_name or self.from_name sender_email = from_email or self.from_email @@ -189,6 +424,23 @@ class EmailService: params['tags'] = [{'name': tag} for tag in tags] if attachments: params['attachments'] = attachments + + # Add headers to improve deliverability + # Note: Resend handles DKIM/SPF/DMARC automatically for verified domains + # But we can add List-Unsubscribe for transactional emails to reduce spam score + headers = {} + + # Add List-Unsubscribe header (helps with spam filters) + unsubscribe_url = self._resend_config.get('unsubscribe_url') + if unsubscribe_url: + headers['List-Unsubscribe'] = f'<{unsubscribe_url}>' + headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click' + + # Add mailer identification header + headers['X-Mailer'] = 'IGNY8 Email Service' + + if headers: + params['headers'] = headers response = resend.Emails.send(params) @@ -353,7 +605,7 @@ The IGNY8 Team 'plan_name': subscription.plan.name if subscription and subscription.plan else 'N/A', 'approved_at': payment.approved_at or payment.processed_at, 'frontend_url': frontend_url, - 'dashboard_url': f'{frontend_url}/dashboard', + 'dashboard_url': f'{frontend_url}/', } try: @@ -406,7 +658,7 @@ The IGNY8 Team 'manual_reference': payment.manual_reference or payment.transaction_reference or '', 'reason': reason, 'frontend_url': frontend_url, - 'billing_url': f'{frontend_url}/account/billing', + 'billing_url': f'{frontend_url}/account/plans', } try: @@ -572,7 +824,7 @@ The IGNY8 Team 'included_credits': plan.included_credits or 0, 'period_end': subscription.current_period_end, 'frontend_url': frontend_url, - 'dashboard_url': f'{frontend_url}/dashboard', + 'dashboard_url': f'{frontend_url}/', } try: @@ -714,7 +966,7 @@ def send_welcome_email(user, account): context = { 'user_name': user.first_name or user.email, 'account_name': account.name, - 'login_url': f'{frontend_url}/login', + 'login_url': f'{frontend_url}/signin', 'frontend_url': frontend_url, } diff --git a/backend/igny8_core/business/billing/views/paypal_views.py b/backend/igny8_core/business/billing/views/paypal_views.py index 006b4c28..4a5df942 100644 --- a/backend/igny8_core/business/billing/views/paypal_views.py +++ b/backend/igny8_core/business/billing/views/paypal_views.py @@ -786,6 +786,13 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) - f"plan={plan.name}" ) + # Send subscription activated email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_subscription_activated_email(account, subscription) + except Exception as e: + logger.error(f"Failed to send subscription activated email: {e}") + return { 'subscription_id': subscription.id, 'plan_name': plan.name, @@ -915,6 +922,14 @@ def _handle_subscription_activated(resource: dict): if update_fields != ['updated_at']: account.save(update_fields=update_fields) + # Send subscription activated email + try: + subscription = Subscription.objects.get(account=account, external_payment_id=subscription_id) + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_subscription_activated_email(account, subscription) + except Exception as e: + logger.error(f"Failed to send subscription activated email: {e}") + except Account.DoesNotExist: logger.error(f"Account {custom_id} not found for PayPal subscription activation") @@ -973,7 +988,16 @@ def _handle_subscription_payment_failed(resource: dict): subscription.status = 'past_due' subscription.save(update_fields=['status', 'updated_at']) - # TODO: Send payment failure notification email + # Send payment failure notification email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + BillingEmailService.send_payment_failed_notification( + account=subscription.account, + subscription=subscription, + failure_reason='PayPal payment could not be processed' + ) + except Exception as e: + logger.error(f"Failed to send payment failed email: {e}") except Subscription.DoesNotExist: pass diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py index fede16c7..cc06a847 100644 --- a/backend/igny8_core/business/billing/views/stripe_views.py +++ b/backend/igny8_core/business/billing/views/stripe_views.py @@ -28,6 +28,7 @@ from ..services.stripe_service import StripeService, StripeConfigurationError from ..services.payment_service import PaymentService from ..services.invoice_service import InvoiceService from ..services.credit_service import CreditService +from ..services.email_service import BillingEmailService logger = logging.getLogger(__name__) @@ -608,6 +609,12 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s f"plan={plan.name}, credits={plan.included_credits}" ) + # Send subscription activated email + try: + BillingEmailService.send_subscription_activated_email(account, subscription) + except Exception as e: + logger.error(f"Failed to send subscription activated email: {e}") + def _add_purchased_credits(account, credit_package_id: str, credit_amount: str, session: dict): """ @@ -736,7 +743,7 @@ def _handle_payment_failed(invoice: dict): """ Handle failed payment. - Updates subscription status to past_due. + Updates subscription status to past_due and sends notification email. """ subscription_id = invoice.get('subscription') if not subscription_id: @@ -749,7 +756,17 @@ def _handle_payment_failed(invoice: dict): logger.info(f"Subscription {subscription_id} marked as past_due due to payment failure") - # TODO: Send payment failure notification email + # Send payment failure notification email + try: + from igny8_core.business.billing.services.email_service import BillingEmailService + failure_reason = invoice.get('last_finalization_error', {}).get('message', 'Payment could not be processed') + BillingEmailService.send_payment_failed_notification( + account=subscription.account, + subscription=subscription, + failure_reason=failure_reason + ) + except Exception as e: + logger.error(f"Failed to send payment failed email: {e}") except Subscription.DoesNotExist: logger.warning(f"Subscription not found for payment failure: {subscription_id}") diff --git a/backend/igny8_core/modules/system/__init__.py b/backend/igny8_core/modules/system/__init__.py index 6e9abda4..09f79d2c 100644 --- a/backend/igny8_core/modules/system/__init__.py +++ b/backend/igny8_core/modules/system/__init__.py @@ -18,4 +18,8 @@ __all__ = [ # New centralized models 'IntegrationProvider', 'AISettings', + # Email models + 'EmailSettings', + 'EmailTemplate', + 'EmailLog', ] diff --git a/backend/igny8_core/modules/system/admin.py b/backend/igny8_core/modules/system/admin.py index 1c94223d..b920b289 100644 --- a/backend/igny8_core/modules/system/admin.py +++ b/backend/igny8_core/modules/system/admin.py @@ -696,3 +696,6 @@ class SystemAISettingsAdmin(Igny8ModelAdmin): obj.updated_by = request.user super().save_model(request, obj, form, change) + +# Import Email Admin (EmailSettings, EmailTemplate, EmailLog) +from .email_admin import EmailSettingsAdmin, EmailTemplateAdmin, EmailLogAdmin diff --git a/backend/igny8_core/modules/system/email_admin.py b/backend/igny8_core/modules/system/email_admin.py new file mode 100644 index 00000000..efa4a292 --- /dev/null +++ b/backend/igny8_core/modules/system/email_admin.py @@ -0,0 +1,320 @@ +""" +Email Admin Configuration for IGNY8 + +Provides admin interface for managing: +- Email Settings (global configuration) +- Email Templates (template metadata and testing) +- Email Logs (sent email history) +""" +from django.contrib import admin +from django.utils.html import format_html +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages +from django.http import JsonResponse + +from unfold.admin import ModelAdmin as UnfoldModelAdmin +from igny8_core.admin.base import Igny8ModelAdmin +from .email_models import EmailSettings, EmailTemplate, EmailLog + + +@admin.register(EmailSettings) +class EmailSettingsAdmin(Igny8ModelAdmin): + """ + Admin for EmailSettings - Global email configuration (Singleton) + """ + + list_display = [ + 'from_email', + 'from_name', + 'reply_to_email', + 'send_welcome_emails', + 'send_billing_emails', + 'updated_at', + ] + readonly_fields = ['updated_at'] + + fieldsets = ( + ('Sender Configuration', { + 'fields': ('from_email', 'from_name', 'reply_to_email'), + 'description': 'Default sender settings. Email address must be verified in Resend.', + }), + ('Company Branding', { + 'fields': ('company_name', 'company_address', 'logo_url'), + 'description': 'Company information shown in email templates.', + }), + ('Support Links', { + 'fields': ('support_email', 'support_url', 'unsubscribe_url'), + 'classes': ('collapse',), + }), + ('Email Types', { + 'fields': ( + 'send_welcome_emails', + 'send_billing_emails', + 'send_subscription_emails', + 'send_low_credit_warnings', + ), + 'description': 'Enable/disable specific email types globally.', + }), + ('Thresholds', { + 'fields': ('low_credit_threshold', 'renewal_reminder_days'), + }), + ('Metadata', { + 'fields': ('updated_by', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + def has_add_permission(self, request): + """Only allow one instance (singleton)""" + return not EmailSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + """Prevent deletion of singleton""" + return False + + def save_model(self, request, obj, form, change): + """Set updated_by to current user""" + obj.updated_by = request.user + super().save_model(request, obj, form, change) + + +@admin.register(EmailTemplate) +class EmailTemplateAdmin(Igny8ModelAdmin): + """ + Admin for EmailTemplate - Manage email templates and testing + """ + + list_display = [ + 'display_name', + 'template_type', + 'template_name', + 'is_active', + 'send_count', + 'last_sent_at', + 'test_email_button', + ] + list_filter = ['template_type', 'is_active'] + search_fields = ['display_name', 'template_name', 'description'] + readonly_fields = ['send_count', 'last_sent_at', 'created_at', 'updated_at'] + + fieldsets = ( + ('Template Info', { + 'fields': ('template_name', 'template_path', 'display_name', 'description'), + }), + ('Email Settings', { + 'fields': ('template_type', 'default_subject'), + }), + ('Context Configuration', { + 'fields': ('required_context', 'sample_context'), + 'description': 'Define required variables and sample data for testing.', + 'classes': ('collapse',), + }), + ('Status', { + 'fields': ('is_active',), + }), + ('Statistics', { + 'fields': ('send_count', 'last_sent_at'), + 'classes': ('collapse',), + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + def test_email_button(self, obj): + """Add test email button in list view""" + url = reverse('admin:system_emailtemplate_test', args=[obj.pk]) + return format_html( + 'Test', + url + ) + test_email_button.short_description = 'Test' + test_email_button.allow_tags = True + + def get_urls(self): + """Add custom URL for test email""" + urls = super().get_urls() + custom_urls = [ + path( + '/test/', + self.admin_site.admin_view(self.test_email_view), + name='system_emailtemplate_test' + ), + path( + '/send-test/', + self.admin_site.admin_view(self.send_test_email), + name='system_emailtemplate_send_test' + ), + ] + return custom_urls + urls + + def test_email_view(self, request, template_id): + """Show test email form""" + template = EmailTemplate.objects.get(pk=template_id) + + context = { + **self.admin_site.each_context(request), + 'title': f'Test Email: {template.display_name}', + 'template': template, + 'opts': self.model._meta, + } + + return render(request, 'admin/system/emailtemplate/test_email.html', context) + + def send_test_email(self, request, template_id): + """Send test email""" + if request.method != 'POST': + return JsonResponse({'error': 'POST required'}, status=405) + + import json + from django.utils import timezone + from igny8_core.business.billing.services.email_service import get_email_service + + template = EmailTemplate.objects.get(pk=template_id) + + to_email = request.POST.get('to_email', request.user.email) + custom_context = request.POST.get('context', '{}') + + try: + context = json.loads(custom_context) if custom_context else {} + except json.JSONDecodeError: + context = template.sample_context or {} + + # Merge sample context with any custom values + final_context = {**(template.sample_context or {}), **context} + + # Add default context values + final_context.setdefault('user_name', 'Test User') + final_context.setdefault('account_name', 'Test Account') + final_context.setdefault('frontend_url', 'https://app.igny8.com') + + service = get_email_service() + + try: + result = service.send_transactional( + to=to_email, + subject=f'[TEST] {template.default_subject}', + template=template.template_path, + context=final_context, + tags=['test', template.template_type], + ) + + if result.get('success'): + # Update template stats + template.send_count += 1 + template.last_sent_at = timezone.now() + template.save(update_fields=['send_count', 'last_sent_at']) + + # Log the email + EmailLog.objects.create( + message_id=result.get('id', ''), + to_email=to_email, + from_email=service.from_email, + subject=f'[TEST] {template.default_subject}', + template_name=template.template_name, + status='sent', + provider=result.get('provider', 'resend'), + tags=['test', template.template_type], + ) + + messages.success( + request, + f'Test email sent successfully to {to_email}! (ID: {result.get("id", "N/A")})' + ) + else: + messages.error(request, f'Failed to send: {result.get("error", "Unknown error")}') + + except Exception as e: + messages.error(request, f'Error sending test email: {str(e)}') + + return redirect(reverse('admin:system_emailtemplate_changelist')) + + +@admin.register(EmailLog) +class EmailLogAdmin(Igny8ModelAdmin): + """ + Admin for EmailLog - View sent email history + """ + + list_display = [ + 'sent_at', + 'to_email', + 'subject_truncated', + 'template_name', + 'status_badge', + 'provider', + 'message_id_short', + ] + list_filter = ['status', 'provider', 'template_name', 'sent_at'] + search_fields = ['to_email', 'subject', 'message_id'] + readonly_fields = [ + 'message_id', 'to_email', 'from_email', 'subject', + 'template_name', 'status', 'provider', 'error_message', + 'tags', 'sent_at' + ] + date_hierarchy = 'sent_at' + + fieldsets = ( + ('Email Details', { + 'fields': ('to_email', 'from_email', 'subject'), + }), + ('Delivery Info', { + 'fields': ('status', 'provider', 'message_id'), + }), + ('Template', { + 'fields': ('template_name', 'tags'), + }), + ('Error Info', { + 'fields': ('error_message',), + 'classes': ('collapse',), + }), + ('Timestamp', { + 'fields': ('sent_at',), + }), + ) + + def has_add_permission(self, request): + """Logs are created automatically""" + return False + + def has_change_permission(self, request, obj=None): + """Logs are read-only""" + return False + + def has_delete_permission(self, request, obj=None): + """Allow deletion for cleanup""" + return request.user.is_superuser + + def subject_truncated(self, obj): + """Truncate long subjects""" + if len(obj.subject) > 50: + return f'{obj.subject[:50]}...' + return obj.subject + subject_truncated.short_description = 'Subject' + + def message_id_short(self, obj): + """Show truncated message ID""" + if obj.message_id: + return f'{obj.message_id[:20]}...' if len(obj.message_id) > 20 else obj.message_id + return '-' + message_id_short.short_description = 'Message ID' + + def status_badge(self, obj): + """Show status with color badge""" + colors = { + 'sent': '#3b82f6', + 'delivered': '#22c55e', + 'failed': '#ef4444', + 'bounced': '#f59e0b', + } + color = colors.get(obj.status, '#6b7280') + return format_html( + '{}', + color, obj.status.upper() + ) + status_badge.short_description = 'Status' + status_badge.allow_tags = True diff --git a/backend/igny8_core/modules/system/email_models.py b/backend/igny8_core/modules/system/email_models.py new file mode 100644 index 00000000..4325e90f --- /dev/null +++ b/backend/igny8_core/modules/system/email_models.py @@ -0,0 +1,292 @@ +""" +Email Configuration Models for IGNY8 + +Provides database-driven email settings, template management, and send test functionality. +Works with the existing EmailService and IntegrationProvider models. +""" +from django.db import models +from django.conf import settings + + +class EmailSettings(models.Model): + """ + Global email settings - singleton model for email configuration. + + Stores default email settings that can be managed through Django admin. + These settings work alongside IntegrationProvider (resend) configuration. + """ + + # Default sender settings + from_email = models.EmailField( + default='noreply@igny8.com', + help_text='Default sender email address (must be verified in Resend)' + ) + from_name = models.CharField( + max_length=100, + default='IGNY8', + help_text='Default sender display name' + ) + reply_to_email = models.EmailField( + default='support@igny8.com', + help_text='Default reply-to email address' + ) + + # Company branding for emails + company_name = models.CharField( + max_length=100, + default='IGNY8', + help_text='Company name shown in emails' + ) + company_address = models.TextField( + blank=True, + help_text='Company address for email footer (CAN-SPAM compliance)' + ) + logo_url = models.URLField( + blank=True, + help_text='URL to company logo for emails' + ) + + # Support links + support_email = models.EmailField( + default='support@igny8.com', + help_text='Support email shown in emails' + ) + support_url = models.URLField( + blank=True, + help_text='Link to support/help center' + ) + unsubscribe_url = models.URLField( + blank=True, + help_text='URL for email unsubscribe (for marketing emails)' + ) + + # Feature flags + send_welcome_emails = models.BooleanField( + default=True, + help_text='Send welcome email on user registration' + ) + send_billing_emails = models.BooleanField( + default=True, + help_text='Send payment confirmation, invoice emails' + ) + send_subscription_emails = models.BooleanField( + default=True, + help_text='Send subscription renewal reminders' + ) + send_low_credit_warnings = models.BooleanField( + default=True, + help_text='Send low credit warning emails' + ) + + # Credit warning threshold + low_credit_threshold = models.IntegerField( + default=100, + help_text='Send warning when credits fall below this value' + ) + renewal_reminder_days = models.IntegerField( + default=7, + help_text='Days before subscription renewal to send reminder' + ) + + # Audit + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='email_settings_updates' + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'igny8_email_settings' + verbose_name = 'Email Settings' + verbose_name_plural = 'Email Settings' + + def __str__(self): + return f'Email Settings (from: {self.from_email})' + + def save(self, *args, **kwargs): + """Ensure only one instance exists (singleton)""" + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def get_settings(cls): + """Get singleton settings instance, creating if needed""" + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + +class EmailTemplate(models.Model): + """ + Email template metadata - tracks available email templates + and their usage/configuration. + + Templates are stored as Django templates in templates/emails/. + This model provides admin visibility and test sending capability. + """ + + TEMPLATE_TYPE_CHOICES = [ + ('auth', 'Authentication'), + ('billing', 'Billing'), + ('notification', 'Notification'), + ('marketing', 'Marketing'), + ] + + # Template identification + template_name = models.CharField( + max_length=100, + unique=True, + help_text='Template file name without extension (e.g., "welcome")' + ) + template_path = models.CharField( + max_length=200, + help_text='Full template path (e.g., "emails/welcome.html")' + ) + + # Display info + display_name = models.CharField( + max_length=100, + help_text='Human-readable template name' + ) + description = models.TextField( + blank=True, + help_text='Description of when this template is used' + ) + template_type = models.CharField( + max_length=20, + choices=TEMPLATE_TYPE_CHOICES, + default='notification' + ) + + # Default subject + default_subject = models.CharField( + max_length=200, + help_text='Default email subject line' + ) + + # Required context variables + required_context = models.JSONField( + default=list, + blank=True, + help_text='List of required context variables for this template' + ) + + # Sample context for testing + sample_context = models.JSONField( + default=dict, + blank=True, + help_text='Sample context for test sending (JSON)' + ) + + # Status + is_active = models.BooleanField( + default=True, + help_text='Whether this template is currently in use' + ) + + # Stats + send_count = models.IntegerField( + default=0, + help_text='Number of emails sent using this template' + ) + last_sent_at = models.DateTimeField( + null=True, + blank=True, + help_text='Last time an email was sent with this template' + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'igny8_email_templates' + verbose_name = 'Email Template' + verbose_name_plural = 'Email Templates' + ordering = ['template_type', 'display_name'] + + def __str__(self): + return f'{self.display_name} ({self.template_type})' + + +class EmailLog(models.Model): + """ + Log of sent emails for audit and debugging. + """ + + STATUS_CHOICES = [ + ('sent', 'Sent'), + ('delivered', 'Delivered'), + ('failed', 'Failed'), + ('bounced', 'Bounced'), + ] + + # Email identification + message_id = models.CharField( + max_length=200, + blank=True, + help_text='Provider message ID (from Resend)' + ) + + # Recipients + to_email = models.EmailField( + help_text='Recipient email' + ) + from_email = models.EmailField( + help_text='Sender email' + ) + + # Content + subject = models.CharField( + max_length=500, + help_text='Email subject' + ) + template_name = models.CharField( + max_length=100, + blank=True, + help_text='Template used (if any)' + ) + + # Status + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='sent' + ) + provider = models.CharField( + max_length=50, + default='resend', + help_text='Email provider used' + ) + + # Error tracking + error_message = models.TextField( + blank=True, + help_text='Error message if failed' + ) + + # Metadata + tags = models.JSONField( + default=list, + blank=True, + help_text='Email tags for categorization' + ) + + # Timestamps + sent_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'igny8_email_log' + verbose_name = 'Email Log' + verbose_name_plural = 'Email Logs' + ordering = ['-sent_at'] + indexes = [ + models.Index(fields=['to_email', 'sent_at']), + models.Index(fields=['status', 'sent_at']), + models.Index(fields=['template_name', 'sent_at']), + ] + + def __str__(self): + return f'{self.subject} → {self.to_email} ({self.status})' diff --git a/backend/igny8_core/modules/system/migrations/0020_add_email_models.py b/backend/igny8_core/modules/system/migrations/0020_add_email_models.py new file mode 100644 index 00000000..89e33980 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0020_add_email_models.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.10 on 2026-01-08 01:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0019_model_schema_update'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EmailTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('template_name', models.CharField(help_text='Template file name without extension (e.g., "welcome")', max_length=100, unique=True)), + ('template_path', models.CharField(help_text='Full template path (e.g., "emails/welcome.html")', max_length=200)), + ('display_name', models.CharField(help_text='Human-readable template name', max_length=100)), + ('description', models.TextField(blank=True, help_text='Description of when this template is used')), + ('template_type', models.CharField(choices=[('auth', 'Authentication'), ('billing', 'Billing'), ('notification', 'Notification'), ('marketing', 'Marketing')], default='notification', max_length=20)), + ('default_subject', models.CharField(help_text='Default email subject line', max_length=200)), + ('required_context', models.JSONField(blank=True, default=list, help_text='List of required context variables for this template')), + ('sample_context', models.JSONField(blank=True, default=dict, help_text='Sample context for test sending (JSON)')), + ('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')), + ('send_count', models.IntegerField(default=0, help_text='Number of emails sent using this template')), + ('last_sent_at', models.DateTimeField(blank=True, help_text='Last time an email was sent with this template', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Email Template', + 'verbose_name_plural': 'Email Templates', + 'db_table': 'igny8_email_templates', + 'ordering': ['template_type', 'display_name'], + }, + ), + migrations.CreateModel( + name='EmailLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message_id', models.CharField(blank=True, help_text='Provider message ID (from Resend)', max_length=200)), + ('to_email', models.EmailField(help_text='Recipient email', max_length=254)), + ('from_email', models.EmailField(help_text='Sender email', max_length=254)), + ('subject', models.CharField(help_text='Email subject', max_length=500)), + ('template_name', models.CharField(blank=True, help_text='Template used (if any)', max_length=100)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], default='sent', max_length=20)), + ('provider', models.CharField(default='resend', help_text='Email provider used', max_length=50)), + ('error_message', models.TextField(blank=True, help_text='Error message if failed')), + ('tags', models.JSONField(blank=True, default=list, help_text='Email tags for categorization')), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Email Log', + 'verbose_name_plural': 'Email Logs', + 'db_table': 'igny8_email_log', + 'ordering': ['-sent_at'], + 'indexes': [models.Index(fields=['to_email', 'sent_at'], name='igny8_email_to_emai_f0efbd_idx'), models.Index(fields=['status', 'sent_at'], name='igny8_email_status_7107f0_idx'), models.Index(fields=['template_name', 'sent_at'], name='igny8_email_templat_e979b9_idx')], + }, + ), + migrations.CreateModel( + name='EmailSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_email', models.EmailField(default='noreply@igny8.com', help_text='Default sender email address (must be verified in Resend)', max_length=254)), + ('from_name', models.CharField(default='IGNY8', help_text='Default sender display name', max_length=100)), + ('reply_to_email', models.EmailField(default='support@igny8.com', help_text='Default reply-to email address', max_length=254)), + ('company_name', models.CharField(default='IGNY8', help_text='Company name shown in emails', max_length=100)), + ('company_address', models.TextField(blank=True, help_text='Company address for email footer (CAN-SPAM compliance)')), + ('logo_url', models.URLField(blank=True, help_text='URL to company logo for emails')), + ('support_email', models.EmailField(default='support@igny8.com', help_text='Support email shown in emails', max_length=254)), + ('support_url', models.URLField(blank=True, help_text='Link to support/help center')), + ('unsubscribe_url', models.URLField(blank=True, help_text='URL for email unsubscribe (for marketing emails)')), + ('send_welcome_emails', models.BooleanField(default=True, help_text='Send welcome email on user registration')), + ('send_billing_emails', models.BooleanField(default=True, help_text='Send payment confirmation, invoice emails')), + ('send_subscription_emails', models.BooleanField(default=True, help_text='Send subscription renewal reminders')), + ('send_low_credit_warnings', models.BooleanField(default=True, help_text='Send low credit warning emails')), + ('low_credit_threshold', models.IntegerField(default=100, help_text='Send warning when credits fall below this value')), + ('renewal_reminder_days', models.IntegerField(default=7, help_text='Days before subscription renewal to send reminder')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_settings_updates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Email Settings', + 'verbose_name_plural': 'Email Settings', + 'db_table': 'igny8_email_settings', + }, + ), + # Note: AccountIntegrationOverride delete removed - table doesn't exist in DB + ] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 19691eed..30b4a9ef 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -785,6 +785,18 @@ UNFOLD = { {"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"}, ], }, + # Email Settings (NEW) + { + "title": "Email Settings", + "icon": "email", + "collapsible": True, + "items": [ + {"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"}, + {"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"}, + {"title": "Email Logs", "icon": "history", "link": lambda request: "/admin/system/emaillog/"}, + {"title": "Resend Provider", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/resend/change/"}, + ], + }, # Global Settings { "title": "Global Settings", diff --git a/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html b/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html new file mode 100644 index 00000000..d890769c --- /dev/null +++ b/backend/igny8_core/templates/admin/system/emailtemplate/test_email.html @@ -0,0 +1,78 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} +
+
+

Test Email: {{ template.display_name }}

+ +
+

+ Template Path: {{ template.template_path }} +

+

+ Type: {{ template.get_template_type_display }} +

+

+ Description: {{ template.description|default:"No description" }} +

+
+ +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +

+ Required variables: {{ template.required_context|default:"None specified"|safe }} +

+
+ +
+ + + Cancel + +
+
+
+ +
+

⚠️ Testing Tips

+
    +
  • Test emails are prefixed with [TEST] in the subject line
  • +
  • Make sure your Resend API key is configured in Integration Providers
  • +
  • Use sample_context to pre-fill test data for this template
  • +
  • Check Email Logs after sending to verify delivery status
  • +
+
+
+{% endblock %} diff --git a/backend/igny8_core/templates/emails/base.html b/backend/igny8_core/templates/emails/base.html index bf771356..a5d4058f 100644 --- a/backend/igny8_core/templates/emails/base.html +++ b/backend/igny8_core/templates/emails/base.html @@ -3,7 +3,7 @@ - {% block title %}IGNY8{% endblock %} + {% block title %}{{ company_name|default:"IGNY8" }}{% endblock %}
{% block content %}{% endblock %}
diff --git a/backend/igny8_core/templates/emails/email_verification.html b/backend/igny8_core/templates/emails/email_verification.html index 471a34d9..d011afe0 100644 --- a/backend/igny8_core/templates/emails/email_verification.html +++ b/backend/igny8_core/templates/emails/email_verification.html @@ -28,6 +28,6 @@

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/low_credits.html b/backend/igny8_core/templates/emails/low_credits.html index b8b4381e..05276ec1 100644 --- a/backend/igny8_core/templates/emails/low_credits.html +++ b/backend/igny8_core/templates/emails/low_credits.html @@ -34,6 +34,6 @@

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/password_reset.html b/backend/igny8_core/templates/emails/password_reset.html index c6ef2939..e7e80550 100644 --- a/backend/igny8_core/templates/emails/password_reset.html +++ b/backend/igny8_core/templates/emails/password_reset.html @@ -26,6 +26,6 @@

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/payment_approved.html b/backend/igny8_core/templates/emails/payment_approved.html index fcb07e99..eee95b0c 100644 --- a/backend/igny8_core/templates/emails/payment_approved.html +++ b/backend/igny8_core/templates/emails/payment_approved.html @@ -41,7 +41,7 @@

-Thank you for choosing IGNY8!
-The IGNY8 Team +Thank you for choosing {{ company_name|default:"IGNY8" }}!
+The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/payment_confirmation.html b/backend/igny8_core/templates/emails/payment_confirmation.html index 3224e9b8..acdc6594 100644 --- a/backend/igny8_core/templates/emails/payment_confirmation.html +++ b/backend/igny8_core/templates/emails/payment_confirmation.html @@ -40,6 +40,6 @@

Thank you for your patience,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/payment_failed.html b/backend/igny8_core/templates/emails/payment_failed.html index 4c00d0b3..5f1e2981 100644 --- a/backend/igny8_core/templates/emails/payment_failed.html +++ b/backend/igny8_core/templates/emails/payment_failed.html @@ -30,10 +30,10 @@ Update Payment Method

-

If you need assistance, please contact our support team by replying to this email.

+

If you need assistance, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/payment_rejected.html b/backend/igny8_core/templates/emails/payment_rejected.html index 69384885..43f0824f 100644 --- a/backend/igny8_core/templates/emails/payment_rejected.html +++ b/backend/igny8_core/templates/emails/payment_rejected.html @@ -40,10 +40,10 @@ Retry Payment

-

If you believe this is an error or have questions, please contact our support team by replying to this email.

+

If you believe this is an error or have questions, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/refund_notification.html b/backend/igny8_core/templates/emails/refund_notification.html index 85baef4e..3dc39f48 100644 --- a/backend/igny8_core/templates/emails/refund_notification.html +++ b/backend/igny8_core/templates/emails/refund_notification.html @@ -40,10 +40,10 @@

The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.

-

If you have any questions about this refund, please contact our support team by replying to this email.

+

If you have any questions about this refund, please contact our support team{% if support_url %} at {{ support_url }} or{% endif %} by replying to this email.

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/subscription_activated.html b/backend/igny8_core/templates/emails/subscription_activated.html index bdd0fe65..34dc093f 100644 --- a/backend/igny8_core/templates/emails/subscription_activated.html +++ b/backend/igny8_core/templates/emails/subscription_activated.html @@ -28,14 +28,14 @@ -

You now have full access to all features included in your plan. Start exploring what IGNY8 can do for you!

+

You now have full access to all features included in your plan. Start exploring what {{ company_name|default:"IGNY8" }} can do for you!

Go to Dashboard

-Thank you for choosing IGNY8!
-The IGNY8 Team +Thank you for choosing {{ company_name|default:"IGNY8" }}!
+The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/subscription_renewal.html b/backend/igny8_core/templates/emails/subscription_renewal.html index 5352651d..ff6d6aea 100644 --- a/backend/igny8_core/templates/emails/subscription_renewal.html +++ b/backend/igny8_core/templates/emails/subscription_renewal.html @@ -36,6 +36,6 @@

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/backend/igny8_core/templates/emails/welcome.html b/backend/igny8_core/templates/emails/welcome.html index 961aa3a6..e48e2f90 100644 --- a/backend/igny8_core/templates/emails/welcome.html +++ b/backend/igny8_core/templates/emails/welcome.html @@ -1,8 +1,8 @@ {% extends "emails/base.html" %} -{% block title %}Welcome to IGNY8{% endblock %} +{% block title %}Welcome to {{ company_name|default:"IGNY8" }}{% endblock %} {% block content %} -

Welcome to IGNY8!

+

Welcome to {{ company_name|default:"IGNY8" }}!

Hi {{ user_name }},

@@ -21,10 +21,10 @@ Go to Dashboard

-

If you have any questions, our support team is here to help. Just reply to this email or visit our help center.

+

If you have any questions, our support team is here to help. {% if support_url %}Visit our help center or {% endif %}reply to this email.

Best regards,
-The IGNY8 Team +The {{ company_name|default:"IGNY8" }} Team

{% endblock %} diff --git a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md index 04352891..ecff18fa 100644 --- a/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md +++ b/docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md @@ -325,25 +325,113 @@ Config (JSON): 4. Click **"Save"** -### 5.3 Testing Email Delivery +### 5.3 Email Settings Management -After configuring Resend, test email delivery: +IGNY8 provides a dedicated **Email Settings** navigation group in Django Admin: + +| Menu Item | URL | Purpose | +|-----------|-----|---------| +| Email Configuration | `/admin/system/emailsettings/` | Global email defaults (from, reply-to, feature flags) | +| Email Templates | `/admin/system/emailtemplate/` | Manage/test email templates | +| Email Logs | `/admin/system/emaillog/` | View sent email history | +| Resend Provider | `/admin/system/integrationprovider/resend/change/` | API key & config | + +**Email Configuration Settings:** +- `from_email` - Default sender (must be verified in Resend) +- `from_name` - Display name for sender +- `reply_to_email` - Reply-to address +- `send_welcome_emails` - Toggle welcome emails on/off +- `send_billing_emails` - Toggle payment/invoice emails +- `send_subscription_emails` - Toggle renewal reminders +- `low_credit_threshold` - Credits level to trigger warning email + +### 5.4 Testing Email Delivery + +**Method 1: Django Admin UI (Recommended)** + +1. Go to **Email Settings → Email Templates** +2. Click the **"Test"** button next to any template +3. Enter recipient email and customize context JSON +4. Click **"Send Test Email"** +5. Check **Email Logs** to verify delivery + +**Method 2: Command Line (Docker)** ```bash -cd /data/app/igny8/backend -python manage.py shell -``` - -```python +docker exec -it igny8_backend python manage.py shell -c " from igny8_core.business.billing.services.email_service import get_email_service service = get_email_service() -service.send_transactional( +result = service.send_transactional( to='your-email@example.com', subject='Test Email from IGNY8', html='

Test Email

If you receive this, Resend is configured correctly!

', text='Test Email. If you receive this, Resend is configured correctly!' ) +print('Result:', result) +" +``` + +**Expected successful response:** +```python +{'success': True, 'id': '81193754-6f27-4b1a-9c36-d83ae18f6a9a', 'provider': 'resend'} +``` + +**Method 3: Test with Template** + +```bash +docker exec -it igny8_backend python manage.py shell -c " +from igny8_core.business.billing.services.email_service import get_email_service + +service = get_email_service() +result = service.send_transactional( + to='your-email@example.com', + subject='Welcome Test', + template='emails/welcome.html', + context={ + 'user_name': 'Test User', + 'account_name': 'Test Account', + 'login_url': 'https://app.igny8.com/login', + 'frontend_url': 'https://app.igny8.com', + }, + tags=['test'] +) +print('Result:', result) +" +``` + +### 5.5 Available Email Templates + +| Template | Type | Trigger | +|----------|------|---------| +| `welcome` | Auth | User registration | +| `password_reset` | Auth | Password reset request | +| `email_verification` | Auth | Email verification | +| `payment_confirmation` | Billing | Manual payment submitted | +| `payment_approved` | Billing | Payment approved | +| `payment_rejected` | Billing | Payment declined | +| `payment_failed` | Billing | Auto-payment failed | +| `subscription_activated` | Billing | Subscription activated | +| `subscription_renewal` | Billing | Renewal reminder | +| `refund_notification` | Billing | Refund processed | +| `low_credits` | Notification | Credits below threshold | + +### 5.6 Email Service API Reference + +```python +send_transactional( + to: str | List[str], # Required: recipient email(s) + subject: str, # Required: email subject + html: str = None, # HTML content + text: str = None, # Plain text content + template: str = None, # Template path (e.g., 'emails/welcome.html') + context: dict = None, # Template context variables + from_email: str = None, # Override sender email + from_name: str = None, # Override sender name + reply_to: str = None, # Reply-to address + attachments: List = None, # File attachments + tags: List[str] = None # Email tags for tracking +) ``` --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf11a09e..9f31e0d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,12 @@ import NotFound from "./pages/OtherPage/NotFound"; const Terms = lazy(() => import("./pages/legal/Terms")); const Privacy = lazy(() => import("./pages/legal/Privacy")); +// Auth pages - Lazy loaded (password reset, verification, etc.) +const ForgotPassword = lazy(() => import("./pages/AuthPages/ForgotPassword")); +const ResetPassword = lazy(() => import("./pages/AuthPages/ResetPassword")); +const VerifyEmail = lazy(() => import("./pages/AuthPages/VerifyEmail")); +const Unsubscribe = lazy(() => import("./pages/AuthPages/Unsubscribe")); + // Lazy load all other pages - only loads when navigated to const Home = lazy(() => import("./pages/Dashboard/Home")); @@ -147,6 +153,12 @@ export default function App() { {/* Legal Pages - Public */} } /> } /> + + {/* Auth Flow Pages - Public */} + } /> + } /> + } /> + } /> {/* Protected Routes - Require Authentication */} ('form'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await fetchAPI('/v1/auth/password-reset/', { + method: 'POST', + body: JSON.stringify({ email }), + }); + setFormState('success'); + } catch (err: unknown) { + // Always show success to prevent email enumeration + setFormState('success'); + } finally { + setLoading(false); + } + }; + + // Success State + if (formState === 'success') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Check Your Email +

+

+ If an account exists for {email}, you'll receive a password reset link shortly. +

+
+

+ Didn't receive the email? +

+
    +
  • Check your spam folder
  • +
  • Make sure you entered the correct email
  • +
  • Wait a few minutes and try again
  • +
+
+ + Back to Sign In + +
+
+
+ + ); + } + + // Form State + return ( + <> + +
+
+ {/* Logo */} +
+ + IGNY8 + IGNY8 + +
+ + {/* Form Card */} +
+ + + Back to Sign In + + +
+ +
+ +

+ Forgot Password? +

+

+ No worries! Enter your email and we'll send you reset instructions. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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 + /> +
+ + +
+ +

+ Remember your password?{' '} + + Sign In + +

+
+
+
+ + ); +} diff --git a/frontend/src/pages/AuthPages/ResetPassword.tsx b/frontend/src/pages/AuthPages/ResetPassword.tsx new file mode 100644 index 00000000..a38bd466 --- /dev/null +++ b/frontend/src/pages/AuthPages/ResetPassword.tsx @@ -0,0 +1,344 @@ +/** + * Reset Password Page + * Handles password reset with token from email link + */ + +import { useState, useEffect } from 'react'; +import { Link, useSearchParams, useNavigate } from 'react-router-dom'; +import { fetchAPI } from '../../services/api'; +import PageMeta from '../../components/common/PageMeta'; +import { EyeIcon, EyeCloseIcon, ChevronLeftIcon, CheckCircleIcon, AlertIcon, LockIcon } from '../../icons'; + +type ResetState = 'form' | 'success' | 'error' | 'expired'; + +export default function ResetPassword() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [resetState, setResetState] = useState('form'); + + // Redirect to forgot-password if no token + useEffect(() => { + if (!token) { + // No token - redirect to forgot password page + navigate('/forgot-password', { replace: true }); + } + }, [token, navigate]); + + // Password validation checks + const passwordChecks = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + }; + + const isPasswordStrong = Object.values(passwordChecks).every(Boolean); + const passwordsMatch = password === confirmPassword && confirmPassword.length > 0; + const isPasswordValid = isPasswordStrong && passwordsMatch; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!token) { + setResetState('expired'); + return; + } + + if (!isPasswordValid) { + setError('Please ensure your password meets all requirements and both passwords match.'); + return; + } + + setLoading(true); + + try { + // Correct API endpoint for password reset confirmation + await fetchAPI('/v1/auth/password-reset/confirm/', { + method: 'POST', + body: JSON.stringify({ token, new_password: password }), + }); + setResetState('success'); + } catch (err: unknown) { + const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } }; + const message = errorResponse?.response?.data?.message || + errorResponse?.response?.data?.error || + errorResponse?.response?.data?.detail || + 'Failed to reset password. Please try again.'; + + if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) { + setResetState('expired'); + } else { + setError(message); + } + } finally { + setLoading(false); + } + }; + + // Expired/Invalid Token State + if (resetState === 'expired') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Link Expired +

+

+ This password reset link has expired or is invalid. Please request a new one. +

+ + Back to Sign In + +

+ Need help?{' '} + + Contact Support + +

+
+
+
+ + ); + } + + // Success State + if (resetState === 'success') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Password Reset! +

+

+ Your password has been successfully reset. You can now sign in with your new password. +

+ +
+
+
+ + ); + } + + // Form State + return ( + <> + +
+
+ {/* Logo */} +
+ + IGNY8 + IGNY8 + +
+ + {/* Form Card */} +
+ + + Back to Sign In + + +
+ +
+ +

+ Reset Your Password +

+

+ Create a strong password for your account. +

+ + {error && ( +
+ +

{error}

+
+ )} + +
+ {/* Password Field */} +
+ +
+ 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 + /> + +
+ + {/* Password Requirements - Always visible for clarity */} +
+

Password Requirements:

+ {[ + { check: passwordChecks.length, label: 'At least 8 characters' }, + { check: passwordChecks.uppercase, label: 'One uppercase letter' }, + { check: passwordChecks.lowercase, label: 'One lowercase letter' }, + { check: passwordChecks.number, label: 'One number' }, + ].map((req, i) => ( +
+ + {password.length === 0 ? '○' : req.check ? '✓' : '✕'} + + {req.label} +
+ ))} +
+
+ + {/* Confirm Password Field */} +
+ +
+ 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 + /> + +
+ {confirmPassword.length > 0 && ( +

+ + {passwordsMatch ? '✓' : '✕'} + + {passwordsMatch ? 'Passwords match' : 'Passwords do not match'} +

+ )} +
+ + {/* Submit Button */} + +
+
+
+
+ + ); +} diff --git a/frontend/src/pages/AuthPages/Unsubscribe.tsx b/frontend/src/pages/AuthPages/Unsubscribe.tsx new file mode 100644 index 00000000..77a55f8c --- /dev/null +++ b/frontend/src/pages/AuthPages/Unsubscribe.tsx @@ -0,0 +1,83 @@ +/** + * Email Preferences Page + * Redirects to account settings for managing email preferences + * Transactional emails cannot be unsubscribed - they're always sent + */ + +import { useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import PageMeta from '../../components/common/PageMeta'; +import { EnvelopeIcon, SettingsIcon } from '../../icons'; + +export default function Unsubscribe() { + const navigate = useNavigate(); + + // Auto-redirect to account settings after 3 seconds + useEffect(() => { + const timer = setTimeout(() => { + navigate('/account/settings?tab=notifications'); + }, 5000); + return () => clearTimeout(timer); + }, [navigate]); + + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Manage Email Preferences +

+

+ You can manage your email notification preferences from your account settings. +

+ +
+

+ Important transactional emails cannot be disabled: +

+
    +
  • Password reset requests
  • +
  • Security alerts
  • +
  • Payment confirmations & invoices
  • +
  • Account status notifications
  • +
+
+ + + + Go to Account Settings + + + + Back to Home + + +

+ Redirecting to account settings in 5 seconds... +

+
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/pages/AuthPages/VerifyEmail.tsx b/frontend/src/pages/AuthPages/VerifyEmail.tsx new file mode 100644 index 00000000..f8335f4f --- /dev/null +++ b/frontend/src/pages/AuthPages/VerifyEmail.tsx @@ -0,0 +1,220 @@ +/** + * Verify Email Page + * Handles email verification with token from email link + */ + +import { useState, useEffect } from 'react'; +import { Link, useSearchParams, useNavigate } from 'react-router-dom'; +import { fetchAPI } from '../../services/api'; +import PageMeta from '../../components/common/PageMeta'; +import { CheckCircleIcon, AlertIcon, EnvelopeIcon } from '../../icons'; + +type VerifyState = 'verifying' | 'success' | 'error' | 'expired'; + +export default function VerifyEmail() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [verifyState, setVerifyState] = useState('verifying'); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const verifyEmail = async () => { + if (!token) { + setVerifyState('expired'); + setErrorMessage('No verification token provided.'); + return; + } + + try { + await fetchAPI('/v1/auth/users/verify_email/', { + method: 'POST', + body: JSON.stringify({ token }), + }); + setVerifyState('success'); + } catch (err: unknown) { + const errorResponse = err as { response?: { data?: { message?: string; detail?: string; error?: string } } }; + const message = errorResponse?.response?.data?.error || + errorResponse?.response?.data?.message || + errorResponse?.response?.data?.detail || + 'Failed to verify email'; + + if (message.toLowerCase().includes('expired') || message.toLowerCase().includes('invalid')) { + setVerifyState('expired'); + setErrorMessage('This verification link has expired or is invalid.'); + } else if (message.toLowerCase().includes('already verified')) { + setVerifyState('success'); + } else { + setVerifyState('error'); + setErrorMessage(message); + } + } + }; + + verifyEmail(); + }, [token]); + + // Verifying State + if (verifyState === 'verifying') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ + + + +
+

+ Verifying Your Email +

+

+ Please wait while we verify your email address... +

+
+
+
+ + ); + } + + // Success State + if (verifyState === 'success') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Email Verified! +

+

+ Your email has been successfully verified. You can now access all features of your account. +

+ +
+
+
+ + ); + } + + // Expired State + if (verifyState === 'expired') { + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Link Expired +

+

+ {errorMessage || 'This verification link has expired or is invalid.'} +

+ + Sign In to Resend + +

+ Need help?{' '} + + Contact Support + +

+
+
+
+ + ); + } + + // Error State + return ( + <> + +
+
+
+ + IGNY8 + IGNY8 + +
+
+
+ +
+

+ Verification Failed +

+

+ {errorMessage || 'We could not verify your email address. Please try again.'} +

+ + Back to Sign In + +

+ Need help?{' '} + + Contact Support + +

+
+
+
+ + ); +} diff --git a/frontend/src/pages/legal/Privacy.tsx b/frontend/src/pages/legal/Privacy.tsx index 466fa381..05329d48 100644 --- a/frontend/src/pages/legal/Privacy.tsx +++ b/frontend/src/pages/legal/Privacy.tsx @@ -28,7 +28,7 @@ export default function Privacy() {
IGNY8 diff --git a/frontend/src/pages/legal/Terms.tsx b/frontend/src/pages/legal/Terms.tsx index 58b43697..62100b1d 100644 --- a/frontend/src/pages/legal/Terms.tsx +++ b/frontend/src/pages/legal/Terms.tsx @@ -28,7 +28,7 @@ export default function Terms() {
IGNY8