From 3dd596ba50fcfb2d86f9f6d2a8a555582742ce1b Mon Sep 17 00:00:00 2001 From: Maarten Draijer Date: Mon, 22 Jun 2026 16:29:16 +0000 Subject: [PATCH] fix: guard compose Send against double-submit Every Send control was disabled only by `canSend` (recipient/subject/body validity), which never reflects an in-flight submission, so the composer stayed interactive during the JMAP round-trip. Clicking Send quickly more than once - or a click racing the keyboard send shortcut - invoked handleSend once per click and sent the message multiple times (duplicate deliveries and duplicate Sent entries), most easily hit on higher-latency connections. Add a synchronous re-entry guard: a ref (not state, which updates asynchronously and wouldn't block a second click in the same tick) set once handleSend clears its "don't send" early returns and reset in a finally, plus an isSending state that disables every Send control. Covers all entry points - the three Send buttons, the keyboard shortcut, the schedule dialog, and the attachment-warning confirm. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/email/email-composer.tsx | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) 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 */} - +