diff --git a/backend/igny8_core/business/billing/services/email_service.py b/backend/igny8_core/business/billing/services/email_service.py index d7d6d418..e299b49a 100644 --- a/backend/igny8_core/business/billing/services/email_service.py +++ b/backend/igny8_core/business/billing/services/email_service.py @@ -2,15 +2,22 @@ Email Service - Multi-provider email sending Uses Resend for transactional emails with fallback to Django's send_mail. +Also supports direct SMTP configuration. Supports template rendering and multiple email types. Configuration stored in IntegrationProvider model (provider_id='resend') +and EmailSettings model for SMTP and provider selection. """ import hashlib import hmac import logging import re +import smtplib import time +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders from typing import Optional, List, Dict, Any from urllib.parse import urlencode from django.core.mail import send_mail @@ -162,9 +169,10 @@ class EmailService: Unified email service supporting multiple providers. Primary: Resend (for production transactional emails) + Alternative: SMTP (configurable via EmailSettings) Fallback: Django's send_mail (uses EMAIL_BACKEND from settings) - Uses EmailSettings model for configuration (from_email, from_name, etc.) + Uses EmailSettings model for configuration (from_email, from_name, provider selection, SMTP settings) """ def __init__(self): @@ -172,6 +180,8 @@ class EmailService: self._resend_config = {} self._brevo_configured = False self._brevo_config = {} + self._smtp_configured = False + self._smtp_config = {} self._email_settings = None self._setup_providers() @@ -193,6 +203,22 @@ class EmailService: else: logger.info("Resend provider not configured in IntegrationProvider") + # Setup SMTP from EmailSettings + if self._email_settings: + smtp_host = self._email_settings.smtp_host + if smtp_host: + self._smtp_config = { + 'host': smtp_host, + 'port': self._email_settings.smtp_port, + 'username': self._email_settings.smtp_username, + 'password': self._email_settings.smtp_password, + 'use_tls': self._email_settings.smtp_use_tls, + 'use_ssl': self._email_settings.smtp_use_ssl, + 'timeout': self._email_settings.smtp_timeout, + } + self._smtp_configured = True + logger.info(f"SMTP email provider initialized: {smtp_host}:{self._email_settings.smtp_port}") + # Setup Brevo (future - for marketing emails) brevo_provider = IntegrationProvider.get_provider('brevo') if brevo_provider and brevo_provider.api_key: @@ -200,6 +226,14 @@ class EmailService: self._brevo_configured = True logger.info("Brevo email provider initialized") + @property + def email_provider(self) -> str: + """Get selected email provider from EmailSettings""" + settings_obj = self._get_settings() + if settings_obj and settings_obj.email_provider: + return settings_obj.email_provider + return 'resend' # Default to resend + def _get_settings(self): """Get fresh email settings (refreshes on each call)""" if not self._email_settings: @@ -373,8 +407,32 @@ class EmailService: sender_email = from_email or self.from_email from_address = f"{sender_name} <{sender_email}>" - # Try Resend first - if self._resend_configured: + # Select email provider based on EmailSettings configuration + selected_provider = self.email_provider + + if selected_provider == 'smtp' and self._smtp_configured: + return self._send_via_smtp( + to=to, + subject=subject, + html=html, + text=text, + from_address=from_address, + reply_to=reply_to or self.reply_to, + attachments=attachments, + ) + elif selected_provider == 'resend' and self._resend_configured: + return self._send_via_resend( + to=to, + subject=subject, + html=html, + text=text, + from_address=from_address, + reply_to=reply_to or self.reply_to, + attachments=attachments, + tags=tags, + ) + elif self._resend_configured: + # Fallback to Resend if it's configured return self._send_via_resend( to=to, subject=subject, @@ -465,6 +523,101 @@ class EmailService: from_email=from_address.split('<')[-1].rstrip('>'), ) + def _send_via_smtp( + self, + to: List[str], + subject: str, + html: Optional[str], + text: Optional[str], + from_address: str, + reply_to: Optional[str], + attachments: Optional[List[Dict]], + ) -> Dict[str, Any]: + """Send email via direct SMTP connection""" + try: + # Create message + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = from_address + msg['To'] = ', '.join(to) + + if reply_to: + msg['Reply-To'] = reply_to + + # Add custom headers + msg['X-Mailer'] = 'IGNY8 Email Service' + + # Attach text version + if text: + part1 = MIMEText(text, 'plain', 'utf-8') + msg.attach(part1) + + # Attach HTML version + if html: + part2 = MIMEText(html, 'html', 'utf-8') + msg.attach(part2) + + # Handle attachments + if attachments: + for attachment in attachments: + part = MIMEBase('application', 'octet-stream') + content = attachment.get('content', b'') + if isinstance(content, str): + content = content.encode('utf-8') + part.set_payload(content) + encoders.encode_base64(part) + filename = attachment.get('filename', 'attachment') + part.add_header('Content-Disposition', f'attachment; filename="{filename}"') + msg.attach(part) + + # Get SMTP configuration + smtp_host = self._smtp_config.get('host') + smtp_port = self._smtp_config.get('port', 587) + smtp_username = self._smtp_config.get('username') + smtp_password = self._smtp_config.get('password') + use_tls = self._smtp_config.get('use_tls', True) + use_ssl = self._smtp_config.get('use_ssl', False) + timeout = self._smtp_config.get('timeout', 30) + + # Connect and send + if use_ssl: + server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=timeout) + else: + server = smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) + if use_tls: + server.starttls() + + if smtp_username and smtp_password: + server.login(smtp_username, smtp_password) + + server.sendmail( + from_address.split('<')[-1].rstrip('>'), + to, + msg.as_string() + ) + server.quit() + + logger.info(f"Email sent via SMTP: {subject} to {to}") + + return { + 'success': True, + 'id': None, # SMTP doesn't provide message IDs like Resend + 'provider': 'smtp', + } + + except Exception as e: + logger.error(f"Failed to send email via SMTP: {str(e)}") + + # Fallback to Django mail + logger.info("Falling back to Django mail backend") + return self._send_via_django( + to=to, + subject=subject, + html=html, + text=text, + from_email=from_address.split('<')[-1].rstrip('>'), + ) + def _send_via_django( self, to: List[str], diff --git a/backend/igny8_core/modules/system/email_admin.py b/backend/igny8_core/modules/system/email_admin.py index efa4a292..781ff507 100644 --- a/backend/igny8_core/modules/system/email_admin.py +++ b/backend/igny8_core/modules/system/email_admin.py @@ -27,6 +27,7 @@ class EmailSettingsAdmin(Igny8ModelAdmin): list_display = [ 'from_email', 'from_name', + 'email_provider', 'reply_to_email', 'send_welcome_emails', 'send_billing_emails', @@ -35,9 +36,26 @@ class EmailSettingsAdmin(Igny8ModelAdmin): readonly_fields = ['updated_at'] fieldsets = ( + ('Email Provider', { + 'fields': ('email_provider',), + 'description': 'Select the active email service provider. Configure SMTP settings below if using SMTP.', + }), + ('SMTP Configuration', { + 'fields': ( + 'smtp_host', + 'smtp_port', + 'smtp_username', + 'smtp_password', + 'smtp_use_tls', + 'smtp_use_ssl', + 'smtp_timeout', + ), + 'description': 'SMTP server settings. Required when email_provider is set to SMTP.', + 'classes': ('collapse',), + }), ('Sender Configuration', { 'fields': ('from_email', 'from_name', 'reply_to_email'), - 'description': 'Default sender settings. Email address must be verified in Resend.', + 'description': 'Default sender settings. Email address must be verified in Resend (if using Resend) or configured in SMTP server.', }), ('Company Branding', { 'fields': ('company_name', 'company_address', 'logo_url'), @@ -65,6 +83,114 @@ class EmailSettingsAdmin(Igny8ModelAdmin): }), ) + change_form_template = 'admin/system/emailsettings/change_form.html' + + def get_urls(self): + """Add custom URL for test email""" + urls = super().get_urls() + custom_urls = [ + path( + 'test-email/', + self.admin_site.admin_view(self.test_email_view), + name='system_emailsettings_test_email' + ), + path( + 'send-test-email/', + self.admin_site.admin_view(self.send_test_email), + name='system_emailsettings_send_test' + ), + ] + return custom_urls + urls + + def test_email_view(self, request): + """Show test email form""" + settings = EmailSettings.get_settings() + + context = { + **self.admin_site.each_context(request), + 'title': 'Send Test Email', + 'settings': settings, + 'opts': self.model._meta, + 'default_from_email': settings.from_email, + 'default_to_email': request.user.email, + } + + return render(request, 'admin/system/emailsettings/test_email.html', context) + + def send_test_email(self, request): + """Send test email to verify configuration""" + if request.method != 'POST': + return JsonResponse({'error': 'POST required'}, status=405) + + from django.utils import timezone + from igny8_core.business.billing.services.email_service import EmailService + + to_email = request.POST.get('to_email', request.user.email) + subject = request.POST.get('subject', 'IGNY8 Test Email') + + # Create fresh EmailService instance to pick up latest settings + service = EmailService() + settings = EmailSettings.get_settings() + + test_html = f""" + + +

