From ee6cf2b8022ab70daad39b57150cb3b6b140859c Mon Sep 17 00:00:00 2001 From: poduck Date: Mon, 1 Dec 2025 19:32:45 -0500 Subject: [PATCH] fix(tickets): Remove invalid source_email_address_id in PlatformEmailReceiver PlatformEmailReceiver was setting source_email_address_id to a PlatformEmailAddress ID, but the Ticket model expects a TicketEmailAddress foreign key. This caused an integrity error that was rolling back the entire transaction (due to ATOMIC_REQUESTS=True), preventing tickets from being created. --- PLAN_MULTI_EMAIL_TICKETING.md | 653 +++++++++++++++++++++++ smoothschedule/tickets/email_receiver.py | 2 +- 2 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 PLAN_MULTI_EMAIL_TICKETING.md diff --git a/PLAN_MULTI_EMAIL_TICKETING.md b/PLAN_MULTI_EMAIL_TICKETING.md new file mode 100644 index 0000000..04e817a --- /dev/null +++ b/PLAN_MULTI_EMAIL_TICKETING.md @@ -0,0 +1,653 @@ +# Implementation Plan: Multi-Email Ticketing System + +## Executive Summary + +Add support for multiple email addresses per business in the ticketing system, with color-coded visual indicators and per-email IMAP/SMTP configuration. + +## Current System Analysis + +### Existing Components + +1. **Django Backend (`tickets` app)** + - `Ticket` model: Core ticket entity + - `TicketComment` model: Ticket responses + - `TicketEmailSettings` model: **Singleton** platform-wide email config + - `IncomingTicketEmail` model: Email audit log + - `TicketEmailReceiver` class: IMAP email fetching + - `TicketEmailService` class: SMTP email sending + +2. **Frontend** + - `Tickets.tsx`: Main ticket listing page + - `TicketModal.tsx`: Ticket detail modal + - `useTickets` hook: Fetch tickets + - `useTicketEmailSettings` hook: Manage email settings (singleton) + - `Settings.tsx`: Business settings page + +3. **Current Email Flow** + - Single email account configured platform-wide + - Emails matched to tickets by ID in subject/address + - Comments created from email replies + - New tickets created from unmatched emails + +## Requirements (from user clarification) + +1. **Per-Business Email Addresses** + - Each business provides their own email account(s) and credentials + - Multiple email addresses per business + - Each email has independent IMAP/SMTP settings + +2. **Email Address Properties** + - Display name (e.g., "Support", "Billing") + - Email address + - IMAP settings (host, port, username, password, SSL) + - SMTP settings (host, port, username, password, TLS/SSL) + - Color for visual identification (hex color code) + - Active/inactive status + +3. **Ticket Routing** + - Incoming emails matched to business by email address configuration + - Reply emails matched to existing tickets + - New emails create tickets for that business + - System attempts to match sender email to customer/staff in business + +4. **UI Requirements** + - Colored left border on ticket rows indicating source email + - Business settings page to manage email addresses + - Test connection buttons for IMAP/SMTP + - Email address selector when creating tickets manually + +## Implementation Plan + +### Phase 1: Django Backend Models + +#### 1.1 Create `TicketEmailAddress` Model + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/models.py` + +```python +class TicketEmailAddress(models.Model): + """ + Per-business email address configuration for ticket management. + Each business can have multiple email addresses with their own settings. + """ + tenant = models.ForeignKey( + Tenant, + on_delete=models.CASCADE, + related_name='ticket_email_addresses', + help_text="Business this email address belongs to" + ) + + # 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) + imap_port = models.IntegerField(default=993) + imap_use_ssl = models.BooleanField(default=True) + imap_username = models.CharField(max_length=255) + imap_password = models.CharField(max_length=255) # Encrypted in production + imap_folder = models.CharField(max_length=100, default='INBOX') + + # SMTP settings (outbound) + smtp_host = models.CharField(max_length=255) + smtp_port = models.IntegerField(default=587) + smtp_use_tls = models.BooleanField(default=True) + smtp_use_ssl = models.BooleanField(default=False) + smtp_username = models.CharField(max_length=255) + smtp_password = models.CharField(max_length=255) # Encrypted in production + + # 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) + last_error = models.TextField(blank=True, default='') + emails_processed_count = models.IntegerField(default=0) + + 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']), + ] + + 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) +``` + +#### 1.2 Update `Ticket` Model + +Add field to track which email address received/sent the ticket: + +```python +class Ticket(models.Model): + # ... existing fields ... + + 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" + ) +``` + +#### 1.3 Update `IncomingTicketEmail` Model + +Add field to track which email address received the email: + +```python +class IncomingTicketEmail(models.Model): + # ... existing fields ... + + 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" + ) +``` + +### Phase 2: Django Backend Logic + +#### 2.1 Update Email Receiver + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/email_receiver.py` + +- Modify `TicketEmailReceiver` to iterate through all active `TicketEmailAddress` objects +- Connect to each email address's IMAP server +- Process emails for each address +- Associate processed tickets with the source email address + +#### 2.2 Update Email Sender + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/email_notifications.py` + +- Modify `TicketEmailService` to use the ticket's `source_email_address` for sending +- Fall back to business's default email address if none specified + +### Phase 3: Django Backend API + +#### 3.1 Create Serializers + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/serializers.py` + +```python +class TicketEmailAddressSerializer(serializers.ModelSerializer): + class Meta: + model = TicketEmailAddress + fields = [ + 'id', 'tenant', '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' + ] + read_only_fields = ['tenant', 'last_check_at', 'last_error', + 'emails_processed_count', 'created_at', 'updated_at'] + extra_kwargs = { + 'imap_password': {'write_only': True}, + 'smtp_password': {'write_only': True}, + } + +class TicketEmailAddressListSerializer(serializers.ModelSerializer): + """Lightweight serializer without passwords""" + class Meta: + model = TicketEmailAddress + fields = [ + 'id', 'display_name', 'email_address', 'color', + 'is_active', 'is_default', 'last_check_at', + 'emails_processed_count' + ] +``` + +Update `TicketSerializer` to include email address: + +```python +class TicketSerializer(serializers.ModelSerializer): + # ... existing fields ... + source_email_address = TicketEmailAddressListSerializer(read_only=True) +``` + +#### 3.2 Create ViewSet + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/views.py` + +```python +class TicketEmailAddressViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing ticket email addresses. + Only business owners and managers can manage email addresses. + """ + serializer_class = TicketEmailAddressSerializer + permission_classes = [IsTenantUser] + + def get_queryset(self): + user = self.request.user + # Business users see their own email addresses + if user.role in ['owner', 'manager', 'staff']: + return TicketEmailAddress.objects.filter( + tenant=user.tenant + ) + # Platform users see all + elif user.role in ['superuser', 'platform_manager']: + return TicketEmailAddress.objects.all() + return TicketEmailAddress.objects.none() + + def get_serializer_class(self): + if self.action == 'list': + return TicketEmailAddressListSerializer + return TicketEmailAddressSerializer + + def perform_create(self, serializer): + # Automatically set tenant from current user + serializer.save(tenant=self.request.user.tenant) + + @action(detail=True, methods=['post']) + def test_imap(self, request, pk=None): + """Test IMAP connection for this email address""" + email_address = self.get_object() + # Test IMAP connection logic + return Response({'status': 'success'}) + + @action(detail=True, methods=['post']) + def test_smtp(self, request, pk=None): + """Test SMTP connection for this email address""" + email_address = self.get_object() + # Test SMTP connection logic + return Response({'status': 'success'}) + + @action(detail=True, methods=['post']) + def fetch_now(self, request, pk=None): + """Manually trigger email fetch for this address""" + email_address = self.get_object() + # Trigger email fetch + return Response({'status': 'fetching'}) +``` + +#### 3.3 Add URL Routes + +**File:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/tickets/urls.py` + +```python +router.register(r'email-addresses', views.TicketEmailAddressViewSet, basename='ticketemailaddress') +``` + +### Phase 4: Frontend - React Hooks + +#### 4.1 Create API Client Functions + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/api/ticketEmailAddresses.ts` (new file) + +```typescript +export interface TicketEmailAddress { + id: number; + display_name: string; + email_address: string; + color: string; + imap_host: string; + imap_port: number; + imap_use_ssl: boolean; + imap_username: string; + imap_password?: string; + imap_folder: string; + smtp_host: string; + smtp_port: number; + smtp_use_tls: boolean; + smtp_use_ssl: boolean; + smtp_username: string; + smtp_password?: string; + is_active: boolean; + is_default: boolean; + last_check_at?: string; + last_error?: string; + emails_processed_count: number; + created_at: string; + updated_at: string; +} + +export type TicketEmailAddressCreate = Omit; + +export const getTicketEmailAddresses = async (): Promise => { + const response = await apiClient.get('/tickets/email-addresses/'); + return response.data; +}; + +export const createTicketEmailAddress = async (data: TicketEmailAddressCreate): Promise => { + const response = await apiClient.post('/tickets/email-addresses/', data); + return response.data; +}; + +export const updateTicketEmailAddress = async (id: number, data: Partial): Promise => { + const response = await apiClient.patch(`/tickets/email-addresses/${id}/`, data); + return response.data; +}; + +export const deleteTicketEmailAddress = async (id: number): Promise => { + await apiClient.delete(`/tickets/email-addresses/${id}/`); +}; + +export const testImapConnection = async (id: number): Promise<{ status: string; message?: string }> => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/test_imap/`); + return response.data; +}; + +export const testSmtpConnection = async (id: number): Promise<{ status: string; message?: string }> => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/test_smtp/`); + return response.data; +}; + +export const fetchEmailsNow = async (id: number): Promise<{ status: string }> => { + const response = await apiClient.post(`/tickets/email-addresses/${id}/fetch_now/`); + return response.data; +}; +``` + +#### 4.2 Create React Query Hooks + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/hooks/useTicketEmailAddresses.ts` (new file) + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getTicketEmailAddresses, + createTicketEmailAddress, + updateTicketEmailAddress, + deleteTicketEmailAddress, + testImapConnection, + testSmtpConnection, + fetchEmailsNow, + TicketEmailAddress, + TicketEmailAddressCreate, +} from '../api/ticketEmailAddresses'; + +const QUERY_KEY = 'ticketEmailAddresses'; + +export const useTicketEmailAddresses = () => { + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: getTicketEmailAddresses, + }); +}; + +export const useCreateTicketEmailAddress = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: TicketEmailAddressCreate) => createTicketEmailAddress(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +export const useUpdateTicketEmailAddress = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: Partial }) => + updateTicketEmailAddress(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +export const useDeleteTicketEmailAddress = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteTicketEmailAddress(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +}; + +export const useTestImapConnection = () => { + return useMutation({ + mutationFn: (id: number) => testImapConnection(id), + }); +}; + +export const useTestSmtpConnection = () => { + return useMutation({ + mutationFn: (id: number) => testSmtpConnection(id), + }); +}; + +export const useFetchEmailsNow = () => { + return useMutation({ + mutationFn: (id: number) => fetchEmailsNow(id), + }); +}; +``` + +### Phase 5: Frontend - React Components + +#### 5.1 Email Address Management Component + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/components/TicketEmailAddressManager.tsx` (new file) + +Features: +- List all email addresses for the business +- Add new email address +- Edit existing email address +- Delete email address +- Test IMAP/SMTP connections +- Set default email address +- Color picker for visual identification +- Enable/disable email addresses + +#### 5.2 Update Ticket List UI + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/pages/Tickets.tsx` + +Modify ticket rows to include colored left border: + +```tsx +
+ {/* Ticket content */} +
+``` + +#### 5.3 Update Types + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/types.ts` + +```typescript +export interface TicketEmailAddress { + id: number; + display_name: string; + email_address: string; + color: string; + is_active: boolean; + is_default: boolean; + last_check_at?: string; + emails_processed_count: number; +} + +export interface Ticket { + // ... existing fields ... + source_email_address?: TicketEmailAddress; +} +``` + +#### 5.4 Add to Business Settings + +**File:** `/home/poduck/Desktop/smoothschedule2/frontend/src/pages/Settings.tsx` + +Add new tab for "Email Addresses" that renders `TicketEmailAddressManager` component. + +### Phase 6: Database Migration + +#### 6.1 Create Migration + +```bash +cd /home/poduck/Desktop/smoothschedule2/smoothschedule +docker compose -f docker-compose.local.yml exec django python manage.py makemigrations tickets +``` + +#### 6.2 Run Migration + +```bash +docker compose -f docker-compose.local.yml exec django python manage.py migrate tickets +``` + +#### 6.3 Data Migration (if needed) + +If there's existing `TicketEmailSettings` data, create a data migration to convert it to `TicketEmailAddress` records for each tenant. + +### Phase 7: Testing + +#### 7.1 Backend Tests + +- Test email address CRUD operations +- Test email receiver with multiple addresses +- Test email sender using correct source address +- Test tenant isolation + +#### 7.2 Frontend Tests + +- Test email address list rendering +- Test add/edit/delete operations +- Test connection testing UI +- Test ticket list color borders + +### Phase 8: Documentation + +#### 8.1 User Documentation + +- How to add email addresses +- How to configure IMAP/SMTP settings +- How to test connections +- Color coding explanation + +#### 8.2 Developer Documentation + +- API endpoints documentation +- Model relationships +- Email processing flow +- Celery task schedule (if applicable) + +## Migration Strategy + +### Option 1: Keep Legacy System (Recommended) + +- Keep `TicketEmailSettings` for platform-level configuration +- New `TicketEmailAddress` for per-business configuration +- Businesses can opt-in to multi-email system +- Existing single-email businesses continue working + +### Option 2: Full Migration + +- Deprecate `TicketEmailSettings` +- Migrate all existing data to `TicketEmailAddress` +- All businesses use new system + +**Recommendation:** Option 1 for backward compatibility + +## Risks & Considerations + +1. **Security** + - Email passwords stored in database (consider encryption) + - SMTP/IMAP credentials exposure risk + - Recommend OAuth2 for Gmail/Outlook in future + +2. **Performance** + - Multiple IMAP connections may increase load + - Consider Celery task queue for email fetching + - Implement rate limiting + +3. **Email Deliverability** + - Each business responsible for their own SPF/DKIM records + - No centralized email reputation management + +4. **UI/UX** + - Color picker needs to be user-friendly + - Color accessibility (contrast ratio) + - Mobile responsiveness + +## Future Enhancements + +1. **OAuth2 Support** + - Google Workspace integration + - Microsoft 365 integration + +2. **Email Templates Per Address** + - Different signatures per email address + - Custom auto-responses + +3. **Analytics** + - Email volume by address + - Response time by address + +4. **Auto-Assignment** + - Route tickets to specific staff based on email address + +## Implementation Timeline + +- **Phase 1-2 (Backend Models & Logic):** 2-3 days +- **Phase 3 (Backend API):** 1-2 days +- **Phase 4-5 (Frontend):** 3-4 days +- **Phase 6-7 (Migration & Testing):** 1-2 days +- **Phase 8 (Documentation):** 1 day + +**Total Estimated Time:** 8-12 days + +## Approval Required + +Before proceeding with implementation, please confirm: + +1. ✅ Per-business email addresses (not platform-wide) +2. ✅ Businesses provide their own IMAP/SMTP credentials +3. ✅ Colored left border for visual identification +4. ✅ Email address management in business settings (not platform dashboard) +5. ⚠️ Security approach for storing email passwords +6. ⚠️ Migration strategy (keep legacy vs full migration) + +## Questions for Product Owner + +1. Should we encrypt email passwords in the database? +2. Do we need email address approval workflow (platform admin approval)? +3. Should there be a limit on number of email addresses per business? +4. Do we need email forwarding (forward to another address)? +5. Should unmatched emails (not tied to a ticket) create new tickets or be ignored? diff --git a/smoothschedule/tickets/email_receiver.py b/smoothschedule/tickets/email_receiver.py index 7b6f11e..a9cc9a8 100644 --- a/smoothschedule/tickets/email_receiver.py +++ b/smoothschedule/tickets/email_receiver.py @@ -901,7 +901,7 @@ class PlatformEmailReceiver: 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, + # Note: source_email_address is for TicketEmailAddress, not PlatformEmailAddress ) TicketComment.objects.create(