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>
443 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|