"use client"; import React, { 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, ShieldCheck, Lock } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; import { useSmimeStore } from "@/stores/smime-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder"; import type { MimeAttachment } from "@/lib/smime/mime-builder"; import { smimeSign } from "@/lib/smime/smime-sign"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; 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"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { findReplyIdentityId } from "@/lib/reply-identity"; import { RichTextEditor } from "@/components/email/rich-text-editor"; /** Strip HTML tags and decode entities to get a plain-text version */ function htmlToPlainText(html: string): string { const doc = new DOMParser().parseFromString(html, 'text/html'); return doc.body.textContent || ''; } export interface ComposerDraftData { to: string; cc: string; bcc: string; subject: string; body: string; showCc: boolean; showBcc: boolean; selectedIdentityId: string | null; subAddressTag: string; mode: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: EmailComposerProps['replyTo']; draftId: string | null; } interface EmailComposerProps { onSend?: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string; htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; identityId?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; }) => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: { from?: { email?: string; name?: string }[]; replyToAddresses?: { email?: string; name?: string }[]; to?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[]; bcc?: { email?: string; name?: string }[]; subject?: string; body?: string; htmlBody?: string; receivedAt?: string; accountId?: string; attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>; }; } type ComposerAttachment = { file?: File; name: string; type: string; size: number; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController; }; export function EmailComposer({ onSend, onClose, onDiscardDraft, onSaveState, className, initialDraftText, initialData, mode = 'compose', replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextMode = useSettingsStore((state) => state.plainTextMode); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); // Initialize with reply/forward data if provided const getInitialTo = () => { if (!replyTo) return ""; // RFC 5322: use Reply-To header if present, otherwise fall back to From const replyTarget = replyTo.replyToAddresses?.length ? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ") : replyTo.from?.[0]?.email || ""; if (mode === 'reply') { return replyTarget ? replyTarget + ', ' : ""; } else if (mode === 'replyAll') { const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; const combined = [replyTarget, originalTo].filter(Boolean).join(", "); return combined ? combined + ', ' : ""; } return ""; }; const getInitialCc = () => { if (!replyTo || mode !== 'replyAll') return ""; const cc = replyTo.cc?.map(r => r.email).join(", ") || ""; return cc ? cc + ', ' : ""; }; 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 (plainTextMode) { // Plain text mode: produce plain text body with no HTML const prefix = initialDraftText || ""; if (!replyTo?.body && !replyTo?.htmlBody) return prefix; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); if (mode === 'forward') { return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; } return prefix; } const prefix = initialDraftText ? `

${initialDraftText.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

` : ""; if (!replyTo?.body && !replyTo?.htmlBody) return prefix; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const quoteHeader = mode === 'forward' ? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` : `On ${date}, ${fromStr} wrote:
`; return `${prefix}
${quoteHeader}
${replyTo.htmlBody}
`; } if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { return `${prefix}

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${prefix}

On ${date}, ${fromStr} wrote:
${escapedOriginal}
`; } } return prefix; }; const [to, setTo] = useState(initialData?.to ?? getInitialTo()); const [cc, setCc] = useState(initialData?.cc ?? getInitialCc()); const [bcc, setBcc] = useState(initialData?.bcc ?? ""); const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject()); const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); const [attachments, setAttachments] = useState(() => { if (mode === 'forward' && replyTo?.attachments?.length) { return replyTo.attachments // Skip inline cid-referenced images - they're embedded in the forwarded HTML body // (matches the viewer's hideInlineImageAttachments logic). .filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) .map(att => ({ name: att.name || 'attachment', type: att.type || 'application/octet-stream', size: att.size, blobId: att.blobId, })); } return []; }); const inlineImagesRef = useRef>([]); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [shakeField, setShakeField] = useState(null); const [selectedIdentityId, setSelectedIdentityId] = useState(initialData?.selectedIdentityId ?? null); const [subAddressTag, setSubAddressTag] = useState(initialData?.subAddressTag ?? ''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); const [smimeSign_, setSmimeSign] = useState(false); const [smimeEncrypt_, setSmimeEncrypt] = useState(false); const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, onEscape: () => setShowSaveAsTemplate(false), restoreFocus: true, }); const closeDialogRef = useFocusTrap({ isActive: showCloseDialog, onEscape: () => setShowCloseDialog(false), restoreFocus: true, }); const attachmentWarningRef = useFocusTrap({ isActive: showAttachmentWarning, onEscape: () => setShowAttachmentWarning(false), restoreFocus: true, }); const { client } = useAuthStore(); const identities = useIdentityStore((s) => s.identities); const primaryIdentity = identities[0] ?? null; const currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; if (mode !== 'reply' && mode !== 'replyAll') return; const matchedIdentityId = findReplyIdentityId(identities, { to: replyTo?.to, cc: replyTo?.cc, bcc: replyTo?.bcc, }); if (matchedIdentityId) { setSelectedIdentityId(matchedIdentityId); return; } // Fallback: match identity by the account's email when replying from unified view if (replyTo?.accountId) { const account = useAccountStore.getState().getAccountById(replyTo.accountId); if (account?.email) { const accountEmail = account.email.trim().toLowerCase(); const accountIdentity = identities.find( (identity) => identity.email.trim().toLowerCase() === accountEmail ); if (accountIdentity) { setSelectedIdentityId(accountIdentity.id); } } } }, [ autoSelectReplyIdentity, identities, initialData?.selectedIdentityId, mode, replyTo?.accountId, replyTo?.bcc, replyTo?.cc, replyTo?.to, selectedIdentityId, ]); const composerSignatureHtml = currentIdentity?.htmlSignature ? `
${sanitizeEmailHtml(currentIdentity.htmlSignature)}
` : currentIdentity?.textSignature ? `
${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); const sendRawEmail = useEmailStore((s) => s.sendRawEmail); const smimeStore = useSmimeStore(); // Determine S/MIME availability for the selected identity const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id; const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined; const canSmimeSign = !!smimeKeyRecord; const canSmimeEncrypt = (() => { if (!smimeKeyRecord) return false; const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean); const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean); const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean); const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs]; if (allRecipients.length === 0) return false; const { missing } = smimeStore.getRecipientCerts(allRecipients); return missing.length === 0; })(); // Initialize S/MIME defaults from store when identity changes useEffect(() => { if (currentSmimeIdentityId) { setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign); } setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt); // Only run when identity changes, not on every recipient edit // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentSmimeIdentityId]); // Keep a ref to current state for the unmount save const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }); stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }; // Track initial values for dirty detection (captured once on first render) const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length }); const isDirtyRef = useRef(false); isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc || bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount; // Ref to latest saveDraft for use in event handlers with stale closures const saveDraftRef = useRef<() => Promise>(() => Promise.resolve(null)); // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (onSaveState && isDirtyRef.current) { const s = stateRef.current; onSaveState({ ...s, mode, replyTo, }); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Auto-save draft to server on page close (best-effort) useEffect(() => { const handleBeforeUnload = () => { if (isDirtyRef.current) { saveDraftRef.current(); } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, []); // Auto-focus the To field when composing a new email or forwarding useEffect(() => { if (mode === 'forward' || mode === 'compose') { // Small delay to ensure the input is rendered const timer = setTimeout(() => { toInputRef.current?.focus(); }, 100); return () => clearTimeout(timer); } }, [mode]); 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 subjectInputRef = useRef(null); const bodyRef = useRef(null); const editorContainerRef = useRef(null); const toDropdownRef = useRef(null); const ccDropdownRef = useRef(null); const bccDropdownRef = useRef(null); const focusSubject = useCallback(() => { subjectInputRef.current?.focus(); }, []); const focusBody = useCallback(() => { if (plainTextMode) { bodyRef.current?.focus(); } else { const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null; proseMirror?.focus(); } }, [plainTextMode]); 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(',').map(s => s.trim()).filter(Boolean); if (!getter.trimEnd().endsWith(',') && parts.length > 0) { parts.pop(); } parts.push(email); setter(parts.join(', ') + ', '); 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; // In plain text mode, use template body as-is; otherwise convert to HTML const bodyContent = plainTextMode ? filledBody : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; if (mode === 'compose') { setSubject(filledSubject); setBody(bodyContent); 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) => bodyContent + (plainTextMode ? '\n' : '') + prev); } if (template.identityId) { setSelectedIdentityId(template.identityId); } setShowTemplatePicker(false); }, [mode, plainTextMode]); useEffect(() => { const handleTemplateKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const tag = target?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (target?.getAttribute('contenteditable') === 'true') 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 addFiles = useCallback(async (files: File[]) => { if (!client || files.length === 0) return; const newAttachments: ComposerAttachment[] = files.map(file => { const controller = new AbortController(); return { file, name: file.name, type: file.type || 'application/octet-stream', size: file.size, 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 ) ); } } }, [client, t]); const handleImageUpload = useCallback(async ( file: File, ): Promise<{ src: string; cid: string } | null> => { if (!client) return null; try { const readAsDataUrl = new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => resolve((e.target?.result as string) ?? null); reader.onerror = () => resolve(null); reader.readAsDataURL(file); }); const [{ blobId }, dataUrl] = await Promise.all([ client.uploadBlob(file), readAsDataUrl, ]); if (!dataUrl) throw new Error('Failed to read image as data URL'); const cid = `${generateUUID()}@webmail`; inlineImagesRef.current.push({ cid, blobId, type: file.type || 'application/octet-stream', name: file.name, size: file.size, dataUrl, }); return { src: dataUrl, cid }; } catch (error) { debug.error(`Failed to upload inline image ${file.name}:`, error); toast.error(t('upload_failed', { filename: file.name })); return null; } }, [client, t]); const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; await addFiles(Array.from(event.target.files)); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const [isDraggingOver, setIsDraggingOver] = useState(false); const dragTimeoutRef = useRef(null); const clearDragState = useCallback(() => { if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); dragTimeoutRef.current = null; setIsDraggingOver(false); }, []); const resetDragTimeout = useCallback(() => { if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current); dragTimeoutRef.current = setTimeout(clearDragState, 150); }, [clearDragState]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) { setIsDraggingOver(true); resetDragTimeout(); } }, [resetDragTimeout]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); resetDragTimeout(); }, [resetDragTimeout]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); resetDragTimeout(); }, [resetDragTimeout]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); clearDragState(); if (e.dataTransfer.files?.length) { addFiles(Array.from(e.dataTransfer.files)); } }, [addFiles, clearDragState]); 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 && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) { return null; } // Prepare attachments for draft const uploadedAttachments = attachments .filter(att => att.blobId && !att.uploading) .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type, size: att.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 // 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'), plainTextMode ? body : htmlToPlainText(body), ccAddresses, bccAddresses, currentIdentity?.id, fromEmail, draftId || undefined, uploadedAttachments, currentIdentity?.name || undefined ); 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; } }; // Keep saveDraftRef pointing to latest saveDraft saveDraftRef.current = saveDraft; // Trigger auto-save when content changes (only if user modified something) useEffect(() => { // Clear existing timeout if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } // Don't auto-save if nothing has changed from initial state if (!isDirtyRef.current) { 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 bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim(); const hasContent = bodyPlainText || 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; }; // Rewrite data: URLs of dropped images (tagged with data-cid) into cid: // references so recipient clients that strip data URIs can still render them. const rewriteInlineImages = (html: string): { html: string; attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; } => { const known = inlineImagesRef.current; if (known.length === 0) return { html, attachments: [] }; const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); const used = new Map(); doc.querySelectorAll('img[data-cid]').forEach((img) => { const cid = img.getAttribute('data-cid'); if (!cid) return; const entry = known.find((e) => e.cid === cid); if (!entry) return; img.setAttribute('src', `cid:${cid}`); img.removeAttribute('data-cid'); used.set(cid, entry); }); return { html: doc.body.innerHTML, attachments: Array.from(used.values()).map((e) => ({ blobId: e.blobId, name: e.name, type: e.type, size: e.size, disposition: 'inline' as const, cid: e.cid, })), }; }; const handleSend = async (skipAttachmentCheck = false) => { 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; } // Attachment reminder check if (!skipAttachmentCheck && attachmentReminderEnabled) { const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error); if (!hasAttachments) { const bodyText = htmlToPlainText(body); const searchText = `${subject} ${bodyText}`.toLowerCase(); const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); if (matched) { setAttachmentWarningKeyword(matched); setShowAttachmentWarning(true); 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 fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) : currentIdentity.email : undefined; // Body is already HTML from the rich text editor (or plain text in plain text mode). // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { if (currentIdentity?.htmlSignature) { return `

--
${sanitizeEmailHtml(currentIdentity.htmlSignature)}`; } if (currentIdentity?.textSignature) { return `

--
${currentIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; // In plain text mode, send text/plain only (no HTML body) const finalBody = plainTextMode ? appendPlainTextSignature(body, currentIdentity) : appendPlainTextSignature(htmlToPlainText(body), currentIdentity); const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode ? undefined : `
${rewritten!.html}
${buildSignatureHtml()}`; const inlineAttachments = rewritten?.attachments ?? []; try { // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // 1. Resolve S/MIME key if (smimeSign_ && !smimeKeyRecord) { throw new Error('No S/MIME key bound to this identity'); } // 2. Ensure key is unlocked for signing if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { const passphrase = await new Promise((resolve, reject) => { setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject }); }); try { await smimeStore.unlockKey(smimeKeyRecord.id, passphrase); } finally { setSmimePassphrasePrompt(null); setSmimePassphraseInput(''); setSmimePassphraseError(''); } } // 3. Resolve attachments as ArrayBuffers const mimeAttachments: MimeAttachment[] = []; for (const att of attachments) { if (att.error || att.uploading) continue; let content: ArrayBuffer; if (att.file && att.file.size > 0) { content = await att.file.arrayBuffer(); } else if (att.blobId && client) { content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type); } else { continue; } mimeAttachments.push({ filename: att.name, contentType: att.type || 'application/octet-stream', content, }); } for (const inline of inlineAttachments) { if (!client) break; const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type); mimeAttachments.push({ filename: inline.name, contentType: inline.type, content, cid: inline.cid, }); } // 4. Build canonical MIME const mimeBytes = buildMimeMessage({ from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, to: toAddresses.map(e => ({ email: e })), cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, subject, textBody: finalBody, htmlBody: finalHtmlBody, attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, }); let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); const smimeHeaders = { from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, to: toAddresses.map(e => ({ email: e })), cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, subject, }; // 5. Sign if enabled if (smimeSign_ && smimeKeyRecord) { const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); if (!privateKey) throw new Error('S/MIME key is not unlocked'); const cmsBlob = await smimeSign( mimeBytes, privateKey, smimeKeyRecord.certificate, smimeKeyRecord.certificateChain || [], ); const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' }); } // 6. Encrypt if enabled if (smimeEncrypt_ && smimeKeyRecord) { const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses]; const { found, missing } = smimeStore.getRecipientCerts(allRecipients); if (missing.length > 0) { throw new Error(`Missing certificates for: ${missing.join(', ')}`); } const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer); const payloadBytes = new Uint8Array(await payload.arrayBuffer()); const cmsBlob = await smimeEncrypt( payloadBytes, recipientCertsDer, smimeKeyRecord.certificate, ); const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' }); } // 7. Send via raw email path await sendRawEmail(client, payload, currentIdentity.id); } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size })); uploadedAttachments.push(...inlineAttachments); await onSend?.({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: finalBody, htmlBody: finalHtmlBody, draftId: finalDraftId || undefined, fromEmail, fromName: currentIdentity?.name || undefined, identityId: currentIdentity?.id, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, }); } setTo(""); setCc(""); setBcc(""); setSubject(""); setBody(""); setDraftId(null); setSubAddressTag(""); setValidationErrors({}); // Clear ref so unmount effect doesn't re-save stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; } catch (err) { debug.error('Failed to send email:', err); toast.error(t('send_failed')); } }; const cleanClose = () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; onClose?.(); }; const handleSaveDraftAndClose = async () => { setShowCloseDialog(false); if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } await saveDraft(); stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; onClose?.(); }; const handleDiscardAndClose = () => { setShowCloseDialog(false); if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } if (draftId && onDiscardDraft) { onDiscardDraft(draftId); } stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; onClose?.(); }; const handleClose = () => { if (isDirtyRef.current) { setShowCloseDialog(true); } else { cleanClose(); } }; return (
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
{/* Drag overlay */} {isDraggingOver && (
{t('drop_files')}
)} {/* Header - mobile: clean bar with close/send, desktop: title bar */}

{t('new_message')}

{saveStatus === 'saving' && (
{t('saving')}
)} {saveStatus === 'saved' && (
{t('draft_saved')}
)} {saveStatus === 'error' && (
{t('save_failed')}
)}
{/* Mobile: send button in header */}
{/* Fields section */}
{/* From field */}
{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 && ( )}
{/* To field */}
{t('to')}: { setTo(v); if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); }} inputRef={toInputRef} placeholder={t('to_placeholder')} field="to" onAutocomplete={handleAutocomplete} onAutoKeyDown={handleAutoKeyDown} onAutoBlur={handleAutoBlur} activeAutoField={activeAutoField} autocompleteResults={autocompleteResults} autoSelectedIndex={autoSelectedIndex} dropdownRef={toDropdownRef} onInsertAutocomplete={insertAutocomplete} validationError={validationErrors.to} validationMessage={t('validation.recipient_required')} onTab={focusSubject} />
{/* Cc field */} {showCc && (
{t('cc_label')}
)} {/* Bcc field */} {showBcc && (
{t('bcc_label')}
)} {/* Subject field */}
{t('subject_label')} { setSubject(e.target.value); if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); }} onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); focusBody(); } }} className={cn( "flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm", validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400" )} aria-invalid={validationErrors.subject || undefined} />
{/* Body */} {plainTextMode ? (