import { useState, useEffect, useRef } from 'react'; import { X, Minus, Maximize2, Paperclip, Image, Link2, Smile, Loader2 } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; import { RichTextEditor } from './RichTextEditor'; import { ContactAutocomplete } from './ContactAutocomplete'; import { TrackingToggle } from './TrackingToggle'; import { QuotedMessages } from './QuotedMessages'; import { Contact, contactService } from '@/services/ContactService'; import type { Email } from '@/types'; interface ComposeModalProps { isOpen: boolean; onClose: () => void; onSend?: ( to: string, subject: string, body: string, threadingMetadata?: { inReplyTo: string; references: string[]; } ) => Promise; sending?: boolean; replyTo?: Email | null; forwardEmail?: Email | null; onOptimisticUpdate?: (email: Email) => void; } export function ComposeModal({ isOpen, onClose, onSend, sending, replyTo, forwardEmail, onOptimisticUpdate }: ComposeModalProps) { const { getToken } = useAuth(); const [to, setTo] = useState(''); const [cc, setCc] = useState(''); const [bcc, setBcc] = useState(''); const [showCc, setShowCc] = useState(false); const [showBcc, setShowBcc] = useState(false); const [subject, setSubject] = useState(''); const [body, setBody] = useState(''); const [isMinimized, setIsMinimized] = useState(false); const [error, setError] = useState(''); const [signature, setSignature] = useState(''); const [attachments, setAttachments] = useState([]); const [attachmentError, setAttachmentError] = useState(''); const [trackingEnabled, setTrackingEnabled] = useState(false); const [isSending, setIsSending] = useState(false); const fileInputRef = useRef(null); // Constants const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // 25MB const BLOCKED_EXTENSIONS = [ 'exe', 'bat', 'cmd', 'com', 'scr', 'vbs', 'js', 'jar', 'msi', 'dll' ]; // Helper function to extract email and display name from "Name " format const extractEmailAndName = (emailString: string): { email: string; displayName: string } => { const match = emailString.match(/^(.+?)\s*<(.+?)>$/); if (match) { return { displayName: match[1].trim(), email: match[2].trim(), }; } return { email: emailString.trim(), displayName: emailString.trim(), }; }; // Handle modal close const handleClose = () => { setAttachments([]); setAttachmentError(''); setCc(''); setBcc(''); setShowCc(false); setShowBcc(false); setTrackingEnabled(false); onClose(); }; // Load signature from settings useEffect(() => { (async () => { try { const token = await getToken(); const res = await fetch('/api/settings', { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); if (data.signature) { setSignature(data.signature); } } catch (e) { console.error('Failed to load signature:', e); } })(); }, [getToken]); // Prefill when replying or forwarding useEffect(() => { if (replyTo) { // Reply logic — only set reply area + signature in the editor setTo(replyTo.from.email); setSubject( replyTo.subject.startsWith('Re:') ? replyTo.subject : `Re: ${replyTo.subject}` ); const replyAreaHtml = '


'; const signatureHtml = signature ? `


${signature}
` : ''; setBody(`${replyAreaHtml}${signatureHtml}`); setTrackingEnabled(false); } else if (forwardEmail) { // Forward logic — only set reply area + signature in the editor setTo(''); setSubject( forwardEmail.subject.startsWith('Fwd:') ? forwardEmail.subject : `Fwd: ${forwardEmail.subject}` ); const replyAreaHtml = '


'; const signatureHtml = signature ? `


${signature}
` : ''; setBody(`${replyAreaHtml}${signatureHtml}`); setTrackingEnabled(false); } else { // New email: just the signature with proper spacing setTo(''); setSubject(''); const signatureHtml = signature ? `


${signature}
` : ''; setBody(signatureHtml); setTrackingEnabled(false); } }, [replyTo, forwardEmail, signature]); if (!isOpen) return null; // File selection handler const handleFileSelect = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); // Validate file types const hasBlockedType = files.some(file => { const ext = file.name.split('.').pop()?.toLowerCase(); return ext && BLOCKED_EXTENSIONS.includes(ext); }); if (hasBlockedType) { setAttachmentError('Executable files are not allowed'); return; } // Validate total size const currentSize = attachments.reduce((sum, f) => sum + f.size, 0); const newSize = files.reduce((sum, f) => sum + f.size, 0); const totalSize = currentSize + newSize; if (totalSize > MAX_TOTAL_SIZE) { const totalMB = (totalSize / (1024 * 1024)).toFixed(1); setAttachmentError(`Total size ${totalMB}MB exceeds 25MB limit`); return; } // Add files setAttachments(prev => [...prev, ...files]); setAttachmentError(''); // Reset input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Remove attachment handler const handleRemoveAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); setAttachmentError(''); }; // Format file size helper const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; }; /** * Build the full email body by combining user reply + quoted content. * This is used on send so the quoted thread is included in the sent email. */ const buildFullBody = (): string => { const quotedEmail = replyTo || forwardEmail; if (!quotedEmail) return body; const separator = replyTo ? '--- Original Message ---' : '--- Forwarded Message ---'; const fromLine = `From: ${quotedEmail.from.name} <${quotedEmail.from.email}>`; const dateLine = `Date: ${quotedEmail.date}`; const subjectLine = `Subject: ${quotedEmail.subject}`; const toLine = forwardEmail ? `To: ${forwardEmail.to.map(t => `${t.name} <${t.email}>`).join(', ')}` : ''; const quotedBody = quotedEmail.body || quotedEmail.bodyText || ''; const quotedHtml = [ `


${separator}
`, `
${fromLine}
`, `
${dateLine}
`, toLine ? `
${toLine}
` : '', `
${subjectLine}
`, `

${quotedBody}
`, ].filter(Boolean).join(''); return `${body}${quotedHtml}`; }; const handleSend = async () => { if (!to || !subject) return; setError(''); // Immediate UI feedback - set loading state within 100ms setIsSending(true); // Build the full body (reply + quoted content) for sending const fullBody = buildFullBody(); // Optimistic UI update - create temporary email object const optimisticEmail: Email = { id: `temp-${Date.now()}`, from: { name: '', email: '' }, // Will be filled by backend to: [{ name: to, email: to }], subject, preview: body.substring(0, 150), body: fullBody, bodyText: fullBody, date: new Date().toISOString(), read: true, starred: false, folder: 'sent', hasAttachment: attachments.length > 0, labels: [], trackingId: trackingEnabled ? `temp-tracking-${Date.now()}` : undefined, }; // Apply optimistic update immediately if (onOptimisticUpdate) { onOptimisticUpdate(optimisticEmail); } try { // Build FormData const formData = new FormData(); formData.append('to', to); formData.append('subject', subject); formData.append('body', fullBody); // Extract threading metadata only if replying to an email (not forwarding) if (replyTo && !forwardEmail) { // Check if messageId exists before building threading metadata if (replyTo.messageId) { // Build new references array: existing references + original messageId const existingRefs = replyTo.references || []; const newReferences = [...existingRefs, replyTo.messageId]; formData.append('inReplyTo', replyTo.messageId); formData.append('references', JSON.stringify(newReferences)); } else { // Log warning when replying to an email without messageId console.warn( '[ComposeModal] Replying to email without messageId - threading headers will be omitted', { emailId: replyTo.id, subject: replyTo.subject } ); } } // Add attachments attachments.forEach(file => { formData.append('attachments', file); }); // Add tracking parameter if (trackingEnabled) { formData.append('tracking', 'true'); } // Send request const token = await getToken(); const response = await fetch('/api/mail/send', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, }, body: formData, }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || 'Failed to send'); } // After successful email send, save contact asynchronously // Extract email and display name from "to" field const { email, displayName } = extractEmailAndName(to); // Save contact without blocking (fire and forget) contactService.saveContact(email, displayName).catch((error) => { // Log error but don't block email send flow console.error('[ComposeModal] Failed to save contact after email send:', error); }); // Clear form setTo(''); setSubject(''); setBody(''); setAttachments([]); setAttachmentError(''); setTrackingEnabled(false); handleClose(); } catch (err: any) { setError(err.message || 'Failed to send'); // Note: In a production app, we would rollback the optimistic update here // For now, the email list will refresh and remove the temporary email } finally { setIsSending(false); } }; if (isMinimized) { return (
setIsMinimized(false)} > {subject || 'New Message'}
); } return (
{/* Header */}
{replyTo ? 'Reply' : forwardEmail ? 'Forward' : 'New Message'}
{/* Error */} {error && (
{error}
)} {/* Attachment Error */} {attachmentError && (
{attachmentError}
)} {/* Fields */}
{ setTo(contact.email); }} placeholder="recipient@example.com" /> {!showCc && !showBcc && (
)}
{showCc && (
{ setCc(contact.email); }} placeholder="cc@example.com" />
)} {showBcc && (
{ setBcc(contact.email); }} placeholder="bcc@example.com" />
)}
setSubject(e.target.value)} className="flex-1 py-2.5 text-[13px] text-gray-900 outline-none placeholder:text-gray-300" placeholder="Subject" />
{/* Body */}
{/* Collapsible quoted messages */} {replyTo && } {forwardEmail && }
{/* Attachments Display */} {attachments.length > 0 && (
{attachments.map((file, index) => (
{file.name} ({formatFileSize(file.size)})
))}
)} {/* Hidden File Input */} {/* Footer */}
); }