Add staff email client with WebSocket real-time updates
Implements a complete email client for platform staff members: Backend: - Add routing_mode field to PlatformEmailAddress (PLATFORM/STAFF) - Create staff_email app with models for folders, emails, attachments, labels - IMAP service for fetching emails with folder mapping - SMTP service for sending emails with attachment support - Celery tasks for periodic sync and full sync operations - WebSocket consumer for real-time notifications - Comprehensive API viewsets with filtering and actions Frontend: - Thunderbird-style three-pane email interface - Multi-account support with drag-and-drop ordering - Email composer with rich text editor - Email viewer with thread support - Real-time WebSocket updates for new emails and sync status - 94 unit tests covering models, serializers, views, services, and consumers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,11 +102,20 @@ export const useLogout = () => {
|
||||
queryClient.removeQueries({ queryKey: ['currentUser'] });
|
||||
queryClient.clear();
|
||||
|
||||
// Redirect to login page on root domain
|
||||
// Redirect to appropriate login page based on current subdomain
|
||||
const protocol = window.location.protocol;
|
||||
const baseDomain = getBaseDomain();
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Check if on platform subdomain
|
||||
if (hostname.startsWith('platform.')) {
|
||||
// Platform users go to platform login page
|
||||
window.location.href = `${protocol}//platform.${baseDomain}${port}/platform/login`;
|
||||
} else {
|
||||
// Business users go to their subdomain's login page
|
||||
window.location.href = `${protocol}//${hostname}${port}/login`;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
514
frontend/src/hooks/useStaffEmail.ts
Normal file
514
frontend/src/hooks/useStaffEmail.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Staff Email Hooks
|
||||
*
|
||||
* React Query hooks for the platform staff email client.
|
||||
* Provides data fetching, mutations, and caching for the email UI.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import * as staffEmailApi from '../api/staffEmail';
|
||||
import {
|
||||
StaffEmailFolder,
|
||||
StaffEmail,
|
||||
StaffEmailListItem,
|
||||
StaffEmailLabel,
|
||||
StaffEmailFilters,
|
||||
StaffEmailCreateDraft,
|
||||
StaffEmailMove,
|
||||
StaffEmailBulkAction,
|
||||
StaffEmailReply,
|
||||
StaffEmailForward,
|
||||
EmailContactSuggestion,
|
||||
} from '../types';
|
||||
|
||||
// Query keys for cache management
|
||||
export const staffEmailKeys = {
|
||||
all: ['staffEmail'] as const,
|
||||
folders: () => [...staffEmailKeys.all, 'folders'] as const,
|
||||
emails: () => [...staffEmailKeys.all, 'emails'] as const,
|
||||
// Use explicit key parts instead of object to ensure proper cache separation
|
||||
emailList: (filters: StaffEmailFilters) => [
|
||||
...staffEmailKeys.emails(),
|
||||
'list',
|
||||
'folder',
|
||||
filters.folderId ?? 'none',
|
||||
'account',
|
||||
filters.emailAddressId ?? 'none',
|
||||
'search',
|
||||
filters.search ?? '',
|
||||
] as const,
|
||||
emailDetail: (id: number) => [...staffEmailKeys.emails(), 'detail', id] as const,
|
||||
emailThread: (threadId: string) => [...staffEmailKeys.emails(), 'thread', threadId] as const,
|
||||
labels: () => [...staffEmailKeys.all, 'labels'] as const,
|
||||
contacts: (query: string) => [...staffEmailKeys.all, 'contacts', query] as const,
|
||||
userEmailAddresses: () => [...staffEmailKeys.all, 'userEmailAddresses'] as const,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Folder Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmailFolders = () => {
|
||||
return useQuery<StaffEmailFolder[]>({
|
||||
queryKey: staffEmailKeys.folders(),
|
||||
queryFn: staffEmailApi.getFolders,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => staffEmailApi.createFolder(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, name }: { id: number; name: string }) => staffEmailApi.updateFolder(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteStaffEmailFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteFolder(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email List Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmails = (filters: StaffEmailFilters = {}, pageSize: number = 50) => {
|
||||
const queryKey = staffEmailKeys.emailList(filters);
|
||||
|
||||
// Debug logging
|
||||
console.log('[useStaffEmails] Hook called with:', { filters, queryKey, enabled: !!filters.folderId });
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
console.log('[useStaffEmails] queryFn executing with:', { filters, pageParam });
|
||||
return staffEmailApi.getEmails(filters, pageParam, pageSize);
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.next) {
|
||||
return allPages.length + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
staleTime: 10000, // 10 seconds
|
||||
// Only fetch when a folder is selected to prevent showing all emails
|
||||
enabled: !!filters.folderId,
|
||||
// Ensure fresh data when filters change
|
||||
refetchOnMount: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStaffEmailList = (filters: StaffEmailFilters = {}, page: number = 1, pageSize: number = 50) => {
|
||||
return useQuery({
|
||||
queryKey: [...staffEmailKeys.emailList(filters), page],
|
||||
queryFn: () => staffEmailApi.getEmails(filters, page, pageSize),
|
||||
staleTime: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Single Email Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmail = (id: number | undefined) => {
|
||||
return useQuery<StaffEmail>({
|
||||
queryKey: staffEmailKeys.emailDetail(id!),
|
||||
queryFn: () => staffEmailApi.getEmail(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStaffEmailThread = (threadId: string | undefined) => {
|
||||
return useQuery<StaffEmail[]>({
|
||||
queryKey: staffEmailKeys.emailThread(threadId!),
|
||||
queryFn: () => staffEmailApi.getEmailThread(threadId!),
|
||||
enabled: !!threadId,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Draft Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useCreateDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailCreateDraft) => staffEmailApi.createDraft(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<StaffEmailCreateDraft> }) =>
|
||||
staffEmailApi.updateDraft(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(variables.id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteDraft = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteDraft(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Send/Reply/Forward Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useSendEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.sendEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReplyToEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: StaffEmailReply }) =>
|
||||
staffEmailApi.replyToEmail(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(variables.id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useForwardEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: StaffEmailForward }) =>
|
||||
staffEmailApi.forwardEmail(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Email Action Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useMarkAsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.markAsRead(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
// Optimistic update
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
const previousEmail = queryClient.getQueryData<StaffEmail>(staffEmailKeys.emailDetail(id));
|
||||
if (previousEmail) {
|
||||
queryClient.setQueryData<StaffEmail>(staffEmailKeys.emailDetail(id), {
|
||||
...previousEmail,
|
||||
isRead: true,
|
||||
});
|
||||
}
|
||||
return { previousEmail };
|
||||
},
|
||||
onError: (err, id, context) => {
|
||||
if (context?.previousEmail) {
|
||||
queryClient.setQueryData(staffEmailKeys.emailDetail(id), context.previousEmail);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkAsUnread = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.markAsUnread(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useStarEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.starEmail(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
},
|
||||
// Optimistic update
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
const previousEmail = queryClient.getQueryData<StaffEmail>(staffEmailKeys.emailDetail(id));
|
||||
if (previousEmail) {
|
||||
queryClient.setQueryData<StaffEmail>(staffEmailKeys.emailDetail(id), {
|
||||
...previousEmail,
|
||||
isStarred: true,
|
||||
});
|
||||
}
|
||||
return { previousEmail };
|
||||
},
|
||||
onError: (err, id, context) => {
|
||||
if (context?.previousEmail) {
|
||||
queryClient.setQueryData(staffEmailKeys.emailDetail(id), context.previousEmail);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnstarEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.unstarEmail(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(id) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useArchiveEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.archiveEmail(id),
|
||||
onSuccess: () => {
|
||||
// Reset and refetch all email list queries
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTrashEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.trashEmail(id),
|
||||
onSuccess: () => {
|
||||
// Reset and refetch all email list queries
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRestoreEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.restoreEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePermanentlyDeleteEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.permanentlyDeleteEmail(id),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMoveEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailMove) => staffEmailApi.moveEmails(data),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useBulkEmailAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: StaffEmailBulkAction) => staffEmailApi.bulkAction(data),
|
||||
onSuccess: () => {
|
||||
queryClient.resetQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Label Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useStaffEmailLabels = () => {
|
||||
return useQuery<StaffEmailLabel[]>({
|
||||
queryKey: staffEmailKeys.labels(),
|
||||
queryFn: staffEmailApi.getLabels,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, color }: { name: string; color: string }) =>
|
||||
staffEmailApi.createLabel(name, color),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: { name?: string; color?: string } }) =>
|
||||
staffEmailApi.updateLabel(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteLabel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteLabel(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.labels() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddLabelToEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ emailId, labelId }: { emailId: number; labelId: number }) =>
|
||||
staffEmailApi.addLabelToEmail(emailId, labelId),
|
||||
onSuccess: (_, { emailId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(emailId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveLabelFromEmail = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ emailId, labelId }: { emailId: number; labelId: number }) =>
|
||||
staffEmailApi.removeLabelFromEmail(emailId, labelId),
|
||||
onSuccess: (_, { emailId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emailDetail(emailId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Contact Search Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useContactSearch = (query: string) => {
|
||||
return useQuery<EmailContactSuggestion[]>({
|
||||
queryKey: staffEmailKeys.contacts(query),
|
||||
queryFn: () => staffEmailApi.searchContacts(query),
|
||||
enabled: query.length >= 2,
|
||||
staleTime: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useUploadAttachment = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ file, emailId }: { file: File; emailId?: number }) =>
|
||||
staffEmailApi.uploadAttachment(file, emailId),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAttachment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => staffEmailApi.deleteAttachment(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sync Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useSyncEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => staffEmailApi.syncEmails(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useFullSyncEmails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => staffEmailApi.fullSyncEmails(),
|
||||
onSuccess: () => {
|
||||
// Invalidate after a delay to allow sync to complete
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.emails() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.folders() });
|
||||
queryClient.invalidateQueries({ queryKey: staffEmailKeys.userEmailAddresses() });
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Email Addresses Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useUserEmailAddresses = () => {
|
||||
return useQuery({
|
||||
queryKey: staffEmailKeys.userEmailAddresses(),
|
||||
queryFn: staffEmailApi.getUserEmailAddresses,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
};
|
||||
345
frontend/src/hooks/useStaffEmailWebSocket.ts
Normal file
345
frontend/src/hooks/useStaffEmailWebSocket.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useCurrentUser } from './useAuth';
|
||||
import { getBaseDomain } from '../utils/domain';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
|
||||
/**
|
||||
* Event types sent by the staff email WebSocket consumer
|
||||
*/
|
||||
type StaffEmailEventType =
|
||||
| 'new_email'
|
||||
| 'email_read'
|
||||
| 'email_unread'
|
||||
| 'email_moved'
|
||||
| 'email_deleted'
|
||||
| 'folder_counts'
|
||||
| 'sync_started'
|
||||
| 'sync_completed'
|
||||
| 'sync_error';
|
||||
|
||||
interface NewEmailData {
|
||||
id?: number;
|
||||
subject?: string;
|
||||
from_name?: string;
|
||||
from_address?: string;
|
||||
snippet?: string;
|
||||
folder_id?: number;
|
||||
email_address_id?: number;
|
||||
}
|
||||
|
||||
interface FolderCountData {
|
||||
[folderId: string]: {
|
||||
unread_count?: number;
|
||||
total_count?: number;
|
||||
folder_type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncStatusData {
|
||||
email_address_id?: number;
|
||||
results?: Record<string, number>;
|
||||
new_count?: number;
|
||||
message?: string;
|
||||
details?: {
|
||||
results?: Record<string, number>;
|
||||
new_count?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type StaffEmailData = NewEmailData | FolderCountData | SyncStatusData;
|
||||
|
||||
interface StaffEmailWebSocketMessage {
|
||||
type: StaffEmailEventType;
|
||||
data: StaffEmailData;
|
||||
}
|
||||
|
||||
interface UseStaffEmailWebSocketOptions {
|
||||
/** Show toast notifications for events (default: true) */
|
||||
showToasts?: boolean;
|
||||
/** Callback when a new email arrives */
|
||||
onNewEmail?: (data: NewEmailData) => void;
|
||||
/** Callback when sync completes */
|
||||
onSyncComplete?: (data: SyncStatusData) => void;
|
||||
/** Callback when folder counts update */
|
||||
onFolderCountsUpdate?: (data: FolderCountData) => void;
|
||||
/** Callback when sync starts */
|
||||
onSyncStarted?: (data: SyncStatusData) => void;
|
||||
/** Callback when sync errors */
|
||||
onSyncError?: (data: SyncStatusData) => void;
|
||||
}
|
||||
|
||||
interface StaffEmailWebSocketResult {
|
||||
/** Whether the WebSocket is currently connected */
|
||||
isConnected: boolean;
|
||||
/** Whether a sync is currently in progress */
|
||||
isSyncing: boolean;
|
||||
/** Manually reconnect the WebSocket */
|
||||
reconnect: () => void;
|
||||
/** Send a message to the WebSocket (for future client commands) */
|
||||
send: (data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage WebSocket connection for real-time staff email updates.
|
||||
* Automatically invalidates React Query cache when email changes occur.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { isConnected, isSyncing } = useStaffEmailWebSocket({
|
||||
* showToasts: true,
|
||||
* onNewEmail: (data) => {
|
||||
* console.log('New email:', data.subject);
|
||||
* },
|
||||
* onSyncComplete: () => {
|
||||
* console.log('Email sync finished');
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const useStaffEmailWebSocket = (
|
||||
options: UseStaffEmailWebSocketOptions = {}
|
||||
): StaffEmailWebSocketResult => {
|
||||
const {
|
||||
showToasts = true,
|
||||
onNewEmail,
|
||||
onSyncComplete,
|
||||
onFolderCountsUpdate,
|
||||
onSyncStarted,
|
||||
onSyncError,
|
||||
} = options;
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const { data: user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
try {
|
||||
const message: StaffEmailWebSocketMessage = JSON.parse(event.data);
|
||||
console.log('Staff Email WebSocket message received:', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'new_email': {
|
||||
const data = message.data as NewEmailData;
|
||||
// Invalidate email list and folders
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
if (showToasts) {
|
||||
toast.success(
|
||||
`New email from ${data.from_name || data.from_address || 'Unknown'}`,
|
||||
{
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
icon: '📧',
|
||||
}
|
||||
);
|
||||
}
|
||||
onNewEmail?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'email_read':
|
||||
case 'email_unread':
|
||||
case 'email_moved':
|
||||
case 'email_deleted': {
|
||||
const data = message.data as NewEmailData;
|
||||
// Invalidate email list and specific email
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
if (data.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['staff-email', data.id],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'folder_counts': {
|
||||
const data = message.data as FolderCountData;
|
||||
// Update folder counts without full refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
onFolderCountsUpdate?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_started': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(true);
|
||||
if (showToasts) {
|
||||
toast.loading('Syncing emails...', {
|
||||
id: 'email-sync',
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
onSyncStarted?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_completed': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(false);
|
||||
// Invalidate all email-related queries
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-emails'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staff-email-addresses'] });
|
||||
if (showToasts) {
|
||||
const newCount = data.details?.new_count || data.new_count || 0;
|
||||
toast.success(
|
||||
newCount > 0 ? `Synced ${newCount} new email${newCount === 1 ? '' : 's'}` : 'Emails synced',
|
||||
{
|
||||
id: 'email-sync',
|
||||
duration: 3000,
|
||||
position: 'bottom-right',
|
||||
}
|
||||
);
|
||||
}
|
||||
onSyncComplete?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sync_error': {
|
||||
const data = message.data as SyncStatusData;
|
||||
setIsSyncing(false);
|
||||
if (showToasts) {
|
||||
const errorMsg = data.details?.message || data.message || 'Sync failed';
|
||||
toast.error(`Email sync error: ${errorMsg}`, {
|
||||
id: 'email-sync',
|
||||
duration: 5000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
onSyncError?.(data);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('Unknown staff email WebSocket message type:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing staff email WebSocket message:', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
showToasts,
|
||||
onNewEmail,
|
||||
onSyncComplete,
|
||||
onFolderCountsUpdate,
|
||||
onSyncStarted,
|
||||
onSyncError,
|
||||
]
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine WebSocket URL using same logic as API config
|
||||
const baseDomain = getBaseDomain();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
// For localhost or lvh.me, use port 8000. In production, no port (Traefik handles it)
|
||||
const isDev = baseDomain === 'localhost' || baseDomain === 'lvh.me';
|
||||
const port = isDev ? ':8000' : '';
|
||||
|
||||
const token = getCookie('access_token');
|
||||
const wsUrl = `${protocol}//api.${baseDomain}${port}/ws/staff-email/?token=${token}`;
|
||||
|
||||
console.log('Connecting to staff email WebSocket:', wsUrl);
|
||||
|
||||
try {
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log('Staff Email WebSocket connected');
|
||||
setIsConnected(true);
|
||||
reconnectAttempts.current = 0; // Reset reconnect attempts on successful connection
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = handleMessage;
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log('Staff Email WebSocket disconnected:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
setIsSyncing(false);
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (user && user.id && reconnectAttempts.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current += 1;
|
||||
console.log(
|
||||
`Attempting to reconnect staff email WebSocket in ${delay}ms (attempt ${reconnectAttempts.current})`
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('Staff Email WebSocket error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create staff email WebSocket:', error);
|
||||
}
|
||||
}, [user, handleMessage]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
// Clear any existing reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
// Close existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
// Reset reconnect attempts
|
||||
reconnectAttempts.current = 0;
|
||||
// Connect
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const send = useCallback((data: unknown) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('Cannot send message: WebSocket is not connected');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isSyncing,
|
||||
reconnect,
|
||||
send,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStaffEmailWebSocket;
|
||||
Reference in New Issue
Block a user