Email COnfigs & setup
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ', ' ')
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user