"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, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu } from "@/hooks/use-context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; 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 { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; 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 { resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, formatRecipient, parseRecipient, parseRecipientList, formatRecipientList, splitPastedRecipients, type Recipient, } from "@/lib/email-composer-utils"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; /** * Derives the text/plain alternative from the composer's HTML body, preserving * line structure from block elements and
tags. Paragraph spacing is on so *

blocks are separated by a blank line, matching their visual rendering (#421). */ function htmlToPlainText(html: string): string { return htmlToPlainTextShared(html, { paragraphSpacing: true }); } /** * Build a floating drag image showing the recipient's address, mirroring the * email-list drag preview so dragging a chip feels consistent. The element is * positioned off-screen; the browser snapshots it for the drag cursor. */ function createChipDragPreview(label: string): HTMLElement { const preview = document.createElement("div"); preview.className = "drag-preview"; preview.style.cssText = ` position: fixed; top: -9999px; left: 0; padding: 4px 12px; background-color: var(--color-primary, #3b82f6); color: var(--color-primary-foreground, #ffffff); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); font-size: 13px; font-weight: 500; z-index: 9999; white-space: nowrap; pointer-events: none; `; preview.textContent = label; document.body.appendChild(preview); return preview; } 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; /** When set, overrides the header From: - sent through the selected identity's envelope. */ fromOverrideEmail?: string; fromOverrideName?: string; fromOverrideEnabled?: boolean; } interface EmailComposerProps { onSend?: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string; htmlBody?: string; draftId?: string; fromEmail?: string; fromName?: string; identityId?: string; envelopeMailFrom?: string; /** Local account ID owning the selected identity. Set when the user * picked an identity from a non-active account in the Pro multi- * account dropdown; parents should send through that account's * client instead of the currently-active one. */ localAccountId?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; delayedUntil?: string; requestReadReceipt?: boolean; }) => void | Promise; onScheduledSendCreated?: () => 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 }>; // Threading: parent's Message-ID and References, used to set RFC 5322 // In-Reply-To and References on outgoing replies. See #234. messageId?: string; inReplyTo?: string[]; references?: string[]; // Pre-built quote header block. Supplied by the composer opener after it // runs emailHooks.onBuildQuoteHeader through plugin transforms. When set, // the composer uses these verbatim instead of building its own default // "On X, Y wrote:" / "---------- Forwarded message ----------" block. quoteHeaderHtml?: string; quoteHeaderText?: string; /** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */ quoteWrapInBlockquote?: boolean; }; } type ComposerAttachment = { file?: File; name: string; type: string; size: number; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController; }; type SignatureIdentityLike = { htmlSignature?: string; textSignature?: string; } | null | undefined; // Render the embedded signature for "above quote" mode. Bracketed with // `data-signature-block` marker paragraphs so we can swap the inner content // when the user switches identity without losing the surrounding draft or // quoted message. The markers are preserved through TipTap by the // StyledParagraph extension. function buildEmbeddedSignatureHtml( identity: SignatureIdentityLike, options: { embed: boolean; separator: boolean } ): string { if (!options.embed) return ''; const startMarker = options.separator ? `

--

` : `

`; const endMarker = `

