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:
committed by
Linus Rath
co-authored by
Claude Opus 4.8
parent
e50fe0d4db
commit
3dd596ba50
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user