diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9d612259..6a7a1a62 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1490,7 +1490,16 @@ export function EmailComposer({ }; }; + // Guard against double-submit. Rapid Send clicks (or a click racing the + // keyboard shortcut) used to invoke handleSend once per click before the + // first submission resolved, sending the message multiple times. The ref is + // a synchronous re-entry guard - state updates are async and wouldn't block a + // second click in the same tick - and isSending drives button disabling. + const [isSending, setIsSending] = useState(false); + const isSendingRef = useRef(false); + const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { + if (isSendingRef.current) return; const ccAddresses = withInput(cc, ccInput); const bccAddresses = withInput(bcc, bccInput); @@ -1525,6 +1534,11 @@ export function EmailComposer({ } } + // Past every "don't send" early return - mark the send in flight so a + // second click is a no-op until this resolves (reset in the finally below). + isSendingRef.current = true; + setIsSending(true); + // 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. @@ -1855,6 +1869,9 @@ export function EmailComposer({ } catch (err) { debug.error('Failed to send email:', err); toast.error(err instanceof Error ? err.message : t('send_failed')); + } finally { + isSendingRef.current = false; + setIsSending(false); } }; @@ -2034,7 +2051,7 @@ export function EmailComposer({ {/* Mobile: send button in header */} - +