"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 } 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 { 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 { 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 { 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 tmp = document.createElement('div'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; } 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 }>; }) => 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 }[]; to?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[]; subject?: string; body?: string; htmlBody?: string; receivedAt?: string; }; } 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); // Initialize with reply/forward data if provided const getInitialTo = () => { if (!replyTo) return ""; if (mode === 'reply') { const email = replyTo.from?.[0]?.email || ""; return email ? email + ', ' : ""; } else if (mode === 'replyAll') { const from = replyTo.from?.[0]?.email || ""; const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; const combined = [from, 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 = () => { 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>([]); 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 saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, onEscape: () => setShowSaveAsTemplate(false), restoreFocus: true, }); const closeDialogRef = useFocusTrap({ isActive: showCloseDialog, onEscape: () => setShowCloseDialog(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; 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: 0 }); 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); }, []); 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(',').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; // Convert template plain text body to HTML for the rich text editor const htmlBody = `

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

`; if (mode === 'compose') { setSubject(filledSubject); setBody(htmlBody); 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) => htmlBody + prev); } if (template.identityId) { setSelectedIdentityId(template.identityId); } setShowTemplatePicker(false); }, [mode]); 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 = 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 ) ); } } }, [client, t]); const handleImageUpload = useCallback(async (file: File): Promise => { if (!client) return null; try { const { blobId } = await client.uploadBlob(file); return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type); } 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 && !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.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 // 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'), 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 = 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; }; 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 fromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag) : currentIdentity.email : undefined; // Body is already HTML from the rich text editor. // Build HTML signature block const buildSignatureHtml = (): string => { if (currentIdentity?.htmlSignature) { return `

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

--
${currentIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; const signatureHtml = buildSignatureHtml(); // Build final HTML body: editor content + signature const finalHtmlBody = `
${body}
${signatureHtml}`; // Generate plain text version from the HTML body for multipart/alternative const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity); 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.size > 0) { content = await att.file.arrayBuffer(); } else if (att.blobId && client) { content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type); } else { continue; } mimeAttachments.push({ filename: att.file.name, contentType: att.file.type || 'application/octet-stream', content, }); } // 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 = attachments .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size })); 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 (
{/* 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')} />
{/* 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 })); }} 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 - Rich Text Editor */} { setBody(html); if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false })); }} onImageUpload={handleImageUpload} placeholder={t('body_placeholder')} hasError={validationErrors.body} /> {composerSignatureHtml && (
--
${composerSignatureHtml}` }} /> )}
{/* Attachments */} {attachments.length > 0 && (
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
{att.uploading && (
)}
{att.uploading ? ( ) : att.error ? ( ) : ( )} {att.file.name} ({formatFileSize(att.file.size)})
))} {attachments.length > 3 && ( )}
)} {/* Bottom toolbar */}
{/* Left side actions */}
{/* S/MIME toggles */} {canSmimeSign && ( <>
)}
{/* Right side - Discard + Send (desktop) */}
{showTemplatePicker && ( setShowTemplatePicker(false)} onSelect={handleTemplateSelect} /> )} {showSaveAsTemplate && (

{t('save_as_template')}

s.trim()).filter(Boolean), cc: cc.split(',').map(s => s.trim()).filter(Boolean), bcc: bcc.split(',').map(s => s.trim()).filter(Boolean), }} onSave={(data) => { addTemplate(data); setShowSaveAsTemplate(false); }} onCancel={() => setShowSaveAsTemplate(false)} />
)} {/* S/MIME passphrase prompt */} {smimePassphrasePrompt && (
e.stopPropagation()} className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200" >

{t('smime_unlock_title')}

{t('smime_unlock_message')}

{ setSmimePassphraseInput(e.target.value); setSmimePassphraseError(''); }} onKeyDown={(e) => { if (e.key === 'Enter' && smimePassphraseInput) { smimePassphrasePrompt.resolve(smimePassphraseInput); } }} placeholder={t('smime_passphrase_placeholder')} className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary" /> {smimePassphraseError && (

{smimePassphraseError}

)}
)} {showCloseDialog && (
setShowCloseDialog(false)} >
e.stopPropagation()} className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200" >

{t('close_draft_title')}

{t('close_draft_message')}

)}
); } const AutocompleteDropdown = React.forwardRef; selectedIndex: number; onSelect: (email: string) => void; }>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) { return (
{results.map((r, i) => ( ))}
); }); function RecipientChipInput({ value, onChange, inputRef, placeholder, field, onAutocomplete, onAutoKeyDown, onAutoBlur, activeAutoField, autocompleteResults, autoSelectedIndex, dropdownRef, onInsertAutocomplete, validationError, validationMessage, }: { value: string; onChange: (value: string) => void; inputRef: React.RefObject; placeholder: string; field: 'to' | 'cc' | 'bcc'; onAutocomplete: (value: string, field: 'to' | 'cc' | 'bcc') => void; onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void; onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void; activeAutoField: 'to' | 'cc' | 'bcc' | null; autocompleteResults: Array<{ name: string; email: string }>; autoSelectedIndex: number; dropdownRef: React.RefObject; onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void; validationError?: boolean; validationMessage?: string; }) { const allParts = value.split(',').map(s => s.trim()).filter(Boolean); const hasTrailingComma = value.trimEnd().endsWith(','); const chips = hasTrailingComma ? allParts : allParts.slice(0, -1); const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] || ''); const handleInputChange = (e: React.ChangeEvent) => { const newInputText = e.target.value; const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : ''; const newValue = chipPart + newInputText; onChange(newValue); onAutocomplete(newValue, field); }; const commitCurrentInput = () => { if (inputText.trim()) { const newChips = [...chips, inputText.trim()]; onChange(newChips.join(', ') + ', '); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (activeAutoField === field && autocompleteResults.length > 0) { if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' || (e.key === 'Enter' && autoSelectedIndex >= 0)) { onAutoKeyDown(e, field); return; } } if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) { if (e.key !== 'Tab') e.preventDefault(); commitCurrentInput(); setTimeout(() => inputRef.current?.focus(), 0); return; } if (e.key === 'Backspace' && !inputText && chips.length > 0) { const lastChip = chips[chips.length - 1]; const remainingChips = chips.slice(0, -1); const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : ''; onChange(chipPart + lastChip); return; } }; const handleChipClick = (index: number) => { const chipEmail = chips[index]; const remainingChips = chips.filter((_, i) => i !== index); const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : ''; onChange(chipPart + chipEmail); setTimeout(() => inputRef.current?.focus(), 0); }; const handleChipRemove = (index: number, e: React.MouseEvent) => { e.stopPropagation(); const remainingChips = chips.filter((_, i) => i !== index); if (remainingChips.length > 0) { onChange(remainingChips.join(', ') + ', ' + inputText); } else { onChange(inputText); } }; const handleBlur = (e: React.FocusEvent) => { const relatedTarget = e.relatedTarget as Node | null; if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { return; } if (inputText.trim()) { const newChips = [...chips, inputText.trim()]; onChange(newChips.join(', ') + ', '); } onAutoBlur(e, field); }; return (
inputRef.current?.focus()} > {chips.map((chip, i) => ( { e.stopPropagation(); handleChipClick(i); }} > {chip} ))} 0} aria-autocomplete="list" aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-invalid={validationError || undefined} />
{validationError && validationMessage && (

{validationMessage}

)} {activeAutoField === field && autocompleteResults.length > 0 && ( onInsertAutocomplete(email, field)} /> )}
); }