"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth-store"; import { useContactStore } from "@/stores/contact-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; interface EmailComposerProps { onSend?: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string; draftId?: string; fromEmail?: string; identityId?: string; }) => void; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; className?: string; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: { from?: { email?: string; name?: string }[]; to?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[]; subject?: string; body?: string; receivedAt?: string; }; } export function EmailComposer({ onSend, onClose, onDiscardDraft, className, mode = 'compose', replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); // Initialize with reply/forward data if provided const getInitialTo = () => { if (!replyTo) return ""; if (mode === 'reply') { return replyTo.from?.[0]?.email || ""; } else if (mode === 'replyAll') { const from = replyTo.from?.[0]?.email || ""; const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; return [from, originalTo].filter(Boolean).join(", "); } return ""; }; const getInitialCc = () => { if (!replyTo || mode !== 'replyAll') return ""; return replyTo.cc?.map(r => r.email).join(", ") || ""; }; const getInitialSubject = () => { if (!replyTo?.subject) return ""; if (mode === 'forward') { const fwdPrefix = t('prefix.forward'); return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`; } else if (mode === 'reply' || mode === 'replyAll') { const rePrefix = t('prefix.reply'); return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`; } return ""; }; const getInitialBody = () => { if (!replyTo?.body) return ""; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); if (mode === 'forward') { return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; } else if (mode === 'reply' || mode === 'replyAll') { return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; } return ""; }; const [to, setTo] = useState(getInitialTo()); const [cc, setCc] = useState(getInitialCc()); const [bcc, setBcc] = useState(""); const [subject, setSubject] = useState(getInitialSubject()); const [body, setBody] = useState(getInitialBody()); const [showCc, setShowCc] = useState(!!getInitialCc()); const [showBcc, setShowBcc] = useState(false); const [draftId, setDraftId] = useState(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); const [attachments, setAttachments] = useState>([]); const fileInputRef = useRef(null); const [selectedIdentityId, setSelectedIdentityId] = useState(null); const [subAddressTag, setSubAddressTag] = useState(''); const { client, identities, primaryIdentity } = useAuthStore(); const getAutocomplete = useContactStore((s) => s.getAutocomplete); const [autocompleteResults, setAutocompleteResults] = useState>([]); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); const autocompleteTimeoutRef = useRef(null); const toInputRef = useRef(null); const ccInputRef = useRef(null); const bccInputRef = useRef(null); const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => { if (autocompleteTimeoutRef.current) { clearTimeout(autocompleteTimeoutRef.current); } const lastPart = value.split(',').pop()?.trim() || ''; if (lastPart.length < 1) { setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); return; } autocompleteTimeoutRef.current = setTimeout(() => { const results = getAutocomplete(lastPart); setAutocompleteResults(results); setActiveAutoField(results.length > 0 ? field : null); setAutoSelectedIndex(-1); }, 200); }, [getAutocomplete]); const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => { const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; const getter = field === 'to' ? to : field === 'cc' ? cc : bcc; const parts = getter.split(','); parts.pop(); parts.push(` ${email}`); setter(parts.join(',').replace(/^,\s*/, '')); setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); const ref = field === 'to' ? toInputRef : field === 'cc' ? ccInputRef : bccInputRef; ref.current?.focus(); }; const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => { if (!activeAutoField || autocompleteResults.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setAutoSelectedIndex((prev) => Math.min(prev + 1, autocompleteResults.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setAutoSelectedIndex((prev) => Math.max(prev - 1, -1)); } else if (e.key === 'Enter' && autoSelectedIndex >= 0) { e.preventDefault(); insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field); } else if (e.key === 'Escape') { setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); } }; // Handle file selection const handleFileSelect = async (event: React.ChangeEvent) => { if (!client || !event.target.files) return; const files = Array.from(event.target.files); // Add files to attachments list with uploading state const newAttachments = files.map(file => ({ file, uploading: true })); setAttachments(prev => [...prev, ...newAttachments]); // Upload each file for (let i = 0; i < files.length; i++) { const file = files[i]; try { const { blobId } = await client.uploadBlob(file); // Update attachment with blobId setAttachments(prev => prev.map(att => att.file === file ? { ...att, blobId, uploading: false } : att ) ); } catch (error) { console.error(`Failed to upload ${file.name}:`, error); // Mark attachment as failed setAttachments(prev => prev.map(att => att.file === file ? { ...att, uploading: false, error: true } : att ) ); } } // Clear the input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Remove attachment const removeAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); }; // Auto-save draft functionality const saveDraft = async (): Promise => { if (!client) return null; const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); // Only save if there's some content if (!toAddresses.length && !subject && !body) { return null; } // Prepare attachments for draft const uploadedAttachments = attachments .filter(att => att.blobId && !att.uploading) .map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type, size: att.file.size, })); // Create a hash of current data to compare with last saved const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag }); // Only save if data has changed if (currentData === lastSavedDataRef.current) { return draftId; } setSaveStatus('saving'); // Get the selected identity or primary identity const currentIdentity = selectedIdentityId ? identities.find(id => id.id === selectedIdentityId) : primaryIdentity; // Generate sub-addressed email if tag is set const fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) : currentIdentity.email : undefined; try { const savedDraftId = await client.createDraft( toAddresses, subject || t('no_subject'), body, ccAddresses, bccAddresses, currentIdentity?.id, fromEmail, draftId || undefined, uploadedAttachments ); setDraftId(savedDraftId); lastSavedDataRef.current = currentData; setSaveStatus('saved'); // Reset status after 2 seconds setTimeout(() => setSaveStatus('idle'), 2000); return savedDraftId; } catch (error) { console.error('Failed to save draft:', error); setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); return null; } }; // Trigger auto-save when content changes useEffect(() => { // Clear existing timeout if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } // Don't auto-save if there's no content if (!to && !subject && !body) { return; } // Set new timeout for auto-save (2 seconds after last change) saveTimeoutRef.current = setTimeout(() => { saveDraft(); }, 2000); // Cleanup on unmount return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up }, [to, cc, bcc, subject, body, attachments]); useEffect(() => { return () => { if (autocompleteTimeoutRef.current) { clearTimeout(autocompleteTimeoutRef.current); } }; }, []); const handleSend = async () => { const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); // Allow sending if we have recipient, subject, and either body text or attachments const hasContent = body || attachments.some(att => att.blobId && !att.uploading); if (toAddresses.length > 0 && subject && hasContent) { // Wait for any pending auto-save to complete and get the latest draft ID let finalDraftId = draftId; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); // saveDraft returns the new draft ID after destroy+create const savedId = await saveDraft(); if (savedId) { finalDraftId = savedId; } } // Get the selected identity or primary identity const currentIdentity = selectedIdentityId ? identities.find(id => id.id === selectedIdentityId) : primaryIdentity; // Generate sub-addressed email if tag is set const fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) : currentIdentity.email : undefined; onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, draftId: finalDraftId || undefined, fromEmail, identityId: currentIdentity?.id, }); // Reset form setTo(""); setCc(""); setBcc(""); setSubject(""); setBody(""); setDraftId(null); setSubAddressTag(""); } }; const handleClose = () => { // If there's a draft with content, ask user if they want to discard if (draftId && (to || subject || body)) { const confirmDiscard = window.confirm(t('discard_draft_confirm')); if (confirmDiscard) { // Clear any pending auto-save if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } // Delete the draft if callback is provided if (onDiscardDraft) { onDiscardDraft(draftId); } onClose?.(); } } else { onClose?.(); } }; return (