IGNY8 Email Test

+

This is a test email to verify your email configuration.

+ +

Configuration Details:

+ + +

+ ✓ If you received this email, your email configuration is working correctly! +

+ +
+

+ This is an automated test email from IGNY8 Admin. +

+ + + """ + + try: + result = service.send_transactional( + to=to_email, + subject=subject, + html=test_html, + tags=['test', 'admin-test'], + ) + + if result.get('success'): + # Log the test email + EmailLog.objects.create( + message_id=result.get('id', ''), + to_email=to_email, + from_email=settings.from_email, + subject=subject, + template_name='admin_test', + status='sent', + provider=result.get('provider', settings.email_provider), + tags=['test', 'admin-test'], + ) + + messages.success( + request, + f'Test email sent successfully to {to_email} via {result.get("provider", "unknown").upper()}!' + ) + 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_emailsettings_changelist')) + def has_add_permission(self, request): """Only allow one instance (singleton)""" return not EmailSettings.objects.exists() diff --git a/backend/igny8_core/modules/system/email_models.py b/backend/igny8_core/modules/system/email_models.py index 4325e90f..41b79b17 100644 --- a/backend/igny8_core/modules/system/email_models.py +++ b/backend/igny8_core/modules/system/email_models.py @@ -16,6 +16,52 @@ class EmailSettings(models.Model): These settings work alongside IntegrationProvider (resend) configuration. """ + EMAIL_PROVIDER_CHOICES = [ + ('resend', 'Resend'), + ('smtp', 'SMTP'), + ] + + # Email provider selection + email_provider = models.CharField( + max_length=20, + choices=EMAIL_PROVIDER_CHOICES, + default='resend', + help_text='Active email service provider' + ) + + # SMTP Configuration + smtp_host = models.CharField( + max_length=255, + blank=True, + help_text='SMTP server hostname (e.g., smtp.gmail.com)' + ) + smtp_port = models.IntegerField( + default=587, + help_text='SMTP server port (587 for TLS, 465 for SSL, 25 for plain)' + ) + smtp_username = models.CharField( + max_length=255, + blank=True, + help_text='SMTP authentication username' + ) + smtp_password = models.CharField( + max_length=255, + blank=True, + help_text='SMTP authentication password' + ) + smtp_use_tls = models.BooleanField( + default=True, + help_text='Use TLS encryption (recommended for port 587)' + ) + smtp_use_ssl = models.BooleanField( + default=False, + help_text='Use SSL encryption (for port 465)' + ) + smtp_timeout = models.IntegerField( + default=30, + help_text='SMTP connection timeout in seconds' + ) + # Default sender settings from_email = models.EmailField( default='noreply@igny8.com', diff --git a/backend/igny8_core/modules/system/migrations/0021_add_smtp_email_settings.py b/backend/igny8_core/modules/system/migrations/0021_add_smtp_email_settings.py new file mode 100644 index 00000000..49077df7 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0021_add_smtp_email_settings.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.10 on 2026-01-08 06:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0020_add_email_models'), + ] + + operations = [ + migrations.AddField( + model_name='emailsettings', + name='email_provider', + field=models.CharField(choices=[('resend', 'Resend'), ('smtp', 'SMTP')], default='resend', help_text='Active email service provider', max_length=20), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_host', + field=models.CharField(blank=True, help_text='SMTP server hostname (e.g., smtp.gmail.com)', max_length=255), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_password', + field=models.CharField(blank=True, help_text='SMTP authentication password', max_length=255), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_port', + field=models.IntegerField(default=587, help_text='SMTP server port (587 for TLS, 465 for SSL, 25 for plain)'), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_timeout', + field=models.IntegerField(default=30, help_text='SMTP connection timeout in seconds'), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_use_ssl', + field=models.BooleanField(default=False, help_text='Use SSL encryption (for port 465)'), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_use_tls', + field=models.BooleanField(default=True, help_text='Use TLS encryption (recommended for port 587)'), + ), + migrations.AddField( + model_name='emailsettings', + name='smtp_username', + field=models.CharField(blank=True, help_text='SMTP authentication username', max_length=255), + ), + ] diff --git a/backend/igny8_core/templates/admin/system/emailsettings/change_form.html b/backend/igny8_core/templates/admin/system/emailsettings/change_form.html new file mode 100644 index 00000000..dd90c902 --- /dev/null +++ b/backend/igny8_core/templates/admin/system/emailsettings/change_form.html @@ -0,0 +1,15 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block submit_buttons_bottom %} +
+ + + + + 📧 Send Test Email + +
+{% endblock %} diff --git a/backend/igny8_core/templates/admin/system/emailsettings/test_email.html b/backend/igny8_core/templates/admin/system/emailsettings/test_email.html new file mode 100644 index 00000000..d799ae7a --- /dev/null +++ b/backend/igny8_core/templates/admin/system/emailsettings/test_email.html @@ -0,0 +1,97 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} +
+
+

