From 0a059e939213b8d7134e8d7545149df06fb507de Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:00:38 +0100
Subject: [PATCH] fix: run the send checks before the schedule dialog and say
Schedule anyway there
The composer test for the schedule arrow now waits for the dialog, since the send checks run before it opens.
---
.../composer-plugin-surfaces.test.tsx | 23 +++++++++++++--
components/email/email-composer.tsx | 29 ++++++++++++++-----
components/email/send-warnings-dialog.tsx | 9 ++++--
locales/ar/common.json | 4 ++-
locales/ca/common.json | 4 ++-
locales/cs/common.json | 4 ++-
locales/da/common.json | 4 ++-
locales/de/common.json | 4 ++-
locales/en/common.json | 4 ++-
locales/es/common.json | 4 ++-
locales/et/common.json | 4 ++-
locales/fa/common.json | 4 ++-
locales/fr/common.json | 4 ++-
locales/he/common.json | 4 ++-
locales/hu/common.json | 4 ++-
locales/it/common.json | 4 ++-
locales/ja/common.json | 4 ++-
locales/ko/common.json | 4 ++-
locales/lv/common.json | 4 ++-
locales/mn/common.json | 4 ++-
locales/nb/common.json | 4 ++-
locales/nl/common.json | 4 ++-
locales/pl/common.json | 4 ++-
locales/pt/common.json | 4 ++-
locales/ro/common.json | 4 ++-
locales/ru/common.json | 4 ++-
locales/sk/common.json | 4 ++-
locales/th/common.json | 4 ++-
locales/tr/common.json | 4 ++-
locales/uk/common.json | 4 ++-
locales/vi/common.json | 4 ++-
locales/zh-TW/common.json | 4 ++-
locales/zh/common.json | 4 ++-
33 files changed, 139 insertions(+), 42 deletions(-)
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 817a277ad..66357fe00 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -1,4 +1,4 @@
-import { act, createEvent, fireEvent, render, screen } from '@testing-library/react';
+import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -332,7 +332,7 @@ describe('composer-send-menu', () => {
});
// compose-23: a menu with "Schedule send" as its only item.
- it('opens the schedule dialog straight from the arrow when it is the only entry', () => {
+ it('opens the schedule dialog straight from the arrow when it is the only entry', async () => {
useAuthStore.setState({
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
});
@@ -343,7 +343,24 @@ describe('composer-send-menu', () => {
fireEvent.click(arrow);
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
- expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
+ // After the send checks, which found nothing here (compose-p2-14).
+ expect(await screen.findByRole('dialog')).toHaveTextContent('schedule_send_description');
+ });
+
+ // compose-p2-14: the checks opened on top of the picked time and said
+ // "Send anyway" though nothing was sent then.
+ it('runs the send checks before the schedule dialog, and says "Schedule anyway"', async () => {
+ useAuthStore.setState({
+ client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
+ });
+ render();
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
+ const warnings = await screen.findByRole('alertdialog');
+ expect(screen.queryByText('schedule_send_description')).toBeNull();
+ fireEvent.click(within(warnings).getByRole('button', { name: 'schedule_anyway' }));
+
+ expect(await screen.findByText('schedule_send_description')).toBeInTheDocument();
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fc292094a..9ba0a6d50 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1259,7 +1259,9 @@ export function EmailComposer({
const [previewAttachment, setPreviewAttachment] = useState(null);
// What the checks before sending found (no subject, no text, placeholders,
// a missing attachment, open proofreading issues), asked about in one dialog.
- const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null);
+ // `schedule`: asked before the schedule dialog, which opens on "Schedule anyway".
+ const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string; schedule?: boolean } | null>(null);
+ const checkThenScheduleRef = useRef<() => void>(() => {});
// Open proofreading issues, once a checker asked to warn about them.
const proofreadingConfirmedRef = useRef(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
@@ -1545,9 +1547,11 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]);
+ // The send checks run first, so they don't open on top of the picked time
+ // saying "Send anyway" when nothing is sent now (compose-p2-14).
const openScheduleDialog = useCallback(() => {
- setShowScheduleDialog(true);
closeSendMenu();
+ checkThenScheduleRef.current();
}, [closeSendMenu]);
// `autoSelectReplyIdentity` fused two behaviours: matching one of the user's
@@ -3836,10 +3840,12 @@ export function EmailComposer({
else editor.commands.focus();
};
- const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
+ const handleSend = async ({ warningsConfirmed = false, delayedUntil, checkOnly = false }: {
/** "Send anyway" in the warnings dialog: don't check those again. */
warningsConfirmed?: boolean;
delayedUntil?: string;
+ /** Only the checks, then the schedule dialog instead of a send. */
+ checkOnly?: boolean;
} = {}) => {
if (isSendingRef.current) return;
@@ -3986,10 +3992,14 @@ export function EmailComposer({
}
if (warnings.length > 0) {
- setSendWarnings({ items: warnings, delayedUntil });
+ setSendWarnings({ items: warnings, delayedUntil, ...(checkOnly ? { schedule: true } : {}) });
return;
}
}
+ if (checkOnly) {
+ setShowScheduleDialog(true);
+ return;
+ }
// Checks other features register (lib/outgoing/send-pipeline.ts), such
// as the external-recipient confirm. They may ask the user, so a second
@@ -4474,6 +4484,8 @@ export function EmailComposer({
};
+ checkThenScheduleRef.current = () => void handleSend({ checkOnly: true });
+
// A host can hand a follow-up to requestCloseRef - "close this session, then
// do that". It runs only once the composer really closes, never when the
// guard dialog is cancelled, and never before onClose has had its say.
@@ -6143,7 +6155,8 @@ export function EmailComposer({
setShowScheduleDialog(false)}
- onSchedule={(delayedUntil) => handleSend({ delayedUntil })}
+ // Checked before the dialog opened (openScheduleDialog).
+ onSchedule={(delayedUntil) => handleSend({ delayedUntil, warningsConfirmed: true })}
supported={!!composerClient?.hasDelayedSend()}
maxDelayedSend={composerClient?.getMaxDelayedSend() ?? 0}
disabled={!canSend || isSending || isSavingAndClosing}
@@ -6154,6 +6167,7 @@ export function EmailComposer({
{
const first = sendWarnings?.items[0];
setSendWarnings(null);
@@ -6171,7 +6185,7 @@ export function EmailComposer({
}}
onSendAnyway={(dontAskAgain) => {
if (!sendWarnings) return;
- const { items, delayedUntil } = sendWarnings;
+ const { items, delayedUntil, schedule } = sendWarnings;
for (const warning of items) {
if (warning.kind === 'subject') {
emptySubjectConfirmedRef.current = true;
@@ -6186,7 +6200,8 @@ export function EmailComposer({
}
}
setSendWarnings(null);
- handleSend({ warningsConfirmed: true, delayedUntil });
+ if (schedule) setShowScheduleDialog(true);
+ else handleSend({ warningsConfirmed: true, delayedUntil });
}}
/>
diff --git a/components/email/send-warnings-dialog.tsx b/components/email/send-warnings-dialog.tsx
index a29709903..0ea8a44eb 100644
--- a/components/email/send-warnings-dialog.tsx
+++ b/components/email/send-warnings-dialog.tsx
@@ -20,6 +20,8 @@ interface SendWarningsDialogProps {
onBack: () => void;
/** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
onSendAnyway: (dontAskAgain: boolean) => void;
+ /** Asked before Schedule send: the button says "Schedule anyway". */
+ scheduling?: boolean;
}
/** The warnings that can be switched off in Settings (subject and placeholders). */
@@ -30,7 +32,7 @@ const HAS_SETTING = new Set(["subject", "placeholders"]);
* own title and text, several are listed in one dialog with one "Send anyway"
* (two dialogs in a row asked about each in turn).
*/
-export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) {
+export function SendWarningsDialog({ warnings, onBack, onSendAnyway, scheduling = false }: SendWarningsDialogProps) {
const t = useTranslations("email_composer");
const tSend = useTranslations("compose_send");
const tProofreading = useTranslations("proofreading");
@@ -89,7 +91,7 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
backLabel = tProofreading("send_warning.review");
sendLabel = tProofreading("send_warning.send");
} else {
- title = tSend("warnings_title");
+ title = tSend(scheduling ? "schedule_warnings_title" : "warnings_title");
body = (
{items.map((w) => (
@@ -113,6 +115,9 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni
);
}
+ // Nothing goes out now: the message is scheduled (compose-p2-14).
+ if (scheduling) sendLabel = tSend("schedule_anyway");
+
return (