Files
SRCmail/components/email/email-composer.tsx
T
ChanceandLinus Rath c44a9ce6e0 fix: fall back to primary identity signature on reply
When auto-select picks an alias identity matching the original recipient,
the alias often has no signature configured. The composer was using the
alias's empty signature for both the visual preview and the appended
signature on send, so neither showed up. New mail worked because no
auto-select runs.

Add a signatureIdentity that falls back to the primary when the current
identity has no signature. From address, identity ID, S/MIME, and draft
saves still use currentIdentity so mail goes out from the right address.
2026-05-09 14:21:05 +02:00

2037 lines
80 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { 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 { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders } from "@/lib/template-utils";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
export interface ComposerDraftData {
to: string;
cc: string;
bcc: string;
subject: string;
body: string;
showCc: boolean;
showBcc: boolean;
selectedIdentityId: string | null;
subAddressTag: string;
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
}
interface EmailComposerProps {
onSend?: (data: {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
htmlBody?: string;
draftId?: string;
fromEmail?: string;
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => void | Promise<void>;
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[];
};
}
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
export function EmailComposer({
onSend,
onClose,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
: replyTo.from?.[0]?.email || "";
if (mode === 'reply') {
return replyTarget ? replyTarget + ', ' : "";
} else if (mode === 'replyAll') {
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
}
return "";
};
const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return "";
const cc = replyTo.cc?.map(r => r.email).join(", ") || "";
return cc ? cc + ', ' : "";
};
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
const fwdPrefix = t('prefix.forward');
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
const rePrefix = t('prefix.reply');
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
const getInitialBody = () => {
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
const prefix = initialDraftText || "";
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
}
return prefix;
}
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>` : "";
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 ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
}
if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') {
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
}
}
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<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
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<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false);
const [smimeSign_, setSmimeSign] = useState(false);
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
onEscape: () => setShowSaveAsTemplate(false),
restoreFocus: true,
});
const closeDialogRef = useFocusTrap({
isActive: showCloseDialog,
onEscape: () => setShowCloseDialog(false),
restoreFocus: true,
});
const attachmentWarningRef = useFocusTrap({
isActive: showAttachmentWarning,
onEscape: () => setShowAttachmentWarning(false),
restoreFocus: true,
});
const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
// 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;
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, {
to: replyTo?.to,
cc: replyTo?.cc,
bcc: replyTo?.bcc,
});
if (matchedIdentityId) {
setSelectedIdentityId(matchedIdentityId);
return;
}
// Fallback: match identity by the account's email when replying from unified view
if (replyTo?.accountId) {
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
if (account?.email) {
const accountEmail = account.email.trim().toLowerCase();
const accountIdentity = identities.find(
(identity) => identity.email.trim().toLowerCase() === accountEmail
);
if (accountIdentity) {
setSelectedIdentityId(accountIdentity.id);
}
}
}
}, [
autoSelectReplyIdentity,
identities,
initialData?.selectedIdentityId,
mode,
replyTo?.accountId,
replyTo?.bcc,
replyTo?.cc,
replyTo?.to,
selectedIdentityId,
]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
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 toAddrs = to.split(',').map(e => e.trim()).filter(Boolean);
const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean);
const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean);
const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs];
if (allRecipients.length === 0) return false;
const { missing } = smimeStore.getRecipientCerts(allRecipients);
return missing.length === 0;
})();
// Initialize S/MIME defaults from store when identity changes
useEffect(() => {
if (currentSmimeIdentityId) {
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
}
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
// Only run when identity changes, not on every recipient edit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSmimeIdentityId]);
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount;
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (onSaveState && isDirtyRef.current) {
const s = stateRef.current;
onSaveState({
...s,
mode,
replyTo,
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-save draft to server on page close (best-effort)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current) {
saveDraftRef.current();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
// Auto-focus the To field when composing a new email or forwarding
useEffect(() => {
if (mode === 'forward' || mode === 'compose') {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
toInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
const bccInputRef = useRef<HTMLInputElement>(null);
const subjectInputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
const ccDropdownRef = useRef<HTMLDivElement>(null);
const bccDropdownRef = useRef<HTMLDivElement>(null);
const focusSubject = useCallback(() => {
subjectInputRef.current?.focus();
}, []);
const focusBody = useCallback(() => {
if (plainTextMode) {
bodyRef.current?.focus();
} else {
const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
proseMirror?.focus();
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
const lastPart = value.split(',').pop()?.trim() || '';
if (lastPart.length < 1) {
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
return;
}
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
// 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: lastPart });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.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<string, string>) => {
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
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
if (mode === 'compose') {
setSubject(filledSubject);
setBody(bodyContent);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ') + ', ');
}
if (template.defaultRecipients?.cc?.length) {
setCc(template.defaultRecipients.cc.join(', ') + ', ');
setShowCc(true);
}
if (template.defaultRecipients?.bcc?.length) {
setBcc(template.defaultRecipients.bcc.join(', ') + ', ');
setShowBcc(true);
}
} else {
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
}
if (template.identityId) {
setSelectedIdentityId(template.identityId);
}
setShowTemplatePicker(false);
}, [mode, plainTextMode]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const tag = target?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (target?.getAttribute('contenteditable') === 'true') return;
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShowTemplatePicker(true);
}
};
window.addEventListener('keydown', handleTemplateKey);
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
// 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<string | null>((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<HTMLInputElement>) => {
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<NodeJS.Timeout | null>(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<string | null> => {
if (!client) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
}
// Prepare attachments for draft
const uploadedAttachments = attachments
.filter(att => att.blobId && !att.uploading)
.map(att => ({
blobId: att.blobId!,
name: att.name,
type: att.type,
size: att.size,
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
return draftId;
}
setSaveStatus('saving');
// Get the selected identity or primary identity
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentity?.id,
fromEmail,
draftId || undefined,
uploadedAttachments,
currentIdentity?.name || undefined,
plainTextMode ? undefined : body
);
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(() => {
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
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
}, [to, cc, bcc, subject, body, attachments]);
useEffect(() => {
return () => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
};
}, []);
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
const getSendTooltip = (): string | undefined => {
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!subject) return t('validation.subject_required');
if (!hasContent) return t('validation.body_required');
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
html: string;
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
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 <p> margins inside table cells,
// inflating row height. Tiptap wraps cell text in <p>, 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) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
if (toAddresses.length === 0) errors.to = true;
if (!subject) errors.subject = true;
if (!hasContent) errors.body = true;
setValidationErrors(errors);
if (errors.to) {
setShakeField('to');
setTimeout(() => setShakeField(null), 400);
toInputRef.current?.focus();
}
return;
}
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setShowAttachmentWarning(true);
return;
}
}
}
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
try {
const savedId = await saveDraft();
if (savedId) {
finalDraftId = savedId;
}
} catch (err) {
debug.error('Failed to save draft before send:', err);
}
}
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
// Body is already HTML from the rich text editor (or plain text in plain text mode).
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (signatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
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 finalBody = plainTextMode
? appendPlainTextSignature(body, signatureIdentity)
: appendPlainTextSignature(htmlToPlainText(body), signatureIdentity);
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
? undefined
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
const inlineAttachments = rewritten?.attachments ?? [];
try {
// 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,
cc: ccAddresses,
bcc: bccAddresses,
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) {
// 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<string>((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.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,
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.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : 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];
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
if (missing.length > 0) {
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
}
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
const cmsBlob = await smimeEncrypt(
payloadBytes,
recipientCertsDer,
smimeKeyRecord.certificate,
);
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
}
// 7. Send via raw email path
await sendRawEmail(client, payload, currentIdentity.id);
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
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);
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: currentIdentity?.name || undefined,
identityId: outgoing.identityId || currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
});
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("");
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 (
<div className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
/>
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
data-tour="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 border-2 border-dashed border-primary rounded-lg pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<Paperclip className="w-8 h-8" />
<span className="text-sm font-medium">{t('drop_files')}</span>
</div>
</div>
)}
{/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<X className="w-5 h-5 md:w-4 md:h-4" />
</Button>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span className="hidden md:inline">{t('saving')}</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span className="hidden md:inline">{t('draft_saved')}</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span className="hidden md:inline">{t('save_failed')}</span>
</div>
)}
</div>
</div>
{/* Mobile: send button in header */}
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 mr-1.5" />
{t('send')}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{/* Fields section */}
<div className="space-y-0 border-b">
{/* From field */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1 min-w-0">
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span>
) : (
<>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
)}
</span>
)}
<SubAddressHelper
baseEmail={
(selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSubAddressTag('')}
className="h-6 px-2 text-xs"
title={t('remove_sub_address')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
value={to}
onChange={(v) => {
setTo(v);
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
}}
inputRef={toInputRef}
placeholder={t('to_placeholder')}
field="to"
onAutocomplete={handleAutocomplete}
onAutoKeyDown={handleAutoKeyDown}
onAutoBlur={handleAutoBlur}
activeAutoField={activeAutoField}
autocompleteResults={autocompleteResults}
autoSelectedIndex={autoSelectedIndex}
dropdownRef={toDropdownRef}
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
/>
<div className="flex gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs h-7 px-2"
>
Cc
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBcc(!showBcc)}
className="text-xs h-7 px-2"
>
Bcc
</Button>
</div>
</div>
{/* Cc field */}
{showCc && (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<RecipientChipInput
value={cc}
onChange={setCc}
inputRef={ccInputRef}
placeholder={t('cc_placeholder')}
field="cc"
onAutocomplete={handleAutocomplete}
onAutoKeyDown={handleAutoKeyDown}
onAutoBlur={handleAutoBlur}
activeAutoField={activeAutoField}
autocompleteResults={autocompleteResults}
autoSelectedIndex={autoSelectedIndex}
dropdownRef={ccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
/>
</div>
)}
{/* Bcc field */}
{showBcc && (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<RecipientChipInput
value={bcc}
onChange={setBcc}
inputRef={bccInputRef}
placeholder={t('bcc_placeholder')}
field="bcc"
onAutocomplete={handleAutocomplete}
onAutoKeyDown={handleAutoKeyDown}
onAutoBlur={handleAutoBlur}
activeAutoField={activeAutoField}
autocompleteResults={autocompleteResults}
autoSelectedIndex={autoSelectedIndex}
dropdownRef={bccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
/>
</div>
)}
{/* Subject field */}
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
type="text"
placeholder={t('subject_placeholder')}
value={subject}
onChange={(e) => {
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}
/>
</div>
</div>
{/* Body */}
{plainTextMode ? (
<textarea
ref={bodyRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
placeholder={t('body_placeholder')}
className={cn(
"w-full min-h-[300px] px-4 py-3 text-sm text-foreground bg-transparent resize-y focus:outline-none font-mono",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400 rounded"
)}
style={{ height: 'calc(100vh - 350px)' }}
aria-invalid={validationErrors.body || undefined}
/>
) : (
<div ref={editorContainerRef}>
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
</div>
)}
{plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(signatureIdentity)}
</div>
) : null
) : composerSignatureHtml ? (
<div
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
/>
) : null}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t shrink-0">
<div className="flex flex-wrap gap-2">
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
<div
key={index}
className={cn(
"relative flex items-center gap-2 px-3 py-1.5 rounded-md text-sm overflow-hidden",
att.error ? "bg-red-500/10 text-red-600 dark:text-red-400" : "bg-muted text-foreground"
)}
>
{att.uploading && (
<div className="absolute inset-0 pointer-events-none">
<div className="h-full bg-primary/10 animate-pulse" />
<div className="absolute bottom-0 left-0 h-0.5 bg-primary/40 animate-[indeterminate_1.5s_ease-in-out_infinite]" style={{ width: '40%' }} />
</div>
)}
<div className="relative flex items-center gap-2">
{att.uploading ? (
<Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
) : att.error ? (
<AlertCircle className="w-3 h-3 flex-shrink-0" />
) : (
<Paperclip className="w-3 h-3 flex-shrink-0" />
)}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.size)})
</span>
<button
onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
title={att.uploading ? t('upload_cancel') : undefined}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
))}
{attachments.length > 3 && (
<button
onClick={() => setShowAllAttachments(prev => !prev)}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{showAllAttachments ? t('show_less') : `+${attachments.length - 3}`}
</button>
)}
</div>
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
className="h-9 w-9"
title={t('attach')}
>
<Paperclip className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
className="h-9 w-9"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
className="h-9 w-9"
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{/* S/MIME toggles */}
{canSmimeSign && (
<>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeSign(v => !v)}
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
>
<ShieldCheck className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeEncrypt(v => !v)}
disabled={!canSmimeEncrypt}
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
>
<Lock className="w-4 h-4" />
</Button>
</>
)}
<PluginSlot name="composer-toolbar" />
</div>
{/* Right side - Discard + Send (desktop) */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors px-2 py-1"
>
{t('discard')}
</button>
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
</div>
</div>
{showTemplatePicker && (
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={handleTemplateSelect}
/>
)}
{showSaveAsTemplate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={saveTemplateModalRef}
role="dialog"
aria-modal="true"
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg p-6 animate-in zoom-in-95 duration-200"
>
<h3 className="text-lg font-semibold text-foreground mb-4">{t('save_as_template')}</h3>
<TemplateForm
initialData={{
subject,
body,
to: to.split(',').map(s => 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)}
/>
</div>
</div>
)}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
<input
type="password"
autoFocus
value={smimePassphraseInput}
onChange={(e) => {
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 && (
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => {
smimePassphrasePrompt.reject();
setSmimePassphrasePrompt(null);
setSmimePassphraseInput('');
setSmimePassphraseError('');
}}>
{t('cancel')}
</Button>
<Button
disabled={!smimePassphraseInput}
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
>
{t('smime_unlock_button')}
</Button>
</div>
</div>
</div>
)}
{showAttachmentWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowAttachmentWarning(false)}
>
<div
ref={attachmentWarningRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('forgot_attachment.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowCloseDialog(false)}
>
<div
ref={closeDialogRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('close_draft_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('close_draft_message')}</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowCloseDialog(false)}>
{t('cancel')}
</Button>
<Button variant="destructive" onClick={handleDiscardAndClose}>
{t('discard')}
</Button>
<Button onClick={handleSaveDraftAndClose}>
<Save className="w-4 h-4 mr-2" />
{t('save_draft')}
</Button>
</div>
</div>
</div>
)}
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
/>
</div>
);
}
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id: string;
results: Array<{ name: string; email: string }>;
selectedIndex: number;
onSelect: (email: string) => void;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
return (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
<button
key={i}
id={`autocomplete-option-${i}`}
type="button"
role="option"
aria-selected={i === selectedIndex}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(r.email);
}}
>
<span className="font-medium truncate">{r.name || r.email}</span>
{r.name && (
<span className="text-muted-foreground truncate">&lt;{r.email}&gt;</span>
)}
</button>
))}
</div>
);
});
function RecipientChipInput({
value,
onChange,
inputRef,
placeholder,
field,
onAutocomplete,
onAutoKeyDown,
onAutoBlur,
activeAutoField,
autocompleteResults,
autoSelectedIndex,
dropdownRef,
onInsertAutocomplete,
validationError,
validationMessage,
onTab,
}: {
value: string;
onChange: (value: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
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<HTMLDivElement | null>;
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
}) {
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<HTMLInputElement>) => {
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();
if (e.key === 'Tab' && onTab) {
e.preventDefault();
setTimeout(() => onTab(), 0);
} else {
setTimeout(() => inputRef.current?.focus(), 0);
}
return;
}
if (e.key === 'Tab' && !e.shiftKey && onTab) {
e.preventDefault();
onTab();
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 (
<div className="flex-1 relative min-w-0">
<div
className={cn(
"flex flex-wrap items-center gap-1 min-h-[32px] cursor-text",
validationError && "ring-2 ring-red-500 dark:ring-red-400 rounded"
)}
onClick={() => inputRef.current?.focus()}
>
{chips.map((chip, i) => (
<span
key={`${chip}-${i}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-secondary text-secondary-foreground text-sm border border-border cursor-pointer hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
handleChipClick(i);
}}
>
<span className="truncate max-w-[200px]">{chip}</span>
<button
type="button"
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
onClick={(e) => handleChipRemove(i, e)}
tabIndex={-1}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
placeholder={chips.length === 0 ? placeholder : ''}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 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}
/>
</div>
{validationError && validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
)}
{activeAutoField === field && autocompleteResults.length > 0 && (
<AutocompleteDropdown
ref={dropdownRef}
id={`autocomplete-${field}`}
results={autocompleteResults}
selectedIndex={autoSelectedIndex}
onSelect={(email) => onInsertAutocomplete(email, field)}
/>
)}
</div>
);
}