Send Test Email

+ +
+

Current Configuration

+
+

+ Provider: + + {{ settings.email_provider|upper }} + +

+

+ From Email: {{ settings.from_email }} +

+

+ From Name: {{ settings.from_name }} +

+

+ Reply-To: {{ settings.reply_to_email }} +

+
+ {% if settings.email_provider == 'smtp' %} +
+

+ SMTP Server: {{ settings.smtp_host }}:{{ settings.smtp_port }} +

+

+ Encryption: + {% if settings.smtp_use_ssl %}SSL{% elif settings.smtp_use_tls %}TLS{% else %}None{% endif %} +

+
+ {% endif %} +
+ +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + + Cancel + +
+
+
+ +
+

✅ What This Test Does

+ +
+ + {% if settings.email_provider == 'smtp' and not settings.smtp_host %} +
+

⚠️ SMTP Not Configured

+

+ You have selected SMTP as your email provider, but SMTP settings are not configured. + Please go back and configure your SMTP server settings, or switch to Resend. +

+
+ {% endif %} +
+{% endblock %} diff --git a/frontend/src/pages/AuthPages/ResetPassword.tsx b/frontend/src/pages/AuthPages/ResetPassword.tsx index a38bd466..009b8322 100644 --- a/frontend/src/pages/AuthPages/ResetPassword.tsx +++ b/frontend/src/pages/AuthPages/ResetPassword.tsx @@ -64,7 +64,11 @@ export default function ResetPassword() { // Correct API endpoint for password reset confirmation await fetchAPI('/v1/auth/password-reset/confirm/', { method: 'POST', - body: JSON.stringify({ token, new_password: password }), + body: JSON.stringify({ + token, + new_password: password, + new_password_confirm: confirmPassword + }), }); setResetState('success'); } catch (err: unknown) {