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

@@ -830,6 +830,15 @@ class PaymentViewSet(AccountModelViewSet):
manual_notes=notes,
)
# Send payment confirmation email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService
BillingEmailService.send_payment_confirmation_email(payment, account)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to send payment confirmation email: {str(e)}')
return success_response(
data={'id': payment.id, 'status': payment.status},
message='Manual payment submitted for approval',

View File

@@ -13,6 +13,33 @@ from igny8_core.auth.models import Account
logger = logging.getLogger(__name__)
def _check_low_credits_warning(account, previous_balance):
"""
Check if credits have fallen below threshold and send warning email.
Only sends if this is the first time falling below threshold.
"""
try:
from igny8_core.modules.system.email_models import EmailSettings
from .email_service import BillingEmailService
settings = EmailSettings.get_settings()
if not settings.send_low_credit_warnings:
return
threshold = settings.low_credit_threshold
# Only send if we CROSSED below the threshold (wasn't already below)
if account.credits < threshold <= previous_balance:
logger.info(f"Credits fell below threshold for account {account.id}: {account.credits} < {threshold}")
BillingEmailService.send_low_credits_warning(
account=account,
current_credits=account.credits,
threshold=threshold
)
except Exception as e:
logger.error(f"Failed to check/send low credits warning: {e}")
class CreditService:
"""Service for managing credits - Token-based only"""
@@ -302,6 +329,9 @@ class CreditService:
# Check sufficient credits (legacy: amount is already calculated)
CreditService.check_credits_legacy(account, amount)
# Store previous balance for low credits check
previous_balance = account.credits
# Deduct from account.credits
account.credits -= amount
account.save(update_fields=['credits'])
@@ -330,6 +360,9 @@ class CreditService:
metadata=metadata or {}
)
# Check and send low credits warning if applicable
_check_low_credits_warning(account, previous_balance)
return account.credits
@staticmethod

View File

