"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useFocusTrap } from "@/hooks/use-focus-trap"; 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, FileText, BookmarkPlus } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth-store"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; import { substitutePlaceholders } from "@/lib/template-utils"; import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; 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 [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, onEscape: () => setShowSaveAsTemplate(false), restoreFocus: true, }); const { client, identities, primaryIdentity } = useAuthStore(); const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); 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); } }; const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record) => { const filledSubject = Object.keys(filledValues).length > 0 ? substitutePlaceholders(template.subject, filledValues) : template.subject; const filledBody = Object.keys(filledValues).length > 0 ? substitutePlaceholders(template.body, filledValues) : template.body; if (mode === 'compose') { setSubject(filledSubject); setBody(filledBody); if (template.defaultRecipients?.to?.length) { setTo(template.defaultRecipients.to.join(', ')); } if (template.defaultRecipients?.cc?.length) { setCc(template.defaultRecipients.cc.join(', ')); setShowCc(true); } if (template.defaultRecipients?.bcc?.length) { setBcc(template.defaultRecipients.bcc.join(', ')); setShowBcc(true); } } else { setBody((prev) => filledBody + prev); } if (template.identityId) { setSelectedIdentityId(template.identityId); } setShowTemplatePicker(false); }, [mode]); useEffect(() => { const handleTemplateKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setShowTemplatePicker(true); } }; window.addEventListener('keydown', handleTemplateKey); return () => window.removeEventListener('keydown', handleTemplateKey); }, []); // 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" />