@@ -128,6 +185,11 @@ const PlatformSupport: React.FC = () => {
key={ticket.id}
onClick={() => handleTicketClick(ticket)}
className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
+ style={{
+ borderLeft: ticket.source_email_address
+ ? `4px solid ${ticket.source_email_address.color}`
+ : undefined
+ }}
>
@@ -147,6 +209,14 @@ const PlatformSupport: React.FC = () => {
}`}>
{t(`tickets.types.${ticket.ticketType.toLowerCase()}`)}
+ {ticket.source_email_address && (
+
+ {ticket.source_email_address.display_name}
+
+ )}
{t('platform.reportedBy')} {ticket.creatorFullName || ticket.creatorEmail}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ca0618f..9c2ff2a 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -210,6 +210,15 @@ export interface TicketComment {
isInternal: boolean;
}
+export interface TicketEmailAddressListItem {
+ id: number;
+ display_name: string;
+ email_address: string;
+ color: string;
+ is_active: boolean;
+ is_default: boolean;
+}
+
export interface Ticket {
id: string;
tenant?: string; // Tenant ID, optional for platform tickets
@@ -236,6 +245,8 @@ export interface Ticket {
// External sender info (for tickets from non-registered users via email)
externalEmail?: string;
externalName?: string;
+ // Source email address (which email address received/sent this ticket)
+ source_email_address?: TicketEmailAddressListItem;
}
export interface TicketTemplate {
diff --git a/smoothschedule/.gitignore b/smoothschedule/.gitignore
index 6ddf68f..7303459 100644
--- a/smoothschedule/.gitignore
+++ b/smoothschedule/.gitignore
@@ -276,3 +276,6 @@ smoothschedule/media/
.env
.envs/*
!.envs/.local/
+
+# SSH keys for mail server access
+.ssh/
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index de39e76..087f21c 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -30,6 +30,12 @@ from schedule.api_views import (
custom_domain_verify_view, custom_domain_set_primary_view,
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
)
+from core.email_autoconfig import (
+ MozillaAutoconfigView,
+ MicrosoftAutodiscoverView,
+ AppleConfigProfileView,
+ WellKnownAutoconfigView,
+)
urlpatterns = [
# Django Admin, use {% url 'admin:index' %}
@@ -39,6 +45,14 @@ urlpatterns = [
path("accounts/", include("allauth.urls")),
# Django Hijack (masquerade) - for admin interface
path("hijack/", include("hijack.urls")),
+
+ # Email Autoconfiguration (for email clients)
+ path("mail/config-v1.1.xml", MozillaAutoconfigView.as_view(), name="autoconfig"),
+ path(".well-known/autoconfig/mail/config-v1.1.xml", WellKnownAutoconfigView.as_view(), name="autoconfig-wellknown"),
+ path("autodiscover/autodiscover.xml", MicrosoftAutodiscoverView.as_view(), name="autodiscover"),
+ path("Autodiscover/Autodiscover.xml", MicrosoftAutodiscoverView.as_view(), name="autodiscover-caps"),
+ path("email/apple-profile.mobileconfig", AppleConfigProfileView.as_view(), name="apple-config"),
+
# Your stuff: custom urls includes go here
# ...
# Media files
diff --git a/smoothschedule/core/email_autoconfig.py b/smoothschedule/core/email_autoconfig.py
new file mode 100644
index 0000000..608968a
--- /dev/null
+++ b/smoothschedule/core/email_autoconfig.py
@@ -0,0 +1,250 @@
+"""
+Email Autoconfiguration Views
+
+Provides automatic email client configuration for:
+- Mozilla Autoconfig (Thunderbird, etc.)
+- Microsoft Autodiscover (Outlook)
+- Apple Mail configuration profile
+
+These endpoints allow email clients to automatically configure
+IMAP and SMTP settings for smoothschedule.com email addresses.
+"""
+
+from django.http import HttpResponse
+from django.views import View
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
+
+
+class MozillaAutoconfigView(View):
+ """
+ Mozilla Autoconfig endpoint.
+
+ URL: /mail/config-v1.1.xml or /.well-known/autoconfig/mail/config-v1.1.xml
+
+ Used by:
+ - Mozilla Thunderbird
+ - Evolution
+ - Other clients supporting Mozilla autoconfig
+ """
+
+ def get(self, request):
+ # Get email address from query parameter
+ email = request.GET.get('emailaddress', '')
+
+ # Extract local part if email provided
+ if '@' in email:
+ local_part = email.split('@')[0]
+ else:
+ local_part = '%EMAILLOCALPART%'
+
+ xml_content = f'''
+
+
+ smoothschedule.com
+ SmoothSchedule Mail
+ SmoothSchedule
+
+
+ mail.talova.net
+ 993
+ SSL
+ password-cleartext
+ %EMAILADDRESS%
+
+
+
+ mail.talova.net
+ 587
+ STARTTLS
+ password-cleartext
+ %EMAILADDRESS%
+
+
+
+ Email configuration help
+
+
+'''
+
+ return HttpResponse(
+ xml_content,
+ content_type='application/xml; charset=utf-8'
+ )
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class MicrosoftAutodiscoverView(View):
+ """
+ Microsoft Autodiscover endpoint.
+
+ URL: /autodiscover/autodiscover.xml
+
+ Used by:
+ - Microsoft Outlook
+ - Windows Mail
+ - Other Microsoft clients
+ """
+
+ def post(self, request):
+ # Microsoft Autodiscover uses POST with XML body
+ # Extract email from request body if needed
+
+ xml_content = '''
+
+
+
+ email
+ settings
+
+ IMAP
+ mail.talova.net
+ 993
+ on
+ on
+
+
+
+ SMTP
+ mail.talova.net
+ 587
+ on
+ TLS
+ on
+
+
+
+
+'''
+
+ return HttpResponse(
+ xml_content,
+ content_type='application/xml; charset=utf-8'
+ )
+
+ def get(self, request):
+ # Some clients may use GET
+ return self.post(request)
+
+
+class AppleConfigProfileView(View):
+ """
+ Apple Configuration Profile for iOS/macOS Mail.
+
+ URL: /email/apple-profile.mobileconfig
+
+ Provides a downloadable .mobileconfig file that can be
+ installed on iOS/macOS devices for automatic email setup.
+ """
+
+ def get(self, request):
+ email = request.GET.get('email', '')
+
+ if not email or '@' not in email:
+ return HttpResponse(
+ 'Email parameter required (e.g., ?email=user@smoothschedule.com)',
+ status=400
+ )
+
+ # Generate a unique identifier for this profile
+ import uuid
+ profile_uuid = str(uuid.uuid4()).upper()
+ account_uuid = str(uuid.uuid4()).upper()
+
+ local_part = email.split('@')[0]
+ display_name = local_part.replace('.', ' ').replace('-', ' ').title()
+
+ plist_content = f'''
+
+
+
+ PayloadContent
+
+
+ EmailAccountDescription
+ SmoothSchedule Mail
+ EmailAccountName
+ {display_name}
+ EmailAccountType
+ EmailTypeIMAP
+ EmailAddress
+ {email}
+ IncomingMailServerAuthentication
+ EmailAuthPassword
+ IncomingMailServerHostName
+ mail.talova.net
+ IncomingMailServerPortNumber
+ 993
+ IncomingMailServerUseSSL
+
+ IncomingMailServerUsername
+ {email}
+ OutgoingMailServerAuthentication
+ EmailAuthPassword
+ OutgoingMailServerHostName
+ mail.talova.net
+ OutgoingMailServerPortNumber
+ 587
+ OutgoingMailServerUseSSL
+
+ OutgoingMailServerUsername
+ {email}
+ OutgoingPasswordSameAsIncomingPassword
+
+ PayloadDescription
+ Configures email account for {email}
+ PayloadDisplayName
+ SmoothSchedule Email
+ PayloadIdentifier
+ com.smoothschedule.email.account.{account_uuid}
+ PayloadType
+ com.apple.mail.managed
+ PayloadUUID
+ {account_uuid}
+ PayloadVersion
+ 1
+ SMIMEEnablePerMessageSwitch
+
+
+
+ PayloadDescription
+ Email configuration for SmoothSchedule
+ PayloadDisplayName
+ SmoothSchedule Email Configuration
+ PayloadIdentifier
+ com.smoothschedule.email.profile.{profile_uuid}
+ PayloadOrganization
+ SmoothSchedule
+ PayloadRemovalDisallowed
+
+ PayloadType
+ Configuration
+ PayloadUUID
+ {profile_uuid}
+ PayloadVersion
+ 1
+
+'''
+
+ response = HttpResponse(
+ plist_content,
+ content_type='application/x-apple-aspen-config'
+ )
+ response['Content-Disposition'] = f'attachment; filename="smoothschedule-email.mobileconfig"'
+ return response
+
+
+class WellKnownAutoconfigView(View):
+ """
+ .well-known autoconfig redirect.
+
+ Some clients look for /.well-known/autoconfig/mail/config-v1.1.xml
+ """
+
+ def get(self, request):
+ from django.shortcuts import redirect
+ email = request.GET.get('emailaddress', '')
+ url = '/mail/config-v1.1.xml'
+ if email:
+ url += f'?emailaddress={email}'
+ return redirect(url)
diff --git a/smoothschedule/platform_admin/mail_server.py b/smoothschedule/platform_admin/mail_server.py
new file mode 100644
index 0000000..15b6187
--- /dev/null
+++ b/smoothschedule/platform_admin/mail_server.py
@@ -0,0 +1,349 @@
+"""
+Mail Server Service for managing email accounts on mail.talova.net.
+
+This service manages email accounts via SSH commands to the docker-mailserver
+container running on mail.talova.net.
+
+Commands used:
+- docker exec mailserver setup email add user@domain password
+- docker exec mailserver setup email update user@domain password
+- docker exec mailserver setup email del user@domain
+- docker exec mailserver setup email list
+"""
+
+import logging
+import subprocess
+import shlex
+from typing import Optional
+from django.conf import settings
+from django.utils import timezone
+
+logger = logging.getLogger(__name__)
+
+
+class MailServerError(Exception):
+ """Exception raised when mail server operations fail."""
+ pass
+
+
+class MailServerService:
+ """
+ Service for managing email accounts on the mail.talova.net server.
+
+ Uses SSH to execute docker commands on the remote mail server.
+ """
+
+ # Mail server configuration
+ SSH_HOST = 'mail.talova.net'
+ SSH_USER = 'poduck'
+ DOCKER_CONTAINER = 'mailserver'
+ DOCKER_COMPOSE_PATH = '~/docker-mailserver'
+
+ # SSH key configuration (persistent keys mounted in container)
+ SSH_KEY_PATH = '/app/.ssh/id_ed25519'
+ SSH_KNOWN_HOSTS_PATH = '/app/.ssh/known_hosts'
+
+ # SSH connection timeout (seconds)
+ SSH_TIMEOUT = 30
+
+ def __init__(self):
+ self.ssh_host = getattr(settings, 'MAIL_SERVER_SSH_HOST', self.SSH_HOST)
+ self.ssh_user = getattr(settings, 'MAIL_SERVER_SSH_USER', self.SSH_USER)
+ self.docker_container = getattr(settings, 'MAIL_SERVER_DOCKER_CONTAINER', self.DOCKER_CONTAINER)
+ self.ssh_key_path = getattr(settings, 'MAIL_SERVER_SSH_KEY_PATH', self.SSH_KEY_PATH)
+ self.ssh_known_hosts_path = getattr(settings, 'MAIL_SERVER_SSH_KNOWN_HOSTS_PATH', self.SSH_KNOWN_HOSTS_PATH)
+
+ def _run_ssh_command(self, command: str, timeout: Optional[int] = None) -> tuple[bool, str, str]:
+ """
+ Execute a command on the mail server via SSH.
+
+ Args:
+ command: The command to execute on the remote server
+ timeout: Optional timeout in seconds
+
+ Returns:
+ Tuple of (success, stdout, stderr)
+ """
+ timeout = timeout or self.SSH_TIMEOUT
+
+ ssh_command = [
+ 'ssh',
+ '-i', self.ssh_key_path,
+ '-o', 'StrictHostKeyChecking=accept-new',
+ '-o', f'UserKnownHostsFile={self.ssh_known_hosts_path}',
+ '-o', 'ConnectTimeout=10',
+ '-o', 'BatchMode=yes',
+ f'{self.ssh_user}@{self.ssh_host}',
+ command
+ ]
+
+ logger.info(f"Executing SSH command: {' '.join(ssh_command[:6])} [command hidden]")
+
+ try:
+ result = subprocess.run(
+ ssh_command,
+ capture_output=True,
+ text=True,
+ timeout=timeout
+ )
+
+ success = result.returncode == 0
+ stdout = result.stdout.strip()
+ stderr = result.stderr.strip()
+
+ if not success:
+ logger.warning(f"SSH command failed: {stderr}")
+ else:
+ logger.debug(f"SSH command succeeded: {stdout[:100]}...")
+
+ return success, stdout, stderr
+
+ except subprocess.TimeoutExpired:
+ logger.error(f"SSH command timed out after {timeout}s")
+ return False, '', f'Command timed out after {timeout} seconds'
+
+ except Exception as e:
+ logger.error(f"SSH command error: {str(e)}")
+ return False, '', str(e)
+
+ def _run_docker_command(self, docker_args: str) -> tuple[bool, str, str]:
+ """
+ Execute a docker command on the mail server.
+
+ Args:
+ docker_args: Arguments to pass to docker exec
+
+ Returns:
+ Tuple of (success, stdout, stderr)
+ """
+ command = f"docker exec {self.docker_container} {docker_args}"
+ return self._run_ssh_command(command)
+
+ def list_accounts(self) -> list[dict]:
+ """
+ List all email accounts on the mail server.
+
+ Returns:
+ List of account dictionaries with email and quota info
+ """
+ success, stdout, stderr = self._run_docker_command('setup email list')
+
+ if not success:
+ raise MailServerError(f"Failed to list accounts: {stderr}")
+
+ accounts = []
+ for line in stdout.split('\n'):
+ line = line.strip()
+ if not line or line.startswith('*'):
+ # Parse format: "* user@domain ( size / quota ) [percent%]"
+ if line.startswith('*'):
+ parts = line.split()
+ if len(parts) >= 2:
+ email = parts[1]
+ accounts.append({
+ 'email': email,
+ 'raw_line': line
+ })
+
+ return accounts
+
+ def account_exists(self, email: str) -> bool:
+ """
+ Check if an email account exists on the mail server.
+
+ Args:
+ email: The email address to check
+
+ Returns:
+ True if the account exists, False otherwise
+ """
+ try:
+ accounts = self.list_accounts()
+ return any(acc['email'].lower() == email.lower() for acc in accounts)
+ except MailServerError:
+ return False
+
+ def create_account(self, email: str, password: str) -> tuple[bool, str]:
+ """
+ Create a new email account on the mail server.
+
+ Args:
+ email: The email address to create
+ password: The password for the account
+
+ Returns:
+ Tuple of (success, message)
+ """
+ # Validate email format
+ if '@' not in email:
+ return False, 'Invalid email format'
+
+ # Check if account already exists
+ if self.account_exists(email):
+ return False, f'Account {email} already exists'
+
+ # Escape password for shell (use single quotes and escape any single quotes in password)
+ escaped_password = password.replace("'", "'\"'\"'")
+
+ # Create the account
+ command = f"setup email add {shlex.quote(email)} '{escaped_password}'"
+ success, stdout, stderr = self._run_docker_command(command)
+
+ if success:
+ logger.info(f"Created email account: {email}")
+ return True, f'Successfully created account {email}'
+ else:
+ logger.error(f"Failed to create account {email}: {stderr}")
+ return False, f'Failed to create account: {stderr}'
+
+ def update_password(self, email: str, new_password: str) -> tuple[bool, str]:
+ """
+ Update the password for an existing email account.
+
+ Args:
+ email: The email address to update
+ new_password: The new password
+
+ Returns:
+ Tuple of (success, message)
+ """
+ # Check if account exists
+ if not self.account_exists(email):
+ return False, f'Account {email} does not exist'
+
+ # Escape password for shell
+ escaped_password = new_password.replace("'", "'\"'\"'")
+
+ # Update the password
+ command = f"setup email update {shlex.quote(email)} '{escaped_password}'"
+ success, stdout, stderr = self._run_docker_command(command)
+
+ if success:
+ logger.info(f"Updated password for: {email}")
+ return True, f'Successfully updated password for {email}'
+ else:
+ logger.error(f"Failed to update password for {email}: {stderr}")
+ return False, f'Failed to update password: {stderr}'
+
+ def delete_account(self, email: str) -> tuple[bool, str]:
+ """
+ Delete an email account from the mail server.
+
+ Args:
+ email: The email address to delete
+
+ Returns:
+ Tuple of (success, message)
+ """
+ # Check if account exists
+ if not self.account_exists(email):
+ return False, f'Account {email} does not exist'
+
+ # Delete the account (use -y to confirm deletion)
+ command = f"setup email del -y {shlex.quote(email)}"
+ success, stdout, stderr = self._run_docker_command(command)
+
+ if success:
+ logger.info(f"Deleted email account: {email}")
+ return True, f'Successfully deleted account {email}'
+ else:
+ logger.error(f"Failed to delete account {email}: {stderr}")
+ return False, f'Failed to delete account: {stderr}'
+
+ def test_connection(self) -> tuple[bool, str]:
+ """
+ Test SSH connection to the mail server.
+
+ Returns:
+ Tuple of (success, message)
+ """
+ success, stdout, stderr = self._run_ssh_command('echo "Connection successful"')
+
+ if success and 'Connection successful' in stdout:
+ return True, 'Successfully connected to mail server'
+ else:
+ return False, f'Failed to connect: {stderr or "Unknown error"}'
+
+ def sync_account(self, platform_email) -> tuple[bool, str]:
+ """
+ Sync a PlatformEmailAddress to the mail server.
+
+ Creates the account if it doesn't exist, or updates the password if it does.
+
+ Args:
+ platform_email: PlatformEmailAddress instance
+
+ Returns:
+ Tuple of (success, message)
+ """
+ email = platform_email.email_address
+ password = platform_email.password
+
+ try:
+ if self.account_exists(email):
+ # Update existing account password
+ success, message = self.update_password(email, password)
+ else:
+ # Create new account
+ success, message = self.create_account(email, password)
+
+ # Update sync status
+ platform_email.mail_server_synced = success
+ platform_email.last_synced_at = timezone.now()
+ if not success:
+ platform_email.last_sync_error = message
+ else:
+ platform_email.last_sync_error = ''
+ platform_email.save(update_fields=[
+ 'mail_server_synced',
+ 'last_synced_at',
+ 'last_sync_error'
+ ])
+
+ return success, message
+
+ except MailServerError as e:
+ platform_email.mail_server_synced = False
+ platform_email.last_sync_error = str(e)
+ platform_email.last_synced_at = timezone.now()
+ platform_email.save(update_fields=[
+ 'mail_server_synced',
+ 'last_synced_at',
+ 'last_sync_error'
+ ])
+ return False, str(e)
+
+ def delete_and_unsync(self, platform_email) -> tuple[bool, str]:
+ """
+ Delete a PlatformEmailAddress from the mail server.
+
+ Args:
+ platform_email: PlatformEmailAddress instance
+
+ Returns:
+ Tuple of (success, message)
+ """
+ email = platform_email.email_address
+
+ try:
+ if self.account_exists(email):
+ success, message = self.delete_account(email)
+ return success, message
+ else:
+ # Account doesn't exist on server, that's fine
+ return True, f'Account {email} was not on mail server'
+
+ except MailServerError as e:
+ return False, str(e)
+
+
+# Singleton instance
+_mail_server_service = None
+
+
+def get_mail_server_service() -> MailServerService:
+ """Get the mail server service singleton instance."""
+ global _mail_server_service
+ if _mail_server_service is None:
+ _mail_server_service = MailServerService()
+ return _mail_server_service
diff --git a/smoothschedule/platform_admin/migrations/0005_subscriptionplan_limits_subscriptionplan_permissions.py b/smoothschedule/platform_admin/migrations/0005_subscriptionplan_limits_subscriptionplan_permissions.py
new file mode 100644
index 0000000..1575026
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0005_subscriptionplan_limits_subscriptionplan_permissions.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.8 on 2025-12-01 16:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0004_subscriptionplan'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='limits',
+ field=models.JSONField(blank=True, default=dict, help_text='Feature limits and capabilities to grant (e.g. max_users, max_resources)'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='permissions',
+ field=models.JSONField(blank=True, default=dict, help_text='Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)'),
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/migrations/0006_subscriptionplan_is_most_popular_and_more.py b/smoothschedule/platform_admin/migrations/0006_subscriptionplan_is_most_popular_and_more.py
new file mode 100644
index 0000000..d41d976
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0006_subscriptionplan_is_most_popular_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.8 on 2025-12-01 16:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0005_subscriptionplan_limits_subscriptionplan_permissions'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='is_most_popular',
+ field=models.BooleanField(default=False, help_text='Whether to highlight this plan as the most popular choice'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='show_price',
+ field=models.BooleanField(default=True, help_text="Whether to display the price on the marketing site (disable for 'Contact Us')"),
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/migrations/0007_platformemailaddress.py b/smoothschedule/platform_admin/migrations/0007_platformemailaddress.py
new file mode 100644
index 0000000..a2de1ed
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0007_platformemailaddress.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.2.8 on 2025-12-01 20:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0006_subscriptionplan_is_most_popular_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PlatformEmailAddress',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('display_name', models.CharField(help_text="Display name (e.g., 'Support', 'Billing', 'Sales')", max_length=100)),
+ ('local_part', models.CharField(help_text='Local part of email address (before @)', max_length=64)),
+ ('domain', models.CharField(choices=[('smoothschedule.com', 'smoothschedule.com'), ('talova.net', 'talova.net')], default='smoothschedule.com', help_text='Email domain', max_length=50)),
+ ('color', models.CharField(default='#3b82f6', help_text='Hex color code for visual identification', max_length=7)),
+ ('password', models.CharField(help_text='Password for the email account (stored encrypted, synced to mail server)', max_length=255)),
+ ('is_active', models.BooleanField(default=True, help_text='Whether this email address is active')),
+ ('is_default', models.BooleanField(default=False, help_text='Default email for platform support')),
+ ('mail_server_synced', models.BooleanField(default=False, help_text='Whether the account exists on the mail server')),
+ ('last_sync_error', models.TextField(blank=True, default='', help_text='Last sync error message, if any')),
+ ('last_synced_at', models.DateTimeField(blank=True, help_text='When this account was last synced to the mail server', null=True)),
+ ('emails_processed_count', models.IntegerField(default=0, help_text='Total number of emails processed for this address')),
+ ('last_check_at', models.DateTimeField(blank=True, help_text='When emails were last checked for this address', null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'Platform Email Address',
+ 'verbose_name_plural': 'Platform Email Addresses',
+ 'ordering': ['-is_default', 'display_name'],
+ 'unique_together': {('local_part', 'domain')},
+ },
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/migrations/0008_add_sender_name_and_assigned_user.py b/smoothschedule/platform_admin/migrations/0008_add_sender_name_and_assigned_user.py
new file mode 100644
index 0000000..6c92674
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0008_add_sender_name_and_assigned_user.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.8 on 2025-12-01 21:35
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0007_platformemailaddress'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='platformemailaddress',
+ name='assigned_user',
+ field=models.ForeignKey(blank=True, help_text='User associated with this email. If set, their name is used as sender name.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_email_addresses', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name='platformemailaddress',
+ name='sender_name',
+ field=models.CharField(blank=True, default='', help_text="Name to show in From header (e.g., 'SmoothSchedule Support'). If blank, uses display_name.", max_length=100),
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/migrations/0009_add_email_check_interval.py b/smoothschedule/platform_admin/migrations/0009_add_email_check_interval.py
new file mode 100644
index 0000000..2e8f24f
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0009_add_email_check_interval.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.8 on 2025-12-01 22:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0008_add_sender_name_and_assigned_user'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='platformsettings',
+ name='email_check_interval_minutes',
+ field=models.PositiveIntegerField(default=5, help_text='How often to check for new incoming emails (in minutes)'),
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/models.py b/smoothschedule/platform_admin/models.py
index edbb0c7..9acafd4 100644
--- a/smoothschedule/platform_admin/models.py
+++ b/smoothschedule/platform_admin/models.py
@@ -74,6 +74,12 @@ class PlatformSettings(models.Model):
# ...
# }
+ # Email settings
+ email_check_interval_minutes = models.PositiveIntegerField(
+ default=5,
+ help_text="How often to check for new incoming emails (in minutes)"
+ )
+
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -218,6 +224,20 @@ class SubscriptionPlan(models.Model):
help_text="List of feature descriptions"
)
+ # Platform permissions (what features this plan grants)
+ permissions = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)"
+ )
+
+ # Feature limits (what capabilities this plan has)
+ limits = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Feature limits and capabilities to grant (e.g. max_users, max_resources)"
+ )
+
# Transaction fees for payment processing
transaction_fee_percent = models.DecimalField(
max_digits=5,
@@ -238,6 +258,14 @@ class SubscriptionPlan(models.Model):
default=True,
help_text="Whether this plan is visible on public pricing page"
)
+ is_most_popular = models.BooleanField(
+ default=False,
+ help_text="Whether to highlight this plan as the most popular choice"
+ )
+ show_price = models.BooleanField(
+ default=True,
+ help_text="Whether to display the price on the marketing site (disable for 'Contact Us')"
+ )
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
@@ -479,3 +507,169 @@ class TenantInvitation(models.Model):
limits=limits or {},
personal_message=personal_message,
)
+
+
+class PlatformEmailAddress(models.Model):
+ """
+ Platform-managed email addresses hosted on mail.talova.net.
+ These are managed directly via SSH/Docker commands on the mail server.
+
+ Unlike TicketEmailAddress which supports arbitrary IMAP/SMTP servers,
+ this model is specifically for platform email addresses managed on our
+ dedicated mail server.
+ """
+
+ class Domain(models.TextChoices):
+ SMOOTHSCHEDULE = 'smoothschedule.com', 'smoothschedule.com'
+ TALOVA = 'talova.net', 'talova.net'
+
+ # Display information
+ display_name = models.CharField(
+ max_length=100,
+ help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
+ )
+ local_part = models.CharField(
+ max_length=64,
+ help_text="Local part of email address (before @)"
+ )
+ domain = models.CharField(
+ max_length=50,
+ choices=Domain.choices,
+ default=Domain.SMOOTHSCHEDULE,
+ help_text="Email domain"
+ )
+ color = models.CharField(
+ max_length=7,
+ default='#3b82f6',
+ help_text="Hex color code for visual identification"
+ )
+ sender_name = models.CharField(
+ max_length=100,
+ blank=True,
+ default='',
+ help_text="Name to show in From header (e.g., 'SmoothSchedule Support'). If blank, uses display_name."
+ )
+ assigned_user = models.ForeignKey(
+ 'users.User',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='platform_email_addresses',
+ help_text="User associated with this email. If set, their name is used as sender name."
+ )
+
+ # Account credentials (stored securely, synced to mail server)
+ password = models.CharField(
+ max_length=255,
+ help_text="Password for the email account (stored encrypted, synced to mail server)"
+ )
+
+ # Status
+ is_active = models.BooleanField(
+ default=True,
+ help_text="Whether this email address is active"
+ )
+ is_default = models.BooleanField(
+ default=False,
+ help_text="Default email for platform support"
+ )
+
+ # Mail server sync status
+ mail_server_synced = models.BooleanField(
+ default=False,
+ help_text="Whether the account exists on the mail server"
+ )
+ last_sync_error = models.TextField(
+ blank=True,
+ default='',
+ help_text="Last sync error message, if any"
+ )
+ last_synced_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When this account was last synced to the mail server"
+ )
+
+ # Usage tracking
+ emails_processed_count = models.IntegerField(
+ default=0,
+ help_text="Total number of emails processed for this address"
+ )
+ last_check_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When emails were last checked for this address"
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ app_label = 'platform_admin'
+ ordering = ['-is_default', 'display_name']
+ unique_together = [['local_part', 'domain']]
+ verbose_name = 'Platform Email Address'
+ verbose_name_plural = 'Platform Email Addresses'
+
+ def __str__(self):
+ return f"{self.display_name} <{self.email_address}>"
+
+ @property
+ def email_address(self):
+ """Full email address."""
+ return f"{self.local_part}@{self.domain}"
+
+ @property
+ def effective_sender_name(self):
+ """
+ Name to use in From header.
+ Priority: assigned_user's full name > sender_name > display_name
+ """
+ if self.assigned_user:
+ user_name = self.assigned_user.get_full_name()
+ if user_name:
+ return user_name
+ if self.sender_name:
+ return self.sender_name
+ return self.display_name
+
+ def save(self, *args, **kwargs):
+ # Ensure only one default
+ if self.is_default:
+ PlatformEmailAddress.objects.filter(
+ is_default=True
+ ).exclude(pk=self.pk).update(is_default=False)
+ super().save(*args, **kwargs)
+
+ # Pre-configured mail server settings
+ MAIL_SERVER_HOST = 'mail.talova.net'
+ IMAP_HOST = 'mail.talova.net'
+ IMAP_PORT = 993
+ IMAP_USE_SSL = True
+ SMTP_HOST = 'mail.talova.net'
+ SMTP_PORT = 587
+ SMTP_USE_TLS = True
+ SMTP_USE_SSL = False
+
+ def get_imap_settings(self):
+ """Get IMAP connection settings."""
+ return {
+ 'host': self.IMAP_HOST,
+ 'port': self.IMAP_PORT,
+ 'use_ssl': self.IMAP_USE_SSL,
+ 'username': self.email_address,
+ 'password': self.password,
+ 'folder': 'INBOX',
+ }
+
+ def get_smtp_settings(self):
+ """Get SMTP connection settings."""
+ return {
+ 'host': self.SMTP_HOST,
+ 'port': self.SMTP_PORT,
+ 'use_tls': self.SMTP_USE_TLS,
+ 'use_ssl': self.SMTP_USE_SSL,
+ 'username': self.email_address,
+ 'password': self.password,
+ }
diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py
index 3b36286..14e3fc8 100644
--- a/smoothschedule/platform_admin/serializers.py
+++ b/smoothschedule/platform_admin/serializers.py
@@ -5,7 +5,7 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
from rest_framework import serializers
from core.models import Tenant, Domain
from smoothschedule.users.models import User
-from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
+from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
class PlatformSettingsSerializer(serializers.Serializer):
@@ -19,6 +19,7 @@ class PlatformSettingsSerializer(serializers.Serializer):
stripe_validation_error = serializers.CharField(read_only=True)
has_stripe_keys = serializers.SerializerMethodField()
stripe_keys_from_env = serializers.SerializerMethodField()
+ email_check_interval_minutes = serializers.IntegerField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
def get_stripe_secret_key_masked(self, obj):
@@ -108,8 +109,10 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
'id', 'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
- 'features', 'transaction_fee_percent', 'transaction_fee_fixed',
- 'is_active', 'is_public', 'created_at', 'updated_at'
+ 'features', 'limits', 'permissions',
+ 'transaction_fee_percent', 'transaction_fee_fixed',
+ 'is_active', 'is_public', 'is_most_popular', 'show_price',
+ 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
@@ -124,8 +127,10 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
'price_monthly', 'price_yearly', 'business_tier',
- 'features', 'transaction_fee_percent', 'transaction_fee_fixed',
- 'is_active', 'is_public', 'create_stripe_product'
+ 'features', 'limits', 'permissions',
+ 'transaction_fee_percent', 'transaction_fee_fixed',
+ 'is_active', 'is_public', 'is_most_popular', 'show_price',
+ 'create_stripe_product'
]
def create(self, validated_data):
@@ -579,3 +584,255 @@ class TenantInvitationDetailSerializer(TenantInvitationSerializer):
'invited_by': {'read_only': True},
}
+
+class AssignedUserSerializer(serializers.Serializer):
+ """Lightweight serializer for assigned user info."""
+ id = serializers.IntegerField(read_only=True)
+ email = serializers.EmailField(read_only=True)
+ first_name = serializers.CharField(read_only=True)
+ last_name = serializers.CharField(read_only=True)
+ full_name = serializers.SerializerMethodField()
+
+ def get_full_name(self, obj):
+ return obj.get_full_name() or obj.email
+
+
+class PlatformEmailAddressListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for listing platform email addresses."""
+ email_address = serializers.ReadOnlyField()
+ effective_sender_name = serializers.ReadOnlyField()
+ assigned_user = AssignedUserSerializer(read_only=True)
+
+ class Meta:
+ model = PlatformEmailAddress
+ fields = [
+ 'id', 'display_name', 'sender_name', 'effective_sender_name',
+ 'local_part', 'domain', 'email_address', 'color',
+ 'assigned_user',
+ 'is_active', 'is_default', 'mail_server_synced',
+ 'last_check_at', 'emails_processed_count',
+ 'created_at', 'updated_at'
+ ]
+ read_only_fields = [
+ 'email_address', 'effective_sender_name', 'mail_server_synced',
+ 'last_check_at', 'emails_processed_count',
+ 'created_at', 'updated_at'
+ ]
+
+
+class PlatformEmailAddressSerializer(serializers.ModelSerializer):
+ """Full serializer for platform email addresses."""
+ email_address = serializers.ReadOnlyField()
+ effective_sender_name = serializers.ReadOnlyField()
+ assigned_user = AssignedUserSerializer(read_only=True)
+ assigned_user_id = serializers.IntegerField(
+ write_only=True,
+ required=False,
+ allow_null=True
+ )
+ imap_settings = serializers.SerializerMethodField()
+ smtp_settings = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PlatformEmailAddress
+ fields = [
+ 'id', 'display_name', 'sender_name', 'effective_sender_name',
+ 'local_part', 'domain', 'email_address', 'color',
+ 'assigned_user', 'assigned_user_id',
+ 'password', 'is_active', 'is_default',
+ 'mail_server_synced', 'last_sync_error', 'last_synced_at',
+ 'last_check_at', 'emails_processed_count',
+ 'created_at', 'updated_at',
+ 'imap_settings', 'smtp_settings'
+ ]
+ read_only_fields = [
+ 'email_address', 'effective_sender_name',
+ 'mail_server_synced', 'last_sync_error', 'last_synced_at',
+ 'last_check_at', 'emails_processed_count',
+ 'created_at', 'updated_at',
+ 'imap_settings', 'smtp_settings'
+ ]
+ extra_kwargs = {
+ 'password': {'write_only': True},
+ }
+
+ def validate_assigned_user_id(self, value):
+ """Validate and convert assigned_user_id to User instance."""
+ if value is None:
+ return None
+ from smoothschedule.users.models import User
+ try:
+ user = User.objects.get(
+ pk=value,
+ role__in=['superuser', 'platform_manager', 'platform_support'],
+ is_active=True
+ )
+ return user
+ except User.DoesNotExist:
+ raise serializers.ValidationError("User not found or not a platform user.")
+
+ def create(self, validated_data):
+ assigned_user = validated_data.pop('assigned_user_id', None)
+ instance = super().create(validated_data)
+ if assigned_user is not None:
+ instance.assigned_user = assigned_user
+ instance.save(update_fields=['assigned_user'])
+ return instance
+
+ def update(self, instance, validated_data):
+ if 'assigned_user_id' in validated_data:
+ instance.assigned_user = validated_data.pop('assigned_user_id')
+ return super().update(instance, validated_data)
+
+ def get_imap_settings(self, obj):
+ """Return IMAP settings without password."""
+ settings = obj.get_imap_settings()
+ settings.pop('password', None)
+ return settings
+
+ def get_smtp_settings(self, obj):
+ """Return SMTP settings without password."""
+ settings = obj.get_smtp_settings()
+ settings.pop('password', None)
+ return settings
+
+ def validate_local_part(self, value):
+ """Validate local part of email address."""
+ import re
+ value = value.lower().strip()
+
+ # Check format
+ if not re.match(r'^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$', value):
+ raise serializers.ValidationError(
+ "Local part must start and end with a letter or number, "
+ "and can only contain letters, numbers, dots, underscores, and hyphens"
+ )
+
+ if len(value) > 64:
+ raise serializers.ValidationError("Local part cannot exceed 64 characters")
+
+ return value
+
+ def validate_password(self, value):
+ """Validate password strength."""
+ if len(value) < 8:
+ raise serializers.ValidationError("Password must be at least 8 characters")
+ return value
+
+ def validate(self, attrs):
+ """Cross-field validation."""
+ local_part = attrs.get('local_part', getattr(self.instance, 'local_part', None))
+ domain = attrs.get('domain', getattr(self.instance, 'domain', None))
+
+ if local_part and domain:
+ # Check uniqueness
+ qs = PlatformEmailAddress.objects.filter(
+ local_part=local_part.lower(),
+ domain=domain
+ )
+ if self.instance:
+ qs = qs.exclude(pk=self.instance.pk)
+ if qs.exists():
+ raise serializers.ValidationError({
+ 'local_part': f'Email address {local_part}@{domain} already exists'
+ })
+
+ return attrs
+
+
+class PlatformEmailAddressCreateSerializer(PlatformEmailAddressSerializer):
+ """Serializer for creating platform email addresses with mail server sync."""
+
+ def create(self, validated_data):
+ """Create the email address and sync to mail server."""
+ from .mail_server import get_mail_server_service
+
+ # Normalize local_part
+ validated_data['local_part'] = validated_data['local_part'].lower()
+
+ # Create the database record first
+ instance = super().create(validated_data)
+
+ # Sync to mail server
+ service = get_mail_server_service()
+ success, message = service.sync_account(instance)
+
+ if not success:
+ # Delete the database record if mail server sync failed
+ instance.delete()
+ raise serializers.ValidationError({
+ 'mail_server': f'Failed to create email account on mail server: {message}'
+ })
+
+ return instance
+
+
+class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
+ """Serializer for updating platform email addresses."""
+ email_address = serializers.ReadOnlyField()
+ assigned_user_id = serializers.IntegerField(
+ write_only=True,
+ required=False,
+ allow_null=True
+ )
+
+ class Meta:
+ model = PlatformEmailAddress
+ fields = [
+ 'id', 'display_name', 'sender_name', 'email_address', 'color',
+ 'assigned_user_id',
+ 'password', 'is_active', 'is_default'
+ ]
+ read_only_fields = ['id', 'email_address']
+ extra_kwargs = {
+ 'password': {'write_only': True, 'required': False},
+ 'sender_name': {'required': False},
+ }
+
+ def validate_assigned_user_id(self, value):
+ """Validate and convert assigned_user_id to User instance."""
+ if value is None:
+ return None
+ from smoothschedule.users.models import User
+ try:
+ user = User.objects.get(
+ pk=value,
+ role__in=['superuser', 'platform_manager', 'platform_support'],
+ is_active=True
+ )
+ return user
+ except User.DoesNotExist:
+ raise serializers.ValidationError("User not found or not a platform user.")
+
+ def validate_password(self, value):
+ """Validate password strength if provided."""
+ if value and len(value) < 8:
+ raise serializers.ValidationError("Password must be at least 8 characters")
+ return value
+
+ def update(self, instance, validated_data):
+ """Update the email address and sync password to mail server if changed."""
+ from .mail_server import get_mail_server_service
+
+ password = validated_data.get('password')
+ password_changed = password and password != instance.password
+
+ # Handle assigned_user_id separately
+ if 'assigned_user_id' in validated_data:
+ instance.assigned_user = validated_data.pop('assigned_user_id')
+
+ # Update the instance
+ instance = super().update(instance, validated_data)
+
+ # Sync to mail server if password changed
+ if password_changed:
+ service = get_mail_server_service()
+ success, message = service.sync_account(instance)
+
+ if not success:
+ raise serializers.ValidationError({
+ 'mail_server': f'Failed to update password on mail server: {message}'
+ })
+
+ return instance
+
diff --git a/smoothschedule/platform_admin/urls.py b/smoothschedule/platform_admin/urls.py
index 9aea09e..a1aec56 100644
--- a/smoothschedule/platform_admin/urls.py
+++ b/smoothschedule/platform_admin/urls.py
@@ -9,12 +9,14 @@ from .views import (
TenantInvitationViewSet,
SubscriptionPlanViewSet,
PlatformSettingsView,
+ GeneralSettingsView,
StripeKeysView,
StripeValidateView,
StripeWebhooksView,
StripeWebhookDetailView,
StripeWebhookRotateSecretView,
OAuthSettingsView,
+ PlatformEmailAddressViewSet,
)
app_name = 'platform'
@@ -24,12 +26,14 @@ router.register(r'businesses', TenantViewSet, basename='business')
router.register(r'users', PlatformUserViewSet, basename='user')
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
+router.register(r'email-addresses', PlatformEmailAddressViewSet, basename='email-address')
urlpatterns = [
path('', include(router.urls)),
# Platform settings endpoints
path('settings/', PlatformSettingsView.as_view(), name='settings'),
+ path('settings/general/', GeneralSettingsView.as_view(), name='general-settings'),
path('settings/stripe/keys/', StripeKeysView.as_view(), name='stripe-keys'),
path('settings/stripe/validate/', StripeValidateView.as_view(), name='stripe-validate'),
path('settings/oauth/', OAuthSettingsView.as_view(), name='oauth-settings'),
diff --git a/smoothschedule/platform_admin/views.py b/smoothschedule/platform_admin/views.py
index 3becfac..039e80e 100644
--- a/smoothschedule/platform_admin/views.py
+++ b/smoothschedule/platform_admin/views.py
@@ -17,7 +17,7 @@ from django_tenants.utils import schema_context
from core.models import Tenant, Domain
from smoothschedule.users.models import User
-from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
+from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
from .serializers import (
TenantSerializer,
TenantCreateSerializer,
@@ -34,6 +34,10 @@ from .serializers import (
OAuthSettingsResponseSerializer,
SubscriptionPlanSerializer,
SubscriptionPlanCreateSerializer,
+ PlatformEmailAddressListSerializer,
+ PlatformEmailAddressSerializer,
+ PlatformEmailAddressCreateSerializer,
+ PlatformEmailAddressUpdateSerializer,
)
from .permissions import IsPlatformAdmin, IsPlatformUser
@@ -139,6 +143,42 @@ class StripeValidateView(APIView):
}, status=status.HTTP_400_BAD_REQUEST)
+class GeneralSettingsView(APIView):
+ """
+ POST /api/platform/settings/general/
+ Update general platform settings (email check interval, etc.)
+ """
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def post(self, request):
+ settings = PlatformSettings.get_instance()
+
+ # Update email check interval if provided
+ email_check_interval = request.data.get('email_check_interval_minutes')
+ if email_check_interval is not None:
+ try:
+ interval = int(email_check_interval)
+ if interval < 1:
+ return Response(
+ {'error': 'Email check interval must be at least 1 minute'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ if interval > 60:
+ return Response(
+ {'error': 'Email check interval cannot exceed 60 minutes'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ settings.email_check_interval_minutes = interval
+ except (ValueError, TypeError):
+ return Response(
+ {'error': 'Invalid email check interval'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ settings.save()
+ return Response(PlatformSettingsSerializer(settings).data)
+
+
class OAuthSettingsView(APIView):
"""
GET/POST /api/platform/settings/oauth/
@@ -1007,3 +1047,328 @@ class TenantInvitationViewSet(viewsets.ModelViewSet):
invitation.accept(tenant, owner_user)
return Response({"detail": "Invitation accepted, tenant and user created."}, status=status.HTTP_201_CREATED)
+
+
+class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing platform email addresses.
+ These are email addresses hosted on mail.talova.net that are
+ managed directly via SSH commands to the mail server.
+
+ Platform admins only.
+ """
+ queryset = PlatformEmailAddress.objects.all().order_by('-is_default', 'display_name')
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def get_serializer_class(self):
+ if self.action == 'list':
+ return PlatformEmailAddressListSerializer
+ if self.action == 'create':
+ return PlatformEmailAddressCreateSerializer
+ if self.action in ['update', 'partial_update']:
+ return PlatformEmailAddressUpdateSerializer
+ return PlatformEmailAddressSerializer
+
+ def perform_destroy(self, instance):
+ """Delete email address from both database and mail server."""
+ from .mail_server import get_mail_server_service
+
+ # Delete from mail server first
+ service = get_mail_server_service()
+ success, message = service.delete_and_unsync(instance)
+
+ if not success:
+ # Log the error but still delete from database
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f"Failed to delete email from mail server: {message}")
+
+ # Delete from database
+ instance.delete()
+
+ @action(detail=True, methods=['post'])
+ def sync(self, request, pk=None):
+ """
+ Manually sync this email address to the mail server.
+ Creates the account if it doesn't exist, or updates the password if it does.
+ """
+ from .mail_server import get_mail_server_service
+
+ email_address = self.get_object()
+ service = get_mail_server_service()
+
+ success, message = service.sync_account(email_address)
+
+ if success:
+ return Response({
+ 'success': True,
+ 'message': message,
+ 'mail_server_synced': email_address.mail_server_synced,
+ 'last_synced_at': email_address.last_synced_at,
+ })
+ else:
+ return Response({
+ 'success': False,
+ 'message': message,
+ 'mail_server_synced': email_address.mail_server_synced,
+ 'last_sync_error': email_address.last_sync_error,
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=True, methods=['post'])
+ def set_as_default(self, request, pk=None):
+ """Set this email address as the default for platform support."""
+ email_address = self.get_object()
+
+ # Unset all other defaults
+ PlatformEmailAddress.objects.filter(
+ is_default=True
+ ).exclude(pk=email_address.pk).update(is_default=False)
+
+ # Set this one as default
+ email_address.is_default = True
+ email_address.save()
+
+ return Response({
+ 'success': True,
+ 'message': f'{email_address.display_name} is now the default email address',
+ })
+
+ @action(detail=True, methods=['post'])
+ def test_imap(self, request, pk=None):
+ """Test IMAP connection for this email address."""
+ import imaplib
+
+ email_address = self.get_object()
+ settings = email_address.get_imap_settings()
+
+ try:
+ if settings['use_ssl']:
+ imap = imaplib.IMAP4_SSL(settings['host'], settings['port'])
+ else:
+ imap = imaplib.IMAP4(settings['host'], settings['port'])
+
+ imap.login(settings['username'], settings['password'])
+ imap.select(settings['folder'])
+ imap.logout()
+
+ return Response({
+ 'success': True,
+ 'message': 'IMAP connection successful',
+ })
+ except Exception as e:
+ return Response({
+ 'success': False,
+ 'message': f'IMAP connection failed: {str(e)}',
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=True, methods=['post'])
+ def test_smtp(self, request, pk=None):
+ """Test SMTP connection for this email address."""
+ import smtplib
+
+ email_address = self.get_object()
+ settings = email_address.get_smtp_settings()
+
+ try:
+ if settings['use_ssl']:
+ smtp = smtplib.SMTP_SSL(settings['host'], settings['port'])
+ else:
+ smtp = smtplib.SMTP(settings['host'], settings['port'])
+ if settings['use_tls']:
+ smtp.starttls()
+
+ smtp.login(settings['username'], settings['password'])
+ smtp.quit()
+
+ return Response({
+ 'success': True,
+ 'message': 'SMTP connection successful',
+ })
+ except Exception as e:
+ return Response({
+ 'success': False,
+ 'message': f'SMTP connection failed: {str(e)}',
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=False, methods=['post'])
+ def test_mail_server(self, request):
+ """Test SSH connection to the mail server."""
+ from .mail_server import get_mail_server_service
+
+ service = get_mail_server_service()
+ success, message = service.test_connection()
+
+ if success:
+ return Response({
+ 'success': True,
+ 'message': message,
+ })
+ else:
+ return Response({
+ 'success': False,
+ 'message': message,
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=False, methods=['get'])
+ def mail_server_accounts(self, request):
+ """List all email accounts on the mail server."""
+ from .mail_server import get_mail_server_service, MailServerError
+
+ service = get_mail_server_service()
+
+ try:
+ accounts = service.list_accounts()
+ return Response({
+ 'success': True,
+ 'accounts': accounts,
+ 'count': len(accounts),
+ })
+ except MailServerError as e:
+ return Response({
+ 'success': False,
+ 'message': str(e),
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=False, methods=['get'])
+ def available_domains(self, request):
+ """Get available email domains."""
+ domains = [
+ {'value': choice[0], 'label': choice[1]}
+ for choice in PlatformEmailAddress.Domain.choices
+ ]
+ return Response({
+ 'domains': domains,
+ })
+
+ @action(detail=False, methods=['get'])
+ def assignable_users(self, request):
+ """Get users that can be assigned to email addresses."""
+ from smoothschedule.users.models import User
+
+ users = User.objects.filter(
+ role__in=['superuser', 'platform_manager', 'platform_support'],
+ is_active=True
+ ).order_by('first_name', 'last_name', 'email')
+
+ user_list = [
+ {
+ 'id': user.id,
+ 'email': user.email,
+ 'first_name': user.first_name,
+ 'last_name': user.last_name,
+ 'full_name': user.get_full_name() or user.email,
+ 'role': user.role,
+ }
+ for user in users
+ ]
+
+ return Response({
+ 'users': user_list,
+ })
+
+ @action(detail=True, methods=['post'])
+ def remove_local(self, request, pk=None):
+ """
+ Remove email address from database only, without deleting from mail server.
+ Useful for removing an address from the platform while keeping
+ the mail server account intact.
+ """
+ email_address = self.get_object()
+ email = email_address.email_address
+ display_name = email_address.display_name
+
+ # Just delete from database, don't touch mail server
+ email_address.delete()
+
+ return Response({
+ 'success': True,
+ 'message': f'Removed {display_name} ({email}) from database. Account still exists on mail server.',
+ })
+
+ @action(detail=False, methods=['post'])
+ def import_from_mail_server(self, request):
+ """
+ Import existing email accounts from the mail server.
+ Only imports accounts with supported domains that don't already exist in the database.
+ """
+ from .mail_server import get_mail_server_service, MailServerError
+ import secrets
+
+ service = get_mail_server_service()
+
+ try:
+ accounts = service.list_accounts()
+ except MailServerError as e:
+ return Response({
+ 'success': False,
+ 'message': str(e),
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ # Only import smoothschedule.com addresses
+ supported_domains = ['smoothschedule.com']
+
+ # Get existing email addresses (construct from local_part + domain)
+ existing_emails = set(
+ f"{addr.local_part}@{addr.domain}".lower()
+ for addr in PlatformEmailAddress.objects.only('local_part', 'domain')
+ )
+
+ imported = []
+ skipped = []
+
+ for account in accounts:
+ email = account.get('email', '')
+ if not email or '@' not in email:
+ continue
+
+ local_part, domain = email.rsplit('@', 1)
+
+ # Skip if domain not supported
+ if domain not in supported_domains:
+ skipped.append({
+ 'email': email,
+ 'reason': 'Unsupported domain',
+ })
+ continue
+
+ # Skip if already exists
+ if email.lower() in existing_emails:
+ skipped.append({
+ 'email': email,
+ 'reason': 'Already exists in database',
+ })
+ continue
+
+ # Create the email address with a placeholder password
+ # User will need to update the password to sync properly
+ placeholder_password = secrets.token_urlsafe(16)
+
+ try:
+ email_address = PlatformEmailAddress.objects.create(
+ display_name=local_part.title().replace('.', ' ').replace('-', ' '),
+ local_part=local_part,
+ domain=domain,
+ password=placeholder_password,
+ is_active=True,
+ is_default=False,
+ mail_server_synced=True, # Already exists on server
+ )
+ imported.append({
+ 'id': email_address.id,
+ 'email': email_address.email_address,
+ 'display_name': email_address.display_name,
+ })
+ except Exception as e:
+ skipped.append({
+ 'email': email,
+ 'reason': str(e),
+ })
+
+ return Response({
+ 'success': True,
+ 'imported': imported,
+ 'imported_count': len(imported),
+ 'skipped': skipped,
+ 'skipped_count': len(skipped),
+ 'message': f'Imported {len(imported)} email addresses, skipped {len(skipped)}',
+ })
diff --git a/smoothschedule/tickets/admin.py b/smoothschedule/tickets/admin.py
index 92a4bdb..3397819 100644
--- a/smoothschedule/tickets/admin.py
+++ b/smoothschedule/tickets/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
+from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailAddress
@admin.register(Ticket)
@@ -91,3 +91,38 @@ class CannedResponseAdmin(admin.ModelAdmin):
'classes': ('collapse',)
}),
)
+
+
+@admin.register(TicketEmailAddress)
+class TicketEmailAddressAdmin(admin.ModelAdmin):
+ list_display = ('id', 'display_name', 'email_address', 'tenant', 'is_active', 'is_default', 'color', 'emails_processed_count', 'last_check_at')
+ list_filter = ('is_active', 'is_default', 'tenant', 'created_at')
+ search_fields = ('display_name', 'email_address', 'tenant__name')
+ readonly_fields = ('last_check_at', 'last_error', 'emails_processed_count', 'created_at', 'updated_at')
+ raw_id_fields = ('tenant',)
+ ordering = ('tenant', '-is_default', 'display_name')
+
+ fieldsets = (
+ (None, {
+ 'fields': ('tenant', 'display_name', 'email_address', 'color')
+ }),
+ ('IMAP Settings (Inbound)', {
+ 'fields': ('imap_host', 'imap_port', 'imap_use_ssl', 'imap_username', 'imap_password', 'imap_folder'),
+ 'classes': ('collapse',)
+ }),
+ ('SMTP Settings (Outbound)', {
+ 'fields': ('smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username', 'smtp_password'),
+ 'classes': ('collapse',)
+ }),
+ ('Status & Settings', {
+ 'fields': ('is_active', 'is_default')
+ }),
+ ('Statistics', {
+ 'fields': ('last_check_at', 'last_error', 'emails_processed_count'),
+ 'classes': ('collapse',)
+ }),
+ ('Timestamps', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
diff --git a/smoothschedule/tickets/email_notifications.py b/smoothschedule/tickets/email_notifications.py
index 8214d49..98fd033 100644
--- a/smoothschedule/tickets/email_notifications.py
+++ b/smoothschedule/tickets/email_notifications.py
@@ -12,6 +12,9 @@ Uses email templates from the EmailTemplate model with ticket-specific context v
"""
import logging
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
from typing import Optional, Dict, Any
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
@@ -22,6 +25,23 @@ from .models import Ticket, TicketComment
logger = logging.getLogger(__name__)
+def get_default_platform_email():
+ """
+ Get the default PlatformEmailAddress for sending platform emails.
+ Returns None if no default is configured.
+ """
+ try:
+ from platform_admin.models import PlatformEmailAddress
+ return PlatformEmailAddress.objects.filter(
+ is_default=True,
+ is_active=True,
+ mail_server_synced=True
+ ).first()
+ except Exception as e:
+ logger.warning(f"Could not get default platform email: {e}")
+ return None
+
+
class TicketEmailService:
"""
Service for sending ticket-related email notifications.
@@ -139,6 +159,9 @@ class TicketEmailService:
"""
Send an email with both HTML and plain text versions.
+ For platform-level tickets (no tenant), uses the default PlatformEmailAddress
+ with direct SMTP. For tenant tickets, uses Django's email backend.
+
Args:
to_email: Recipient email address
subject: Email subject
@@ -153,10 +176,39 @@ class TicketEmailService:
logger.warning("Cannot send email: no recipient address")
return False
+ # Generate reply-to address
+ if not reply_to:
+ reply_domain = None
+ try:
+ from .models import TicketEmailSettings
+ email_settings = TicketEmailSettings.get_instance()
+ if email_settings.support_email_domain:
+ reply_domain = email_settings.support_email_domain
+ except Exception:
+ pass
+
+ if not reply_domain:
+ reply_domain = getattr(settings, 'SUPPORT_EMAIL_DOMAIN', 'smoothschedule.com')
+
+ reply_to = f"support+ticket-{self.ticket.id}@{reply_domain}"
+
+ # For platform-level tickets, try to use the default PlatformEmailAddress
+ if not self.tenant:
+ platform_email = get_default_platform_email()
+ if platform_email:
+ return self._send_via_platform_smtp(
+ platform_email=platform_email,
+ to_email=to_email,
+ subject=subject,
+ html_content=html_content,
+ text_content=text_content,
+ reply_to=reply_to
+ )
+
+ # Fall back to Django's email backend
try:
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
- # Create email message
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
@@ -164,32 +216,10 @@ class TicketEmailService:
to=[to_email],
)
- # Add HTML version
if html_content:
msg.attach_alternative(html_content, 'text/html')
- # Add Reply-To header with ticket ID for inbound processing
- if reply_to:
- msg.reply_to = [reply_to]
- else:
- # Generate reply-to with ticket ID for threading
- # Try to get domain from TicketEmailSettings first, then fall back to settings
- reply_domain = None
- try:
- from .models import TicketEmailSettings
- email_settings = TicketEmailSettings.get_instance()
- if email_settings.support_email_domain:
- reply_domain = email_settings.support_email_domain
- except Exception:
- pass
-
- if not reply_domain:
- reply_domain = getattr(settings, 'SUPPORT_EMAIL_DOMAIN', 'smoothschedule.com')
-
- # Format: support+ticket-{id}@domain.com
- msg.reply_to = [f"support+ticket-{self.ticket.id}@{reply_domain}"]
-
- # Add headers for email threading
+ msg.reply_to = [reply_to]
msg.extra_headers = {
'X-Ticket-ID': str(self.ticket.id),
'X-Ticket-Type': self.ticket.ticket_type,
@@ -203,6 +233,68 @@ class TicketEmailService:
logger.error(f"Failed to send ticket email to {to_email}: {e}")
return False
+ def _send_via_platform_smtp(
+ self,
+ platform_email,
+ to_email: str,
+ subject: str,
+ html_content: str,
+ text_content: str,
+ reply_to: str
+ ) -> bool:
+ """
+ Send email using the PlatformEmailAddress SMTP settings.
+
+ Args:
+ platform_email: PlatformEmailAddress instance
+ to_email: Recipient email address
+ subject: Email subject
+ html_content: HTML email body
+ text_content: Plain text email body
+ reply_to: Reply-To address
+
+ Returns:
+ True if email sent successfully, False otherwise
+ """
+ try:
+ smtp_settings = platform_email.get_smtp_settings()
+
+ # Build the email
+ if html_content:
+ msg = MIMEMultipart('alternative')
+ msg.attach(MIMEText(text_content, 'plain'))
+ msg.attach(MIMEText(html_content, 'html'))
+ else:
+ msg = MIMEText(text_content, 'plain')
+
+ # Set headers
+ from_addr = f"{platform_email.effective_sender_name} <{platform_email.email_address}>"
+ msg['Subject'] = subject
+ msg['From'] = from_addr
+ msg['To'] = to_email
+ msg['Reply-To'] = reply_to
+ msg['X-Ticket-ID'] = str(self.ticket.id)
+ msg['X-Ticket-Type'] = self.ticket.ticket_type
+
+ # Connect and send
+ if smtp_settings['use_ssl']:
+ server = smtplib.SMTP_SSL(smtp_settings['host'], smtp_settings['port'])
+ else:
+ server = smtplib.SMTP(smtp_settings['host'], smtp_settings['port'])
+ if smtp_settings['use_tls']:
+ server.starttls()
+
+ server.login(smtp_settings['username'], smtp_settings['password'])
+ server.sendmail(platform_email.email_address, [to_email], msg.as_string())
+ server.quit()
+
+ logger.info(f"Sent platform ticket email from {platform_email.email_address} to {to_email}: {subject}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to send platform email via SMTP to {to_email}: {e}")
+ return False
+
def send_assignment_notification(self) -> bool:
"""
Send notification when ticket is assigned to someone.
diff --git a/smoothschedule/tickets/email_receiver.py b/smoothschedule/tickets/email_receiver.py
index 2b1e232..7b6f11e 100644
--- a/smoothschedule/tickets/email_receiver.py
+++ b/smoothschedule/tickets/email_receiver.py
@@ -4,6 +4,7 @@ Inbound Email Receiver Service
Processes incoming emails and creates ticket comments from replies.
Supports:
- IMAP polling for new emails
+- Multiple email addresses (platform-wide and business-specific)
- Ticket ID extraction from reply-to addresses and subject lines
- Reply text extraction (stripping quoted content)
- User matching by email address
@@ -11,8 +12,10 @@ Supports:
Usage:
from tickets.email_receiver import TicketEmailReceiver
+ from tickets.models import TicketEmailAddress
- receiver = TicketEmailReceiver()
+ email_address = TicketEmailAddress.objects.get(id=1)
+ receiver = TicketEmailReceiver(email_address)
processed_count = receiver.fetch_and_process_emails()
"""
@@ -31,7 +34,7 @@ from django.db import transaction
from .models import (
Ticket,
TicketComment,
- TicketEmailSettings,
+ TicketEmailAddress,
IncomingTicketEmail
)
from smoothschedule.users.models import User
@@ -54,14 +57,16 @@ class TicketEmailReceiver:
r'#(\d+)', # #123 in subject (less specific)
]
- def __init__(self):
- """Initialize the email receiver with settings from database."""
- self.settings = TicketEmailSettings.get_instance()
+ def __init__(self, email_address: TicketEmailAddress):
+ """Initialize the email receiver with a specific email address configuration."""
+ self.email_address = email_address
self.connection = None
def is_configured(self) -> bool:
"""Check if email receiving is properly configured."""
- return self.settings.is_configured() and self.settings.is_enabled
+ return (self.email_address.is_imap_configured and
+ self.email_address.is_smtp_configured and
+ self.email_address.is_active)
def connect(self) -> bool:
"""
@@ -70,37 +75,37 @@ class TicketEmailReceiver:
Returns:
True if connection successful, False otherwise
"""
- if not self.settings.is_configured():
- logger.error("IMAP settings not configured")
+ if not self.email_address.is_imap_configured:
+ logger.error(f"[{self.email_address.display_name}] IMAP settings not configured")
return False
try:
- if self.settings.imap_use_ssl:
+ if self.email_address.imap_use_ssl:
self.connection = imaplib.IMAP4_SSL(
- self.settings.imap_host,
- self.settings.imap_port
+ self.email_address.imap_host,
+ self.email_address.imap_port
)
else:
self.connection = imaplib.IMAP4(
- self.settings.imap_host,
- self.settings.imap_port
+ self.email_address.imap_host,
+ self.email_address.imap_port
)
self.connection.login(
- self.settings.imap_username,
- self.settings.imap_password
+ self.email_address.imap_username,
+ self.email_address.imap_password
)
- logger.info(f"Connected to IMAP server {self.settings.imap_host}")
+ logger.info(f"[{self.email_address.display_name}] Connected to IMAP server {self.email_address.imap_host}")
return True
except imaplib.IMAP4.error as e:
- logger.error(f"IMAP login failed: {e}")
- self._update_settings_error(f"IMAP login failed: {e}")
+ logger.error(f"[{self.email_address.display_name}] IMAP login failed: {e}")
+ self._update_email_address_error(f"IMAP login failed: {e}")
return False
except Exception as e:
- logger.error(f"Failed to connect to IMAP server: {e}")
- self._update_settings_error(f"Connection failed: {e}")
+ logger.error(f"[{self.email_address.display_name}] Failed to connect to IMAP server: {e}")
+ self._update_email_address_error(f"Connection failed: {e}")
return False
def disconnect(self):
@@ -130,37 +135,34 @@ class TicketEmailReceiver:
try:
# Select the inbox folder
- self.connection.select(self.settings.imap_folder)
+ self.connection.select(self.email_address.imap_folder)
# Search for unread emails
status, messages = self.connection.search(None, 'UNSEEN')
if status != 'OK':
- logger.error(f"Failed to search emails: {status}")
+ logger.error(f"[{self.email_address.display_name}] Failed to search emails: {status}")
return 0
email_ids = messages[0].split()
- logger.info(f"Found {len(email_ids)} unread emails")
+ logger.info(f"[{self.email_address.display_name}] Found {len(email_ids)} unread emails")
for email_id in email_ids:
try:
if self._process_single_email(email_id):
processed_count += 1
- # Delete email from server if configured
- if self.settings.delete_after_processing:
- self._delete_email(email_id)
except Exception as e:
- logger.error(f"Error processing email {email_id}: {e}")
+ logger.error(f"[{self.email_address.display_name}] Error processing email {email_id}: {e}")
- # Update settings with last check time
- self.settings.last_check_at = timezone.now()
- self.settings.last_error = ''
- self.settings.emails_processed_count += processed_count
- self.settings.save()
+ # Update email address with last check time
+ self.email_address.last_check_at = timezone.now()
+ self.email_address.last_error = ''
+ self.email_address.emails_processed_count += processed_count
+ self.email_address.save()
except Exception as e:
- logger.error(f"Error fetching emails: {e}")
- self._update_settings_error(str(e))
+ logger.error(f"[{self.email_address.display_name}] Error fetching emails: {e}")
+ self._update_email_address_error(str(e))
finally:
self.disconnect()
@@ -665,11 +667,11 @@ class TicketEmailReceiver:
except Exception:
return None
- def _update_settings_error(self, error: str):
- """Update settings with error message."""
- self.settings.last_error = error
- self.settings.last_check_at = timezone.now()
- self.settings.save()
+ def _update_email_address_error(self, error: str):
+ """Update email address with error message."""
+ self.email_address.last_error = error
+ self.email_address.last_check_at = timezone.now()
+ self.email_address.save()
def _delete_email(self, email_id: bytes):
"""
@@ -688,6 +690,437 @@ class TicketEmailReceiver:
logger.error(f"Failed to delete email {email_id}: {e}")
+class PlatformEmailReceiver:
+ """
+ Service for receiving and processing inbound ticket emails from PlatformEmailAddress.
+ Similar to TicketEmailReceiver but adapted for platform-managed email addresses.
+ """
+
+ # Patterns to extract ticket ID from email addresses
+ TICKET_ID_PATTERNS = [
+ r'ticket[_-](\d+)', # ticket-123 or ticket_123
+ r'\+ticket[_-](\d+)', # +ticket-123 (subaddressing)
+ r'reply[_-](\d+)', # reply-123
+ r'\[Ticket #(\d+)\]', # [Ticket #123] in subject
+ r'#(\d+)', # #123 in subject (less specific)
+ ]
+
+ def __init__(self, email_address):
+ """Initialize with a PlatformEmailAddress instance."""
+ from platform_admin.models import PlatformEmailAddress
+ self.email_address = email_address
+ self.connection = None
+
+ def connect(self) -> bool:
+ """Establish connection to IMAP server."""
+ imap_settings = self.email_address.get_imap_settings()
+
+ try:
+ if imap_settings['use_ssl']:
+ self.connection = imaplib.IMAP4_SSL(
+ imap_settings['host'],
+ imap_settings['port']
+ )
+ else:
+ self.connection = imaplib.IMAP4(
+ imap_settings['host'],
+ imap_settings['port']
+ )
+
+ self.connection.login(
+ imap_settings['username'],
+ imap_settings['password']
+ )
+
+ logger.info(f"[Platform: {self.email_address.display_name}] Connected to IMAP server")
+ return True
+
+ except imaplib.IMAP4.error as e:
+ logger.error(f"[Platform: {self.email_address.display_name}] IMAP login failed: {e}")
+ self._update_error(f"IMAP login failed: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"[Platform: {self.email_address.display_name}] Failed to connect: {e}")
+ self._update_error(f"Connection failed: {e}")
+ return False
+
+ def disconnect(self):
+ """Close IMAP connection."""
+ if self.connection:
+ try:
+ self.connection.logout()
+ except Exception:
+ pass
+ self.connection = None
+
+ def fetch_and_process_emails(self) -> int:
+ """Fetch new emails and process them into tickets."""
+ if not self.email_address.is_active:
+ return 0
+
+ if not self.connect():
+ return 0
+
+ processed_count = 0
+
+ try:
+ self.connection.select('INBOX')
+ status, messages = self.connection.search(None, 'UNSEEN')
+
+ if status != 'OK':
+ logger.error(f"[Platform: {self.email_address.display_name}] Failed to search emails")
+ return 0
+
+ email_ids = messages[0].split()
+ logger.info(f"[Platform: {self.email_address.display_name}] Found {len(email_ids)} unread emails")
+
+ for email_id in email_ids:
+ try:
+ if self._process_single_email(email_id):
+ processed_count += 1
+ except Exception as e:
+ logger.error(f"[Platform: {self.email_address.display_name}] Error processing email {email_id}: {e}")
+
+ # Update last check time
+ self.email_address.last_check_at = timezone.now()
+ self.email_address.emails_processed_count += processed_count
+ self.email_address.save(update_fields=['last_check_at', 'emails_processed_count'])
+
+ except Exception as e:
+ logger.error(f"[Platform: {self.email_address.display_name}] Error fetching emails: {e}")
+ self._update_error(str(e))
+ finally:
+ self.disconnect()
+
+ return processed_count
+
+ def _process_single_email(self, email_id: bytes) -> bool:
+ """Process a single email message."""
+ status, msg_data = self.connection.fetch(email_id, '(RFC822)')
+
+ if status != 'OK':
+ return False
+
+ raw_email = msg_data[0][1]
+ msg = email.message_from_bytes(raw_email)
+
+ email_data = self._extract_email_data(msg)
+
+ # Check for duplicate
+ if IncomingTicketEmail.objects.filter(message_id=email_data['message_id']).exists():
+ logger.info(f"Duplicate email: {email_data['message_id']}")
+ return False
+
+ # Create incoming email record
+ incoming_email = IncomingTicketEmail.objects.create(
+ message_id=email_data['message_id'],
+ from_address=email_data['from_address'],
+ from_name=email_data['from_name'],
+ to_address=email_data['to_address'],
+ subject=email_data['subject'],
+ body_text=email_data['body_text'],
+ body_html=email_data['body_html'],
+ extracted_reply=email_data['extracted_reply'],
+ raw_headers=email_data['headers'],
+ email_date=email_data['date'],
+ ticket_id_from_email=email_data.get('ticket_id', ''),
+ )
+
+ # Try to match to existing ticket
+ ticket = self._find_matching_ticket(email_data)
+ user = self._find_user_by_email(email_data['from_address'])
+
+ if not ticket:
+ # Create new ticket
+ return self._create_new_ticket_from_email(email_data, incoming_email, user)
+
+ # Add comment to existing ticket
+ if not user:
+ if ticket.creator and ticket.creator.email.lower() == email_data['from_address'].lower():
+ user = ticket.creator
+ elif ticket.assignee and ticket.assignee.email.lower() == email_data['from_address'].lower():
+ user = ticket.assignee
+
+ is_external_sender = False
+ if not user and ticket.external_email:
+ if ticket.external_email.lower() == email_data['from_address'].lower():
+ is_external_sender = True
+
+ if not user and not is_external_sender:
+ logger.warning(f"Could not match user for email from {email_data['from_address']}")
+ incoming_email.mark_failed("Could not match sender")
+ return False
+
+ try:
+ with transaction.atomic():
+ TicketComment.objects.create(
+ ticket=ticket,
+ author=user,
+ comment_text=email_data['extracted_reply'] or email_data['body_text'],
+ is_internal=False,
+ source=TicketComment.Source.EMAIL,
+ incoming_email=incoming_email,
+ external_author_email=email_data['from_address'] if is_external_sender else None,
+ external_author_name=email_data['from_name'] if is_external_sender else '',
+ )
+
+ if ticket.status == Ticket.Status.AWAITING_RESPONSE:
+ if user == ticket.creator or is_external_sender:
+ ticket.status = Ticket.Status.OPEN
+ ticket.save()
+
+ incoming_email.mark_processed(ticket=ticket, user=user)
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to create comment: {e}")
+ incoming_email.mark_failed(str(e))
+ return False
+
+ def _create_new_ticket_from_email(self, email_data, incoming_email, user) -> bool:
+ """Create a new ticket from an incoming email."""
+ try:
+ with transaction.atomic():
+ subject = email_data['subject'] or 'Email Support Request'
+ subject = re.sub(r'^(Re|Fwd|FW|RE|FWD):\s*', '', subject, flags=re.IGNORECASE).strip()
+ if not subject:
+ subject = 'Email Support Request'
+
+ description = email_data['body_text'] or email_data['extracted_reply'] or ''
+
+ ticket = Ticket.objects.create(
+ tenant=None,
+ creator=user,
+ assignee=None,
+ ticket_type=Ticket.TicketType.PLATFORM,
+ status=Ticket.Status.OPEN,
+ priority=Ticket.Priority.LOW,
+ category=Ticket.Category.GENERAL_INQUIRY,
+ subject=subject[:255],
+ description=description,
+ is_sandbox=False,
+ external_email=email_data['from_address'] if not user else None,
+ external_name=email_data['from_name'] if not user else '',
+ source_email_address_id=self.email_address.id,
+ )
+
+ TicketComment.objects.create(
+ ticket=ticket,
+ author=user,
+ comment_text=email_data['extracted_reply'] or email_data['body_text'] or description,
+ is_internal=False,
+ source=TicketComment.Source.EMAIL,
+ incoming_email=incoming_email,
+ )
+
+ incoming_email.mark_processed(ticket=ticket, user=user)
+ logger.info(f"Created new ticket #{ticket.id} from email: {subject}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to create ticket from email: {e}")
+ incoming_email.mark_failed(str(e))
+ return False
+
+ def _extract_email_data(self, msg):
+ """Extract relevant data from email message."""
+ message_id = msg.get('Message-ID', '') or f"generated-{timezone.now().timestamp()}"
+
+ from_name, from_address = parseaddr(msg.get('From', ''))
+ from_name = self._decode_header(from_name)
+ _, to_address = parseaddr(msg.get('To', ''))
+ subject = self._decode_header(msg.get('Subject', ''))
+
+ try:
+ email_date = parsedate_to_datetime(msg.get('Date', ''))
+ except Exception:
+ email_date = timezone.now()
+
+ body_text, body_html = self._extract_body(msg)
+ extracted_reply = self._extract_reply_text(body_text)
+ ticket_id = self._extract_ticket_id(to_address, subject)
+
+ headers = {
+ 'from': msg.get('From', ''),
+ 'to': msg.get('To', ''),
+ 'subject': subject,
+ 'date': msg.get('Date', ''),
+ 'message-id': message_id,
+ 'in-reply-to': msg.get('In-Reply-To', ''),
+ 'references': msg.get('References', ''),
+ 'x-ticket-id': msg.get('X-Ticket-ID', ''),
+ }
+
+ return {
+ 'message_id': message_id,
+ 'from_name': from_name,
+ 'from_address': from_address.lower(),
+ 'to_address': to_address.lower(),
+ 'subject': subject,
+ 'body_text': body_text,
+ 'body_html': body_html,
+ 'extracted_reply': extracted_reply,
+ 'date': email_date,
+ 'headers': headers,
+ 'ticket_id': ticket_id,
+ }
+
+ def _decode_header(self, header_value: str) -> str:
+ """Decode an email header value."""
+ if not header_value:
+ return ''
+ decoded_parts = decode_header(header_value)
+ result = []
+ for content, charset in decoded_parts:
+ if isinstance(content, bytes):
+ charset = charset or 'utf-8'
+ try:
+ content = content.decode(charset)
+ except Exception:
+ content = content.decode('utf-8', errors='replace')
+ result.append(content)
+ return ''.join(result)
+
+ def _extract_body(self, msg) -> Tuple[str, str]:
+ """Extract text and HTML body from email."""
+ text_body = ''
+ html_body = ''
+
+ if msg.is_multipart():
+ for part in msg.walk():
+ content_type = part.get_content_type()
+ if 'attachment' in str(part.get('Content-Disposition', '')):
+ continue
+ try:
+ body = part.get_payload(decode=True)
+ if body:
+ charset = part.get_content_charset() or 'utf-8'
+ body = body.decode(charset, errors='replace')
+ if content_type == 'text/plain':
+ text_body = body
+ elif content_type == 'text/html':
+ html_body = body
+ except Exception:
+ pass
+ else:
+ content_type = msg.get_content_type()
+ try:
+ body = msg.get_payload(decode=True)
+ if body:
+ charset = msg.get_content_charset() or 'utf-8'
+ body = body.decode(charset, errors='replace')
+ if content_type == 'text/plain':
+ text_body = body
+ elif content_type == 'text/html':
+ html_body = body
+ except Exception:
+ pass
+
+ if not text_body and html_body:
+ text_body = self._html_to_text(html_body)
+
+ return text_body, html_body
+
+ def _html_to_text(self, html: str) -> str:
+ """Convert HTML to plain text."""
+ text = re.sub(r'', '', html, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'', '', text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'
', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'
', '\n\n', text, flags=re.IGNORECASE)
+ text = re.sub(r'<[^>]+>', '', text)
+ import html as html_module
+ text = html_module.unescape(text)
+ text = re.sub(r'\n\s*\n', '\n\n', text)
+ return text.strip()
+
+ def _extract_reply_text(self, text: str) -> str:
+ """Extract reply portion, removing quoted text."""
+ if not text:
+ return ''
+
+ lines = text.split('\n')
+ reply_lines = []
+
+ quote_patterns = [
+ r'^On .+ wrote:$',
+ r'^On .+, at .+, .+ wrote:$',
+ r'^From:.*',
+ r'^-{3,}\s*Original Message\s*-{3,}',
+ r'^_{3,}',
+ r'^\*From:\*',
+ r'^Sent from my ',
+ r'^Get Outlook for ',
+ ]
+
+ for line in lines:
+ stripped = line.strip()
+ is_quote_start = any(re.match(p, stripped, re.IGNORECASE) for p in quote_patterns)
+ if is_quote_start:
+ break
+ if stripped.startswith('>'):
+ continue
+ reply_lines.append(line)
+
+ reply = '\n'.join(reply_lines)
+ reply = re.sub(r'\n\s*--\s*\n.*$', '', reply, flags=re.DOTALL)
+ return reply.strip()
+
+ def _extract_ticket_id(self, to_address: str, subject: str) -> str:
+ """Extract ticket ID from address or subject."""
+ for pattern in self.TICKET_ID_PATTERNS:
+ match = re.search(pattern, to_address, re.IGNORECASE)
+ if match:
+ return match.group(1)
+ for pattern in self.TICKET_ID_PATTERNS:
+ match = re.search(pattern, subject, re.IGNORECASE)
+ if match:
+ return match.group(1)
+ return ''
+
+ def _find_matching_ticket(self, email_data) -> Optional[Ticket]:
+ """Find the ticket this email is replying to."""
+ ticket_id = email_data.get('ticket_id')
+ if ticket_id:
+ try:
+ return Ticket.objects.get(id=int(ticket_id))
+ except (Ticket.DoesNotExist, ValueError):
+ pass
+
+ x_ticket_id = email_data['headers'].get('x-ticket-id')
+ if x_ticket_id:
+ try:
+ return Ticket.objects.get(id=int(x_ticket_id))
+ except (Ticket.DoesNotExist, ValueError):
+ pass
+
+ in_reply_to = email_data['headers'].get('in-reply-to', '')
+ references = email_data['headers'].get('references', '')
+
+ for ref in [in_reply_to, references]:
+ for pattern in self.TICKET_ID_PATTERNS:
+ match = re.search(pattern, ref, re.IGNORECASE)
+ if match:
+ try:
+ return Ticket.objects.get(id=int(match.group(1)))
+ except (Ticket.DoesNotExist, ValueError):
+ pass
+
+ return None
+
+ def _find_user_by_email(self, email_address: str) -> Optional[User]:
+ """Find a user by email address."""
+ try:
+ return User.objects.filter(email__iexact=email_address).first()
+ except Exception:
+ return None
+
+ def _update_error(self, error: str):
+ """Update email address with error message."""
+ self.email_address.last_sync_error = error
+ self.email_address.last_check_at = timezone.now()
+ self.email_address.save(update_fields=['last_sync_error', 'last_check_at'])
+
+
def test_imap_connection() -> Tuple[bool, str]:
"""
Test IMAP connection with current settings.
diff --git a/smoothschedule/tickets/management/commands/fetch_ticket_emails.py b/smoothschedule/tickets/management/commands/fetch_ticket_emails.py
index 34151a4..f47c2f2 100644
--- a/smoothschedule/tickets/management/commands/fetch_ticket_emails.py
+++ b/smoothschedule/tickets/management/commands/fetch_ticket_emails.py
@@ -37,45 +37,44 @@ class Command(BaseCommand):
def handle(self, *args, **options):
from tickets.email_receiver import TicketEmailReceiver
- from tickets.models import TicketEmailSettings
+ from tickets.models import TicketEmailAddress
- settings = TicketEmailSettings.get_instance()
+ # Get all active email addresses (platform-wide and business-specific)
+ email_addresses = TicketEmailAddress.objects.filter(is_active=True)
- if not settings.is_configured():
+ if not email_addresses.exists():
self.stderr.write(self.style.ERROR(
- 'Email settings not configured. Please configure IMAP settings first.'
+ 'No active email addresses configured. Please configure email addresses first.'
))
return
- if not settings.is_enabled:
- self.stderr.write(self.style.WARNING(
- 'Email receiving is disabled. Enable it in settings to fetch emails.'
- ))
- if not options['daemon']:
- return
-
- receiver = TicketEmailReceiver()
-
if options['daemon']:
# Daemon mode - continuous polling
- interval = options['interval'] or settings.check_interval_seconds
+ interval = options['interval'] or 60 # Default to 60 seconds
self.stdout.write(self.style.SUCCESS(
- f'Starting email fetch daemon (polling every {interval}s)...'
+ f'Starting email fetch daemon (polling every {interval}s for {email_addresses.count()} addresses)...'
))
while True:
try:
- # Refresh settings in case they changed
- settings.refresh_from_db()
+ # Refresh email addresses in case they changed
+ email_addresses = TicketEmailAddress.objects.filter(is_active=True)
- if settings.is_enabled and settings.is_configured():
- processed = receiver.fetch_and_process_emails()
- if processed > 0:
- self.stdout.write(
- f'Processed {processed} emails'
- )
- else:
- logger.debug('Email receiving disabled or not configured')
+ total_processed = 0
+ for email_address in email_addresses:
+ receiver = TicketEmailReceiver(email_address)
+ try:
+ processed = receiver.fetch_and_process_emails()
+ total_processed += processed
+ if processed > 0:
+ self.stdout.write(
+ f'[{email_address.display_name}] Processed {processed} emails'
+ )
+ except Exception as e:
+ self.stderr.write(self.style.ERROR(
+ f'[{email_address.display_name}] Error: {e}'
+ ))
+ logger.exception(f'Error fetching emails for {email_address.display_name}')
time.sleep(interval)
@@ -89,8 +88,23 @@ class Command(BaseCommand):
else:
# Single fetch
- self.stdout.write('Fetching emails...')
- processed = receiver.fetch_and_process_emails()
+ self.stdout.write(f'Fetching emails from {email_addresses.count()} addresses...')
+ total_processed = 0
+
+ for email_address in email_addresses:
+ receiver = TicketEmailReceiver(email_address)
+ try:
+ processed = receiver.fetch_and_process_emails()
+ total_processed += processed
+ self.stdout.write(
+ f'[{email_address.display_name}] Processed {processed} emails'
+ )
+ except Exception as e:
+ self.stderr.write(self.style.ERROR(
+ f'[{email_address.display_name}] Error: {e}'
+ ))
+ logger.exception(f'Error fetching emails for {email_address.display_name}')
+
self.stdout.write(self.style.SUCCESS(
- f'Done. Processed {processed} emails.'
+ f'Done. Processed {total_processed} total emails.'
))
diff --git a/smoothschedule/tickets/migrations/0010_ticketemailaddress_incomingticketemail_email_address_and_more.py b/smoothschedule/tickets/migrations/0010_ticketemailaddress_incomingticketemail_email_address_and_more.py
new file mode 100644
index 0000000..bb66e51
--- /dev/null
+++ b/smoothschedule/tickets/migrations/0010_ticketemailaddress_incomingticketemail_email_address_and_more.py
@@ -0,0 +1,71 @@
+# Generated by Django 5.2.8 on 2025-12-01 16:54
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0010_add_oauth_credential_model'),
+ ('tickets', '0009_add_oauth_credential_to_email_settings'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TicketEmailAddress',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('display_name', models.CharField(help_text="Display name (e.g., 'Support', 'Billing', 'Sales')", max_length=100)),
+ ('email_address', models.EmailField(help_text='Email address for sending/receiving tickets', max_length=254)),
+ ('color', models.CharField(default='#3b82f6', help_text='Hex color code for visual identification (e.g., #3b82f6)', max_length=7)),
+ ('imap_host', models.CharField(help_text='IMAP server hostname (e.g., imap.gmail.com)', max_length=255)),
+ ('imap_port', models.IntegerField(default=993, help_text='IMAP port (993 for SSL, 143 for non-SSL)')),
+ ('imap_use_ssl', models.BooleanField(default=True, help_text='Use SSL/TLS for IMAP')),
+ ('imap_username', models.CharField(help_text='IMAP username (usually email address)', max_length=255)),
+ ('imap_password', models.CharField(help_text='IMAP password or app-specific password', max_length=255)),
+ ('imap_folder', models.CharField(default='INBOX', help_text='IMAP folder to monitor', max_length=100)),
+ ('smtp_host', models.CharField(help_text='SMTP server hostname (e.g., smtp.gmail.com)', max_length=255)),
+ ('smtp_port', models.IntegerField(default=587, help_text='SMTP port (587 for TLS, 465 for SSL)')),
+ ('smtp_use_tls', models.BooleanField(default=True, help_text='Use STARTTLS for SMTP')),
+ ('smtp_use_ssl', models.BooleanField(default=False, help_text='Use SSL/TLS for SMTP (port 465)')),
+ ('smtp_username', models.CharField(help_text='SMTP username (usually email address)', max_length=255)),
+ ('smtp_password', models.CharField(help_text='SMTP password or app-specific password', max_length=255)),
+ ('is_active', models.BooleanField(default=True, help_text='Whether this email address is actively checked')),
+ ('is_default', models.BooleanField(default=False, help_text='Default email for new tickets in this business')),
+ ('last_check_at', models.DateTimeField(blank=True, help_text='When emails were last checked for this address', null=True)),
+ ('last_error', models.TextField(blank=True, default='', help_text='Last error message if any')),
+ ('emails_processed_count', models.IntegerField(default=0, help_text='Total number of emails processed for this address')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('tenant', models.ForeignKey(help_text='Business this email address belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='ticket_email_addresses', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Ticket Email Address',
+ 'verbose_name_plural': 'Ticket Email Addresses',
+ 'ordering': ['-is_default', 'display_name'],
+ },
+ ),
+ migrations.AddField(
+ model_name='incomingticketemail',
+ name='email_address',
+ field=models.ForeignKey(blank=True, help_text='Email address configuration that received this email', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_emails', to='tickets.ticketemailaddress'),
+ ),
+ migrations.AddField(
+ model_name='ticket',
+ name='source_email_address',
+ field=models.ForeignKey(blank=True, help_text='Email address this ticket was received from or sent to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='tickets.ticketemailaddress'),
+ ),
+ migrations.AddIndex(
+ model_name='ticketemailaddress',
+ index=models.Index(fields=['tenant', 'is_active'], name='tickets_tic_tenant__b4c1e5_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='ticketemailaddress',
+ index=models.Index(fields=['email_address'], name='tickets_tic_email_a_e2aa4a_idx'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='ticketemailaddress',
+ unique_together={('tenant', 'email_address')},
+ ),
+ ]
diff --git a/smoothschedule/tickets/migrations/0011_alter_ticketemailaddress_tenant.py b/smoothschedule/tickets/migrations/0011_alter_ticketemailaddress_tenant.py
new file mode 100644
index 0000000..ffd9db0
--- /dev/null
+++ b/smoothschedule/tickets/migrations/0011_alter_ticketemailaddress_tenant.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2.8 on 2025-12-01 20:06
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0010_add_oauth_credential_model'),
+ ('tickets', '0010_ticketemailaddress_incomingticketemail_email_address_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='ticketemailaddress',
+ name='tenant',
+ field=models.ForeignKey(blank=True, help_text='Business this email address belongs to (null for platform-wide)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_email_addresses', to='core.tenant'),
+ ),
+ ]
diff --git a/smoothschedule/tickets/migrations/0012_migrate_email_settings_to_addresses.py b/smoothschedule/tickets/migrations/0012_migrate_email_settings_to_addresses.py
new file mode 100644
index 0000000..643220b
--- /dev/null
+++ b/smoothschedule/tickets/migrations/0012_migrate_email_settings_to_addresses.py
@@ -0,0 +1,79 @@
+# Generated by Django 5.2.8 on 2025-12-01 20:13
+
+from django.db import migrations
+
+
+def migrate_email_settings_to_addresses(apps, schema_editor):
+ """
+ Migrate data from TicketEmailSettings (singleton) to TicketEmailAddress (multi-email system).
+ Creates a platform-wide email address from the existing settings.
+ """
+ TicketEmailSettings = apps.get_model('tickets', 'TicketEmailSettings')
+ TicketEmailAddress = apps.get_model('tickets', 'TicketEmailAddress')
+
+ # Get the existing settings
+ settings = TicketEmailSettings.objects.first()
+
+ if settings and settings.imap_host and settings.smtp_host:
+ # Check if we already have a platform email address with this email
+ existing = TicketEmailAddress.objects.filter(
+ tenant__isnull=True,
+ email_address=settings.support_email_address
+ ).first()
+
+ if not existing:
+ # Create new TicketEmailAddress from settings
+ TicketEmailAddress.objects.create(
+ tenant=None, # Platform-wide
+ display_name='Platform Support',
+ email_address=settings.support_email_address or settings.smtp_from_email or settings.imap_username,
+ color='#3b82f6', # Default blue
+ # IMAP settings
+ imap_host=settings.imap_host,
+ imap_port=settings.imap_port,
+ imap_use_ssl=settings.imap_use_ssl,
+ imap_username=settings.imap_username,
+ imap_password=settings.imap_password,
+ imap_folder=settings.imap_folder,
+ # SMTP settings
+ smtp_host=settings.smtp_host,
+ smtp_port=settings.smtp_port,
+ smtp_use_tls=settings.smtp_use_tls,
+ smtp_use_ssl=settings.smtp_use_ssl,
+ smtp_username=settings.smtp_username,
+ smtp_password=settings.smtp_password,
+ # Status
+ is_active=settings.is_enabled,
+ is_default=True,
+ )
+ print(f"✓ Migrated email settings to new TicketEmailAddress: {settings.support_email_address}")
+ else:
+ print(f"✓ Platform email address already exists: {settings.support_email_address}")
+ else:
+ print("✓ No email settings to migrate")
+
+
+def reverse_migration(apps, schema_editor):
+ """
+ Reverse migration - delete the migrated platform email address.
+ """
+ TicketEmailAddress = apps.get_model('tickets', 'TicketEmailAddress')
+
+ # Delete platform-wide email addresses
+ deleted_count = TicketEmailAddress.objects.filter(
+ tenant__isnull=True,
+ display_name='Platform Support'
+ ).delete()[0]
+
+ print(f"✓ Deleted {deleted_count} platform email address(es)")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tickets', '0011_alter_ticketemailaddress_tenant'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_email_settings_to_addresses, reverse_migration),
+ ]
diff --git a/smoothschedule/tickets/models.py b/smoothschedule/tickets/models.py
index 019325e..7e0098f 100644
--- a/smoothschedule/tickets/models.py
+++ b/smoothschedule/tickets/models.py
@@ -133,6 +133,16 @@ class Ticket(models.Model):
help_text="Display name of external sender."
)
+ # Email address tracking
+ source_email_address = models.ForeignKey(
+ 'TicketEmailAddress',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='tickets',
+ help_text="Email address this ticket was received from or sent to"
+ )
+
# SLA tracking
due_at = models.DateTimeField(
null=True,
@@ -364,201 +374,6 @@ class TicketComment(models.Model):
return f"Comment on Ticket #{self.ticket.id} by {author_str} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"
-class TicketEmailSettings(models.Model):
- """
- Configuration for inbound and outbound email processing.
- Singleton model - one per system (platform-wide settings).
- """
- # IMAP server settings (inbound)
- imap_host = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="IMAP server hostname (e.g., imap.gmail.com)"
- )
- imap_port = models.IntegerField(
- default=993,
- help_text="IMAP server port (993 for SSL, 143 for non-SSL)"
- )
- imap_use_ssl = models.BooleanField(
- default=True,
- help_text="Use SSL/TLS connection"
- )
- imap_username = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="IMAP login username (usually email address)"
- )
- imap_password = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="IMAP login password or app-specific password"
- )
- imap_folder = models.CharField(
- max_length=100,
- default='INBOX',
- help_text="IMAP folder to monitor for incoming emails"
- )
-
- # SMTP server settings (outbound)
- smtp_host = models.CharField(
- max_length=255,
- blank=True,
- default='',
- 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 non-secure)"
- )
- smtp_use_tls = models.BooleanField(
- default=True,
- help_text="Use STARTTLS encryption"
- )
- smtp_use_ssl = models.BooleanField(
- default=False,
- help_text="Use SSL/TLS encryption (usually for port 465)"
- )
- smtp_username = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="SMTP login username (usually email address)"
- )
- smtp_password = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="SMTP login password or app-specific password"
- )
- smtp_from_email = models.EmailField(
- blank=True,
- default='',
- help_text="From email address for outgoing emails"
- )
- smtp_from_name = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="From name for outgoing emails (e.g., 'SmoothSchedule Support')"
- )
-
- # Email address configuration
- support_email_address = models.EmailField(
- blank=True,
- default='',
- help_text="Support email address (e.g., support@yourdomain.com)"
- )
- support_email_domain = models.CharField(
- max_length=255,
- blank=True,
- default='',
- help_text="Domain for ticket reply addresses (e.g., mail.talova.net)"
- )
-
- # Processing settings
- is_enabled = models.BooleanField(
- default=False,
- help_text="Enable inbound email processing"
- )
- delete_after_processing = models.BooleanField(
- default=True,
- help_text="Delete emails from server after successful processing"
- )
- check_interval_seconds = models.IntegerField(
- default=60,
- help_text="How often to check for new emails (in seconds)"
- )
- max_attachment_size_mb = models.IntegerField(
- default=10,
- help_text="Maximum attachment size in MB"
- )
- allowed_attachment_types = models.JSONField(
- default=list,
- blank=True,
- help_text="List of allowed attachment MIME types (empty = all allowed)"
- )
-
- # Status tracking
- last_check_at = models.DateTimeField(
- null=True,
- blank=True,
- help_text="When emails were last checked"
- )
- last_error = models.TextField(
- blank=True,
- default='',
- help_text="Last error message if any"
- )
- emails_processed_count = models.IntegerField(
- default=0,
- help_text="Total number of emails processed"
- )
-
- # OAuth credential for XOAUTH2 authentication (alternative to password)
- oauth_credential = models.ForeignKey(
- 'core.OAuthCredential',
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='ticket_email_settings',
- help_text="OAuth credential for XOAUTH2 authentication (Gmail/Microsoft)"
- )
-
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- class Meta:
- verbose_name = 'Ticket Email Settings'
- verbose_name_plural = 'Ticket Email Settings'
-
- def __str__(self):
- status = "Enabled" if self.is_enabled else "Disabled"
- return f"Ticket Email Settings ({status})"
-
- def save(self, *args, **kwargs):
- # Ensure only one instance exists (singleton)
- self.pk = 1
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- # Prevent deletion
- pass
-
- @classmethod
- def get_instance(cls):
- """Get or create the singleton instance."""
- instance, _ = cls.objects.get_or_create(pk=1)
- return instance
-
- def uses_oauth(self):
- """Check if using OAuth for authentication."""
- return self.oauth_credential is not None and self.oauth_credential.is_valid
-
- def is_imap_configured(self):
- """Check if IMAP (inbound) settings are properly configured."""
- has_host = bool(self.imap_host)
- has_username = bool(self.imap_username)
- # Either password or OAuth credential is required
- has_auth = bool(self.imap_password) or self.uses_oauth()
- return has_host and has_username and has_auth
-
- def is_smtp_configured(self):
- """Check if SMTP (outbound) settings are properly configured."""
- has_host = bool(self.smtp_host)
- has_username = bool(self.smtp_username)
- has_from = bool(self.smtp_from_email)
- # Either password or OAuth credential is required
- has_auth = bool(self.smtp_password) or self.uses_oauth()
- return has_host and has_username and has_from and has_auth
-
- def is_configured(self):
- """Check if email settings are properly configured (both IMAP and SMTP)."""
- return self.is_imap_configured() and self.is_smtp_configured()
-
-
class IncomingTicketEmail(models.Model):
"""
Logs all incoming emails for ticket replies.
@@ -637,6 +452,14 @@ class IncomingTicketEmail(models.Model):
related_name='incoming_ticket_emails',
help_text="User matched by email address"
)
+ email_address = models.ForeignKey(
+ 'TicketEmailAddress',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='incoming_emails',
+ help_text="Email address configuration that received this email"
+ )
ticket_id_from_email = models.CharField(
max_length=50,
blank=True,
@@ -704,4 +527,148 @@ class IncomingTicketEmail(models.Model):
"""Mark email as having no matching ticket."""
self.processing_status = self.ProcessingStatus.NO_MATCH
self.processed_at = timezone.now()
- self.save()
\ No newline at end of file
+ self.save()
+
+
+class TicketEmailAddress(models.Model):
+ """
+ Email address configuration for ticket management.
+ Can be tied to a business (tenant) or platform-wide (tenant=null).
+ Each business/platform can have multiple email addresses with their own IMAP/SMTP settings.
+ """
+ tenant = models.ForeignKey(
+ Tenant,
+ on_delete=models.CASCADE,
+ related_name='ticket_email_addresses',
+ null=True,
+ blank=True,
+ help_text="Business this email address belongs to (null for platform-wide)"
+ )
+
+ # Display information
+ display_name = models.CharField(
+ max_length=100,
+ help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
+ )
+ email_address = models.EmailField(
+ help_text="Email address for sending/receiving tickets"
+ )
+ color = models.CharField(
+ max_length=7,
+ default='#3b82f6',
+ help_text="Hex color code for visual identification (e.g., #3b82f6)"
+ )
+
+ # IMAP settings (inbound)
+ imap_host = models.CharField(
+ max_length=255,
+ help_text="IMAP server hostname (e.g., imap.gmail.com)"
+ )
+ imap_port = models.IntegerField(
+ default=993,
+ help_text="IMAP port (993 for SSL, 143 for non-SSL)"
+ )
+ imap_use_ssl = models.BooleanField(
+ default=True,
+ help_text="Use SSL/TLS for IMAP"
+ )
+ imap_username = models.CharField(
+ max_length=255,
+ help_text="IMAP username (usually email address)"
+ )
+ imap_password = models.CharField(
+ max_length=255,
+ help_text="IMAP password or app-specific password"
+ )
+ imap_folder = models.CharField(
+ max_length=100,
+ default='INBOX',
+ help_text="IMAP folder to monitor"
+ )
+
+ # SMTP settings (outbound)
+ smtp_host = models.CharField(
+ max_length=255,
+ help_text="SMTP server hostname (e.g., smtp.gmail.com)"
+ )
+ smtp_port = models.IntegerField(
+ default=587,
+ help_text="SMTP port (587 for TLS, 465 for SSL)"
+ )
+ smtp_use_tls = models.BooleanField(
+ default=True,
+ help_text="Use STARTTLS for SMTP"
+ )
+ smtp_use_ssl = models.BooleanField(
+ default=False,
+ help_text="Use SSL/TLS for SMTP (port 465)"
+ )
+ smtp_username = models.CharField(
+ max_length=255,
+ help_text="SMTP username (usually email address)"
+ )
+ smtp_password = models.CharField(
+ max_length=255,
+ help_text="SMTP password or app-specific password"
+ )
+
+ # Status and tracking
+ is_active = models.BooleanField(
+ default=True,
+ help_text="Whether this email address is actively checked"
+ )
+ is_default = models.BooleanField(
+ default=False,
+ help_text="Default email for new tickets in this business"
+ )
+ last_check_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When emails were last checked for this address"
+ )
+ last_error = models.TextField(
+ blank=True,
+ default='',
+ help_text="Last error message if any"
+ )
+ emails_processed_count = models.IntegerField(
+ default=0,
+ help_text="Total number of emails processed for this address"
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-is_default', 'display_name']
+ unique_together = [['tenant', 'email_address']]
+ indexes = [
+ models.Index(fields=['tenant', 'is_active']),
+ models.Index(fields=['email_address']),
+ ]
+ verbose_name = 'Ticket Email Address'
+ verbose_name_plural = 'Ticket Email Addresses'
+
+ def __str__(self):
+ return f"{self.display_name} <{self.email_address}> ({self.tenant.name})"
+
+ def save(self, *args, **kwargs):
+ # Ensure only one default per tenant
+ if self.is_default:
+ TicketEmailAddress.objects.filter(
+ tenant=self.tenant,
+ is_default=True
+ ).exclude(pk=self.pk).update(is_default=False)
+ super().save(*args, **kwargs)
+
+ def is_imap_configured(self):
+ """Check if IMAP settings are properly configured."""
+ return bool(self.imap_host and self.imap_username and self.imap_password)
+
+ def is_smtp_configured(self):
+ """Check if SMTP settings are properly configured."""
+ return bool(self.smtp_host and self.smtp_username and self.smtp_password)
+
+ def is_fully_configured(self):
+ """Check if both IMAP and SMTP are configured."""
+ return self.is_imap_configured() and self.is_smtp_configured()
\ No newline at end of file
diff --git a/smoothschedule/tickets/serializers.py b/smoothschedule/tickets/serializers.py
index b374ea1..5cf62c7 100644
--- a/smoothschedule/tickets/serializers.py
+++ b/smoothschedule/tickets/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
+from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
from smoothschedule.users.models import User
from core.models import Tenant
@@ -16,6 +16,19 @@ class TicketCommentSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at', 'source', 'source_display']
+
+class TicketEmailAddressListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for listing email addresses (no passwords)."""
+ class Meta:
+ model = TicketEmailAddress
+ fields = [
+ 'id', 'display_name', 'email_address', 'color',
+ 'is_active', 'is_default', 'last_check_at',
+ 'emails_processed_count', 'created_at', 'updated_at'
+ ]
+ read_only_fields = ['last_check_at', 'emails_processed_count', 'created_at', 'updated_at']
+
+
class TicketSerializer(serializers.ModelSerializer):
creator_email = serializers.ReadOnlyField(source='creator.email')
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
@@ -23,6 +36,7 @@ class TicketSerializer(serializers.ModelSerializer):
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
is_overdue = serializers.ReadOnlyField()
comments = TicketCommentSerializer(many=True, read_only=True)
+ source_email_address = TicketEmailAddressListSerializer(read_only=True)
class Meta:
model = Ticket
@@ -32,7 +46,7 @@ class TicketSerializer(serializers.ModelSerializer):
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at', 'comments',
- 'external_email', 'external_name'
+ 'external_email', 'external_name', 'source_email_address'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments',
@@ -141,90 +155,6 @@ class CannedResponseSerializer(serializers.ModelSerializer):
return super().create(validated_data)
-class TicketEmailSettingsSerializer(serializers.ModelSerializer):
- """Serializer for ticket email settings (platform-wide configuration)."""
- is_configured = serializers.SerializerMethodField()
- is_imap_configured = serializers.SerializerMethodField()
- is_smtp_configured = serializers.SerializerMethodField()
- imap_password_masked = serializers.SerializerMethodField()
- smtp_password_masked = serializers.SerializerMethodField()
-
- class Meta:
- model = TicketEmailSettings
- fields = [
- # IMAP settings
- 'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
- 'imap_password', 'imap_password_masked', 'imap_folder',
- # SMTP settings
- 'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
- 'smtp_password', 'smtp_password_masked', 'smtp_from_email', 'smtp_from_name',
- # General settings
- 'support_email_address', 'support_email_domain',
- 'is_enabled', 'delete_after_processing', 'check_interval_seconds',
- 'max_attachment_size_mb', 'allowed_attachment_types',
- # Status fields
- 'last_check_at', 'last_error', 'emails_processed_count',
- 'is_configured', 'is_imap_configured', 'is_smtp_configured',
- 'created_at', 'updated_at'
- ]
- read_only_fields = [
- 'last_check_at', 'last_error', 'emails_processed_count',
- 'is_configured', 'is_imap_configured', 'is_smtp_configured',
- 'imap_password_masked', 'smtp_password_masked',
- 'created_at', 'updated_at'
- ]
- extra_kwargs = {
- 'imap_password': {'write_only': True},
- 'smtp_password': {'write_only': True}
- }
-
- def get_is_configured(self, obj):
- return obj.is_configured()
-
- def get_is_imap_configured(self, obj):
- return obj.is_imap_configured()
-
- def get_is_smtp_configured(self, obj):
- return obj.is_smtp_configured()
-
- def get_imap_password_masked(self, obj):
- if obj.imap_password:
- return '********'
- return ''
-
- def get_smtp_password_masked(self, obj):
- if obj.smtp_password:
- return '********'
- return ''
-
-
-class TicketEmailSettingsUpdateSerializer(serializers.ModelSerializer):
- """Serializer for updating email settings (allows partial updates)."""
-
- class Meta:
- model = TicketEmailSettings
- fields = [
- # IMAP settings
- 'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
- 'imap_password', 'imap_folder',
- # SMTP settings
- 'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
- 'smtp_password', 'smtp_from_email', 'smtp_from_name',
- # General settings
- 'support_email_address', 'support_email_domain',
- 'is_enabled', 'delete_after_processing', 'check_interval_seconds',
- 'max_attachment_size_mb', 'allowed_attachment_types',
- ]
-
- def update(self, instance, validated_data):
- # Only update passwords if new ones are provided
- if 'imap_password' in validated_data and not validated_data['imap_password']:
- validated_data.pop('imap_password')
- if 'smtp_password' in validated_data and not validated_data['smtp_password']:
- validated_data.pop('smtp_password')
- return super().update(instance, validated_data)
-
-
class IncomingTicketEmailSerializer(serializers.ModelSerializer):
"""Serializer for incoming email records."""
processing_status_display = serializers.CharField(source='get_processing_status_display', read_only=True)
@@ -253,3 +183,41 @@ class IncomingTicketEmailListSerializer(serializers.ModelSerializer):
'ticket', 'processing_status', 'processing_status_display',
'email_date', 'received_at'
]
+
+
+class TicketEmailAddressSerializer(serializers.ModelSerializer):
+ """Full serializer for email addresses with all settings."""
+ tenant_name = serializers.SerializerMethodField()
+ is_imap_configured = serializers.ReadOnlyField()
+ is_smtp_configured = serializers.ReadOnlyField()
+ is_fully_configured = serializers.ReadOnlyField()
+
+ def get_tenant_name(self, obj):
+ return obj.tenant.name if obj.tenant else 'Platform'
+
+ class Meta:
+ model = TicketEmailAddress
+ fields = [
+ 'id', 'tenant', 'tenant_name', 'display_name', 'email_address', 'color',
+ 'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
+ 'imap_password', 'imap_folder',
+ 'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl',
+ 'smtp_username', 'smtp_password',
+ 'is_active', 'is_default', 'last_check_at', 'last_error',
+ 'emails_processed_count', 'created_at', 'updated_at',
+ 'is_imap_configured', 'is_smtp_configured', 'is_fully_configured'
+ ]
+ read_only_fields = ['tenant', 'tenant_name', 'last_check_at', 'last_error',
+ 'emails_processed_count', 'created_at', 'updated_at',
+ 'is_imap_configured', 'is_smtp_configured', 'is_fully_configured']
+ extra_kwargs = {
+ 'imap_password': {'write_only': True},
+ 'smtp_password': {'write_only': True},
+ }
+
+ def create(self, validated_data):
+ # Automatically set tenant from current user
+ if 'tenant' not in validated_data and self.context['request'].user.is_authenticated:
+ if hasattr(self.context['request'].user, 'tenant') and self.context['request'].user.tenant:
+ validated_data['tenant'] = self.context['request'].user.tenant
+ return super().create(validated_data)
diff --git a/smoothschedule/tickets/tasks.py b/smoothschedule/tickets/tasks.py
index 4d34762..34f920e 100644
--- a/smoothschedule/tickets/tasks.py
+++ b/smoothschedule/tickets/tasks.py
@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
def fetch_incoming_emails(self):
"""
Celery task to fetch and process incoming ticket emails.
+ Processes emails for all active email addresses (platform and business).
This task should be scheduled to run periodically (e.g., every minute)
via Celery Beat.
@@ -30,29 +31,75 @@ def fetch_incoming_emails(self):
},
}
"""
- from .email_receiver import TicketEmailReceiver
- from .models import TicketEmailSettings
+ from .email_receiver import TicketEmailReceiver, PlatformEmailReceiver
+ from .models import TicketEmailAddress
+ from platform_admin.models import PlatformEmailAddress
- # Check if email receiving is enabled
- settings = TicketEmailSettings.get_instance()
+ total_processed = 0
+ results = []
- if not settings.is_enabled:
- logger.debug("Ticket email receiving is disabled")
- return {'status': 'disabled', 'processed': 0}
+ # Process platform email addresses (default one for tickets)
+ try:
+ default_platform_email = PlatformEmailAddress.objects.filter(
+ is_active=True,
+ is_default=True
+ ).first()
- if not settings.is_configured():
- logger.warning("Ticket email settings not configured")
- return {'status': 'not_configured', 'processed': 0}
+ if default_platform_email:
+ try:
+ receiver = PlatformEmailReceiver(default_platform_email)
+ processed = receiver.fetch_and_process_emails()
+ total_processed += processed
+ results.append({
+ 'address': default_platform_email.display_name,
+ 'type': 'platform',
+ 'processed': processed,
+ 'status': 'success'
+ })
+ logger.info(f"[Platform: {default_platform_email.display_name}] Processed {processed} emails")
+ except Exception as e:
+ logger.error(f"[Platform: {default_platform_email.display_name}] Error processing emails: {e}")
+ results.append({
+ 'address': default_platform_email.display_name,
+ 'type': 'platform',
+ 'processed': 0,
+ 'status': 'error',
+ 'error': str(e)
+ })
+ except Exception as e:
+ logger.error(f"Error fetching platform email addresses: {e}")
- # Process emails
- receiver = TicketEmailReceiver()
- processed_count = receiver.fetch_and_process_emails()
+ # Process tenant/business email addresses
+ email_addresses = TicketEmailAddress.objects.filter(is_active=True)
- logger.info(f"Processed {processed_count} incoming ticket emails")
+ for email_address in email_addresses:
+ try:
+ receiver = TicketEmailReceiver(email_address)
+ processed = receiver.fetch_and_process_emails()
+ total_processed += processed
+ results.append({
+ 'address': email_address.display_name,
+ 'type': 'tenant',
+ 'processed': processed,
+ 'status': 'success'
+ })
+ logger.info(f"[Tenant: {email_address.display_name}] Processed {processed} emails")
+ except Exception as e:
+ logger.error(f"[Tenant: {email_address.display_name}] Error processing emails: {e}")
+ results.append({
+ 'address': email_address.display_name,
+ 'type': 'tenant',
+ 'processed': 0,
+ 'status': 'error',
+ 'error': str(e)
+ })
+
+ logger.info(f"Processed {total_processed} total incoming ticket emails from {len(results)} addresses")
return {
'status': 'success',
- 'processed': processed_count,
+ 'processed': total_processed,
+ 'results': results,
}
diff --git a/smoothschedule/tickets/urls.py b/smoothschedule/tickets/urls.py
index e3b7315..476bd86 100644
--- a/smoothschedule/tickets/urls.py
+++ b/smoothschedule/tickets/urls.py
@@ -3,9 +3,8 @@ from rest_framework.routers import DefaultRouter
from .views import (
TicketViewSet, TicketCommentViewSet,
TicketTemplateViewSet, CannedResponseViewSet,
- TicketEmailSettingsView, TicketEmailTestConnectionView,
- TicketEmailTestSmtpView, TicketEmailFetchNowView,
- IncomingTicketEmailViewSet, EmailProviderDetectView
+ IncomingTicketEmailViewSet, EmailProviderDetectView,
+ TicketEmailAddressViewSet, RefreshTicketEmailsView
)
app_name = 'tickets'
@@ -32,17 +31,20 @@ canned_router.register(r'', CannedResponseViewSet, basename='canned-response')
incoming_emails_router = DefaultRouter()
incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incoming-email')
-urlpatterns = [
- # Email settings endpoints (platform admin only) - must be BEFORE router.urls
- path('email-settings/', TicketEmailSettingsView.as_view(), name='email-settings'),
- path('email-settings/detect/', EmailProviderDetectView.as_view(), name='email-detect'),
- path('email-settings/test-imap/', TicketEmailTestConnectionView.as_view(), name='email-test-imap'),
- path('email-settings/test-smtp/', TicketEmailTestSmtpView.as_view(), name='email-test-smtp'),
- path('email-settings/fetch-now/', TicketEmailFetchNowView.as_view(), name='email-fetch-now'),
+# Router for email addresses
+email_addresses_router = DefaultRouter()
+email_addresses_router.register(r'', TicketEmailAddressViewSet, basename='email-address')
+urlpatterns = [
# Incoming emails audit log - must be BEFORE router.urls
path('incoming-emails/', include(incoming_emails_router.urls)),
+ # Email addresses (per-business and platform email configuration)
+ path('email-addresses/', include(email_addresses_router.urls)),
+
+ # Manual email refresh endpoint
+ path('refresh-emails/', RefreshTicketEmailsView.as_view(), name='refresh-emails'),
+
# Other static paths
path('templates/', include(templates_router.urls)),
path('canned-responses/', include(canned_router.urls)),
diff --git a/smoothschedule/tickets/views.py b/smoothschedule/tickets/views.py
index 5d7f780..fe72524 100644
--- a/smoothschedule/tickets/views.py
+++ b/smoothschedule/tickets/views.py
@@ -9,12 +9,12 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from core.models import Tenant
from smoothschedule.users.models import User
-from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
+from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
from .serializers import (
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
TicketTemplateSerializer, CannedResponseSerializer,
- TicketEmailSettingsSerializer, TicketEmailSettingsUpdateSerializer,
- IncomingTicketEmailSerializer, IncomingTicketEmailListSerializer
+ IncomingTicketEmailSerializer, IncomingTicketEmailListSerializer,
+ TicketEmailAddressSerializer, TicketEmailAddressListSerializer
)
@@ -376,167 +376,6 @@ class IsPlatformAdmin(IsAuthenticated):
return is_platform_admin(request.user)
-class TicketEmailSettingsView(APIView):
- """
- API endpoint for managing ticket email settings (inbound email configuration).
- Only accessible by platform administrators.
-
- GET: Retrieve current email settings
- PUT/PATCH: Update email settings
- """
- permission_classes = [IsPlatformAdmin]
-
- def get(self, request):
- """Get current email settings."""
- settings = TicketEmailSettings.get_instance()
- serializer = TicketEmailSettingsSerializer(settings)
- return Response(serializer.data)
-
- def put(self, request):
- """Update all email settings."""
- settings = TicketEmailSettings.get_instance()
- serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data)
- if serializer.is_valid():
- serializer.save()
- # Return full settings with read-only fields
- return Response(TicketEmailSettingsSerializer(settings).data)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
- def patch(self, request):
- """Partially update email settings."""
- settings = TicketEmailSettings.get_instance()
- serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data, partial=True)
- if serializer.is_valid():
- serializer.save()
- # Return full settings with read-only fields
- return Response(TicketEmailSettingsSerializer(settings).data)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-
-class TicketEmailTestConnectionView(APIView):
- """
- API endpoint to test IMAP connection with current settings.
- Only accessible by platform administrators.
-
- POST: Test the IMAP connection
- """
- permission_classes = [IsPlatformAdmin]
-
- def post(self, request):
- """Test IMAP connection with current settings."""
- from .email_receiver import test_imap_connection
-
- success, message = test_imap_connection()
-
- return Response({
- 'success': success,
- 'message': message,
- }, status=status.HTTP_200_OK if success else status.HTTP_400_BAD_REQUEST)
-
-
-class TicketEmailTestSmtpView(APIView):
- """
- API endpoint to test SMTP connection with current settings.
- Only accessible by platform administrators.
-
- POST: Test the SMTP connection
- """
- permission_classes = [IsPlatformAdmin]
-
- def post(self, request):
- """Test SMTP connection with current settings."""
- import smtplib
- import ssl
-
- settings = TicketEmailSettings.get_instance()
-
- if not settings.smtp_host or not settings.smtp_username or not settings.smtp_password:
- return Response({
- 'success': False,
- 'message': 'SMTP settings not configured. Please provide host, username, and password.',
- }, status=status.HTTP_400_BAD_REQUEST)
-
- try:
- if settings.smtp_use_ssl:
- # SSL connection (typically port 465)
- context = ssl.create_default_context()
- server = smtplib.SMTP_SSL(
- settings.smtp_host,
- settings.smtp_port,
- context=context,
- timeout=10
- )
- else:
- # Regular connection with optional STARTTLS
- server = smtplib.SMTP(
- settings.smtp_host,
- settings.smtp_port,
- timeout=10
- )
- server.ehlo()
- if settings.smtp_use_tls:
- context = ssl.create_default_context()
- server.starttls(context=context)
- server.ehlo()
-
- # Authenticate
- server.login(settings.smtp_username, settings.smtp_password)
- server.quit()
-
- return Response({
- 'success': True,
- 'message': f'Successfully connected to SMTP server at {settings.smtp_host}:{settings.smtp_port}',
- })
-
- except smtplib.SMTPAuthenticationError as e:
- return Response({
- 'success': False,
- 'message': f'SMTP authentication failed: {str(e)}',
- }, status=status.HTTP_400_BAD_REQUEST)
- except smtplib.SMTPConnectError as e:
- return Response({
- 'success': False,
- 'message': f'Failed to connect to SMTP server: {str(e)}',
- }, status=status.HTTP_400_BAD_REQUEST)
- except Exception as e:
- return Response({
- 'success': False,
- 'message': f'SMTP connection error: {str(e)}',
- }, status=status.HTTP_400_BAD_REQUEST)
-
-
-class TicketEmailFetchNowView(APIView):
- """
- API endpoint to manually trigger email fetch.
- Only accessible by platform administrators.
-
- POST: Trigger immediate email fetch
- """
- permission_classes = [IsPlatformAdmin]
-
- def post(self, request):
- """Manually trigger email fetch."""
- from .email_receiver import TicketEmailReceiver
-
- settings = TicketEmailSettings.get_instance()
-
- if not settings.is_imap_configured():
- return Response({
- 'success': False,
- 'message': 'IMAP settings not configured',
- 'processed': 0,
- }, status=status.HTTP_400_BAD_REQUEST)
-
- receiver = TicketEmailReceiver()
- processed_count = receiver.fetch_and_process_emails()
-
- return Response({
- 'success': True,
- 'message': f'Successfully fetched and processed {processed_count} emails',
- 'processed': processed_count,
- })
-
-
class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for viewing incoming email records (audit log).
@@ -939,4 +778,207 @@ class EmailProviderDetectView(APIView):
# Provide common default port suggestions
'suggested_imap_port': 993,
'suggested_smtp_port': 587,
+ })
+
+
+class TicketEmailAddressViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing ticket email addresses.
+ Business owners and managers can manage their own email addresses.
+ Platform admins can view all email addresses.
+ """
+ serializer_class = TicketEmailAddressSerializer
+ permission_classes = [IsTenantUser]
+ filter_backends = [SearchFilter, OrderingFilter]
+ search_fields = ['display_name', 'email_address']
+ ordering_fields = ['display_name', 'email_address', 'is_default', 'created_at']
+ ordering = ['-is_default', 'display_name']
+
+ def get_queryset(self):
+ user = self.request.user
+
+ # Platform users see platform-wide email addresses (tenant=null)
+ if is_platform_admin(user):
+ return TicketEmailAddress.objects.filter(tenant__isnull=True).select_related('tenant')
+
+ # Business users see only their own email addresses
+ if hasattr(user, 'tenant') and user.tenant:
+ # Only owners and managers can view/manage email addresses
+ if user.role in [User.Role.OWNER, User.Role.MANAGER]:
+ return TicketEmailAddress.objects.filter(tenant=user.tenant)
+
+ return TicketEmailAddress.objects.none()
+
+ def get_serializer_class(self):
+ if self.action == 'list':
+ return TicketEmailAddressListSerializer
+ return TicketEmailAddressSerializer
+
+ def perform_create(self, serializer):
+ # Set tenant based on user type
+ user = self.request.user
+ if is_platform_admin(user):
+ # Platform admins create platform-wide email addresses (tenant=None)
+ serializer.save(tenant=None)
+ elif hasattr(user, 'tenant') and user.tenant:
+ # Business users create email addresses for their business
+ serializer.save(tenant=user.tenant)
+ else:
+ # Should not happen - validation should catch this
+ serializer.save()
+
+ @action(detail=True, methods=['post'])
+ def test_imap(self, request, pk=None):
+ """Test IMAP connection for this email address."""
+ email_address = self.get_object()
+
+ # Import here to avoid circular imports
+ from .email_receiver import TicketEmailReceiver
+
+ try:
+ receiver = TicketEmailReceiver(email_address)
+ if receiver.connect():
+ receiver.disconnect()
+ return Response({
+ 'success': True,
+ 'message': 'IMAP connection successful'
+ })
+ else:
+ return Response({
+ 'success': False,
+ 'message': 'Failed to connect to IMAP server'
+ }, status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ return Response({
+ 'success': False,
+ 'message': f'IMAP connection failed: {str(e)}'
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=True, methods=['post'])
+ def test_smtp(self, request, pk=None):
+ """Test SMTP connection for this email address."""
+ email_address = self.get_object()
+
+ # Import here to avoid circular imports
+ from .email_notifications import TicketEmailService
+
+ try:
+ service = TicketEmailService()
+ # Test connection using this email address's settings
+ success = service._test_smtp_connection(email_address)
+ if success:
+ return Response({
+ 'success': True,
+ 'message': 'SMTP connection successful'
+ })
+ else:
+ return Response({
+ 'success': False,
+ 'message': 'Failed to connect to SMTP server'
+ }, status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ return Response({
+ 'success': False,
+ 'message': f'SMTP connection failed: {str(e)}'
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=True, methods=['post'])
+ def fetch_now(self, request, pk=None):
+ """Manually trigger email fetch for this address."""
+ email_address = self.get_object()
+
+ # Import here to avoid circular imports
+ from .email_receiver import TicketEmailReceiver
+
+ try:
+ receiver = TicketEmailReceiver(email_address)
+ processed = receiver.fetch_and_process_emails()
+ return Response({
+ 'success': True,
+ 'message': f'Fetched {processed} emails',
+ 'processed': processed,
+ 'errors': 0
+ })
+ except Exception as e:
+ return Response({
+ 'success': False,
+ 'message': f'Failed to fetch emails: {str(e)}'
+ }, status=status.HTTP_400_BAD_REQUEST)
+
+ @action(detail=True, methods=['post'])
+ def set_as_default(self, request, pk=None):
+ """Set this email address as the default for the business."""
+ email_address = self.get_object()
+
+ # Set this as default and unset all others for this tenant
+ TicketEmailAddress.objects.filter(
+ tenant=email_address.tenant,
+ is_default=True
+ ).exclude(pk=email_address.pk).update(is_default=False)
+
+ email_address.is_default = True
+ email_address.save()
+
+ return Response({
+ 'success': True,
+ 'message': f'{email_address.display_name} is now the default email address'
+ })
+
+
+class RefreshTicketEmailsView(APIView):
+ """
+ POST /api/tickets/refresh-emails/
+ Manually trigger a check for new incoming emails.
+ Platform admins only.
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ if not is_platform_admin(request.user):
+ return Response(
+ {'error': 'Only platform administrators can refresh emails'},
+ status=status.HTTP_403_FORBIDDEN
+ )
+
+ from .email_receiver import PlatformEmailReceiver
+ from platform_admin.models import PlatformEmailAddress
+
+ results = []
+ total_processed = 0
+
+ # Check default platform email address
+ try:
+ default_email = PlatformEmailAddress.objects.filter(
+ is_active=True,
+ is_default=True
+ ).first()
+
+ if default_email:
+ receiver = PlatformEmailReceiver(default_email)
+ processed = receiver.fetch_and_process_emails()
+ total_processed += processed
+ results.append({
+ 'address': default_email.email_address,
+ 'display_name': default_email.display_name,
+ 'processed': processed,
+ 'status': 'success',
+ 'last_check_at': default_email.last_check_at.isoformat() if default_email.last_check_at else None,
+ })
+ else:
+ results.append({
+ 'address': None,
+ 'status': 'no_default',
+ 'message': 'No default platform email address configured'
+ })
+ except Exception as e:
+ results.append({
+ 'address': 'platform',
+ 'status': 'error',
+ 'error': str(e)
+ })
+
+ return Response({
+ 'success': True,
+ 'processed': total_processed,
+ 'results': results,
})
\ No newline at end of file