"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react"; import { cn, formatFileSize } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; 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 | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; className?: string; initialDraftText?: 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, initialDraftText, 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 = () => { const prefix = initialDraftText || ""; if (!replyTo?.body) return prefix; 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 `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; } return prefix; }; 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 [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [shakeField, setShakeField] = useState(null); const [selectedIdentityId, setSelectedIdentityId] = useState(null); const [subAddressTag, setSubAddressTag] = useState(''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); 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 toDropdownRef = useRef(null); const ccDropdownRef = useRef(null); const bccDropdownRef = 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 handleAutoBlur = useCallback((e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => { const dropdownRef = field === 'to' ? toDropdownRef : field === 'cc' ? ccDropdownRef : bccDropdownRef; const relatedTarget = e.relatedTarget as Node | null; if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { return; } if (activeAutoField === field) { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, [activeAutoField]); 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); }, []); const handleFileSelect = async (event: React.ChangeEvent) => { if (!client || !event.target.files) return; const files = Array.from(event.target.files); // AbortController tracks cancellation state but uploadBlob doesn't accept a signal, // so abort only prevents post-upload state updates (cosmetic cancellation) const newAttachments = files.map(file => { const controller = new AbortController(); return { file, uploading: true, abortController: controller }; }); setAttachments(prev => [...prev, ...newAttachments]); for (let i = 0; i < files.length; i++) { const file = files[i]; const controller = newAttachments[i].abortController; try { if (controller?.signal.aborted) continue; const { blobId } = await client.uploadBlob(file); if (controller?.signal.aborted) continue; setAttachments(prev => prev.map(att => att.file === file ? { ...att, blobId, uploading: false, abortController: undefined } : att ) ); } catch (error) { if (controller?.signal.aborted) continue; debug.error(`Failed to upload ${file.name}:`, error); toast.error(t('upload_failed', { filename: file.name })); setAttachments(prev => prev.map(att => att.file === file ? { ...att, uploading: false, error: true, abortController: undefined } : att ) ); } } if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const removeAttachment = (index: number) => { const att = attachments[index]; att?.abortController?.abort(); 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); 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 toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const hasContent = body || attachments.some(att => att.blobId && !att.uploading); const canSend = toAddresses.length > 0 && !!subject && hasContent; const getSendTooltip = (): string | undefined => { if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); if (!subject) return t('validation.subject_required'); if (!hasContent) return t('validation.body_required'); return undefined; }; const handleSend = async () => { const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); if (!canSend) { const errors: { to?: boolean; subject?: boolean; body?: boolean } = {}; if (toAddresses.length === 0) errors.to = true; if (!subject) errors.subject = true; if (!hasContent) errors.body = true; setValidationErrors(errors); if (errors.to) { setShakeField('to'); setTimeout(() => setShakeField(null), 400); toInputRef.current?.focus(); } return; } let finalDraftId = draftId; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); try { const savedId = await saveDraft(); if (savedId) { finalDraftId = savedId; } } catch (err) { debug.error('Failed to save draft before send:', err); } } const currentIdentity = selectedIdentityId ? identities.find(id => id.id === selectedIdentityId) : primaryIdentity; const fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) : currentIdentity.email : undefined; try { await onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, draftId: finalDraftId || undefined, fromEmail, identityId: currentIdentity?.id, }); setTo(""); setCc(""); setBcc(""); setSubject(""); setBody(""); setDraftId(null); setSubAddressTag(""); setValidationErrors({}); } catch (err) { debug.error('Failed to send email:', err); toast.error(t('send_failed')); } }; const handleClose = async () => { if (draftId && (to || subject || body)) { const confirmed = await confirm({ title: t('discard_draft_title'), message: t('discard_draft_confirm'), confirmText: t('discard'), variant: "destructive", }); if (confirmed) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } 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); if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); handleAutocomplete(e.target.value, 'to'); }} onKeyDown={(e) => handleAutoKeyDown(e, 'to')} onBlur={(e) => handleAutoBlur(e, 'to')} className={cn( "border-0 focus-visible:ring-0", validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400" )} 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} aria-invalid={validationErrors.to || undefined} /> {validationErrors.to && (

{t('validation.recipient_required')}

)} {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={(e) => handleAutoBlur(e, 'cc')} 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={(e) => handleAutoBlur(e, 'bcc')} 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); if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); }} className={cn( "flex-1 border-0 focus-visible:ring-0", validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400" )} aria-invalid={validationErrors.subject || undefined} />