{t('new_message')}

{saveStatus === 'saving' && (
{t('saving')}
)} {saveStatus === 'saved' && (
{t('draft_saved')}
)} {saveStatus === 'error' && (
{t('save_failed')}
)}
{/* From field - show dropdown if multiple identities, otherwise display email */}
{t('from')}:
{identities.length > 1 ? ( ) : ( {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag)} ) : ( <> {primaryIdentity?.name ? `${primaryIdentity.name} <${primaryIdentity.email}>` : primaryIdentity?.email || ''} )} )} id.id === selectedIdentityId)?.email : primaryIdentity?.email) || '' } recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)} onSelectTag={setSubAddressTag} /> {subAddressTag && ( )}
{t('to')}:
{ setTo(e.target.value); handleAutocomplete(e.target.value, 'to'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'to')} onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} className="border-0 focus-visible:ring-0" role="combobox" aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0} aria-autocomplete="list" aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined} aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} /> {activeAutoField === 'to' && autocompleteResults.length > 0 && ( insertAutocomplete(email, 'to')} /> )}
{showCc && (
{t('cc_label')}
{ setCc(e.target.value); handleAutocomplete(e.target.value, 'cc'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'cc')} onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} className="border-0 focus-visible:ring-0" role="combobox" aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} aria-autocomplete="list" aria-controls={activeAutoField === 'cc' ? 'autocomplete-cc' : undefined} aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} /> {activeAutoField === 'cc' && autocompleteResults.length > 0 && ( insertAutocomplete(email, 'cc')} /> )}
)} {showBcc && (
{t('bcc_label')}
{ setBcc(e.target.value); handleAutocomplete(e.target.value, 'bcc'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')} onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)} className="border-0 focus-visible:ring-0" role="combobox" aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} aria-autocomplete="list" aria-controls={activeAutoField === 'bcc' ? 'autocomplete-bcc' : undefined} aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} /> {activeAutoField === 'bcc' && autocompleteResults.length > 0 && ( insertAutocomplete(email, 'bcc')} /> )}
)}
{t('subject_label')} setSubject(e.target.value)} className="flex-1 border-0 focus-visible:ring-0" />