`; if (identity?.htmlSignature) { return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`; } if (identity?.textSignature) { const escaped = identity.textSignature .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); return `${startMarker}

${escaped}

${endMarker}`; } return ''; } function formatLocalDateTimeInput(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function getDefaultScheduleValue(): string { const tomorrowAtEight = new Date(); tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1); tomorrowAtEight.setHours(8, 0, 0, 0); return formatLocalDateTimeInput(tomorrowAtEight); } export function EmailComposer({ onSend, onScheduledSendCreated, onClose, onDiscardDraft, onSaveState, className, initialDraftText, initialData, mode = 'compose', replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextMode = useSettingsStore((state) => state.plainTextMode); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); // Pro shell: surface identities from every connected account, grouped // for the From dropdown's s. Outside Pro this collapses to // the active account's identities only. const multiAccountIdentities = useProMultiAccountIdentities(); const identities = multiAccountIdentities.enabled ? multiAccountIdentities.allIdentities : activeIdentities; const identityGroups = multiAccountIdentities.enabled ? multiAccountIdentities.groups : []; const primaryIdentity = activeIdentities[0] ?? null; // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation // below, but uses initialData (or primary) since selectedIdentityId state // does not exist yet at this point. const initialCurrentIdentityForSig = initialData?.selectedIdentityId ? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity : primaryIdentity; const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature) ? initialCurrentIdentityForSig : primaryIdentity; const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature); const shouldEmbedSignatureAboveQuote = (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote' && hasInitialSignature; // New-mail composes always embed the signature into the editor body so it's // editable/removable (the previous read-only preview below the editor was // never spec-correct - see #329). Compose mode also ignores any leftover // `replyTo` from a still-selected email; getInitialBody short-circuits below. const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature; // Format a single EmailAddress for display in the composer input const toRecipient = (r: { name?: string; email?: string }): Recipient => ({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" }); // Initialize with reply/forward data if provided const getInitialTo = (): Recipient[] => { 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(toRecipient) : (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []); if (mode === 'reply') { return replyTarget; } else if (mode === 'replyAll') { const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); const originalTo = (replyTo.to ?? []) .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) .map(toRecipient); return [...replyTarget, ...originalTo]; } return []; }; const getInitialCc = (): Recipient[] => { if (!replyTo || mode !== 'replyAll') return []; const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean)); return (replyTo.cc ?? []) .filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase())) .map(toRecipient); }; const getInitialSubject = () => { if (!replyTo?.subject) return ""; if (mode === 'forward') { return buildForwardSubject(replyTo.subject, t('prefix.forward')); } else if (mode === 'reply' || mode === 'replyAll') { return buildReplySubject(replyTo.subject, t('prefix.reply')); } return ""; }; const getInitialBody = () => { if (plainTextMode) { // Plain text mode: produce plain text body with no HTML const prefix = initialDraftText || ""; // Compose mode: ignore any leftover replyTo (e.g. a selected mail in the // viewer) and embed the signature directly into the body so it's // editable. Fixes #329 (A,B). if (mode === 'compose') { if (!shouldEmbedSignatureInNewMail) return prefix; const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`; } 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'); // Forward "From:" shows the full sender incl. address; reply line keeps // the bare name (reads naturally in the localized "On … wrote:" line). const fromStrFull = from ? (from.name && from.email && from.name !== from.email ? `${from.name} <${from.email}>` : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); // When "above quote" is configured, splice signature between the user's // drafting area and the quoted content so it reads naturally as a // closing for the reply body. Send-time append is skipped - see // shouldEmbedSignatureAboveQuote. const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; const signatureBlock = shouldEmbedSignatureAboveQuote ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}` : ''; // Plugin override (resolved at composer open via onBuildQuoteHeader). if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const body = mode === 'forward' ? originalText : quotedText; return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`; } if (mode === 'forward') { return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; } return prefix; } const prefix = initialDraftText ? `

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

` : ""; // Compose mode: ignore any leftover replyTo and embed the signature // directly into the body so the user can edit/delete it. The leading // empty paragraph gives the cursor a place to land above the signature. if (mode === 'compose') { if (!shouldEmbedSignatureInNewMail) return prefix; const composePrefix = prefix || '

'; const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: true, separator: signatureSeparatorEnabled, }); return `${composePrefix}${embedded}`; } 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 fromStrFull = from ? (from.name && from.email && from.name !== from.email ? `${from.name} <${from.email}>` : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: shouldEmbedSignatureAboveQuote, separator: signatureSeparatorEnabled, }); // Plugin override (resolved at composer open via onBuildQuoteHeader). if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody) { // Layout-heavy original: embed verbatim as a QuotedHtml island so // nested tables / MJML survive 1:1 (sanitize strips scripts/styles // first; cid rewrite runs after so its data-cid markers survive). const island = buildQuotedHtmlBlock( rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)) ); return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${island}`; } const escaped = replyTo.body ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') : ''; const wrap = replyTo.quoteWrapInBlockquote !== false; const bodyHtml = wrap ? `
${escaped}
` : escaped; return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${bodyHtml}`; } // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const quoteHeader = mode === 'forward' ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` : `${tQuote('reply_line', { date, from: fromStr })}
`; // Embed the original as a QuotedHtml island (verbatim, schema-free) so // its layout survives the editor round-trip. Sanitize first to strip // scripts/styles/head; cid rewrite afterwards so data-cid markers // aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false. const island = buildQuotedHtmlBlock( rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)) ); return `${prefix}${signatureBlock}
${quoteHeader}
${island}`; } if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

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

${tQuote('reply_line', { date, from: fromStr })}
${escapedOriginal}
`; } } return prefix; }; // Committed recipients are structured arrays; the in-progress text the user // is typing lives in a separate `*Input` string per field. This keeps a // display name containing a comma (e.g. "Doo, John") intact instead of // tearing it apart on a delimiter. const [to, setTo] = useState(initialData ? parseRecipientList(initialData.to) : getInitialTo()); const [cc, setCc] = useState(initialData ? parseRecipientList(initialData.cc) : getInitialCc()); const [bcc, setBcc] = useState(initialData ? parseRecipientList(initialData.bcc) : []); const [toInput, setToInput] = useState(''); const [ccInput, setCcInput] = useState(''); const [bccInput, setBccInput] = useState(''); const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject()); const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); // Committed recipients plus any not-yet-committed text the user has typed. // Send/validation/draft paths treat a typed-but-uncommitted address as a // real recipient, matching the previous string-based behavior. const withInput = (chips: Recipient[], input: string): Recipient[] => input.trim() ? [...chips, parseRecipient(input)] : chips; const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false); const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false); const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's // setDraftId is async, so a queued saveDraft would otherwise see the old // value and try to destroy a draft that was just replaced. const draftIdRef = useRef(initialData?.draftId ?? null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); // Tracks the currently-running saveDraft so concurrent callers (autosave // timer + send button) serialize instead of issuing parallel destroy/create // requests with the same draftId. See bug #303. const inflightSaveRef = useRef | null>(null); 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 [fromOverrideEnabled, setFromOverrideEnabled] = useState(initialData?.fromOverrideEnabled ?? false); const [fromOverrideEmail, setFromOverrideEmail] = useState(initialData?.fromOverrideEmail ?? ''); const [fromOverrideName, setFromOverrideName] = useState(initialData?.fromOverrideName ?? ''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); const [previewAttachment, setPreviewAttachment] = useState(null); 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 [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState(); const [showScheduleDialog, setShowScheduleDialog] = useState(false); const [scheduleValue, setScheduleValue] = useState(''); const [scheduleError, setScheduleError] = useState(''); const [showSendMenu, setShowSendMenu] = useState(false); const sendMenuRef = useRef(null); 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 currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; // When the selected identity belongs to a non-active account (Pro // multi-account dropdown), `currentIdentity.id` carries a "::" // namespace and JMAP calls must be routed through that account's // client with the un-prefixed id. `composerClient` and // `currentIdentityRawId` are what save/send code should use. const currentIdentityParts = currentIdentity?.id ? stripCrossAccountIdentityPrefix(currentIdentity.id) : { localAccountId: null, rawId: undefined }; const composerClient = currentIdentityParts.localAccountId ? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client) : client; const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id; // Alias identities often lack a configured signature - fall back to the primary // identity's signature so replies (which auto-select a matching alias) still // populate the user's signature. const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) ? currentIdentity : primaryIdentity; // Hold the TipTap editor instance so we can swap the embedded signature // when the user switches identity in "above quote" mode without rebuilding // the whole body (which would lose user edits to the surrounding draft). const editorRef = useRef(null); const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id); const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled); useEffect(() => { const editor = editorRef.current; const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id; const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled; prevSignatureIdentityIdRef.current = signatureIdentity?.id; prevSignatureSeparatorRef.current = signatureSeparatorEnabled; if (!editor) return; if (!identityChanged && !separatorChanged) return; if (plainTextMode) return; // Replies/forwards only embed when configured for "above quote". Compose // always embeds (see getInitialBody), so swap on identity change there too. const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward'; if (!isReplyLike && mode !== 'compose') return; if (isReplyLike && signaturePosition !== 'above_quote') return; // serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim // body isn't lost during the signature splice + setContent round-trip. const currentHtml = serializeEditorContent(editor); const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); if (!startEl) return; const endEl = doc.querySelector('[data-signature-block="end"]'); const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, { embed: true, separator: signatureSeparatorEnabled, }); if (!newSignature) return; // Build a temporary container holding the replacement nodes so we can // splice them in without re-serializing/parsing twice. const replacementHost = doc.createElement('div'); replacementHost.innerHTML = newSignature; const replacementNodes = Array.from(replacementHost.childNodes); const parent = startEl.parentNode; if (!parent) return; // Remove the existing signature range [startEl … endEl] inclusive, or // from startEl to the next quote boundary if no end marker is present. // The quote boundary is either a legacy
or the QuotedHtml // island wrapper (
) - stop before either so the // signature splice never eats into the quoted body. const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; const isQuoteBoundary = (n: Node | null): boolean => { if (!n || n.nodeType !== 1) return false; const el = n as Element; return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html'); }; const toRemove: Node[] = []; let cursor: Node | null = startEl; while (cursor) { toRemove.push(cursor); if (cursor === removeUntil) break; const next: Node | null = cursor.nextSibling; if (!removeUntil && isQuoteBoundary(next)) break; cursor = next; } const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; toRemove.forEach((node) => parent.removeChild(node)); replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore)); const nextHtml = doc.body.innerHTML; if (nextHtml !== currentHtml) { editor.commands.setContent(nextHtml, { emitUpdate: true }); } // Intentionally keyed to the signature-relevant fields plus the internal // prev*Ref guards above; depending on the whole `signatureIdentity` object // would re-run on unrelated identity-field changes and re-splice the // signature into the live editor. // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); useEffect(() => { const handleClickOutsideSendMenu = (event: MouseEvent) => { if (!sendMenuRef.current?.contains(event.target as Node)) { setShowSendMenu(false); } }; document.addEventListener('mousedown', handleClickOutsideSendMenu); return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu); }, []); const openScheduleDialog = useCallback(() => { setScheduleError(''); setScheduleValue(getDefaultScheduleValue()); setShowScheduleDialog(true); setShowSendMenu(false); }, []); useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; if (mode !== 'reply' && mode !== 'replyAll') return; const resolved = resolveReplyFrom(identities, { to: replyTo?.to, cc: replyTo?.cc, bcc: replyTo?.bcc, }); if (resolved) { setSelectedIdentityId(resolved.identityId); if (resolved.overrideEmail && !fromOverrideEnabled) { setFromOverrideEnabled(true); setFromOverrideEmail(resolved.overrideEmail); if (resolved.overrideName) setFromOverrideName(resolved.overrideName); } 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, fromOverrideEnabled, identities, initialData?.selectedIdentityId, mode, replyTo?.accountId, replyTo?.bcc, replyTo?.cc, replyTo?.to, selectedIdentityId, ]); // Hydrate inline images referenced by the quoted body (issue #163). // `getInitialBody` rewrites `` to placeholder src + // data-cid; here we (1) register each inline attachment in inlineImagesRef // so the send path re-attaches the blob with the right cid, and (2) fetch // each blob as a data URL and swap it into the body so the editor actually // shows the image instead of a blank placeholder. useEffect(() => { if (plainTextMode) return; if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return; if (!composerClient || !replyTo?.attachments?.length) return; const inlineAtts = replyTo.attachments.filter((att) => att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/') ); if (inlineAtts.length === 0) return; // Seed the ref synchronously so a fast Send still attaches the right blobs // even if the FileReader work below hasn't resolved yet. for (const att of inlineAtts) { if (!att.cid) continue; if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue; inlineImagesRef.current.push({ cid: att.cid, blobId: att.blobId, type: att.type, name: att.name || 'inline', size: att.size, dataUrl: '', }); } let cancelled = false; (async () => { const updates = new Map(); for (const att of inlineAtts) { if (!att.cid) continue; try { const buffer = await composerClient.fetchBlobArrayBuffer( att.blobId, att.name || 'inline', att.type, ); if (cancelled) return; const blob = new Blob([buffer], { type: att.type }); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); if (cancelled) return; const entry = inlineImagesRef.current.find((e) => e.cid === att.cid); if (entry) entry.dataUrl = dataUrl; updates.set(att.cid, dataUrl); } catch (err) { debug.error('Failed to load inline image for compose', err); } } if (cancelled || updates.size === 0) return; setBody((prev) => replaceInlineImagePlaceholders(prev, updates)); })(); return () => { cancelled = true; }; // We deliberately hydrate once per composer open - subsequent replyTo // object identity churn from parent renders shouldn't refetch. // eslint-disable-next-line react-hooks/exhaustive-deps }, [composerClient, plainTextMode, mode]); const composerSignatureHtml = signatureIdentity?.htmlSignature ? `
${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
` : signatureIdentity?.textSignature ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); 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 allRecipients = [ ...withInput(to, toInput), ...withInput(cc, ccInput), ...withInput(bcc, bccInput), ].map(r => r.email); 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]); // Serialized recipient strings for ComposerDraftData (string-shaped) and for // by-value dirty comparison. Folds in any uncommitted typed text. const toStr = formatRecipientList(withInput(to, toInput)); const ccStr = formatRecipientList(withInput(cc, ccInput)); const bccStr = formatRecipientList(withInput(bcc, bccInput)); // Keep a ref to current state for the unmount save const stateRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName }); stateRef.current = { to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName }; // Track initial values for dirty detection (captured once on first render) const initialValuesRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, attachmentCount: attachments.length }); const isDirtyRef = useRef(false); isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc || bccStr !== 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)); // Set by the explicit close paths (clean close, save-and-close, discard) so // the unmount auto-save below doesn't fire and stash a stale pendingDraft // on the parent (#329 D). Without this, "Reply → Discard → New mail" would // open the next composer with the discarded reply's mode/replyTo. const explicitCloseRef = useRef(false); // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (explicitCloseRef.current) 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 handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => { if (fromField === toField) return; const setters = { to: setTo, cc: setCc, bcc: setBcc }; const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? ''); setters[fromField](prev => { const idx = prev.findIndex(r => sameRecipient(r, recipient)); return idx === -1 ? prev : prev.filter((_, i) => i !== idx); }); setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]); if (toField === 'cc') setShowCc(true); if (toField === 'bcc') setShowBcc(true); }, [setTo, setCc, setBcc, setShowCc, setShowBcc]); const handleAutocomplete = useCallback((inputText: string, field: 'to' | 'cc' | 'bcc') => { if (autocompleteTimeoutRef.current) { clearTimeout(autocompleteTimeoutRef.current); } const query = inputText.trim(); if (query.length < 1) { setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); return; } autocompleteTimeoutRef.current = setTimeout(async () => { const localResults = getAutocomplete(query); // Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …). const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email })); const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query }); setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email }))); setActiveAutoField(merged.length > 0 ? field : null); setAutoSelectedIndex(-1); }, 200); }, [getAutocomplete]); const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => { const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput; setter(prev => [...prev, toRecipient(suggestion)]); inputSetter(''); 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], 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.map(parseRecipient)); } if (template.defaultRecipients?.cc?.length) { setCc(template.defaultRecipients.cc.map(parseRecipient)); setShowCc(true); } if (template.defaultRecipients?.bcc?.length) { setBcc(template.defaultRecipients.bcc.map(parseRecipient)); 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; // Let plugins veto each upload before it's queued. const allowedFiles: File[] = []; for (const file of files) { const ok = await emailHooks.onBeforeAttachmentUpload.intercept({ name: file.name, type: file.type || 'application/octet-stream', size: file.size, }); if (ok) allowedFiles.push(file); } if (allowedFiles.length === 0) return; files = allowedFiles; 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 ) ); emailHooks.onAfterAttachmentUpload.emit({ name: file.name, type: file.type || 'application/octet-stream', size: file.size, blobId, }); } 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)); }; // Inline preview for composer attachments, reusing the message viewer's // FilePreviewModal (so previewability and the open-in-new-tab safety gate are // handled there). Prefer the local File - no network - and fall back to the // uploaded blob (forwarded attachments, which carry only a blobId). const getPreviewAttachmentContent = useCallback(async () => { if (!previewAttachment) throw new Error('No attachment selected'); if (previewAttachment.file) { return { blob: previewAttachment.file, contentType: previewAttachment.type || previewAttachment.file.type || 'application/octet-stream', }; } if (composerClient && previewAttachment.blobId) { const blob = await composerClient.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream' }; } throw new Error('No attachment content available'); }, [previewAttachment, composerClient]); const handlePreviewAttachmentDownload = useCallback(async () => { if (!previewAttachment) return; if (previewAttachment.file) { const url = URL.createObjectURL(previewAttachment.file); const a = document.createElement('a'); a.href = url; a.download = previewAttachment.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); return; } if (composerClient && previewAttachment.blobId) { await composerClient.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type); } }, [previewAttachment, composerClient]); // Auto-save draft functionality const saveDraftOnce = async (): Promise => { if (!client || !composerClient) return null; const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email)); const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)); const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)); 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 draftIdRef.current; } setSaveStatus('saving'); // Get the selected identity or primary identity // Generate sub-addressed email if tag is set const identityFromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) : currentIdentity.email : undefined; const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) ? fromOverrideEmail.trim() : identityFromEmail; const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) ? (fromOverrideName.trim() || undefined) : (currentIdentity?.name || undefined); try { const previousDraftId = draftIdRef.current; // Use the JMAP client and raw identity id for the *owning* account // - falls back to active client for single-account / same-account // identities. See `composerClient` derivation above. const savedDraftId = await composerClient.createDraft( toAddresses, subject || t('no_subject'), plainTextMode ? body : htmlToPlainText(body), ccAddresses, bccAddresses, currentIdentityRawId, fromEmail, previousDraftId || undefined, uploadedAttachments, fromName, plainTextMode ? undefined : body ); // Update the ref synchronously so a queued save sees the new id and // doesn't try to destroy the just-replaced draft. draftIdRef.current = savedDraftId; 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; } }; // Serialize saves: each call waits for the previous in-flight save before // running. This prevents the autosave timer and the send button from // racing two `Email/set { destroy, create }` requests against the same // draftId, which left orphan drafts and (when EmailSubmission failed) // looked like "send didn't happen" (#303). const saveDraft = (): Promise => { const previous = inflightSaveRef.current; const promise = (async (): Promise => { if (previous) { try { await previous; } catch { /* prior failure already reported */ } } return saveDraftOnce(); })(); inflightSaveRef.current = promise; promise.finally(() => { if (inflightSaveRef.current === promise) { inflightSaveRef.current = null; } }); return promise; }; // 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(() => { // Clear the ref so handleSend can distinguish "save scheduled" from // "save in flight" - the former still needs flushing, the latter is // tracked via inflightSaveRef. saveTimeoutRef.current = null; // Plugin observers (AI assist, grammar, …) get a debounced snapshot here. emailHooks.onDraftChange.emit({ to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)), cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)), bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)), subject, htmlBody: plainTextMode ? '' : body, textBody: plainTextMode ? body : htmlToPlainText(body), identityId: selectedIdentityId || '', attachments: attachments .filter(a => a.blobId && !a.uploading && !a.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), }); 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 }, [toStr, ccStr, bccStr, subject, body, attachments]); useEffect(() => { return () => { if (autocompleteTimeoutRef.current) { clearTimeout(autocompleteTimeoutRef.current); } }; }, []); const toAddresses = withInput(to, toInput); 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; }; const validateScheduleValue = (value: string): string | null => { if (!value) return t('schedule_send_required'); const time = new Date(value).getTime(); if (!Number.isFinite(time)) return t('schedule_send_invalid'); if (time <= Date.now()) return t('schedule_send_future'); if (composerClient) { const maxDelayedSend = composerClient.getMaxDelayedSend(); if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { return t('schedule_send_too_late'); } } return null; }; const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise => { if (requestedDelayedUntil) return requestedDelayedUntil; if (sendDelaySeconds === 0) return undefined; if (composerClient?.hasDelayedSend()) { return new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); } const confirmed = window.confirm(t('send_delay_unsupported_confirm')); if (!confirmed) { throw new Error(t('send_delay_unsupported')); } 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; const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); const used = new Map(); if (known.length > 0) { 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); }); } // Recipient mail clients apply default

margins inside table cells, // inflating row height. Tiptap wraps cell text in

, so force margin:0 // to match the composer's tight rows. doc.querySelectorAll('td > p, th > p').forEach((p) => { const existing = p.getAttribute('style') || ''; p.setAttribute('style', `margin:0;${existing}`); }); 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, delayedUntil?: string) => { const ccAddresses = withInput(cc, ccInput); const bccAddresses = withInput(bcc, bccInput); 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); setAttachmentWarningDelayedUntil(delayedUntil); setShowAttachmentWarning(true); return; } } } // Resolve the freshest draftId we can. Two cases: // 1. An autosave is currently in flight - wait for it; don't issue a // parallel destroy/create that would race with it on the same id. // 2. A debounced save is scheduled (timer set) - cancel it and flush // now so the latest body content lands on the server. // Use draftIdRef (not the React state) because state updates from // the in-flight save may not have rendered yet when we read here. let finalDraftId = draftIdRef.current; if (inflightSaveRef.current) { try { const savedId = await inflightSaveRef.current; if (savedId) finalDraftId = savedId; } catch (err) { debug.error('In-flight draft save failed before send:', err); } } if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; try { const savedId = await saveDraft(); if (savedId) finalDraftId = savedId; } catch (err) { debug.error('Failed to save draft before send:', err); } } const identityFromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) : currentIdentity.email : undefined; // When the user has typed a From override, that becomes the header From // (and MIME-builder From in the S/MIME path). The identity still drives // the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly // default to the override address. const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0; const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail; const fromName = overrideActive ? (fromOverrideName.trim() || undefined) : (currentIdentity?.name || undefined); const envelopeMailFrom = overrideActive ? identityFromEmail : undefined; // Body is already HTML from the rich text editor (or plain text in plain text mode). // The signature is embedded into the body during init for compose mode // (when the initial identity had a signature) and for above-quote // replies/forwards - skip the trailing append in those cases so we don't // duplicate it. const signatureAlreadyInBody = shouldEmbedSignatureInNewMail || ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote'); // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { if (signatureAlreadyInBody) return ''; const sep = signatureSeparatorEnabled ? `

--
` : `

`; if (signatureIdentity?.htmlSignature) { return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`; } if (signatureIdentity?.textSignature) { return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; // RFC 5322 §3.6.4 threading - only continues the chain on a reply, not a forward. const threadingHeaders = (mode === 'reply' || mode === 'replyAll') ? computeReplyThreadingHeaders(replyTo) : null; // In plain text mode, send text/plain only (no HTML body) const signatureOpts = { separator: signatureSeparatorEnabled }; const finalBody = plainTextMode ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode ? undefined : `

${rewritten!.html}
${buildSignatureHtml()}`; const inlineAttachments = rewritten?.attachments ?? []; try { const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); // Let plugins veto the send (external-mail warning, mistyped-domain // guards, etc.). Returning false from any handler aborts before either // the S/MIME or standard JMAP path runs. const sendablePreview: OutgoingEmail = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), subject, htmlBody: finalHtmlBody || '', textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, attachments: attachments .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], }; const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); if (!sendAllowed) return; // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // S/MIME keys are scoped to one JMAP account's identity - sending // from a cross-account identity via S/MIME would mix accounts' // certs/clients. Refuse upfront and tell the user to switch. const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); if (crossAccount.localAccountId) { throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.'); } // 1. Resolve S/MIME key if (smimeSign_ && !smimeKeyRecord) { throw new Error('No S/MIME key bound to this identity'); } // S/MIME binds to the identity's key; sending from an override address // would produce a signature whose Subject differs from the visible // From, which most clients reject or flag. Refuse up front. if (overrideActive) { throw new Error('Cannot use From override with S/MIME - disable one to send.'); } // 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 // mime-builder takes inReplyTo as a single ref-form msg-id (with brackets); // references stays an array. threadingHeaders contains bare msg-ids. const mimeInReplyTo = threadingHeaders?.inReplyTo[0] ? `<${threadingHeaders.inReplyTo[0]}>` : undefined; const mimeReferences = threadingHeaders?.references.length ? threadingHeaders.references.map(id => `<${id}>`) : undefined; const mimeBytes = buildMimeMessage({ from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, to: toAddresses, cc: ccAddresses.length > 0 ? ccAddresses : undefined, bcc: bccAddresses.length > 0 ? bccAddresses : undefined, subject, inReplyTo: mimeInReplyTo, references: mimeReferences, 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, cc: ccAddresses.length > 0 ? ccAddresses : undefined, subject, inReplyTo: mimeInReplyTo, references: mimeReferences, }; // 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].map(r => r.email); 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 const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email)); if (effectiveDelayedUntil && finalDraftId) { client.deleteEmail(finalDraftId).catch(err => { debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); toast.warning(t('schedule_send_cleanup_warning')); }); } if (result.scheduled) { await onScheduledSendCreated?.(); } } 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); // Let plugins (signatures, link-rewriting, encryption, AI rewrite, …) // transform the outgoing message immediately before submission. const transformInput: OutgoingEmail = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), subject, htmlBody: finalHtmlBody || '', textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], }; const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); // Strip the cross-account namespace from the identity id before // handing it to the parent - the JMAP server only knows the raw // id. The owning local account travels alongside so the parent // can route the send through the right client. const rawIdentityId = outgoing.identityId || currentIdentity?.id; const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId ? stripCrossAccountIdentityPrefix(rawIdentityId) : { localAccountId: null, rawId: undefined }; await onSend?.({ to: outgoing.to, cc: outgoing.cc, bcc: outgoing.bcc, subject: outgoing.subject, body: outgoing.textBody, htmlBody: outgoing.htmlBody || undefined, draftId: finalDraftId || undefined, fromEmail, fromName, identityId: rawId, envelopeMailFrom, localAccountId: identityLocalAccountId ?? undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, requestReadReceipt, delayedUntil: effectiveDelayedUntil, }); if (mode === 'reply' || mode === 'replyAll') { for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, recipient).catch(err => { debug.error('Failed to add trusted sender to address book:', err); }); } else { addTrustedSender(recipient); } } } } setTo([]); setCc([]); setBcc([]); setToInput(""); setCcInput(""); setBccInput(""); setSubject(""); setBody(""); draftIdRef.current = null; setDraftId(null); setSubAddressTag(""); setValidationErrors({}); setShowScheduleDialog(false); setScheduleValue(''); setScheduleError(''); // 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, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; } catch (err) { debug.error('Failed to send email:', err); toast.error(err instanceof Error ? err.message : t('send_failed')); } }; const handleScheduleSend = () => { if (!composerClient?.hasDelayedSend()) { setScheduleError(t('schedule_send_unsupported')); return; } const error = validateScheduleValue(scheduleValue); if (error) { setScheduleError(error); return; } handleSend(false, new Date(scheduleValue).toISOString()); }; // Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose // draft. Scoped to events whose target lives inside this composer's // DOM tree — in Pro mode multiple composer tabs can be mounted at // once (inactive tabs are CSS-hidden, not unmounted), so a window // listener would otherwise fire every mounted composer's handleSend // on a single keystroke. handleSend is rebound every render, so we // route through a ref to keep the listener stable. const composerRootRef = useRef(null); const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise) | undefined>(undefined); handleSendRef.current = handleSend; useEffect(() => { const handleSendShortcut = (e: KeyboardEvent) => { if (e.key !== 'Enter') return; if (!(e.ctrlKey || e.metaKey)) return; if (e.altKey || e.shiftKey) return; const root = composerRootRef.current; if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return; e.preventDefault(); void handleSendRef.current?.(); }; window.addEventListener('keydown', handleSendShortcut); return () => window.removeEventListener('keydown', handleSendShortcut); }, []); const cleanClose = () => { explicitCloseRef.current = true; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; const handleSaveDraftAndClose = async () => { explicitCloseRef.current = true; 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, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; const handleDiscardAndClose = () => { explicitCloseRef.current = true; 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, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; const handleClose = () => { if (isDirtyRef.current) { setShowCloseDialog(true); } else { cleanClose(); } }; const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; const hasPrimaryModifier = e.ctrlKey || e.metaKey; const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey; const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey; if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return; if ( showTemplatePicker || showSaveAsTemplate || showScheduleDialog || smimePassphrasePrompt || showAttachmentWarning || showCloseDialog ) return; if (isPlainEscape) { if (activeAutoField) return; e.preventDefault(); handleClose(); return; } if (isSendShortcut) { if (e.repeat) { e.preventDefault(); return; } e.preventDefault(); handleSend(); return; } if (isScheduleShortcut) { e.preventDefault(); if (!e.repeat && composerClient?.hasDelayedSend()) openScheduleDialog(); } }; 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')}:
{fromOverrideEnabled ? (
setFromOverrideName(e.target.value)} placeholder={t('from_override.name_placeholder')} className="h-7 text-sm w-32 md:w-40 shrink-0" aria-label={t('from_override.name_label')} /> setFromOverrideEmail(e.target.value)} placeholder={t('from_override.email_placeholder')} type="email" className="h-7 text-sm flex-1 min-w-0 font-mono" aria-label={t('from_override.email_label')} />
) : identities.length > 1 ? ( ) : ( {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)} ) : ( <> {primaryIdentity?.name ? `${primaryIdentity.name} <${primaryIdentity.email}>` : primaryIdentity?.email || ''} )} )} {!fromOverrideEnabled && ( id.id === selectedIdentityId)?.email : primaryIdentity?.email) || '' } recipientEmails={withInput(to, toInput).map(r => r.email)} onSelectTag={setSubAddressTag} /> )} {!fromOverrideEnabled && subAddressTag && ( )}
{/* To field */}
{t('to')}: { setTo(next); if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); }} inputText={toInput} onInputChange={setToInput} 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} onMoveChip={handleMoveChip} />
{/* 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 ? (