"
+
+ @patch('smoothschedule.communication.staff_email.models.timezone')
+ def test_increment_use(self, mock_timezone):
+ """Test increment_use updates count and timestamp."""
+ from smoothschedule.communication.staff_email.models import EmailContactSuggestion
+
+ contact = EmailContactSuggestion.__new__(EmailContactSuggestion)
+ object.__setattr__(contact, 'use_count', 5)
+ object.__setattr__(contact, 'last_used_at', None)
+
+ now = Mock()
+ mock_timezone.now.return_value = now
+
+ with patch.object(contact, 'save'):
+ contact.increment_use()
+
+ assert contact.use_count == 6
+ assert contact.last_used_at == now
+
+ @patch('smoothschedule.communication.staff_email.models.EmailContactSuggestion.objects')
+ def test_add_or_update_contact_new(self, mock_objects):
+ """Test add_or_update_contact creates new contact."""
+ from smoothschedule.communication.staff_email.models import EmailContactSuggestion
+
+ mock_user = Mock()
+ mock_contact = Mock()
+ mock_objects.get_or_create.return_value = (mock_contact, True)
+
+ result = EmailContactSuggestion.add_or_update_contact(
+ mock_user,
+ "test@example.com",
+ "Test User"
+ )
+
+ mock_objects.get_or_create.assert_called_once_with(
+ user=mock_user,
+ email="test@example.com",
+ defaults={'name': "Test User"}
+ )
+ assert result == mock_contact
+
+ @patch('smoothschedule.communication.staff_email.models.EmailContactSuggestion.objects')
+ def test_add_or_update_contact_existing(self, mock_objects):
+ """Test add_or_update_contact updates existing contact."""
+ from smoothschedule.communication.staff_email.models import EmailContactSuggestion
+
+ mock_user = Mock()
+ mock_contact = Mock()
+ mock_contact.name = ""
+ mock_objects.get_or_create.return_value = (mock_contact, False)
+
+ result = EmailContactSuggestion.add_or_update_contact(
+ mock_user,
+ "test@example.com",
+ "Test User"
+ )
+
+ mock_contact.increment_use.assert_called_once()
+ # Should update name if it was empty
+ assert mock_contact.name == "Test User"
+
+ @patch('smoothschedule.communication.staff_email.models.EmailContactSuggestion.objects')
+ def test_add_or_update_contact_lowercase_email(self, mock_objects):
+ """Test email is lowercased when adding contact."""
+ from smoothschedule.communication.staff_email.models import EmailContactSuggestion
+
+ mock_user = Mock()
+ mock_objects.get_or_create.return_value = (Mock(), True)
+
+ EmailContactSuggestion.add_or_update_contact(
+ mock_user,
+ "TEST@EXAMPLE.COM",
+ "Test"
+ )
+
+ mock_objects.get_or_create.assert_called_once_with(
+ user=mock_user,
+ email="test@example.com",
+ defaults={'name': "Test"}
+ )
+
+
+class TestActiveStaffEmailManager:
+ """Tests for ActiveStaffEmailManager."""
+
+ @patch('smoothschedule.communication.staff_email.models.models.Manager.get_queryset')
+ def test_excludes_deleted_emails(self, mock_get_queryset):
+ """Test manager excludes permanently deleted emails."""
+ from smoothschedule.communication.staff_email.models import ActiveStaffEmailManager
+
+ mock_qs = Mock()
+ mock_get_queryset.return_value = mock_qs
+
+ manager = ActiveStaffEmailManager()
+ manager.model = Mock()
+
+ result = manager.get_queryset()
+
+ mock_qs.filter.assert_called_with(is_permanently_deleted=False)
diff --git a/smoothschedule/smoothschedule/communication/staff_email/tests/test_serializers.py b/smoothschedule/smoothschedule/communication/staff_email/tests/test_serializers.py
new file mode 100644
index 00000000..35d5d234
--- /dev/null
+++ b/smoothschedule/smoothschedule/communication/staff_email/tests/test_serializers.py
@@ -0,0 +1,500 @@
+"""
+Unit tests for Staff Email serializers.
+
+Tests serializer validation and data transformation.
+"""
+from unittest.mock import Mock, patch, MagicMock
+from datetime import datetime
+
+import pytest
+from rest_framework.exceptions import ValidationError
+
+
+class TestStaffEmailFolderSerializer:
+ """Tests for StaffEmailFolderSerializer."""
+
+ def test_serializer_fields(self):
+ """Test serializer includes expected fields."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailFolderSerializer
+
+ serializer = StaffEmailFolderSerializer()
+ expected_fields = [
+ 'id', 'name', 'folder_type', 'parent', 'color',
+ 'icon', 'display_order', 'unread_count', 'total_count', 'created_at'
+ ]
+
+ for field in expected_fields:
+ assert field in serializer.fields
+
+ def test_read_only_fields(self):
+ """Test read-only fields are correctly set."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailFolderSerializer
+
+ serializer = StaffEmailFolderSerializer()
+
+ assert serializer.fields['id'].read_only is True
+ assert serializer.fields['unread_count'].read_only is True
+ assert serializer.fields['total_count'].read_only is True
+ assert serializer.fields['created_at'].read_only is True
+
+
+
+class TestStaffEmailAttachmentSerializer:
+ """Tests for StaffEmailAttachmentSerializer."""
+
+ def test_serializer_fields(self):
+ """Test serializer includes expected fields."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailAttachmentSerializer
+
+ serializer = StaffEmailAttachmentSerializer()
+ expected_fields = [
+ 'id', 'filename', 'content_type', 'size', 'size_display',
+ 'is_inline', 'content_id', 'download_url', 'created_at'
+ ]
+
+ for field in expected_fields:
+ assert field in serializer.fields
+
+ def test_get_download_url_with_request(self):
+ """Test download_url is generated from request."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailAttachmentSerializer
+
+ mock_request = Mock()
+ mock_request.build_absolute_uri.return_value = 'http://example.com/api/staff-email/attachments/1/download/'
+
+ mock_attachment = Mock()
+ mock_attachment.id = 1
+
+ serializer = StaffEmailAttachmentSerializer(context={'request': mock_request})
+ url = serializer.get_download_url(mock_attachment)
+
+ mock_request.build_absolute_uri.assert_called_once_with(
+ '/api/staff-email/attachments/1/download/'
+ )
+ assert url == 'http://example.com/api/staff-email/attachments/1/download/'
+
+ def test_get_download_url_without_request(self):
+ """Test download_url returns None without request."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailAttachmentSerializer
+
+ mock_attachment = Mock()
+ mock_attachment.id = 1
+
+ serializer = StaffEmailAttachmentSerializer()
+ url = serializer.get_download_url(mock_attachment)
+
+ assert url is None
+
+
+class TestStaffEmailLabelSerializer:
+ """Tests for StaffEmailLabelSerializer."""
+
+ def test_serializer_fields(self):
+ """Test serializer includes expected fields."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailLabelSerializer
+
+ serializer = StaffEmailLabelSerializer()
+ expected_fields = ['id', 'name', 'color', 'email_count', 'created_at']
+
+ for field in expected_fields:
+ assert field in serializer.fields
+
+ def test_get_email_count(self):
+ """Test email_count returns assignment count."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailLabelSerializer
+
+ mock_label = Mock()
+ mock_label.email_assignments = Mock()
+ mock_label.email_assignments.count.return_value = 5
+
+ serializer = StaffEmailLabelSerializer()
+ count = serializer.get_email_count(mock_label)
+
+ assert count == 5
+
+
+
+class TestStaffEmailListSerializer:
+ """Tests for StaffEmailListSerializer."""
+
+ def test_serializer_fields(self):
+ """Test serializer includes expected fields."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailListSerializer
+
+ serializer = StaffEmailListSerializer()
+ expected_fields = [
+ 'id', 'folder', 'folder_name', 'folder_type', 'message_id',
+ 'thread_id', 'from_address', 'from_name', 'to_addresses',
+ 'cc_addresses', 'subject', 'snippet', 'status', 'is_read',
+ 'is_starred', 'is_important', 'is_answered', 'has_attachments',
+ 'attachments_count', 'thread_count', 'labels', 'email_date', 'created_at'
+ ]
+
+ for field in expected_fields:
+ assert field in serializer.fields
+
+ def test_all_fields_read_only(self):
+ """Test all fields are read-only for list serializer."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailListSerializer
+
+ serializer = StaffEmailListSerializer()
+
+ # All fields should be read-only
+ for field_name, field in serializer.fields.items():
+ assert field.read_only is True, f"{field_name} should be read-only"
+
+ def test_get_attachments_count(self):
+ """Test attachments_count returns correct count."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailListSerializer
+
+ mock_email = Mock()
+ mock_email.attachments = Mock()
+ mock_email.attachments.count.return_value = 3
+
+ serializer = StaffEmailListSerializer()
+ count = serializer.get_attachments_count(mock_email)
+
+ assert count == 3
+
+ @patch('smoothschedule.communication.staff_email.serializers.StaffEmailLabelSerializer')
+ def test_get_labels(self, mock_label_serializer):
+ """Test labels returns serialized label data."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailListSerializer
+
+ mock_label = Mock()
+ mock_assignment = Mock()
+ mock_assignment.label = mock_label
+
+ mock_email = Mock()
+ mock_email.label_assignments = Mock()
+ mock_email.label_assignments.select_related.return_value.all.return_value = [mock_assignment]
+
+ mock_label_serializer.return_value.data = [{'name': 'Test Label'}]
+
+ serializer = StaffEmailListSerializer()
+ result = serializer.get_labels(mock_email)
+
+ mock_label_serializer.assert_called_once()
+
+
+class TestStaffEmailDetailSerializer:
+ """Tests for StaffEmailDetailSerializer."""
+
+ def test_serializer_has_body_fields(self):
+ """Test detail serializer includes body content."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailDetailSerializer
+
+ serializer = StaffEmailDetailSerializer()
+
+ assert 'body_text' in serializer.fields
+ assert 'body_html' in serializer.fields
+ assert 'attachments' in serializer.fields
+
+ @patch('smoothschedule.communication.staff_email.serializers.StaffEmail.objects')
+ @patch('smoothschedule.communication.staff_email.serializers.StaffEmailListSerializer')
+ def test_get_thread_emails(self, mock_list_serializer, mock_objects):
+ """Test thread_emails returns other emails in thread."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailDetailSerializer
+
+ mock_email = Mock()
+ mock_email.id = 1
+ mock_email.thread_id = "thread-123"
+ mock_email.owner = Mock()
+
+ mock_thread_emails = [Mock(), Mock()]
+ mock_queryset = Mock()
+ mock_queryset.exclude.return_value.order_by.return_value = mock_thread_emails
+ mock_objects.filter.return_value = mock_queryset
+
+ mock_list_serializer.return_value.data = [{'id': 2}, {'id': 3}]
+
+ serializer = StaffEmailDetailSerializer()
+ result = serializer.get_thread_emails(mock_email)
+
+ mock_objects.filter.assert_called_once_with(
+ owner=mock_email.owner,
+ thread_id="thread-123"
+ )
+ mock_queryset.exclude.assert_called_once_with(id=1)
+
+ def test_get_thread_emails_no_thread(self):
+ """Test thread_emails returns empty list when no thread_id."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailDetailSerializer
+
+ mock_email = Mock()
+ mock_email.thread_id = ""
+
+ serializer = StaffEmailDetailSerializer()
+ result = serializer.get_thread_emails(mock_email)
+
+ assert result == []
+
+
+class TestEmailAddressSerializer:
+ """Tests for EmailAddressSerializer."""
+
+ def test_validates_email_format(self):
+ """Test email field validates format."""
+ from smoothschedule.communication.staff_email.serializers import EmailAddressSerializer
+
+ serializer = EmailAddressSerializer(data={'email': 'invalid-email'})
+ assert serializer.is_valid() is False
+ assert 'email' in serializer.errors
+
+ def test_valid_email_address(self):
+ """Test valid email address data."""
+ from smoothschedule.communication.staff_email.serializers import EmailAddressSerializer
+
+ data = {'email': 'test@example.com', 'name': 'Test User'}
+ serializer = EmailAddressSerializer(data=data)
+
+ assert serializer.is_valid() is True
+ assert serializer.validated_data['email'] == 'test@example.com'
+ assert serializer.validated_data['name'] == 'Test User'
+
+ def test_name_is_optional(self):
+ """Test name field is optional."""
+ from smoothschedule.communication.staff_email.serializers import EmailAddressSerializer
+
+ data = {'email': 'test@example.com'}
+ serializer = EmailAddressSerializer(data=data)
+
+ assert serializer.is_valid() is True
+
+
+class TestStaffEmailCreateSerializer:
+ """Tests for StaffEmailCreateSerializer."""
+
+ def test_validate_email_address_wrong_user(self):
+ """Test validation fails if user doesn't own email address."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailCreateSerializer
+ from rest_framework.exceptions import ValidationError
+
+ mock_request = Mock()
+ mock_request.user = Mock(id=1)
+
+ mock_email_address = Mock()
+ mock_email_address.assigned_user = Mock(id=2) # Different user
+
+ serializer = StaffEmailCreateSerializer(context={'request': mock_request})
+
+ with pytest.raises(ValidationError) as exc_info:
+ serializer.validate_email_address(mock_email_address)
+
+ assert "don't have access" in str(exc_info.value)
+
+ def test_validate_email_address_correct_user(self):
+ """Test validation passes for correct user."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailCreateSerializer
+
+ mock_request = Mock()
+ mock_user = Mock(id=1)
+ mock_request.user = mock_user
+
+ mock_email_address = Mock()
+ mock_email_address.assigned_user = mock_user # Same user
+
+ serializer = StaffEmailCreateSerializer(context={'request': mock_request})
+ result = serializer.validate_email_address(mock_email_address)
+
+ assert result == mock_email_address
+
+
+ def test_update_prevents_sent_email_changes(self):
+ """Test update raises error for sent emails."""
+ from smoothschedule.communication.staff_email.serializers import StaffEmailCreateSerializer
+ from smoothschedule.communication.staff_email.models import StaffEmail
+ from rest_framework.exceptions import ValidationError
+
+ mock_instance = Mock()
+ mock_instance.status = StaffEmail.Status.SENT
+
+ serializer = StaffEmailCreateSerializer()
+
+ with pytest.raises(ValidationError) as exc_info:
+ serializer.update(mock_instance, {})
+
+ assert "Cannot update sent emails" in str(exc_info.value)
+
+
+class TestBulkEmailActionSerializer:
+ """Tests for BulkEmailActionSerializer."""
+
+ def test_valid_bulk_action(self):
+ """Test valid bulk action data."""
+ from smoothschedule.communication.staff_email.serializers import BulkEmailActionSerializer
+
+ data = {
+ 'email_ids': [1, 2, 3],
+ 'action': 'read'
+ }
+ serializer = BulkEmailActionSerializer(data=data)
+
+ assert serializer.is_valid() is True
+
+ def test_invalid_action(self):
+ """Test invalid action is rejected."""
+ from smoothschedule.communication.staff_email.serializers import BulkEmailActionSerializer
+
+ data = {
+ 'email_ids': [1],
+ 'action': 'invalid_action'
+ }
+ serializer = BulkEmailActionSerializer(data=data)
+
+ assert serializer.is_valid() is False
+ assert 'action' in serializer.errors
+
+ def test_empty_email_ids(self):
+ """Test empty email_ids is rejected."""
+ from smoothschedule.communication.staff_email.serializers import BulkEmailActionSerializer
+
+ data = {
+ 'email_ids': [],
+ 'action': 'read'
+ }
+ serializer = BulkEmailActionSerializer(data=data)
+
+ assert serializer.is_valid() is False
+ assert 'email_ids' in serializer.errors
+
+ def test_too_many_email_ids(self):
+ """Test too many email_ids is rejected."""
+ from smoothschedule.communication.staff_email.serializers import BulkEmailActionSerializer
+
+ data = {
+ 'email_ids': list(range(101)), # 101 IDs
+ 'action': 'read'
+ }
+ serializer = BulkEmailActionSerializer(data=data)
+
+ assert serializer.is_valid() is False
+
+
+class TestReplyEmailSerializer:
+ """Tests for ReplyEmailSerializer."""
+
+ def test_valid_reply(self):
+ """Test valid reply data."""
+ from smoothschedule.communication.staff_email.serializers import ReplyEmailSerializer
+
+ data = {
+ 'body_html': 'Reply content
',
+ 'reply_all': False
+ }
+ serializer = ReplyEmailSerializer(data=data)
+
+ assert serializer.is_valid() is True
+
+ def test_body_html_required(self):
+ """Test body_html is required."""
+ from smoothschedule.communication.staff_email.serializers import ReplyEmailSerializer
+
+ data = {'reply_all': False}
+ serializer = ReplyEmailSerializer(data=data)
+
+ assert serializer.is_valid() is False
+ assert 'body_html' in serializer.errors
+
+
+class TestForwardEmailSerializer:
+ """Tests for ForwardEmailSerializer."""
+
+ def test_valid_forward(self):
+ """Test valid forward data."""
+ from smoothschedule.communication.staff_email.serializers import ForwardEmailSerializer
+
+ data = {
+ 'to_addresses': [{'email': 'recipient@example.com', 'name': 'Recipient'}],
+ 'body_html': 'Forward content
',
+ 'include_attachments': True
+ }
+ serializer = ForwardEmailSerializer(data=data)
+
+ assert serializer.is_valid() is True
+
+ def test_to_addresses_required(self):
+ """Test to_addresses is required."""
+ from smoothschedule.communication.staff_email.serializers import ForwardEmailSerializer
+
+ data = {
+ 'body_html': 'Forward content
'
+ }
+ serializer = ForwardEmailSerializer(data=data)
+
+ assert serializer.is_valid() is False
+ assert 'to_addresses' in serializer.errors
+
+
+class TestMoveEmailSerializer:
+ """Tests for MoveEmailSerializer."""
+
+ def test_valid_move(self):
+ """Test valid move data."""
+ from smoothschedule.communication.staff_email.serializers import MoveEmailSerializer
+
+ data = {'folder_id': 1}
+ serializer = MoveEmailSerializer(data=data)
+
+ # Note: validation requires request context
+ assert 'folder_id' in serializer.fields
+
+ @patch('smoothschedule.communication.staff_email.serializers.StaffEmailFolder.objects')
+ def test_validate_folder_id_not_found(self, mock_objects):
+ """Test folder_id validation fails for non-existent folder."""
+ from smoothschedule.communication.staff_email.serializers import MoveEmailSerializer
+ from smoothschedule.communication.staff_email.models import StaffEmailFolder
+ from rest_framework.exceptions import ValidationError
+
+ mock_request = Mock()
+ mock_request.user = Mock(id=1)
+
+ mock_objects.get.side_effect = StaffEmailFolder.DoesNotExist
+
+ serializer = MoveEmailSerializer(context={'request': mock_request})
+
+ with pytest.raises(ValidationError) as exc_info:
+ serializer.validate_folder_id(999)
+
+ assert "Folder not found" in str(exc_info.value)
+
+ @patch('smoothschedule.communication.staff_email.serializers.StaffEmailFolder.objects')
+ def test_validate_folder_id_found(self, mock_objects):
+ """Test folder_id validation passes for existing folder."""
+ from smoothschedule.communication.staff_email.serializers import MoveEmailSerializer
+
+ mock_request = Mock()
+ mock_request.user = Mock(id=1)
+
+ mock_folder = Mock()
+ mock_objects.get.return_value = mock_folder
+
+ serializer = MoveEmailSerializer(context={'request': mock_request})
+ result = serializer.validate_folder_id(1)
+
+ assert result == 1
+
+
+class TestEmailContactSuggestionSerializer:
+ """Tests for EmailContactSuggestionSerializer."""
+
+ def test_serializer_fields(self):
+ """Test serializer includes expected fields."""
+ from smoothschedule.communication.staff_email.serializers import EmailContactSuggestionSerializer
+
+ serializer = EmailContactSuggestionSerializer()
+ expected_fields = [
+ 'id', 'email', 'name', 'is_platform_user', 'use_count', 'last_used_at'
+ ]
+
+ for field in expected_fields:
+ assert field in serializer.fields
+
+ def test_all_fields_read_only(self):
+ """Test all fields are read-only."""
+ from smoothschedule.communication.staff_email.serializers import EmailContactSuggestionSerializer
+
+ serializer = EmailContactSuggestionSerializer()
+
+ for field_name, field in serializer.fields.items():
+ assert field.read_only is True
diff --git a/smoothschedule/smoothschedule/communication/staff_email/tests/test_services.py b/smoothschedule/smoothschedule/communication/staff_email/tests/test_services.py
new file mode 100644
index 00000000..558ff9ff
--- /dev/null
+++ b/smoothschedule/smoothschedule/communication/staff_email/tests/test_services.py
@@ -0,0 +1,167 @@
+"""
+Unit tests for Staff Email services (IMAP and SMTP).
+
+Tests service logic using mocks to avoid actual email server connections.
+"""
+from unittest.mock import Mock, patch, MagicMock
+from datetime import datetime
+import pytest
+
+
+class TestStaffEmailImapServiceParsing:
+ """Tests for IMAP service parsing utilities."""
+
+ def test_html_to_text_strips_tags(self):
+ """Test HTML to text conversion strips tags."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ service = StaffEmailImapService.__new__(StaffEmailImapService)
+
+ html = "Hello World!
"
+ text = service._html_to_text(html)
+
+ assert '' not in text
+ assert '' not in text
+ assert 'Hello' in text
+ assert 'World' in text
+
+ def test_html_to_text_handles_empty(self):
+ """Test HTML to text handles empty input."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ service = StaffEmailImapService.__new__(StaffEmailImapService)
+
+ text = service._html_to_text("")
+ assert text == ""
+
+ def test_html_to_text_handles_complex_html(self):
+ """Test HTML to text handles complex HTML structures."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ service = StaffEmailImapService.__new__(StaffEmailImapService)
+
+ html = """
+
+ Test
+
+ Header
+ Paragraph with link
+
+
+
+ """
+ text = service._html_to_text(html)
+
+ assert '' not in text
+ assert '' not in text
+ assert 'Header' in text
+ assert 'Paragraph' in text
+ assert 'link' in text
+
+ def test_html_to_text_removes_scripts(self):
+ """Test HTML to text removes script tags."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ service = StaffEmailImapService.__new__(StaffEmailImapService)
+
+ html = 'Before
After
'
+ text = service._html_to_text(html)
+
+ assert 'alert' not in text
+ assert 'Before' in text
+ assert 'After' in text
+
+ def test_html_to_text_removes_styles(self):
+ """Test HTML to text removes style tags."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ service = StaffEmailImapService.__new__(StaffEmailImapService)
+
+ html = 'Content
'
+ text = service._html_to_text(html)
+
+ assert 'color' not in text
+ assert 'Content' in text
+
+
+class TestImapConnectionBasics:
+ """Basic tests for IMAP service initialization."""
+
+ def test_service_stores_email_address(self):
+ """Test service stores the email address."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ mock_email_address = Mock()
+ mock_email_address.imap_server = 'imap.example.com'
+ mock_email_address.imap_port = 993
+
+ service = StaffEmailImapService(mock_email_address)
+
+ assert service.email_address == mock_email_address
+
+ def test_service_initializes_disconnected(self):
+ """Test service starts without connection."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ mock_email_address = Mock()
+ mock_email_address.imap_server = 'imap.example.com'
+ mock_email_address.imap_port = 993
+
+ service = StaffEmailImapService(mock_email_address)
+
+ assert service.connection is None
+
+ def test_disconnect_handles_no_connection(self):
+ """Test disconnect handles case when not connected."""
+ from smoothschedule.communication.staff_email.imap_service import StaffEmailImapService
+
+ mock_email_address = Mock()
+ mock_email_address.imap_server = 'imap.example.com'
+ mock_email_address.imap_port = 993
+
+ service = StaffEmailImapService(mock_email_address)
+ # Don't connect, just try to disconnect - should not raise
+ service.disconnect()
+
+
+class TestSmtpConnectionBasics:
+ """Basic tests for SMTP service initialization."""
+
+ def test_service_stores_email_address(self):
+ """Test service stores the email address."""
+ from smoothschedule.communication.staff_email.smtp_service import StaffEmailSmtpService
+
+ mock_email_address = Mock()
+ mock_email_address.smtp_server = 'smtp.example.com'
+ mock_email_address.smtp_port = 465
+
+ service = StaffEmailSmtpService(mock_email_address)
+
+ assert service.email_address == mock_email_address
+
+ def test_service_initializes_disconnected(self):
+ """Test service starts without connection."""
+ from smoothschedule.communication.staff_email.smtp_service import StaffEmailSmtpService
+
+ mock_email_address = Mock()
+ mock_email_address.smtp_server = 'smtp.example.com'
+ mock_email_address.smtp_port = 465
+
+ service = StaffEmailSmtpService(mock_email_address)
+
+ assert service.connection is None
+
+ def test_disconnect_handles_no_connection(self):
+ """Test disconnect handles case when not connected."""
+ from smoothschedule.communication.staff_email.smtp_service import StaffEmailSmtpService
+
+ mock_email_address = Mock()
+ mock_email_address.smtp_server = 'smtp.example.com'
+ mock_email_address.smtp_port = 465
+
+ service = StaffEmailSmtpService(mock_email_address)
+ # Don't connect, just try to disconnect - should not raise
+ service.disconnect()
diff --git a/smoothschedule/smoothschedule/communication/staff_email/tests/test_views.py b/smoothschedule/smoothschedule/communication/staff_email/tests/test_views.py
new file mode 100644
index 00000000..34021e62
--- /dev/null
+++ b/smoothschedule/smoothschedule/communication/staff_email/tests/test_views.py
@@ -0,0 +1,298 @@
+"""
+Unit tests for Staff Email API views.
+
+Tests API endpoints using mocks to avoid database operations.
+"""
+from unittest.mock import Mock, patch, MagicMock
+from datetime import datetime
+
+import pytest
+from rest_framework.test import APIRequestFactory
+from rest_framework import status
+
+
+class TestStaffEmailFolderViewSet:
+ """Tests for StaffEmailFolderViewSet."""
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmailFolder.objects')
+ def test_get_queryset_filters_by_user(self, mock_objects):
+ """Test queryset is filtered by authenticated user."""
+ from smoothschedule.communication.staff_email.views import StaffEmailFolderViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailFolderViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+
+ result = viewset.get_queryset()
+
+ mock_objects.filter.assert_called_once_with(user=mock_user)
+
+
+class TestStaffEmailViewSet:
+ """Tests for StaffEmailViewSet."""
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmail.objects')
+ def test_get_queryset_filters_by_owner(self, mock_objects):
+ """Test queryset filters emails by owner."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_queryset.select_related.return_value = mock_queryset
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+ viewset.request.query_params = {}
+ viewset.action = 'list'
+
+ result = viewset.get_queryset()
+
+ mock_objects.filter.assert_called_once_with(owner=mock_user)
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmail.objects')
+ def test_get_queryset_filters_by_folder(self, mock_objects):
+ """Test queryset filters by folder_id param."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_queryset.select_related.return_value = mock_queryset
+ mock_queryset.filter.return_value = mock_queryset
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+ viewset.request.query_params = {'folder': '5'}
+ viewset.action = 'list'
+
+ result = viewset.get_queryset()
+
+ mock_queryset.filter.assert_any_call(folder_id='5')
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmail.objects')
+ def test_get_queryset_filters_unread(self, mock_objects):
+ """Test queryset filters unread emails."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_queryset.select_related.return_value = mock_queryset
+ mock_queryset.filter.return_value = mock_queryset
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+ viewset.request.query_params = {'is_unread': 'true'}
+ viewset.action = 'list'
+
+ result = viewset.get_queryset()
+
+ mock_queryset.filter.assert_any_call(is_read=False)
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmail.objects')
+ def test_get_queryset_filters_starred(self, mock_objects):
+ """Test queryset filters starred emails."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_queryset.select_related.return_value = mock_queryset
+ mock_queryset.filter.return_value = mock_queryset
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+ viewset.request.query_params = {'is_starred': 'true'}
+ viewset.action = 'list'
+
+ result = viewset.get_queryset()
+
+ mock_queryset.filter.assert_any_call(is_starred=True)
+
+ def test_get_serializer_class_list(self):
+ """Test list action uses list serializer."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+ from smoothschedule.communication.staff_email.serializers import StaffEmailListSerializer
+
+ viewset = StaffEmailViewSet()
+ viewset.action = 'list'
+
+ serializer_class = viewset.get_serializer_class()
+
+ assert serializer_class == StaffEmailListSerializer
+
+ def test_get_serializer_class_create(self):
+ """Test create action uses create serializer."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+ from smoothschedule.communication.staff_email.serializers import StaffEmailCreateSerializer
+
+ viewset = StaffEmailViewSet()
+ viewset.action = 'create'
+
+ serializer_class = viewset.get_serializer_class()
+
+ assert serializer_class == StaffEmailCreateSerializer
+
+ def test_get_serializer_class_retrieve(self):
+ """Test retrieve action uses detail serializer."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+ from smoothschedule.communication.staff_email.serializers import StaffEmailDetailSerializer
+
+ viewset = StaffEmailViewSet()
+ viewset.action = 'retrieve'
+
+ serializer_class = viewset.get_serializer_class()
+
+ assert serializer_class == StaffEmailDetailSerializer
+
+
+class TestMarkReadAction:
+ """Tests for mark_read action."""
+
+ def test_mark_read_updates_email(self):
+ """Test mark_read action marks email as read."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_email = Mock()
+ mock_email.is_read = False
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+
+ response = viewset.mark_read(viewset.request)
+
+ mock_email.mark_as_read.assert_called_once()
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestMarkUnreadAction:
+ """Tests for mark_unread action."""
+
+ def test_mark_unread_updates_email(self):
+ """Test mark_unread action marks email as unread."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_email = Mock()
+ mock_email.is_read = True
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+
+ response = viewset.mark_unread(viewset.request)
+
+ mock_email.mark_as_unread.assert_called_once()
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestArchiveAction:
+ """Tests for archive action."""
+
+ def test_archive_moves_to_archive(self):
+ """Test archive action archives the email."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_email = Mock()
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+
+ response = viewset.archive(viewset.request)
+
+ mock_email.archive.assert_called_once()
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestTrashAction:
+ """Tests for trash action."""
+
+ def test_trash_moves_to_trash(self):
+ """Test trash action moves email to trash."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_email = Mock()
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+
+ response = viewset.trash(viewset.request)
+
+ mock_email.move_to_trash.assert_called_once()
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestMoveAction:
+ """Tests for move action."""
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmailFolder.objects')
+ def test_move_changes_folder(self, mock_folder_objects):
+ """Test move action moves email to specified folder."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+
+ mock_email = Mock()
+ mock_folder = Mock()
+ mock_folder_objects.get.return_value = mock_folder
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+ viewset.request.user = Mock(id=1)
+ viewset.request.data = {'folder_id': 5}
+
+ response = viewset.move(viewset.request)
+
+ mock_email.move_to_folder.assert_called_once_with(mock_folder)
+ assert response.status_code == status.HTTP_200_OK
+
+
+class TestSendAction:
+ """Tests for send action."""
+
+ def test_send_rejects_non_draft(self):
+ """Test send action rejects non-draft emails."""
+ from smoothschedule.communication.staff_email.views import StaffEmailViewSet
+ from smoothschedule.communication.staff_email.models import StaffEmail
+
+ mock_email = Mock()
+ mock_email.status = StaffEmail.Status.SENT
+
+ viewset = StaffEmailViewSet()
+ viewset.get_object = Mock(return_value=mock_email)
+ viewset.request = Mock()
+
+ response = viewset.send(viewset.request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+
+class TestStaffEmailLabelViewSet:
+ """Tests for StaffEmailLabelViewSet."""
+
+ @patch('smoothschedule.communication.staff_email.views.StaffEmailLabel.objects')
+ def test_get_queryset_filters_by_user(self, mock_objects):
+ """Test queryset is filtered by user."""
+ from smoothschedule.communication.staff_email.views import StaffEmailLabelViewSet
+
+ mock_user = Mock(id=1)
+ mock_queryset = Mock()
+ mock_objects.filter.return_value = mock_queryset
+
+ viewset = StaffEmailLabelViewSet()
+ viewset.request = Mock()
+ viewset.request.user = mock_user
+
+ result = viewset.get_queryset()
+
+ mock_objects.filter.assert_called_once_with(user=mock_user)
diff --git a/smoothschedule/smoothschedule/communication/staff_email/urls.py b/smoothschedule/smoothschedule/communication/staff_email/urls.py
new file mode 100644
index 00000000..68369e19
--- /dev/null
+++ b/smoothschedule/smoothschedule/communication/staff_email/urls.py
@@ -0,0 +1,26 @@
+"""
+URL Configuration for Staff Email API
+"""
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+
+from .views import (
+ StaffEmailFolderViewSet,
+ StaffEmailViewSet,
+ StaffEmailLabelViewSet,
+ EmailContactSuggestionViewSet,
+ StaffEmailAttachmentViewSet,
+)
+
+app_name = 'staff_email'
+
+router = DefaultRouter()
+router.register(r'folders', StaffEmailFolderViewSet, basename='folder')
+router.register(r'messages', StaffEmailViewSet, basename='message')
+router.register(r'labels', StaffEmailLabelViewSet, basename='label')
+router.register(r'contacts', EmailContactSuggestionViewSet, basename='contact')
+router.register(r'attachments', StaffEmailAttachmentViewSet, basename='attachment')
+
+urlpatterns = [
+ path('', include(router.urls)),
+]
diff --git a/smoothschedule/smoothschedule/communication/staff_email/views.py b/smoothschedule/smoothschedule/communication/staff_email/views.py
new file mode 100644
index 00000000..026fb04f
--- /dev/null
+++ b/smoothschedule/smoothschedule/communication/staff_email/views.py
@@ -0,0 +1,664 @@
+"""
+ViewSets for Staff Email API
+
+Provides REST API endpoints for the platform staff email client.
+"""
+from rest_framework import viewsets, status, permissions
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework.parsers import MultiPartParser, FormParser
+from rest_framework.pagination import PageNumberPagination
+from django.db import models
+from django.shortcuts import get_object_or_404
+
+
+class StaffEmailPagination(PageNumberPagination):
+ """Pagination for staff email list."""
+ page_size = 50
+ page_size_query_param = 'page_size'
+ max_page_size = 100
+
+from .models import (
+ StaffEmail,
+ StaffEmailFolder,
+ StaffEmailAttachment,
+ StaffEmailLabel,
+ StaffEmailLabelAssignment,
+ EmailContactSuggestion,
+)
+from .serializers import (
+ StaffEmailFolderSerializer,
+ StaffEmailListSerializer,
+ StaffEmailDetailSerializer,
+ StaffEmailCreateSerializer,
+ StaffEmailAttachmentSerializer,
+ StaffEmailLabelSerializer,
+ EmailContactSuggestionSerializer,
+ BulkEmailActionSerializer,
+ ReplyEmailSerializer,
+ ForwardEmailSerializer,
+ MoveEmailSerializer,
+)
+from smoothschedule.identity.users.models import User
+
+
+class IsPlatformUser(permissions.BasePermission):
+ """Permission class that allows only platform users."""
+
+ def has_permission(self, request, view):
+ if not request.user.is_authenticated:
+ return False
+ return request.user.role in [
+ User.Role.SUPERUSER,
+ User.Role.PLATFORM_MANAGER,
+ User.Role.PLATFORM_SUPPORT,
+ ]
+
+
+class StaffEmailFolderViewSet(viewsets.ModelViewSet):
+ """
+ API for managing email folders.
+
+ GET /api/staff-email/folders/ - List all folders
+ POST /api/staff-email/folders/ - Create custom folder
+ PATCH /api/staff-email/folders/{id}/ - Update folder
+ DELETE /api/staff-email/folders/{id}/ - Delete custom folder
+ """
+ serializer_class = StaffEmailFolderSerializer
+ permission_classes = [permissions.IsAuthenticated, IsPlatformUser]
+
+ def get_queryset(self):
+ return StaffEmailFolder.objects.filter(user=self.request.user)
+
+ def perform_create(self, serializer):
+ # Ensure default folders exist
+ StaffEmailFolder.create_default_folders(self.request.user)
+ serializer.save()
+
+ def destroy(self, request, *args, **kwargs):
+ instance = self.get_object()
+
+ # Don't allow deleting system folders
+ if instance.folder_type != StaffEmailFolder.FolderType.CUSTOM:
+ return Response(
+ {'error': 'Cannot delete system folders'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Move emails to inbox before deleting folder
+ inbox = StaffEmailFolder.get_or_create_folder(
+ request.user,
+ StaffEmailFolder.FolderType.INBOX
+ )
+ StaffEmail.objects.filter(folder=instance).update(folder=inbox)
+
+ return super().destroy(request, *args, **kwargs)
+
+
+class StaffEmailViewSet(viewsets.ModelViewSet):
+ """
+ API for managing emails.
+
+ GET /api/staff-email/messages/?folder_id=1&is_unread=true - List emails
+ GET /api/staff-email/messages/{id}/ - Get email detail
+ POST /api/staff-email/messages/ - Create draft
+ PATCH /api/staff-email/messages/{id}/ - Update draft
+ DELETE /api/staff-email/messages/{id}/ - Archive email
+
+ Custom actions:
+ POST /send/ - Send draft
+ POST /reply/ - Create reply
+ POST /forward/ - Forward email
+ POST /move/ - Move to folder
+ POST /mark_read/ - Mark as read
+ POST /mark_unread/ - Mark as unread
+ POST /star/ - Star email
+ POST /unstar/ - Unstar email
+ POST /archive/ - Archive email
+ POST /trash/ - Move to trash
+ POST /restore/ - Restore from trash/archive
+ POST /bulk_action/ - Perform bulk action
+ """
+ permission_classes = [permissions.IsAuthenticated, IsPlatformUser]
+ pagination_class = StaffEmailPagination
+
+ def get_serializer_class(self):
+ if self.action == 'list':
+ return StaffEmailListSerializer
+ elif self.action in ['create', 'update', 'partial_update']:
+ return StaffEmailCreateSerializer
+ return StaffEmailDetailSerializer
+
+ def get_queryset(self):
+ queryset = StaffEmail.objects.filter(
+ owner=self.request.user
+ ).select_related('folder', 'email_address')
+
+ # Filter by folder (accept both 'folder' and 'folder_id')
+ folder_id = self.request.query_params.get('folder') or self.request.query_params.get('folder_id')
+ if folder_id:
+ queryset = queryset.filter(folder_id=folder_id)
+
+ # Filter by email address (for multi-account views)
+ email_address_id = self.request.query_params.get('email_address')
+ if email_address_id:
+ queryset = queryset.filter(email_address_id=email_address_id)
+
+ # Filter by folder type
+ folder_type = self.request.query_params.get('folder_type')
+ if folder_type:
+ queryset = queryset.filter(folder__folder_type=folder_type)
+
+ # Filter by read status
+ is_unread = self.request.query_params.get('is_unread')
+ if is_unread == 'true':
+ queryset = queryset.filter(is_read=False)
+
+ # Filter by starred
+ is_starred = self.request.query_params.get('is_starred')
+ if is_starred == 'true':
+ queryset = queryset.filter(is_starred=True)
+
+ # Filter by status
+ email_status = self.request.query_params.get('status')
+ if email_status:
+ queryset = queryset.filter(status=email_status)
+
+ # Search
+ search = self.request.query_params.get('search')
+ if search:
+ queryset = queryset.filter(
+ models.Q(subject__icontains=search) |
+ models.Q(from_address__icontains=search) |
+ models.Q(from_name__icontains=search) |
+ models.Q(body_text__icontains=search)
+ )
+
+ # Thread view - get only first email per thread
+ thread_view = self.request.query_params.get('thread_view')
+ if thread_view == 'true':
+ # Get distinct thread_ids with latest email date
+ queryset = queryset.order_by('thread_id', '-email_date').distinct('thread_id')
+
+ return queryset.order_by('-email_date')
+
+ def destroy(self, request, *args, **kwargs):
+ """Archive email instead of hard delete."""
+ instance = self.get_object()
+ instance.permanently_delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ @action(detail=True, methods=['post'])
+ def send(self, request, pk=None):
+ """Send a draft email."""
+ email = self.get_object()
+
+ if email.status != StaffEmail.Status.DRAFT:
+ return Response(
+ {'error': 'Only drafts can be sent'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ if not email.to_addresses:
+ return Response(
+ {'error': 'No recipients specified'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Queue for async sending
+ from .tasks import send_staff_email
+ send_staff_email.delay(email.id)
+
+ return Response({'status': 'queued', 'email_id': email.id})
+
+ @action(detail=True, methods=['post'])
+ def reply(self, request, pk=None):
+ """Create a reply to an email."""
+ original = self.get_object()
+ serializer = ReplyEmailSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ if not original.email_address:
+ return Response(
+ {'error': 'No email address associated with this email'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ from .smtp_service import StaffEmailSmtpService
+ service = StaffEmailSmtpService(original.email_address)
+
+ reply = service.create_reply(
+ original_email=original,
+ reply_body_html=serializer.validated_data['body_html'],
+ reply_body_text=serializer.validated_data.get('body_text', ''),
+ reply_all=serializer.validated_data.get('reply_all', False)
+ )
+
+ return Response(
+ StaffEmailDetailSerializer(reply, context={'request': request}).data,
+ status=status.HTTP_201_CREATED
+ )
+
+ @action(detail=True, methods=['post'])
+ def forward(self, request, pk=None):
+ """Forward an email."""
+ original = self.get_object()
+ serializer = ForwardEmailSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ if not original.email_address:
+ return Response(
+ {'error': 'No email address associated with this email'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ from .smtp_service import StaffEmailSmtpService
+ service = StaffEmailSmtpService(original.email_address)
+
+ forward = service.create_forward(
+ original_email=original,
+ to_addresses=serializer.validated_data['to_addresses'],
+ forward_body_html=serializer.validated_data['body_html'],
+ forward_body_text=serializer.validated_data.get('body_text', ''),
+ include_attachments=serializer.validated_data.get('include_attachments', True)
+ )
+
+ return Response(
+ StaffEmailDetailSerializer(forward, context={'request': request}).data,
+ status=status.HTTP_201_CREATED
+ )
+
+ @action(detail=True, methods=['post'])
+ def move(self, request, pk=None):
+ """Move email to a different folder."""
+ email = self.get_object()
+ serializer = MoveEmailSerializer(
+ data=request.data,
+ context={'request': request}
+ )
+ serializer.is_valid(raise_exception=True)
+
+ folder = StaffEmailFolder.objects.get(
+ id=serializer.validated_data['folder_id'],
+ user=request.user
+ )
+ email.move_to_folder(folder)
+
+ return Response({'status': 'moved', 'folder_id': folder.id})
+
+ @action(detail=True, methods=['post'])
+ def mark_read(self, request, pk=None):
+ """Mark email as read."""
+ email = self.get_object()
+ email.mark_as_read()
+
+ # Optionally sync to IMAP server
+ # from .imap_service import StaffEmailImapService
+ # service = StaffEmailImapService(email.email_address)
+ # service.mark_as_read_on_server(email)
+
+ return Response({'status': 'success', 'is_read': True})
+
+ @action(detail=True, methods=['post'])
+ def mark_unread(self, request, pk=None):
+ """Mark email as unread."""
+ email = self.get_object()
+ email.mark_as_unread()
+ return Response({'status': 'success', 'is_read': False})
+
+ @action(detail=True, methods=['post'])
+ def star(self, request, pk=None):
+ """Star an email."""
+ email = self.get_object()
+ email.is_starred = True
+ email.save(update_fields=['is_starred', 'updated_at'])
+ return Response({'status': 'success', 'is_starred': True})
+
+ @action(detail=True, methods=['post'])
+ def unstar(self, request, pk=None):
+ """Unstar an email."""
+ email = self.get_object()
+ email.is_starred = False
+ email.save(update_fields=['is_starred', 'updated_at'])
+ return Response({'status': 'success', 'is_starred': False})
+
+ @action(detail=True, methods=['post'])
+ def archive(self, request, pk=None):
+ """Move email to archive folder."""
+ email = self.get_object()
+ email.archive()
+ return Response({'status': 'archived'})
+
+ @action(detail=True, methods=['post'])
+ def trash(self, request, pk=None):
+ """Move email to trash folder."""
+ email = self.get_object()
+ email.move_to_trash()
+ return Response({'status': 'trashed'})
+
+ @action(detail=True, methods=['post'])
+ def restore(self, request, pk=None):
+ """Restore email from trash or permanent deletion."""
+ # Use all_objects to find permanently deleted emails too
+ email = get_object_or_404(
+ StaffEmail.all_objects,
+ pk=pk,
+ owner=request.user
+ )
+ email.restore()
+ return Response({'status': 'restored'})
+
+ @action(detail=False, methods=['post'])
+ def bulk_action(self, request):
+ """Perform bulk action on multiple emails."""
+ serializer = BulkEmailActionSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ email_ids = serializer.validated_data['email_ids']
+ action_type = serializer.validated_data['action']
+
+ emails = StaffEmail.objects.filter(
+ id__in=email_ids,
+ owner=request.user
+ )
+
+ count = 0
+ for email in emails:
+ if action_type == 'read':
+ email.mark_as_read()
+ elif action_type == 'unread':
+ email.mark_as_unread()
+ elif action_type == 'star':
+ email.is_starred = True
+ email.save(update_fields=['is_starred', 'updated_at'])
+ elif action_type == 'unstar':
+ email.is_starred = False
+ email.save(update_fields=['is_starred', 'updated_at'])
+ elif action_type == 'archive':
+ email.archive()
+ elif action_type == 'trash':
+ email.move_to_trash()
+ elif action_type == 'delete':
+ email.permanently_delete()
+ elif action_type == 'restore':
+ email.restore()
+ count += 1
+
+ return Response({
+ 'status': 'success',
+ 'action': action_type,
+ 'count': count
+ })
+
+ @action(detail=False, methods=['get'])
+ def unread_count(self, request):
+ """Get unread email counts by folder."""
+ folders = StaffEmailFolder.objects.filter(user=request.user)
+ counts = {}
+ for folder in folders:
+ counts[folder.folder_type] = folder.unread_count
+ total = StaffEmail.objects.filter(owner=request.user, is_read=False).count()
+ return Response({'total': total, 'by_folder': counts})
+
+ @action(detail=False, methods=['post'])
+ def sync(self, request):
+ """Trigger email sync from IMAP."""
+ from .tasks import fetch_staff_emails
+ result = fetch_staff_emails.delay()
+ return Response({
+ 'status': 'sync_started',
+ 'task_id': result.id
+ })
+
+ @action(detail=False, methods=['post'])
+ def full_sync(self, request):
+ """
+ Trigger full email sync from IMAP.
+
+ This syncs all folders and all emails (including already-read ones)
+ for all email addresses assigned to the current user.
+ """
+ from smoothschedule.platform.admin.models import PlatformEmailAddress
+ from .tasks import full_sync_staff_email
+
+ # Find email addresses assigned to current user
+ email_addresses = PlatformEmailAddress.objects.filter(
+ assigned_user=request.user,
+ routing_mode=PlatformEmailAddress.RoutingMode.STAFF,
+ is_active=True
+ )
+
+ if not email_addresses.exists():
+ return Response(
+ {'error': 'No email addresses assigned to you'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ task_ids = []
+ for email_addr in email_addresses:
+ result = full_sync_staff_email.delay(email_addr.id)
+ task_ids.append({
+ 'email_address': email_addr.email_address,
+ 'task_id': result.id
+ })
+
+ return Response({
+ 'status': 'full_sync_started',
+ 'tasks': task_ids
+ })
+
+ @action(detail=False, methods=['get'])
+ def email_addresses(self, request):
+ """
+ Get all email addresses assigned to the current user.
+
+ Returns email addresses that are configured for staff routing mode
+ and assigned to the current user.
+ """
+ from smoothschedule.platform.admin.models import PlatformEmailAddress
+
+ email_addresses = PlatformEmailAddress.objects.filter(
+ assigned_user=request.user,
+ routing_mode=PlatformEmailAddress.RoutingMode.STAFF,
+ is_active=True
+ )
+
+ # Serialize manually since email_address is a property
+ result = [
+ {
+ 'id': addr.id,
+ 'email_address': addr.email_address, # computed property
+ 'display_name': addr.display_name,
+ 'color': addr.color,
+ 'is_default': addr.is_default,
+ 'last_check_at': addr.last_check_at,
+ 'emails_processed_count': addr.emails_processed_count,
+ }
+ for addr in email_addresses
+ ]
+
+ return Response(result)
+
+
+class StaffEmailLabelViewSet(viewsets.ModelViewSet):
+ """
+ API for managing email labels.
+
+ GET /api/staff-email/labels/ - List all labels
+ POST /api/staff-email/labels/ - Create label
+ PATCH /api/staff-email/labels/{id}/ - Update label
+ DELETE /api/staff-email/labels/{id}/ - Delete label
+ POST /api/staff-email/labels/{id}/assign/ - Assign to email
+ POST /api/staff-email/labels/{id}/unassign/ - Unassign from email
+ """
+ serializer_class = StaffEmailLabelSerializer
+ permission_classes = [permissions.IsAuthenticated, IsPlatformUser]
+
+ def get_queryset(self):
+ return StaffEmailLabel.objects.filter(user=self.request.user)
+
+ @action(detail=True, methods=['post'])
+ def assign(self, request, pk=None):
+ """Assign label to an email."""
+ label = self.get_object()
+ email_id = request.data.get('email_id')
+
+ if not email_id:
+ return Response(
+ {'error': 'email_id is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ email = get_object_or_404(StaffEmail, id=email_id, owner=request.user)
+
+ StaffEmailLabelAssignment.objects.get_or_create(
+ email=email,
+ label=label
+ )
+
+ return Response({'status': 'assigned'})
+
+ @action(detail=True, methods=['post'])
+ def unassign(self, request, pk=None):
+ """Unassign label from an email."""
+ label = self.get_object()
+ email_id = request.data.get('email_id')
+
+ if not email_id:
+ return Response(
+ {'error': 'email_id is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ StaffEmailLabelAssignment.objects.filter(
+ email_id=email_id,
+ label=label
+ ).delete()
+
+ return Response({'status': 'unassigned'})
+
+
+class EmailContactSuggestionViewSet(viewsets.ReadOnlyModelViewSet):
+ """
+ API for email contact suggestions and autocomplete.
+
+ GET /api/staff-email/contacts/?q=search_term - Search contacts
+ GET /api/staff-email/contacts/platform_users/ - Get all platform users
+ """
+ serializer_class = EmailContactSuggestionSerializer
+ permission_classes = [permissions.IsAuthenticated, IsPlatformUser]
+
+ def get_queryset(self):
+ queryset = EmailContactSuggestion.objects.filter(user=self.request.user)
+
+ q = self.request.query_params.get('q', '')
+ if q and len(q) >= 2:
+ queryset = queryset.filter(
+ models.Q(email__icontains=q) |
+ models.Q(name__icontains=q)
+ )
+
+ return queryset[:20]
+
+ @action(detail=False, methods=['get'])
+ def platform_users(self, request):
+ """Get all platform users for contact suggestions."""
+ users = User.objects.filter(
+ role__in=[
+ User.Role.SUPERUSER,
+ User.Role.PLATFORM_MANAGER,
+ User.Role.PLATFORM_SUPPORT,
+ ],
+ is_active=True
+ ).values('id', 'email', 'first_name', 'last_name')
+
+ contacts = [
+ {
+ 'id': u['id'],
+ 'email': u['email'],
+ 'name': f"{u['first_name']} {u['last_name']}".strip() or u['email'],
+ 'is_platform_user': True,
+ }
+ for u in users
+ ]
+
+ return Response(contacts)
+
+
+class StaffEmailAttachmentViewSet(viewsets.ModelViewSet):
+ """
+ API for managing email attachments.
+
+ GET /api/staff-email/attachments/{id}/ - Get attachment info
+ GET /api/staff-email/attachments/{id}/download/ - Download attachment
+ POST /api/staff-email/attachments/ - Upload attachment
+ DELETE /api/staff-email/attachments/{id}/ - Delete attachment
+ """
+ serializer_class = StaffEmailAttachmentSerializer
+ permission_classes = [permissions.IsAuthenticated, IsPlatformUser]
+ parser_classes = [MultiPartParser, FormParser]
+
+ def get_queryset(self):
+ return StaffEmailAttachment.objects.filter(
+ email__owner=self.request.user
+ )
+
+ def create(self, request, *args, **kwargs):
+ """Upload an attachment."""
+ file = request.FILES.get('file')
+ email_id = request.data.get('email_id')
+
+ if not file:
+ return Response(
+ {'error': 'No file provided'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ if not email_id:
+ return Response(
+ {'error': 'email_id is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ email = get_object_or_404(
+ StaffEmail,
+ id=email_id,
+ owner=request.user,
+ status=StaffEmail.Status.DRAFT
+ )
+
+ # TODO: Upload to DigitalOcean Spaces
+ storage_path = f"email_attachments/{request.user.id}/{email.id}/{file.name}"
+
+ attachment = StaffEmailAttachment.objects.create(
+ email=email,
+ filename=file.name,
+ content_type=file.content_type or 'application/octet-stream',
+ size=file.size,
+ storage_path=storage_path,
+ )
+
+ # Update email has_attachments flag
+ email.has_attachments = True
+ email.save(update_fields=['has_attachments'])
+
+ return Response(
+ StaffEmailAttachmentSerializer(attachment).data,
+ status=status.HTTP_201_CREATED
+ )
+
+ @action(detail=True, methods=['get'])
+ def download(self, request, pk=None):
+ """Get download URL for attachment."""
+ attachment = self.get_object()
+
+ # TODO: Generate presigned URL from DigitalOcean Spaces
+ # For now, return a placeholder
+ download_url = f"/api/staff-email/attachments/{attachment.id}/file/"
+
+ return Response({
+ 'filename': attachment.filename,
+ 'content_type': attachment.content_type,
+ 'size': attachment.size,
+ 'download_url': download_url,
+ })
diff --git a/smoothschedule/smoothschedule/platform/admin/migrations/0014_add_routing_mode_and_email_models.py b/smoothschedule/smoothschedule/platform/admin/migrations/0014_add_routing_mode_and_email_models.py
new file mode 100644
index 00000000..178eb190
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/admin/migrations/0014_add_routing_mode_and_email_models.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.8 on 2025-12-18 03:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0013_remove_business_tier_from_subscription_plan'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='platformemailaddress',
+ name='routing_mode',
+ field=models.CharField(choices=[('PLATFORM', 'Platform Ticketing'), ('STAFF', 'Staff Inbox')], default='PLATFORM', help_text="Where incoming emails are routed: Platform creates tickets, Staff goes to user's inbox", max_length=20),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/platform/admin/models.py b/smoothschedule/smoothschedule/platform/admin/models.py
index b87ffc40..ba26478b 100644
--- a/smoothschedule/smoothschedule/platform/admin/models.py
+++ b/smoothschedule/smoothschedule/platform/admin/models.py
@@ -561,6 +561,10 @@ class PlatformEmailAddress(models.Model):
SMOOTHSCHEDULE = 'smoothschedule.com', 'smoothschedule.com'
TALOVA = 'talova.net', 'talova.net'
+ class RoutingMode(models.TextChoices):
+ PLATFORM = 'PLATFORM', _('Platform Ticketing')
+ STAFF = 'STAFF', _('Staff Inbox')
+
# Display information
display_name = models.CharField(
max_length=100,
@@ -596,6 +600,14 @@ class PlatformEmailAddress(models.Model):
help_text="User associated with this email. If set, their name is used as sender name."
)
+ # Routing mode - determines where incoming emails go
+ routing_mode = models.CharField(
+ max_length=20,
+ choices=RoutingMode.choices,
+ default=RoutingMode.PLATFORM,
+ help_text="Where incoming emails are routed: Platform creates tickets, Staff goes to user's inbox"
+ )
+
# Account credentials (stored securely, synced to mail server)
password = models.CharField(
max_length=255,
diff --git a/smoothschedule/smoothschedule/platform/admin/serializers.py b/smoothschedule/smoothschedule/platform/admin/serializers.py
index d41ea525..49d2ecfd 100644
--- a/smoothschedule/smoothschedule/platform/admin/serializers.py
+++ b/smoothschedule/smoothschedule/platform/admin/serializers.py
@@ -653,7 +653,7 @@ class PlatformEmailAddressListSerializer(serializers.ModelSerializer):
fields = [
'id', 'display_name', 'sender_name', 'effective_sender_name',
'local_part', 'domain', 'email_address', 'color',
- 'assigned_user',
+ 'assigned_user', 'routing_mode',
'is_active', 'is_default', 'mail_server_synced',
'last_check_at', 'emails_processed_count',
'created_at', 'updated_at'
@@ -709,7 +709,7 @@ class PlatformEmailAddressSerializer(serializers.ModelSerializer):
try:
user = User.objects.get(
pk=value,
- role__in=['superuser', 'platform_manager', 'platform_support'],
+ role__in=[User.Role.SUPERUSER, User.Role.PLATFORM_MANAGER, User.Role.PLATFORM_SUPPORT],
is_active=True
)
return user
@@ -842,7 +842,7 @@ class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
try:
user = User.objects.get(
pk=value,
- role__in=['superuser', 'platform_manager', 'platform_support'],
+ role__in=[User.Role.SUPERUSER, User.Role.PLATFORM_MANAGER, User.Role.PLATFORM_SUPPORT],
is_active=True
)
return user
diff --git a/smoothschedule/smoothschedule/platform/admin/tasks.py b/smoothschedule/smoothschedule/platform/admin/tasks.py
index 9c66257b..ce05317c 100644
--- a/smoothschedule/smoothschedule/platform/admin/tasks.py
+++ b/smoothschedule/smoothschedule/platform/admin/tasks.py
@@ -348,3 +348,125 @@ def sync_subscription_plan_to_tenants(self, plan_id: int):
logger.info(f"Completed sync for plan '{plan.name}': {updated_count}/{tenant_count} tenants updated")
return result
+
+
+# ============================================================================
+# Staff Email Tasks
+# ============================================================================
+
+@shared_task(name='platform.fetch_staff_emails')
+def fetch_staff_emails():
+ """
+ Periodic task to fetch emails for all staff-assigned email addresses.
+
+ Checks all PlatformEmailAddress records with routing_mode='STAFF'
+ and fetches new emails from IMAP.
+
+ Returns:
+ Dict with results per email address
+ """
+ from .email_imap_service import fetch_all_staff_emails
+
+ logger.info("Starting staff email fetch task")
+ results = fetch_all_staff_emails()
+
+ total_processed = sum(count for count in results.values() if count > 0)
+ logger.info(f"Staff email fetch complete: {total_processed} emails processed")
+
+ return {
+ 'success': True,
+ 'total_processed': total_processed,
+ 'details': results,
+ }
+
+
+@shared_task(bind=True, max_retries=3, name='platform.send_staff_email')
+def send_staff_email(self, email_id: int):
+ """
+ Send a staff email draft.
+
+ Args:
+ email_id: ID of the StaffEmail to send
+
+ Returns:
+ Dict with success status
+ """
+ from .email_models import StaffEmail
+ from .email_smtp_service import StaffEmailSmtpService
+
+ try:
+ staff_email = StaffEmail.objects.select_related(
+ 'email_address', 'owner'
+ ).get(id=email_id)
+ except StaffEmail.DoesNotExist:
+ logger.error(f"StaffEmail {email_id} not found")
+ return {'success': False, 'error': 'Email not found'}
+
+ if not staff_email.email_address:
+ logger.error(f"StaffEmail {email_id} has no associated email address")
+ return {'success': False, 'error': 'No email address configured'}
+
+ try:
+ service = StaffEmailSmtpService(staff_email.email_address)
+ success = service.send_email(staff_email)
+
+ if success:
+ logger.info(f"Successfully sent staff email {email_id}")
+ # TODO: Send WebSocket notification about sent email
+ return {'success': True, 'email_id': email_id}
+ else:
+ logger.error(f"Failed to send staff email {email_id}")
+ return {'success': False, 'error': 'Send failed'}
+
+ except Exception as e:
+ logger.error(f"Error sending staff email {email_id}: {e}", exc_info=True)
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
+
+
+@shared_task(name='platform.sync_staff_email_folder')
+def sync_staff_email_folder(email_address_id: int, folder_name: str = 'INBOX'):
+ """
+ Sync a specific IMAP folder for a staff email address.
+
+ Args:
+ email_address_id: ID of the PlatformEmailAddress
+ folder_name: IMAP folder to sync
+
+ Returns:
+ Dict with sync results
+ """
+ from .models import PlatformEmailAddress
+ from .email_imap_service import StaffEmailImapService
+
+ try:
+ email_address = PlatformEmailAddress.objects.get(id=email_address_id)
+ except PlatformEmailAddress.DoesNotExist:
+ logger.error(f"PlatformEmailAddress {email_address_id} not found")
+ return {'success': False, 'error': 'Email address not found'}
+
+ if email_address.routing_mode != PlatformEmailAddress.RoutingMode.STAFF:
+ logger.warning(f"Email address {email_address_id} is not in staff mode")
+ return {'success': False, 'error': 'Not a staff email address'}
+
+ try:
+ service = StaffEmailImapService(email_address)
+ synced_count = service.sync_folder(folder_name, full_sync=False)
+
+ logger.info(
+ f"Synced {synced_count} emails for {email_address.email_address} "
+ f"folder {folder_name}"
+ )
+
+ return {
+ 'success': True,
+ 'email_address': email_address.email_address,
+ 'folder': folder_name,
+ 'synced_count': synced_count,
+ }
+
+ except Exception as e:
+ logger.error(
+ f"Error syncing folder {folder_name} for {email_address.email_address}: {e}",
+ exc_info=True
+ )
+ return {'success': False, 'error': str(e)}
diff --git a/smoothschedule/smoothschedule/platform/admin/views.py b/smoothschedule/smoothschedule/platform/admin/views.py
index b002e65e..6da0bc68 100644
--- a/smoothschedule/smoothschedule/platform/admin/views.py
+++ b/smoothschedule/smoothschedule/platform/admin/views.py
@@ -1483,7 +1483,7 @@ class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
from smoothschedule.identity.users.models import User
users = User.objects.filter(
- role__in=['superuser', 'platform_manager', 'platform_support'],
+ role__in=[User.Role.SUPERUSER, User.Role.PLATFORM_MANAGER, User.Role.PLATFORM_SUPPORT],
is_active=True
).order_by('first_name', 'last_name', 'email')
diff --git a/smoothschedule/smoothschedule/scheduling/contracts/views.py b/smoothschedule/smoothschedule/scheduling/contracts/views.py
index 26a595d9..899af6c8 100644
--- a/smoothschedule/smoothschedule/scheduling/contracts/views.py
+++ b/smoothschedule/smoothschedule/scheduling/contracts/views.py
@@ -26,7 +26,8 @@ class HasContractsPermission(BasePermission):
return False
# Platform users (superuser, platform_manager, etc.) can access
- if hasattr(user, 'role') and user.role in ['superuser', 'platform_manager', 'platform_support']:
+ from smoothschedule.identity.users.models import User
+ if hasattr(user, 'role') and user.role in [User.Role.SUPERUSER, User.Role.PLATFORM_MANAGER, User.Role.PLATFORM_SUPPORT]:
return True
# Get tenant from user