Files
smoothschedule/frontend/src/components/email/EmailComposer.tsx
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

421 lines
14 KiB
TypeScript

/**
* Email Composer Component
*
* Compose, reply, and forward emails with rich text editing.
*/
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
X,
Send,
Paperclip,
Trash2,
Minimize2,
Maximize2,
Bold,
Italic,
Underline,
List,
ListOrdered,
Link,
Loader2,
} from 'lucide-react';
import { StaffEmail, StaffEmailCreateDraft } from '../../types';
import {
useCreateDraft,
useUpdateDraft,
useSendEmail,
useUploadAttachment,
useContactSearch,
useUserEmailAddresses,
} from '../../hooks/useStaffEmail';
import toast from 'react-hot-toast';
interface EmailComposerProps {
replyTo?: StaffEmail | null;
forwardFrom?: StaffEmail | null;
onClose: () => void;
onSent: () => void;
}
const EmailComposer: React.FC<EmailComposerProps> = ({
replyTo,
forwardFrom,
onClose,
onSent,
}) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Get available email addresses for sending (only those assigned to current user)
const { data: userEmailAddresses = [] } = useUserEmailAddresses();
// Form state
const [fromAddressId, setFromAddressId] = useState<number | null>(null);
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [bcc, setBcc] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [draftId, setDraftId] = useState<number | null>(null);
// Contact search
const [toQuery, setToQuery] = useState('');
const { data: contactSuggestions = [] } = useContactSearch(toQuery);
// Mutations
const createDraft = useCreateDraft();
const updateDraft = useUpdateDraft();
const sendEmail = useSendEmail();
const uploadAttachment = useUploadAttachment();
// Initialize form for reply/forward
useEffect(() => {
if (replyTo) {
// Reply mode
setTo(replyTo.fromAddress);
setSubject(replyTo.subject.startsWith('Re:') ? replyTo.subject : `Re: ${replyTo.subject}`);
setBody(`\n\n---\nOn ${new Date(replyTo.emailDate).toLocaleString()}, ${replyTo.fromName || replyTo.fromAddress} wrote:\n\n${replyTo.bodyText}`);
} else if (forwardFrom) {
// Forward mode
setSubject(forwardFrom.subject.startsWith('Fwd:') ? forwardFrom.subject : `Fwd: ${forwardFrom.subject}`);
setBody(`\n\n---\nForwarded message:\nFrom: ${forwardFrom.fromName || forwardFrom.fromAddress} <${forwardFrom.fromAddress}>\nDate: ${new Date(forwardFrom.emailDate).toLocaleString()}\nSubject: ${forwardFrom.subject}\nTo: ${forwardFrom.toAddresses.join(', ')}\n\n${forwardFrom.bodyText}`);
}
}, [replyTo, forwardFrom]);
// Set default from address
useEffect(() => {
if (!fromAddressId && userEmailAddresses.length > 0) {
setFromAddressId(userEmailAddresses[0].id);
}
}, [userEmailAddresses, fromAddressId]);
const parseAddresses = (input: string): string[] => {
return input
.split(/[,;]/)
.map((addr) => addr.trim())
.filter((addr) => addr.length > 0);
};
const handleSend = async () => {
if (!fromAddressId) {
toast.error('Please select a From address');
return;
}
const toAddresses = parseAddresses(to);
if (toAddresses.length === 0) {
toast.error('Please enter at least one recipient');
return;
}
try {
// Create or update draft first
let emailId = draftId;
const draftData: StaffEmailCreateDraft = {
emailAddressId: fromAddressId,
toAddresses,
ccAddresses: parseAddresses(cc),
bccAddresses: parseAddresses(bcc),
subject: subject || '(No Subject)',
bodyText: body,
bodyHtml: `<div style="white-space: pre-wrap;">${body.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`,
inReplyTo: replyTo?.id,
threadId: replyTo?.threadId || undefined,
};
if (emailId) {
await updateDraft.mutateAsync({ id: emailId, data: draftData });
} else {
const draft = await createDraft.mutateAsync(draftData);
emailId = draft.id;
setDraftId(emailId);
}
// Send the email
await sendEmail.mutateAsync(emailId);
toast.success('Email sent');
onSent();
} catch (error: any) {
toast.error(error.response?.data?.error || 'Failed to send email');
}
};
const handleSaveDraft = async () => {
if (!fromAddressId) {
toast.error('Please select a From address');
return;
}
try {
const draftData: StaffEmailCreateDraft = {
emailAddressId: fromAddressId,
toAddresses: parseAddresses(to),
ccAddresses: parseAddresses(cc),
bccAddresses: parseAddresses(bcc),
subject: subject || '(No Subject)',
bodyText: body,
bodyHtml: `<div style="white-space: pre-wrap;">${body.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`,
inReplyTo: replyTo?.id,
threadId: replyTo?.threadId || undefined,
};
if (draftId) {
await updateDraft.mutateAsync({ id: draftId, data: draftData });
} else {
const draft = await createDraft.mutateAsync(draftData);
setDraftId(draft.id);
}
toast.success('Draft saved');
} catch (error: any) {
toast.error(error.response?.data?.error || 'Failed to save draft');
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
// TODO: Implement attachment upload when draft is created
toast.error('Attachments not yet implemented');
};
const isSending = createDraft.isPending || updateDraft.isPending || sendEmail.isPending;
if (isMinimized) {
return (
<div className="fixed bottom-0 right-4 w-80 bg-white dark:bg-gray-800 shadow-lg rounded-t-lg border border-gray-200 dark:border-gray-700 z-50">
<div
className="flex items-center justify-between px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-t-lg cursor-pointer"
onClick={() => setIsMinimized(false)}
>
<span className="font-medium text-gray-900 dark:text-white truncate">
{subject || 'New Message'}
</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setIsMinimized(false);
}}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
>
<Maximize2 size={14} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
>
<X size={14} />
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-white dark:bg-gray-800">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<span className="font-medium text-gray-900 dark:text-white">
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setIsMinimized(true)}
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<Minimize2 size={16} />
</button>
<button
onClick={onClose}
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<X size={16} />
</button>
</div>
</div>
{/* Form */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* From */}
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">From:</label>
<select
value={fromAddressId || ''}
onChange={(e) => setFromAddressId(Number(e.target.value))}
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white focus:ring-0 p-0"
>
<option value="">Select email address...</option>
{userEmailAddresses.map((addr) => (
<option key={addr.id} value={addr.id}>
{addr.display_name} &lt;{addr.email_address}&gt;
</option>
))}
</select>
</div>
{/* To */}
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">To:</label>
<input
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@example.com"
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
/>
<div className="flex items-center gap-2 text-sm">
{!showCc && (
<button
onClick={() => setShowCc(true)}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Cc
</button>
)}
{!showBcc && (
<button
onClick={() => setShowBcc(true)}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Bcc
</button>
)}
</div>
</div>
{/* Cc */}
{showCc && (
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Cc:</label>
<input
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@example.com"
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
/>
</div>
)}
{/* Bcc */}
{showBcc && (
<div className="flex items-center px-4 py-2 border-b border-gray-100 dark:border-gray-700">
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Bcc:</label>
<input
type="text"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc@example.com"
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
/>
</div>
)}
{/* Subject */}
<div className="flex items-center px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<label className="w-16 text-sm text-gray-500 dark:text-gray-400">Subject:</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Email subject"
className="flex-1 bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 p-0"
/>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your message..."
className="w-full h-full min-h-[200px] bg-transparent border-0 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 resize-none"
/>
</div>
{/* Footer toolbar */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="flex items-center gap-2">
<button
onClick={handleSend}
disabled={isSending}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{isSending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Send size={16} />
)}
{t('staffEmail.send', 'Send')}
</button>
{/* Formatting buttons - placeholder for future rich text */}
<div className="flex items-center gap-1 ml-2 border-l border-gray-300 dark:border-gray-600 pl-2">
<button
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title="Bold"
>
<Bold size={16} />
</button>
<button
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title="Italic"
>
<Italic size={16} />
</button>
<button
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title="Underline"
>
<Underline size={16} />
</button>
</div>
{/* Attachments */}
<label className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ml-2">
<Paperclip size={16} />
<input
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
/>
</label>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSaveDraft}
disabled={createDraft.isPending || updateDraft.isPending}
className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Save draft
</button>
<button
onClick={onClose}
className="p-1.5 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title="Discard"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default EmailComposer;