Files
smoothschedule/frontend/src/api/staffEmail.ts
poduck 18eeda62e8 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>
2025-12-21 23:40:27 -05:00

443 lines
15 KiB
TypeScript

/**
* Staff Email API Client
*
* Provides API functions for the platform staff email client.
* This is for platform users (superuser, platform_manager, platform_support)
* who have been assigned email addresses in staff routing mode.
*/
import apiClient from './client';
import {
StaffEmailFolder,
StaffEmail,
StaffEmailListItem,
StaffEmailLabel,
StaffEmailAttachment,
StaffEmailFilters,
StaffEmailCreateDraft,
StaffEmailMove,
StaffEmailBulkAction,
StaffEmailReply,
StaffEmailForward,
EmailContactSuggestion,
StaffEmailStats,
} from '../types';
const BASE_URL = '/staff-email';
// ============================================================================
// Folders
// ============================================================================
export const getFolders = async (): Promise<StaffEmailFolder[]> => {
const response = await apiClient.get(`${BASE_URL}/folders/`);
return response.data.map(transformFolder);
};
export const createFolder = async (name: string): Promise<StaffEmailFolder> => {
const response = await apiClient.post(`${BASE_URL}/folders/`, { name });
return transformFolder(response.data);
};
export const updateFolder = async (id: number, name: string): Promise<StaffEmailFolder> => {
const response = await apiClient.patch(`${BASE_URL}/folders/${id}/`, { name });
return transformFolder(response.data);
};
export const deleteFolder = async (id: number): Promise<void> => {
await apiClient.delete(`${BASE_URL}/folders/${id}/`);
};
// ============================================================================
// Emails (Messages)
// ============================================================================
export interface PaginatedEmailResponse {
count: number;
next: string | null;
previous: string | null;
results: StaffEmailListItem[];
}
export const getEmails = async (
filters?: StaffEmailFilters,
page: number = 1,
pageSize: number = 50
): Promise<PaginatedEmailResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('page_size', String(pageSize));
if (filters?.folderId) params.append('folder', String(filters.folderId));
if (filters?.emailAddressId) params.append('email_address', String(filters.emailAddressId));
if (filters?.isRead !== undefined) params.append('is_read', String(filters.isRead));
if (filters?.isStarred !== undefined) params.append('is_starred', String(filters.isStarred));
if (filters?.isImportant !== undefined) params.append('is_important', String(filters.isImportant));
if (filters?.labelId) params.append('label', String(filters.labelId));
if (filters?.search) params.append('search', filters.search);
if (filters?.fromDate) params.append('from_date', filters.fromDate);
if (filters?.toDate) params.append('to_date', filters.toDate);
// Debug logging - remove after fixing folder filter issue
console.log('[StaffEmail API] getEmails called with:', { filters, params: params.toString() });
const response = await apiClient.get(`${BASE_URL}/messages/?${params.toString()}`);
// Handle both paginated response {count, results, ...} and legacy array response
const data = response.data;
console.log('[StaffEmail API] Raw response data:', data);
if (Array.isArray(data)) {
// Legacy format (array of emails)
console.log('[StaffEmail API] Response (legacy array):', { count: data.length });
return {
count: data.length,
next: null,
previous: null,
results: data.map(transformEmailListItem),
};
}
// New paginated format
const result = {
count: data.count ?? 0,
next: data.next ?? null,
previous: data.previous ?? null,
results: (data.results ?? []).map(transformEmailListItem),
};
// Debug logging - remove after fixing folder filter issue
console.log('[StaffEmail API] Response:', { count: result.count, resultCount: result.results.length });
return result;
};
export const getEmail = async (id: number): Promise<StaffEmail> => {
const response = await apiClient.get(`${BASE_URL}/messages/${id}/`);
return transformEmail(response.data);
};
export const getEmailThread = async (threadId: string): Promise<StaffEmail[]> => {
const response = await apiClient.get(`${BASE_URL}/messages/`, {
params: { thread_id: threadId },
});
return response.data.results.map(transformEmail);
};
/**
* Convert string email addresses to the format expected by the backend.
* Backend expects: [{ email: "test@example.com", name: "" }]
* Frontend sends: ["test@example.com"]
*/
function formatEmailAddresses(addresses: string[]): Array<{ email: string; name: string }> {
return addresses.map((addr) => {
// Check if it's already in "Name <email>" format
const match = addr.match(/^(.+?)\s*<(.+?)>$/);
if (match) {
return { name: match[1].trim(), email: match[2].trim() };
}
return { email: addr.trim(), name: '' };
});
}
export const createDraft = async (data: StaffEmailCreateDraft): Promise<StaffEmail> => {
const payload = {
email_address: data.emailAddressId,
to_addresses: formatEmailAddresses(data.toAddresses),
cc_addresses: formatEmailAddresses(data.ccAddresses || []),
bcc_addresses: formatEmailAddresses(data.bccAddresses || []),
subject: data.subject,
body_text: data.bodyText || '',
body_html: data.bodyHtml || '',
in_reply_to: data.inReplyTo,
thread_id: data.threadId,
};
const response = await apiClient.post(`${BASE_URL}/messages/`, payload);
return transformEmail(response.data);
};
export const updateDraft = async (id: number, data: Partial<StaffEmailCreateDraft>): Promise<StaffEmail> => {
const payload: Record<string, any> = {};
if (data.toAddresses !== undefined) payload.to_addresses = formatEmailAddresses(data.toAddresses);
if (data.ccAddresses !== undefined) payload.cc_addresses = formatEmailAddresses(data.ccAddresses);
if (data.bccAddresses !== undefined) payload.bcc_addresses = formatEmailAddresses(data.bccAddresses);
if (data.subject !== undefined) payload.subject = data.subject;
if (data.bodyText !== undefined) payload.body_text = data.bodyText;
if (data.bodyHtml !== undefined) payload.body_html = data.bodyHtml;
const response = await apiClient.patch(`${BASE_URL}/messages/${id}/`, payload);
return transformEmail(response.data);
};
export const deleteDraft = async (id: number): Promise<void> => {
await apiClient.delete(`${BASE_URL}/messages/${id}/`);
};
export const sendEmail = async (id: number): Promise<StaffEmail> => {
const response = await apiClient.post(`${BASE_URL}/messages/${id}/send/`);
return transformEmail(response.data);
};
export const replyToEmail = async (id: number, data: StaffEmailReply): Promise<StaffEmail> => {
const payload = {
body_text: data.bodyText || '',
body_html: data.bodyHtml || '',
reply_all: data.replyAll || false,
};
const response = await apiClient.post(`${BASE_URL}/messages/${id}/reply/`);
return transformEmail(response.data);
};
export const forwardEmail = async (id: number, data: StaffEmailForward): Promise<StaffEmail> => {
const payload = {
to_addresses: formatEmailAddresses(data.toAddresses),
cc_addresses: formatEmailAddresses(data.ccAddresses || []),
body_text: data.bodyText || '',
body_html: data.bodyHtml || '',
};
const response = await apiClient.post(`${BASE_URL}/messages/${id}/forward/`, payload);
return transformEmail(response.data);
};
export const moveEmails = async (data: StaffEmailMove): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/move/`, {
email_ids: data.emailIds,
folder_id: data.folderId,
});
};
export const markAsRead = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/mark_read/`);
};
export const markAsUnread = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/mark_unread/`);
};
export const starEmail = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/star/`);
};
export const unstarEmail = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/unstar/`);
};
export const archiveEmail = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/archive/`);
};
export const trashEmail = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/trash/`);
};
export const restoreEmail = async (id: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${id}/restore/`);
};
export const permanentlyDeleteEmail = async (id: number): Promise<void> => {
await apiClient.delete(`${BASE_URL}/messages/${id}/`);
};
export const bulkAction = async (data: StaffEmailBulkAction): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/bulk_action/`, {
email_ids: data.emailIds,
action: data.action,
});
};
// ============================================================================
// Labels
// ============================================================================
export const getLabels = async (): Promise<StaffEmailLabel[]> => {
const response = await apiClient.get(`${BASE_URL}/labels/`);
return response.data.map(transformLabel);
};
export const createLabel = async (name: string, color: string): Promise<StaffEmailLabel> => {
const response = await apiClient.post(`${BASE_URL}/labels/`, { name, color });
return transformLabel(response.data);
};
export const updateLabel = async (id: number, data: { name?: string; color?: string }): Promise<StaffEmailLabel> => {
const response = await apiClient.patch(`${BASE_URL}/labels/${id}/`, data);
return transformLabel(response.data);
};
export const deleteLabel = async (id: number): Promise<void> => {
await apiClient.delete(`${BASE_URL}/labels/${id}/`);
};
export const addLabelToEmail = async (emailId: number, labelId: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${emailId}/add_label/`, { label_id: labelId });
};
export const removeLabelFromEmail = async (emailId: number, labelId: number): Promise<void> => {
await apiClient.post(`${BASE_URL}/messages/${emailId}/remove_label/`, { label_id: labelId });
};
// ============================================================================
// Contacts
// ============================================================================
export const searchContacts = async (query: string): Promise<EmailContactSuggestion[]> => {
const response = await apiClient.get(`${BASE_URL}/contacts/`, {
params: { search: query },
});
return response.data.map(transformContact);
};
// ============================================================================
// Attachments
// ============================================================================
export const uploadAttachment = async (file: File, emailId?: number): Promise<StaffEmailAttachment> => {
const formData = new FormData();
formData.append('file', file);
if (emailId) {
formData.append('email_id', String(emailId));
}
const response = await apiClient.post(`${BASE_URL}/attachments/`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return transformAttachment(response.data);
};
export const deleteAttachment = async (id: number): Promise<void> => {
await apiClient.delete(`${BASE_URL}/attachments/${id}/`);
};
// ============================================================================
// Sync
// ============================================================================
export const syncEmails = async (): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`${BASE_URL}/messages/sync/`);
return response.data;
};
export interface FullSyncTask {
email_address: string;
task_id: string;
}
export interface FullSyncResponse {
status: string;
tasks: FullSyncTask[];
}
export const fullSyncEmails = async (): Promise<FullSyncResponse> => {
const response = await apiClient.post(`${BASE_URL}/messages/full_sync/`);
return response.data;
};
// ============================================================================
// User's Email Addresses
// ============================================================================
export interface UserEmailAddress {
id: number;
email_address: string;
display_name: string;
color: string;
is_default: boolean;
last_check_at: string | null;
emails_processed_count: number;
}
export const getUserEmailAddresses = async (): Promise<UserEmailAddress[]> => {
const response = await apiClient.get(`${BASE_URL}/messages/email_addresses/`);
return response.data;
};
// ============================================================================
// Transform Functions (snake_case -> camelCase)
// ============================================================================
function transformFolder(data: any): StaffEmailFolder {
return {
id: data.id,
owner: data.owner,
name: data.name,
folderType: data.folder_type,
emailCount: data.email_count || 0,
unreadCount: data.unread_count || 0,
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
function transformEmailListItem(data: any): StaffEmailListItem {
return {
id: data.id,
folder: data.folder,
fromAddress: data.from_address,
fromName: data.from_name || '',
toAddresses: data.to_addresses || [],
subject: data.subject || '(No Subject)',
snippet: data.snippet || '',
status: data.status,
isRead: data.is_read,
isStarred: data.is_starred,
isImportant: data.is_important,
hasAttachments: data.has_attachments || false,
attachmentCount: data.attachment_count || 0,
threadId: data.thread_id,
emailDate: data.email_date,
createdAt: data.created_at,
labels: (data.labels || []).map(transformLabel),
};
}
function transformEmail(data: any): StaffEmail {
return {
...transformEmailListItem(data),
owner: data.owner,
emailAddress: data.email_address,
messageId: data.message_id || '',
inReplyTo: data.in_reply_to,
references: data.references || '',
ccAddresses: data.cc_addresses || [],
bccAddresses: data.bcc_addresses || [],
bodyText: data.body_text || '',
bodyHtml: data.body_html || '',
isAnswered: data.is_answered || false,
isPermanentlyDeleted: data.is_permanently_deleted || false,
deletedAt: data.deleted_at,
attachments: (data.attachments || []).map(transformAttachment),
updatedAt: data.updated_at,
};
}
function transformLabel(data: any): StaffEmailLabel {
return {
id: data.id,
owner: data.owner,
name: data.name,
color: data.color || '#3b82f6',
createdAt: data.created_at,
};
}
function transformAttachment(data: any): StaffEmailAttachment {
return {
id: data.id,
filename: data.filename,
contentType: data.content_type,
size: data.size,
url: data.url || data.file_url || '',
createdAt: data.created_at,
};
}
function transformContact(data: any): EmailContactSuggestion {
return {
id: data.id,
owner: data.owner,
email: data.email,
name: data.name || '',
useCount: data.use_count || 0,
lastUsedAt: data.last_used_at,
};
}