@@ -6,8 +6,13 @@ Supports template rendering and multiple email types.
Configuration stored in IntegrationProvider model (provider_id='resend')
"""
import hashlib
import hmac
import logging
import re
import time
from typing import Optional, List, Dict, Any
from urllib.parse import urlencode
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.template.exceptions import TemplateDoesNotExist
@@ -24,17 +29,142 @@ except ImportError:
logger.info("Resend package not installed, will use Django mail backend")
def html_to_plain_text(html: str) -> str:
"""
Convert HTML to plain text for email multipart.
This improves deliverability as many spam filters prefer
emails with both HTML and plain text versions.
"""
# Remove style and script blocks
text = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
# Convert common tags to plain text equivalents
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
text = re.sub(r'</div>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</tr>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<li[^>]*>', '', text, flags=re.IGNORECASE)
# Convert links to plain text with URL
text = re.sub(r'<a[^>]*href=["\']([^"\']+)["\'][^>]*>([^<]*)</a>', r'\2 (\1)', text, flags=re.IGNORECASE)
# Remove remaining HTML tags
text = re.sub(r'<[^>]+>', '', text)
# Decode HTML entities
text = text.replace('&nbsp;', ' ')
text = text.replace('&amp;', '&')
text = text.replace('&lt;', '<')
text = text.replace('&gt;', '>')
text = text.replace('&quot;', '"')
text = text.replace('&#39;', "'")
# Clean up whitespace
text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 newlines
text = re.sub(r' +', ' ', text) # Multiple spaces to single
text = text.strip()
return text
def generate_unsubscribe_url(email: str, email_type: str = 'all') -> str:
"""
Generate a signed unsubscribe URL for the given email.
Uses HMAC signature to prevent tampering. The URL includes:
- email: The recipient email
- type: Type of emails to unsubscribe from (marketing, billing, all)
- ts: Timestamp for expiration check
- sig: HMAC signature
Returns:
str: Full unsubscribe URL with signed parameters
"""
frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
secret_key = settings.SECRET_KEY
# Create payload with timestamp (valid for 30 days)
timestamp = int(time.time())
# Create signature
message = f"{email}:{email_type}:{timestamp}"
signature = hmac.new(
secret_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()[:32] # Truncate for shorter URL
# Build URL with query params
params = urlencode({
'email': email,
'type': email_type,
'ts': timestamp,
'sig': signature,
})
return f"{frontend_url}/unsubscribe?{params}"
def verify_unsubscribe_signature(email: str, email_type: str, timestamp: int, signature: str) -> bool:
"""
Verify the HMAC signature for unsubscribe requests.
Args:
email: The recipient email
email_type: Type of unsubscribe (marketing, billing, all)
timestamp: Unix timestamp from URL
signature: HMAC signature from URL
Returns:
bool: True if signature is valid and not expired (30 days)
"""
secret_key = settings.SECRET_KEY
# Check expiration (30 days)
current_time = int(time.time())
if current_time - timestamp > 30 * 24 * 60 * 60:
return False
# Recreate signature
message = f"{email}:{email_type}:{timestamp}"
expected_sig = hmac.new(
secret_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()[:32]
return hmac.compare_digest(signature, expected_sig)
class EmailConfigurationError(Exception):
"""Raised when email provider is not properly configured"""
pass
def get_email_settings():
"""
Get email settings from database (EmailSettings model).
Falls back to defaults if not configured.
"""
try:
from igny8_core.modules.system.email_models import EmailSettings
return EmailSettings.get_settings()
except Exception as e:
logger.warning(f"Could not load EmailSettings: {e}")
return None
class EmailService:
"""
Unified email service supporting multiple providers.
Primary: Resend (for production transactional emails)
Fallback: Django's send_mail (uses EMAIL_BACKEND from settings)
Uses EmailSettings model for configuration (from_email, from_name, etc.)
"""
def __init__(self):
@@ -42,12 +172,16 @@ class EmailService:
self._resend_config = {}
self._brevo_configured = False
self._brevo_config = {}
self._email_settings = None
self._setup_providers()
def _setup_providers(self):
"""Initialize email providers from IntegrationProvider"""
"""Initialize email providers from IntegrationProvider and EmailSettings"""
from igny8_core.modules.system.models import IntegrationProvider
# Load email settings from database
self._email_settings = get_email_settings()
# Setup Resend
if RESEND_AVAILABLE:
resend_provider = IntegrationProvider.get_provider('resend')
@@ -66,9 +200,18 @@ class EmailService:
self._brevo_configured = True
logger.info("Brevo email provider initialized")
def _get_settings(self):
"""Get fresh email settings (refreshes on each call)"""
if not self._email_settings:
self._email_settings = get_email_settings()
return self._email_settings
@property
def from_email(self) -> str:
"""Get default from email"""
"""Get default from email from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.from_email:
return settings_obj.from_email
return self._resend_config.get(
'from_email',
getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com')
@@ -76,14 +219,70 @@ class EmailService:
@property
def from_name(self) -> str:
"""Get default from name"""
"""Get default from name from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.from_name:
return settings_obj.from_name
return self._resend_config.get('from_name', 'IGNY8')
@property
def reply_to(self) -> str:
"""Get default reply-to address"""
"""Get default reply-to address from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.reply_to_email:
return settings_obj.reply_to_email
return self._resend_config.get('reply_to', 'support@igny8.com')
@property
def company_name(self) -> str:
"""Get company name from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.company_name:
return settings_obj.company_name
return 'IGNY8'
@property
def company_address(self) -> str:
"""Get company address from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.company_address:
return settings_obj.company_address
return ''
@property
def logo_url(self) -> str:
"""Get logo URL from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.logo_url:
return settings_obj.logo_url
return 'https://app.igny8.com/images/logo/IGNY8_LIGHT_LOGO.png'
@property
def support_email(self) -> str:
"""Get support email from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.support_email:
return settings_obj.support_email
return 'support@igny8.com'
@property
def support_url(self) -> str:
"""Get support/help center URL from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.support_url:
return settings_obj.support_url
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
return f'{frontend_url}/help'
@property
def unsubscribe_url(self) -> str:
"""Get unsubscribe URL from EmailSettings"""
settings_obj = self._get_settings()
if settings_obj and settings_obj.unsubscribe_url:
return settings_obj.unsubscribe_url
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
return f'{frontend_url}/account/settings?tab=notifications'
def send_transactional(
self,
to: str | List[str],
@@ -121,6 +320,37 @@ class EmailService:
if isinstance(to, str):
to = [to]
# Auto-inject common context variables for templates
if context is None:
context = {}
# Add recipient email to context
primary_recipient = to[0] if to else ''
if 'recipient_email' not in context:
context['recipient_email'] = primary_recipient
# Add email settings to context (from EmailSettings model)
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
if 'frontend_url' not in context:
context['frontend_url'] = frontend_url
if 'company_name' not in context:
context['company_name'] = self.company_name
if 'company_address' not in context:
context['company_address'] = self.company_address
if 'logo_url' not in context:
context['logo_url'] = self.logo_url
if 'support_email' not in context:
context['support_email'] = self.support_email
if 'support_url' not in context:
context['support_url'] = self.support_url
if 'current_year' not in context:
from datetime import datetime
context['current_year'] = datetime.now().year
# Add unsubscribe URL to context (from EmailSettings or default)
if 'unsubscribe_url' not in context:
context['unsubscribe_url'] = self.unsubscribe_url
# Render template if provided
if template:
try:
@@ -133,6 +363,11 @@ class EmailService:
if not html and not text:
raise ValueError("Either html, text, or template must be provided")
# Auto-generate plain text version from HTML for better deliverability
# Spam filters prefer emails with both HTML and plain text versions
if html and not text:
text = html_to_plain_text(html)
# Build from address
sender_name = from_name or self.from_name
sender_email = from_email or self.from_email
@@ -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,
}

View File

@@ -786,6 +786,13 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
f"plan={plan.name}"
)
# Send subscription activated email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService
BillingEmailService.send_subscription_activated_email(account, subscription)
except Exception as e:
logger.error(f"Failed to send subscription activated email: {e}")
return {
'subscription_id': subscription.id,
'plan_name': plan.name,
@@ -915,6 +922,14 @@ def _handle_subscription_activated(resource: dict):
if update_fields != ['updated_at']:
account.save(update_fields=update_fields)
# Send subscription activated email
try:
subscription = Subscription.objects.get(account=account, external_payment_id=subscription_id)
from igny8_core.business.billing.services.email_service import BillingEmailService
BillingEmailService.send_subscription_activated_email(account, subscription)
except Exception as e:
logger.error(f"Failed to send subscription activated email: {e}")
except Account.DoesNotExist:
logger.error(f"Account {custom_id} not found for PayPal subscription activation")
@@ -973,7 +988,16 @@ def _handle_subscription_payment_failed(resource: dict):
subscription.status = 'past_due'
subscription.save(update_fields=['status', 'updated_at'])
# TODO: Send payment failure notification email
# Send payment failure notification email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService
BillingEmailService.send_payment_failed_notification(
account=subscription.account,
subscription=subscription,
failure_reason='PayPal payment could not be processed'
)
except Exception as e:
logger.error(f"Failed to send payment failed email: {e}")
except Subscription.DoesNotExist:
pass

View File

@@ -28,6 +28,7 @@ from ..services.stripe_service import StripeService, StripeConfigurationError
from ..services.payment_service import PaymentService
from ..services.invoice_service import InvoiceService
from ..services.credit_service import CreditService
from ..services.email_service import BillingEmailService
logger = logging.getLogger(__name__)
@@ -608,6 +609,12 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
f"plan={plan.name}, credits={plan.included_credits}"
)
# Send subscription activated email
try:
BillingEmailService.send_subscription_activated_email(account, subscription)
except Exception as e:
logger.error(f"Failed to send subscription activated email: {e}")
def _add_purchased_credits(account, credit_package_id: str, credit_amount: str, session: dict):
"""
@@ -736,7 +743,7 @@ def _handle_payment_failed(invoice: dict):
"""
Handle failed payment.
Updates subscription status to past_due.
Updates subscription status to past_due and sends notification email.
"""
subscription_id = invoice.get('subscription')
if not subscription_id:
@@ -749,7 +756,17 @@ def _handle_payment_failed(invoice: dict):
logger.info(f"Subscription {subscription_id} marked as past_due due to payment failure")
# TODO: Send payment failure notification email
# Send payment failure notification email
try:
from igny8_core.business.billing.services.email_service import BillingEmailService
failure_reason = invoice.get('last_finalization_error', {}).get('message', 'Payment could not be processed')
BillingEmailService.send_payment_failed_notification(
account=subscription.account,
subscription=subscription,
failure_reason=failure_reason
)
except Exception as e:
logger.error(f"Failed to send payment failed email: {e}")
except Subscription.DoesNotExist:
logger.warning(f"Subscription not found for payment failure: {subscription_id}")