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) => {
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 */}
<Button
onClick={() => handleSend()}
disabled={!canSend}
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
className="md:hidden h-9 px-4"
@@ -2541,7 +2558,7 @@ export function EmailComposer({
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
<Button
onClick={() => handleSend()}
disabled={!canSend}
disabled={!canSend || isSending}
title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20"
>
@@ -2551,7 +2568,7 @@ export function EmailComposer({
<Button
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend}
disabled={!canSend || isSending}
title={t('schedule_send')}
className="rounded-l-none px-2"
aria-haspopup="menu"
@@ -2579,7 +2596,7 @@ export function EmailComposer({
) : (
<Button
onClick={() => handleSend()}
disabled={!canSend}
disabled={!canSend || isSending}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
@@ -2642,7 +2659,7 @@ export function EmailComposer({
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2">
<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>