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>
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
/**
|
|
* 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
|
|
});
|
|
};
|