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) <noreply@anthropic.com>
This commit is contained in:
Maarten Draijer
2026-06-22 18:42:39 +02:00
committed by Linus Rath
co-authored by Claude Opus 4.8
parent e50fe0d4db
commit 3dd596ba50
+22 -5
View File
@@ -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) => { const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
if (isSendingRef.current) return;
const ccAddresses = withInput(cc, ccInput); const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput); 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: // Resolve the freshest draftId we can. Two cases:
// 1. An autosave is currently in flight - wait for it; don't issue a // 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. // parallel destroy/create that would race with it on the same id.
@@ -1855,6 +1869,9 @@ export function EmailComposer({
} catch (err) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(err instanceof Error ? err.message : t('send_failed')); 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 */} {/* Mobile: send button in header */}
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
size="sm" size="sm"
className="md:hidden h-9 px-4" className="md:hidden h-9 px-4"
@@ -2541,7 +2558,7 @@ export function EmailComposer({
<div ref={sendMenuRef} className="relative hidden md:inline-flex"> <div ref={sendMenuRef} className="relative hidden md:inline-flex">
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20" className="rounded-r-none border-r border-primary-foreground/20"
> >
@@ -2551,7 +2568,7 @@ export function EmailComposer({
<Button <Button
type="button" type="button"
onClick={() => setShowSendMenu((open) => !open)} onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend} disabled={!canSend || isSending}
title={t('schedule_send')} title={t('schedule_send')}
className="rounded-l-none px-2" className="rounded-l-none px-2"
aria-haspopup="menu" aria-haspopup="menu"
@@ -2579,7 +2596,7 @@ export function EmailComposer({
) : ( ) : (
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!canSend} disabled={!canSend || isSending}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex" className="hidden md:inline-flex"
> >
@@ -2642,7 +2659,7 @@ export function EmailComposer({
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>} {scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button> <Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button> <Button onClick={handleScheduleSend} disabled={!canSend || isSending}>{t('schedule_send')}</Button>
</div> </div>
</div> </div>
</div> </div>