From 8720ab6e1a0a900e677063a629acb4823f3661be Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:12:23 +0100
Subject: [PATCH 1/5] feat: ask about everything before sending in one dialog,
and allow sending without text
Tests changed: the "asked once" tests reached the second check through
a second dialog, which no longer exists, so they now stop the send at
"Send this message?" instead. The empty-body test asserted the error
under the editor; an empty body now asks instead of blocking Send.
---
.../email/__tests__/composer-a11y.test.tsx | 14 +-
.../__tests__/empty-subject-warning.test.tsx | 65 +++-
.../__tests__/placeholder-warning.test.tsx | 54 ++-
components/email/email-composer.tsx | 337 ++++++------------
components/email/send-warnings-dialog.tsx | 146 ++++++++
locales/ar/common.json | 12 +
locales/ca/common.json | 12 +
locales/cs/common.json | 12 +
locales/da/common.json | 12 +
locales/de/common.json | 12 +
locales/en/common.json | 12 +
locales/es/common.json | 12 +
locales/et/common.json | 12 +
locales/fa/common.json | 12 +
locales/fr/common.json | 12 +
locales/he/common.json | 12 +
locales/hu/common.json | 12 +
locales/it/common.json | 12 +
locales/ja/common.json | 12 +
locales/ko/common.json | 12 +
locales/lv/common.json | 12 +
locales/mn/common.json | 12 +
locales/nb/common.json | 12 +
locales/nl/common.json | 12 +
locales/pl/common.json | 12 +
locales/pt/common.json | 12 +
locales/ro/common.json | 12 +
locales/ru/common.json | 12 +
locales/sk/common.json | 12 +
locales/th/common.json | 12 +
locales/tr/common.json | 12 +
locales/uk/common.json | 12 +
locales/vi/common.json | 12 +
locales/zh-TW/common.json | 12 +
locales/zh/common.json | 12 +
35 files changed, 714 insertions(+), 262 deletions(-)
create mode 100644 components/email/send-warnings-dialog.tsx
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index bdec82551..9e9207abd 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -370,17 +370,17 @@ describe('message body (SR-07)', () => {
expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA');
});
- it('marks an empty body invalid and links the error text', async () => {
- render( );
+ it('asks before sending an empty body instead of marking it invalid', async () => {
+ const onSend = vi.fn();
+ render( );
const editor = screen.getByTestId('rich-text-editor');
- expect(editor).not.toHaveAttribute('data-invalid');
fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true });
- await act(async () => {});
- expect(editor).toHaveAttribute('data-invalid', 'true');
- const error = document.getElementById(editor.getAttribute('data-describedby')!);
- expect(error).toHaveTextContent('validation.body_required');
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('empty_text_title');
+ expect(editor).not.toHaveAttribute('data-invalid');
+ expect(onSend).not.toHaveBeenCalled();
});
});
diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx
index 4908bc2b9..26b50b734 100644
--- a/components/email/__tests__/empty-subject-warning.test.tsx
+++ b/components/email/__tests__/empty-subject-warning.test.tsx
@@ -263,27 +263,76 @@ describe('composer empty subject warning', () => {
);
});
- it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
+ // compose-27: the subject and the attachment used to be two dialogs in a row.
+ it('asks about a missing subject and a missing attachment in one dialog', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
try {
const onSend = vi.fn();
render( );
fireEvent.click(sendButton());
- fireEvent.click(await screen.findByText('empty_subject.send_anyway'));
- // The body says "Hello", so the attachment reminder stops the send.
- fireEvent.click(await screen.findByText('forgot_attachment.back'));
- await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
- fireEvent.click(sendButton());
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('warnings_title');
+ expect(dialog).toHaveTextContent('warning_subject');
+ expect(dialog).toHaveTextContent('warning_attachment');
+ // Several warnings: no "don't ask again", which would be unclear.
+ expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
- expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
- expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument();
+ fireEvent.click(within(dialog).getByText('send_anyway'));
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument();
} finally {
useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
}
});
+ it('goes back to editing from the combined dialog without sending', async () => {
+ useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
+ try {
+ const onSend = vi.fn();
+ render( );
+
+ fireEvent.click(sendButton());
+ fireEvent.click(await screen.findByText('back'));
+
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ expect(onSend).not.toHaveBeenCalled();
+ } finally {
+ useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
+ }
+ });
+
+ // compose-28: Send works with an empty body and asks first.
+ it('keeps Send enabled with an empty body and asks "Send without text?"', async () => {
+ const onSend = vi.fn();
+ render( );
+ expect(sendButton()).not.toBeDisabled();
+
+ fireEvent.click(sendButton());
+
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('empty_text_title');
+ expect(onSend).not.toHaveBeenCalled();
+
+ fireEvent.click(within(dialog).getByText('send_anyway'));
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(onSend.mock.calls[0][0]).toMatchObject({ subject: 'Lunch?' });
+ });
+
+ it('lists no subject and no text together', async () => {
+ const onSend = vi.fn();
+ render( );
+
+ fireEvent.click(sendButton());
+
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('warning_subject');
+ expect(dialog).toHaveTextContent('warning_text');
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
it('goes back to editing without sending', async () => {
const onSend = vi.fn();
render( );
diff --git a/components/email/__tests__/placeholder-warning.test.tsx b/components/email/__tests__/placeholder-warning.test.tsx
index d73fc79c9..2baf32c91 100644
--- a/components/email/__tests__/placeholder-warning.test.tsx
+++ b/components/email/__tests__/placeholder-warning.test.tsx
@@ -235,6 +235,7 @@ describe('composer placeholder warning', () => {
placeholderWarningEnabled: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
+ sendConfirmation: false,
} as never);
});
@@ -293,33 +294,36 @@ describe('composer placeholder warning', () => {
expect(onSend).not.toHaveBeenCalled();
});
- it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
- useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
- const onSend = vi.fn();
- render( );
-
+ // Send anyway, then "Send this message?" answered with Cancel: the
+ // placeholders were confirmed, the message is still here.
+ const confirmThenCancel = async () => {
fireEvent.click(sendButton());
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
- // The body says "See you", so the attachment reminder stops the send.
- fireEvent.click(await screen.findByText('forgot_attachment.back'));
- await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
+ const confirm = await screen.findByRole('alertdialog');
+ expect(confirm).toHaveTextContent('send_confirm.title');
+ fireEvent.click(within(confirm).getByRole('button', { name: 'cancel' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ };
+
+ it('does not ask again after Send anyway and a cancelled send', async () => {
+ useSettingsStore.setState({ sendConfirmation: true } as never);
+ const onSend = vi.fn();
+ render( );
+ await confirmThenCancel();
fireEvent.click(sendButton());
- expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('send_confirm.title');
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
});
it('asks again when a new placeholder shows up', async () => {
- useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
+ useSettingsStore.setState({ sendConfirmation: true } as never);
const onSend = vi.fn();
render( );
-
- fireEvent.click(sendButton());
- fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
- fireEvent.click(await screen.findByText('forgot_attachment.back'));
- await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
+ await confirmThenCancel();
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } });
fireEvent.click(sendButton());
@@ -330,6 +334,26 @@ describe('composer placeholder warning', () => {
expect(onSend).not.toHaveBeenCalled();
});
+ // compose-27: one question for everything the checks found.
+ it('lists the placeholders and the missing attachment in one dialog', async () => {
+ useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
+ const onSend = vi.fn();
+ render( );
+
+ fireEvent.click(sendButton());
+
+ const dialog = await screen.findByRole('alertdialog');
+ expect(dialog).toHaveTextContent('warnings_title');
+ expect(dialog).toHaveTextContent('warning_placeholders');
+ expect(dialog).toHaveTextContent('warning_attachment');
+ expect(within(dialog).getByText('[TIME]')).toBeInTheDocument();
+
+ fireEvent.click(within(dialog).getByText('send_anyway'));
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+ });
+
it('ignores placeholders in quoted text and in the signature', async () => {
const onSend = vi.fn();
const body = '
Thanks!
'
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 3409f77db..b4d9104a1 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -52,6 +52,7 @@ import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { ScheduleSendDialog } from "./schedule-send-dialog";
+import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
@@ -598,7 +599,6 @@ export function EmailComposer({
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tEdit = useTranslations('desktopEdit');
- const tProofreading = useTranslations('proofreading');
const tOfflineDrafts = useTranslations('offlineDrafts');
const tOffline = useTranslations('offline');
const tOfflineNotes = useTranslations('offlineConflicts');
@@ -1201,19 +1201,11 @@ export function EmailComposer({
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState(null);
- const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
- const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
- const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState();
- const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false);
- const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState();
- const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
- const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
- // A proofreading checker asked to warn before sending with open issues.
- const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(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);
+ // Open proofreading issues, once a checker asked to warn about them.
const proofreadingConfirmedRef = useRef(false);
- const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState([]);
- const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState();
- const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
// Asks before sending at once when the account can't hold a message back.
const { dialogProps: noDelayDialogProps, confirm: confirmNoDelay } = useConfirmDialog();
@@ -3595,14 +3587,14 @@ export function EmailComposer({
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
// A missing subject no longer blocks Send (#684) - users hit the disabled
- // button without understanding why. It is confirmed in a dialog instead.
- const canSend = toAddresses.length > 0 && hasContent;
+ // button without understanding why. It is confirmed in a dialog instead,
+ // and so is a message with no text and no file.
+ const canSend = toAddresses.length > 0;
const getSendTooltip = (): string | undefined => {
if (isWaitingForUploads) return t('validation.attachments_uploading');
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
- if (!hasContent) return t('validation.body_required');
return undefined;
};
@@ -3697,6 +3689,8 @@ export function EmailComposer({
const confirmingSendRef = useRef(false);
// The user confirmed sending without a subject for this message.
const emptySubjectConfirmedRef = useRef(false);
+ // ... and without text.
+ const emptyTextConfirmedRef = useRef(false);
// Placeholders the user chose to send as they are. The warning comes back
// only for one that is not in here.
const confirmedPlaceholdersRef = useRef>(new Set());
@@ -3741,9 +3735,9 @@ export function EmailComposer({
else editor.commands.focus();
};
- const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: {
- skipAttachmentCheck?: boolean;
- skipSubjectCheck?: boolean;
+ const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
+ /** "Send anyway" in the warnings dialog: don't check those again. */
+ warningsConfirmed?: boolean;
delayedUntil?: string;
} = {}) => {
if (isSendingRef.current) return;
@@ -3791,7 +3785,6 @@ export function EmailComposer({
if (!canSend) {
const errors: { to?: boolean; body?: boolean } = {};
if (toAddresses.length === 0) errors.to = true;
- if (!hasContent) errors.body = true;
setValidationErrors(errors);
if (errors.to) {
@@ -3830,69 +3823,69 @@ export function EmailComposer({
return;
}
- // Empty subject confirmation (#684). Asked once per message: after "Send
- // anyway" and a trip back to add an attachment, it asked again.
- if (!skipSubjectCheck && !emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
- setEmptySubjectDontAskAgain(false);
- setEmptySubjectDelayedUntil(delayedUntil);
- setShowEmptySubjectWarning(true);
- return;
- }
-
- // Unfilled placeholders: template fields left as {{name}}, fill-ins like
- // [TIME] from AI drafts, and markers like TODO. The signature is left
- // out, or a placeholder in it would stop every message.
- if (placeholderWarningEnabled) {
- const authored = plainTextMode
- ? plainTextBodyWithoutSignature(body, signatureIdentity)
- : stripEmbeddedSignatureHtml(body);
- const bodyText = extractUserAuthoredText(authored, {
- plainTextMode,
- forwardedSeparator: tQuote('forwarded_separator'),
- });
- // The fill-in words of the UI language, and of English: AI drafts and
- // templates are often English whatever the UI language is.
- const found = findUnfilledPlaceholders(subject, bodyText, [
- enMessages.email_composer.placeholder_words,
- t.raw('placeholder_words'),
- ]);
- if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
- setPlaceholderWarningMatches(found);
- setPlaceholderDontAskAgain(false);
- setPlaceholderWarningDelayedUntil(delayedUntil);
- setShowPlaceholderWarning(true);
- return;
+ // What the user may have missed, asked about in one dialog (two in a
+ // row used to ask about each in turn). Answered with "Send anyway", the
+ // subject, the text, the placeholders listed and the proofreading issues
+ // aren't asked about again for this message.
+ if (!warningsConfirmed) {
+ const warnings: SendWarning[] = [];
+ // Empty subject confirmation (#684).
+ if (!emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
+ warnings.push({ kind: 'subject' });
+ }
+ // No text and no file: Send stays enabled and asks instead.
+ if (!emptyTextConfirmedRef.current && !hasContent) {
+ warnings.push({ kind: 'text' });
}
- }
- // Attachment reminder check
- if (!skipAttachmentCheck && attachmentReminderEnabled) {
- const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
- if (!hasAttachments) {
- // Scan only the user-authored text: the quoted original of a
- // reply/forward often mentions an attachment itself, which used to fire
- // the reminder even when the user typed no keyword and added nothing (#570).
- const bodyText = extractUserAuthoredText(body, {
+ // Unfilled placeholders: template fields left as {{name}}, fill-ins like
+ // [TIME] from AI drafts, and markers like TODO. The signature is left
+ // out, or a placeholder in it would stop every message.
+ if (placeholderWarningEnabled) {
+ const authored = plainTextMode
+ ? plainTextBodyWithoutSignature(body, signatureIdentity)
+ : stripEmbeddedSignatureHtml(body);
+ const bodyText = extractUserAuthoredText(authored, {
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
- const searchText = `${subject} ${bodyText}`.toLowerCase();
- const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
- if (matched) {
- setAttachmentWarningKeyword(matched);
- setAttachmentWarningDelayedUntil(delayedUntil);
- setShowAttachmentWarning(true);
- return;
+ // The fill-in words of the UI language, and of English: AI drafts and
+ // templates are often English whatever the UI language is.
+ const found = findUnfilledPlaceholders(subject, bodyText, [
+ enMessages.email_composer.placeholder_words,
+ t.raw('placeholder_words'),
+ ]);
+ if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
+ warnings.push({ kind: 'placeholders', matches: found });
}
}
- }
- // Open proofreading issues, when a checker asked for the warning (the
- // LanguageTool plugin's "warn before sending" option). Once per message.
- if (!proofreadingConfirmedRef.current) {
- const open = proofreading.sendWarningIssues().length;
- if (open > 0) {
- setProofreadingWarning({ count: open, delayedUntil });
+ // Attachment reminder check
+ if (attachmentReminderEnabled) {
+ const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
+ if (!hasAttachments) {
+ // Scan only the user-authored text: the quoted original of a
+ // reply/forward often mentions an attachment itself, which used to fire
+ // the reminder even when the user typed no keyword and added nothing (#570).
+ const bodyText = extractUserAuthoredText(body, {
+ plainTextMode,
+ forwardedSeparator: tQuote('forwarded_separator'),
+ });
+ const searchText = `${subject} ${bodyText}`.toLowerCase();
+ const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
+ if (matched) warnings.push({ kind: 'attachment', keyword: matched });
+ }
+ }
+
+ // Open proofreading issues, when a checker asked for the warning (the
+ // LanguageTool plugin's "warn before sending" option).
+ if (!proofreadingConfirmedRef.current) {
+ const open = proofreading.sendWarningIssues().length;
+ if (open > 0) warnings.push({ kind: 'proofreading', count: open });
+ }
+
+ if (warnings.length > 0) {
+ setSendWarnings({ items: warnings, delayedUntil });
return;
}
}
@@ -4651,9 +4644,7 @@ export function EmailComposer({
filesPicker ||
showSaveAsTemplate ||
showScheduleDialog ||
- showAttachmentWarning ||
- showEmptySubjectWarning ||
- showPlaceholderWarning ||
+ sendWarnings ||
showCloseDialog
) return;
@@ -5832,163 +5823,45 @@ export function EmailComposer({
disabled={!canSend || isSending || isSavingAndClosing}
/>
- setShowAttachmentWarning(false)}
- role="alertdialog"
- showClose={false}
- title={t('forgot_attachment.title')}
- description={t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
- footer={
- <>
- setShowAttachmentWarning(false)}>
- {t('forgot_attachment.back')}
-
- { setShowAttachmentWarning(false); handleSend({ skipAttachmentCheck: true, skipSubjectCheck: true, delayedUntil: attachmentWarningDelayedUntil }); setAttachmentWarningDelayedUntil(undefined); }}>
- {t('forgot_attachment.send_anyway')}
-
- >
- }
- />
-
- setShowEmptySubjectWarning(false)}
- role="alertdialog"
- showClose={false}
- title={t('empty_subject.title')}
- description={t('empty_subject.message')}
- footer={
- <>
- {
- setShowEmptySubjectWarning(false);
- setEmptySubjectDelayedUntil(undefined);
- // After the focus trap tears down it restores focus to the
- // Send button, so claim the subject field on the next tick.
- setTimeout(() => subjectInputRef.current?.focus(), 0);
- }}
- >
- {t('empty_subject.back')}
-
- {
- if (emptySubjectDontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
- emptySubjectConfirmedRef.current = true;
- setShowEmptySubjectWarning(false);
- handleSend({ skipSubjectCheck: true, delayedUntil: emptySubjectDelayedUntil });
- setEmptySubjectDelayedUntil(undefined);
- }}
- >
- {t('empty_subject.send_anyway')}
-
- >
- }
- >
-
- setEmptySubjectDontAskAgain(e.target.checked)}
- className="rounded border-input"
- />
- {t('empty_subject.dont_ask_again')}
-
-
-
- setShowPlaceholderWarning(false)}
- role="alertdialog"
- showClose={false}
- title={t('placeholder_warning.title')}
- description={t('placeholder_warning.message')}
- footer={
- <>
- {
- setShowPlaceholderWarning(false);
- setPlaceholderWarningDelayedUntil(undefined);
- // The focus trap hands focus back to the Send button as it
- // tears down, so select on the next tick.
- const first = placeholderWarningMatches[0];
- setTimeout(() => { if (first) selectPlaceholder(first); }, 0);
- }}
- >
- {t('placeholder_warning.back')}
-
- {
- if (placeholderDontAskAgain) updateSetting('placeholderWarningEnabled', false);
- placeholderWarningMatches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
- setShowPlaceholderWarning(false);
- handleSend({ skipSubjectCheck: true, delayedUntil: placeholderWarningDelayedUntil });
- setPlaceholderWarningDelayedUntil(undefined);
- }}
- >
- {t('placeholder_warning.send_anyway')}
-
- >
- }
- >
-
- {placeholderWarningMatches.slice(0, 5).map((p) => (
- {p}
- ))}
- {placeholderWarningMatches.length > 5 && (
-
- {t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
-
- )}
-
-
- setPlaceholderDontAskAgain(e.target.checked)}
- className="rounded border-input"
- />
- {t('placeholder_warning.dont_ask_again')}
-
-
-
- setProofreadingWarning(null)}
- role="alertdialog"
- showClose={false}
- title={tProofreading('send_warning.title')}
- description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })}
- footer={
- <>
- {
- setProofreadingWarning(null);
- // After the focus trap hands focus back, go to the first issue.
- setTimeout(() => proofreading.navigate(1), 0);
- }}
- >
- {tProofreading('send_warning.review')}
-
- {
- const delayed = proofreadingWarning?.delayedUntil;
- proofreadingConfirmedRef.current = true;
- setProofreadingWarning(null);
- handleSend({ skipSubjectCheck: true, skipAttachmentCheck: true, delayedUntil: delayed });
- }}
- >
- {tProofreading('send_warning.send')}
-
- >
- }
+ {
+ const first = sendWarnings?.items[0];
+ setSendWarnings(null);
+ // The focus trap hands focus back to the Send button as it tears
+ // down: go to what the first warning is about on the next tick.
+ setTimeout(() => {
+ if (first?.kind === 'subject') subjectInputRef.current?.focus();
+ else if (first?.kind === 'placeholders') selectPlaceholder(first.matches[0]);
+ else if (first?.kind === 'proofreading') proofreading.navigate(1);
+ else if (first?.kind === 'text') {
+ if (plainTextModeRef.current) bodyRef.current?.focus();
+ else editorRef.current?.commands.focus();
+ }
+ }, 0);
+ }}
+ onSendAnyway={(dontAskAgain) => {
+ if (!sendWarnings) return;
+ const { items, delayedUntil } = sendWarnings;
+ for (const warning of items) {
+ if (warning.kind === 'subject') {
+ emptySubjectConfirmedRef.current = true;
+ if (dontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
+ } else if (warning.kind === 'text') {
+ emptyTextConfirmedRef.current = true;
+ } else if (warning.kind === 'placeholders') {
+ warning.matches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
+ if (dontAskAgain) updateSetting('placeholderWarningEnabled', false);
+ } else if (warning.kind === 'proofreading') {
+ proofreadingConfirmedRef.current = true;
+ }
+ }
+ setSendWarnings(null);
+ handleSend({ warningsConfirmed: true, delayedUntil });
+ }}
/>
void;
+ /** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
+ onSendAnyway: (dontAskAgain: boolean) => void;
+}
+
+/** The warnings that can be switched off in Settings (subject and placeholders). */
+const HAS_SETTING = new Set(["subject", "placeholders"]);
+
+/**
+ * One question before sending, whatever it found: a single warning keeps its
+ * 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) {
+ const t = useTranslations("email_composer");
+ const tSend = useTranslations("compose_send");
+ const tProofreading = useTranslations("proofreading");
+ const [dontAskAgain, setDontAskAgain] = useState(false);
+ const [shownFor, setShownFor] = useState(null);
+ // A new question starts with the box unticked.
+ if (warnings !== shownFor) {
+ setShownFor(warnings);
+ setDontAskAgain(false);
+ }
+
+ const items = warnings ?? [];
+ const single = items.length === 1 ? items[0] : null;
+
+ const placeholderChips = (matches: string[]) => (
+
+ {matches.slice(0, 5).map((p) => (
+ {p}
+ ))}
+ {matches.length > 5 && (
+
+ {t("placeholder_warning.more", { count: matches.length - 5 })}
+
+ )}
+
+ );
+
+ let title: string;
+ let description: string | undefined;
+ let backLabel = tSend("back");
+ let sendLabel = tSend("send_anyway");
+ let body: React.ReactNode = null;
+
+ if (single?.kind === "subject") {
+ title = t("empty_subject.title");
+ description = t("empty_subject.message");
+ backLabel = t("empty_subject.back");
+ sendLabel = t("empty_subject.send_anyway");
+ } else if (single?.kind === "text") {
+ title = tSend("empty_text_title");
+ description = tSend("empty_text_message");
+ } else if (single?.kind === "placeholders") {
+ title = t("placeholder_warning.title");
+ description = t("placeholder_warning.message");
+ backLabel = t("placeholder_warning.back");
+ sendLabel = t("placeholder_warning.send_anyway");
+ body = placeholderChips(single.matches);
+ } else if (single?.kind === "attachment") {
+ title = t("forgot_attachment.title");
+ description = t("forgot_attachment.message", { keyword: single.keyword });
+ backLabel = t("forgot_attachment.back");
+ sendLabel = t("forgot_attachment.send_anyway");
+ } else if (single?.kind === "proofreading") {
+ title = tProofreading("send_warning.title");
+ description = tProofreading("send_warning.body", { count: single.count });
+ backLabel = tProofreading("send_warning.review");
+ sendLabel = tProofreading("send_warning.send");
+ } else {
+ title = tSend("warnings_title");
+ body = (
+
+ {items.map((w) => (
+
+ •
+
+ {w.kind === "subject" && tSend("warning_subject")}
+ {w.kind === "text" && tSend("warning_text")}
+ {w.kind === "attachment" && tSend("warning_attachment", { keyword: w.keyword })}
+ {w.kind === "proofreading" && tSend("warning_proofreading", { count: w.count })}
+ {w.kind === "placeholders" && (
+ <>
+ {tSend("warning_placeholders")}
+ {placeholderChips(w.matches)}
+ >
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+ 0}
+ onClose={onBack}
+ role="alertdialog"
+ showClose={false}
+ title={title}
+ description={description}
+ testId="send-warnings"
+ footer={
+ <>
+ {backLabel}
+ onSendAnyway(dontAskAgain)}>{sendLabel}
+ >
+ }
+ >
+ {body}
+ {single && HAS_SETTING.has(single.kind) && (
+
+ setDontAskAgain(e.target.checked)}
+ className="rounded border-input"
+ />
+ {single.kind === "subject" ? t("empty_subject.dont_ask_again") : t("placeholder_warning.dont_ask_again")}
+
+ )}
+
+ );
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 5bf41a9d5..053453a2a 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "العودة إلى التعديل",
+ "send_anyway": "الإرسال على أي حال",
+ "warnings_title": "هل تريد إرسال هذه الرسالة على أي حال؟",
+ "warning_subject": "بلا موضوع",
+ "warning_text": "بلا نص",
+ "warning_attachment": "تذكر \"{keyword}\" لكن لا يوجد ملف مرفق",
+ "warning_placeholders": "نص مؤقت سيراه المستلمون:",
+ "warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}",
+ "empty_text_title": "هل تريد الإرسال بدون نص؟",
+ "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟"
+ },
"org_templates": {
"section_personal": "قوالبي",
"section_organisation": "المؤسسة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index eca36135b..7f4471792 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Torna a l'edició",
+ "send_anyway": "Envia igualment",
+ "warnings_title": "Vols enviar aquest missatge igualment?",
+ "warning_subject": "Sense assumpte",
+ "warning_text": "Sense text",
+ "warning_attachment": "Esmenta «{keyword}», però no hi ha cap fitxer adjunt",
+ "warning_placeholders": "Text de mostra que veuran els destinataris:",
+ "warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}",
+ "empty_text_title": "Vols enviar-lo sense text?",
+ "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?"
+ },
"org_templates": {
"section_personal": "Les meves plantilles",
"section_organisation": "Organització",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index f1f5076dd..c20188847 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Zpět k úpravám",
+ "send_anyway": "Přesto odeslat",
+ "warnings_title": "Přesto tuto zprávu odeslat?",
+ "warning_subject": "Bez předmětu",
+ "warning_text": "Bez textu",
+ "warning_attachment": "Zmiňuje „{keyword}“, ale není připojen žádný soubor",
+ "warning_placeholders": "Zástupný text, který příjemci uvidí:",
+ "warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}",
+ "empty_text_title": "Odeslat bez textu?",
+ "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?"
+ },
"org_templates": {
"section_personal": "Moje šablony",
"section_organisation": "Organizace",
diff --git a/locales/da/common.json b/locales/da/common.json
index 7461d652b..9c99fd35c 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Tilbage til redigering",
+ "send_anyway": "Send alligevel",
+ "warnings_title": "Send denne besked alligevel?",
+ "warning_subject": "Intet emne",
+ "warning_text": "Ingen tekst",
+ "warning_attachment": "Nævner \"{keyword}\", men ingen fil er vedhæftet",
+ "warning_placeholders": "Pladsholdertekst, som modtagerne vil se:",
+ "warning_proofreading": "Åbne stave- eller grammatikfejl: {count}",
+ "empty_text_title": "Send uden tekst?",
+ "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?"
+ },
"org_templates": {
"section_personal": "Mine skabeloner",
"section_organisation": "Organisation",
diff --git a/locales/de/common.json b/locales/de/common.json
index 31d15c0ed..a45ec38e1 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Zurück zur Bearbeitung",
+ "send_anyway": "Trotzdem senden",
+ "warnings_title": "Diese Nachricht trotzdem senden?",
+ "warning_subject": "Kein Betreff",
+ "warning_text": "Kein Text",
+ "warning_attachment": "Erwähnt „{keyword}“, aber keine Datei ist angehängt",
+ "warning_placeholders": "Platzhaltertext, den die Empfänger sehen:",
+ "warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}",
+ "empty_text_title": "Ohne Text senden?",
+ "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?"
+ },
"org_templates": {
"section_personal": "Meine Vorlagen",
"section_organisation": "Organisation",
diff --git a/locales/en/common.json b/locales/en/common.json
index e0491ee32..26fa9449a 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Back to editing",
+ "send_anyway": "Send anyway",
+ "warnings_title": "Send this message anyway?",
+ "warning_subject": "No subject",
+ "warning_text": "No text",
+ "warning_attachment": "Mentions \"{keyword}\", but no file is attached",
+ "warning_placeholders": "Placeholder text that recipients will see:",
+ "warning_proofreading": "Open spelling or grammar issues: {count}",
+ "empty_text_title": "Send without text?",
+ "empty_text_message": "This message has no text and no attachment. Send it anyway?"
+ },
"org_templates": {
"section_personal": "My templates",
"section_organisation": "Organisation",
diff --git a/locales/es/common.json b/locales/es/common.json
index 1050f0f56..3fb63b84d 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Volver a editar",
+ "send_anyway": "Enviar de todos modos",
+ "warnings_title": "¿Enviar este mensaje de todos modos?",
+ "warning_subject": "Sin asunto",
+ "warning_text": "Sin texto",
+ "warning_attachment": "Menciona «{keyword}», pero no hay ningún archivo adjunto",
+ "warning_placeholders": "Texto de marcador que verán los destinatarios:",
+ "warning_proofreading": "Errores de ortografía o gramática pendientes: {count}",
+ "empty_text_title": "¿Enviar sin texto?",
+ "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?"
+ },
"org_templates": {
"section_personal": "Mis plantillas",
"section_organisation": "Organización",
diff --git a/locales/et/common.json b/locales/et/common.json
index df425b8bd..76ac8af66 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Tagasi muutmise juurde",
+ "send_anyway": "Saada ikkagi",
+ "warnings_title": "Kas saata see kiri ikkagi?",
+ "warning_subject": "Teema puudub",
+ "warning_text": "Tekst puudub",
+ "warning_attachment": "Mainib sõna „{keyword}“, kuid ühtegi faili pole lisatud",
+ "warning_placeholders": "Kohatäitetekst, mida saajad näevad:",
+ "warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}",
+ "empty_text_title": "Kas saata ilma tekstita?",
+ "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?"
+ },
"org_templates": {
"section_personal": "Minu mallid",
"section_organisation": "Organisatsioon",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index bf242d98d..40122d82c 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "بازگشت به ویرایش",
+ "send_anyway": "ارسال به هر حال",
+ "warnings_title": "این پیام به هر حال ارسال شود؟",
+ "warning_subject": "بدون موضوع",
+ "warning_text": "بدون متن",
+ "warning_attachment": "به «{keyword}» اشاره میکند، اما هیچ فایلی پیوست نشده است",
+ "warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:",
+ "warning_proofreading": "خطاهای املایی یا دستوری باز: {count}",
+ "empty_text_title": "بدون متن ارسال شود؟",
+ "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟"
+ },
"org_templates": {
"section_personal": "قالبهای من",
"section_organisation": "سازمان",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 03545491c..fb6acde3b 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Retour à l'édition",
+ "send_anyway": "Envoyer quand même",
+ "warnings_title": "Envoyer ce message quand même ?",
+ "warning_subject": "Pas d'objet",
+ "warning_text": "Pas de texte",
+ "warning_attachment": "Mentionne « {keyword} », mais aucun fichier n'est joint",
+ "warning_placeholders": "Texte de remplacement que les destinataires verront :",
+ "warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}",
+ "empty_text_title": "Envoyer sans texte ?",
+ "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?"
+ },
"org_templates": {
"section_personal": "Mes modèles",
"section_organisation": "Organisation",
diff --git a/locales/he/common.json b/locales/he/common.json
index f8258a747..411fd5d51 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3793,6 +3793,18 @@
}
}
},
+ "compose_send": {
+ "back": "חזור לעריכה",
+ "send_anyway": "שלח בכל זאת",
+ "warnings_title": "לשלוח את ההודעה בכל זאת?",
+ "warning_subject": "אין נושא",
+ "warning_text": "אין טקסט",
+ "warning_attachment": "מוזכר \"{keyword}\", אבל לא צורף קובץ",
+ "warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:",
+ "warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}",
+ "empty_text_title": "לשלוח ללא טקסט?",
+ "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?"
+ },
"org_templates": {
"section_personal": "התבניות שלי",
"section_organisation": "ארגון",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index bd65a8ec0..ec24d247c 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Vissza a szerkesztéshez",
+ "send_anyway": "Küldés így is",
+ "warnings_title": "Elküldi így is ezt az üzenetet?",
+ "warning_subject": "Nincs tárgy",
+ "warning_text": "Nincs szöveg",
+ "warning_attachment": "Említi ezt: „{keyword}”, de nincs csatolt fájl",
+ "warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:",
+ "warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}",
+ "empty_text_title": "Küldés szöveg nélkül?",
+ "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?"
+ },
"org_templates": {
"section_personal": "Saját sablonok",
"section_organisation": "Szervezet",
diff --git a/locales/it/common.json b/locales/it/common.json
index b79013a3a..6198c7e9a 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Torna alla modifica",
+ "send_anyway": "Invia comunque",
+ "warnings_title": "Inviare comunque questo messaggio?",
+ "warning_subject": "Nessun oggetto",
+ "warning_text": "Nessun testo",
+ "warning_attachment": "Menziona \"{keyword}\", ma non c'è nessun file allegato",
+ "warning_placeholders": "Testo segnaposto che i destinatari vedranno:",
+ "warning_proofreading": "Errori di ortografia o grammatica aperti: {count}",
+ "empty_text_title": "Inviare senza testo?",
+ "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?"
+ },
"org_templates": {
"section_personal": "I miei modelli",
"section_organisation": "Organizzazione",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 0fc511808..374b5b7e2 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "編集に戻る",
+ "send_anyway": "そのまま送信",
+ "warnings_title": "このメッセージをそのまま送信しますか?",
+ "warning_subject": "件名がありません",
+ "warning_text": "本文がありません",
+ "warning_attachment": "「{keyword}」とありますが、ファイルが添付されていません",
+ "warning_placeholders": "受信者に表示されるプレースホルダー:",
+ "warning_proofreading": "未解決のスペル・文法の問題: {count}",
+ "empty_text_title": "本文なしで送信しますか?",
+ "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?"
+ },
"org_templates": {
"section_personal": "マイテンプレート",
"section_organisation": "組織",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 21bc7b936..cf92bbb07 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "편집으로 돌아가기",
+ "send_anyway": "그래도 보내기",
+ "warnings_title": "이 메일을 그래도 보낼까요?",
+ "warning_subject": "제목 없음",
+ "warning_text": "본문 없음",
+ "warning_attachment": "\"{keyword}\"라고 적혀 있지만 첨부된 파일이 없어요",
+ "warning_placeholders": "받는 사람에게 보이는 자리 표시 텍스트:",
+ "warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}",
+ "empty_text_title": "본문 없이 보낼까요?",
+ "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?"
+ },
"org_templates": {
"section_personal": "내 템플릿",
"section_organisation": "조직",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 2958f811c..ce05b0de3 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Atpakaļ pie rediģēšanas",
+ "send_anyway": "Sūtīt tik un tā",
+ "warnings_title": "Vai tomēr sūtīt šo vēstuli?",
+ "warning_subject": "Nav temata",
+ "warning_text": "Nav teksta",
+ "warning_attachment": "Piemin “{keyword}”, bet neviens fails nav pievienots",
+ "warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:",
+ "warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}",
+ "empty_text_title": "Sūtīt bez teksta?",
+ "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?"
+ },
"org_templates": {
"section_personal": "Manas veidnes",
"section_organisation": "Organizācija",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 57128748e..8c6d231f2 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Засварлах руу буцах",
+ "send_anyway": "Ямар ч байсан илгээх",
+ "warnings_title": "Энэ захиаг ямар ч байсан илгээх үү?",
+ "warning_subject": "Гарчиггүй",
+ "warning_text": "Текстгүй",
+ "warning_attachment": "\"{keyword}\" гэж дурдсан ч файл хавсаргаагүй",
+ "warning_placeholders": "Хүлээн авагчид харах орлуулах текст:",
+ "warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}",
+ "empty_text_title": "Текстгүй илгээх үү?",
+ "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?"
+ },
"org_templates": {
"section_personal": "Миний загварууд",
"section_organisation": "Байгууллага",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 27c9f7642..ab96e67a2 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Tilbake til redigering",
+ "send_anyway": "Send likevel",
+ "warnings_title": "Sende denne meldingen likevel?",
+ "warning_subject": "Ingen emne",
+ "warning_text": "Ingen tekst",
+ "warning_attachment": "Nevner «{keyword}», men ingen fil er lagt ved",
+ "warning_placeholders": "Plassholdertekst som mottakerne vil se:",
+ "warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}",
+ "empty_text_title": "Sende uten tekst?",
+ "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?"
+ },
"org_templates": {
"section_personal": "Mine maler",
"section_organisation": "Organisasjon",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 69e5afbcc..bccdc4a88 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Terug naar bewerken",
+ "send_anyway": "Toch verzenden",
+ "warnings_title": "Dit bericht toch verzenden?",
+ "warning_subject": "Geen onderwerp",
+ "warning_text": "Geen tekst",
+ "warning_attachment": "Noemt ‘{keyword}’, maar er is geen bestand bijgevoegd",
+ "warning_placeholders": "Tijdelijke tekst die ontvangers zien:",
+ "warning_proofreading": "Openstaande spel- of grammaticafouten: {count}",
+ "empty_text_title": "Verzenden zonder tekst?",
+ "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?"
+ },
"org_templates": {
"section_personal": "Mijn sjablonen",
"section_organisation": "Organisatie",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 35e69ac26..c011ba2f6 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Wróć do edycji",
+ "send_anyway": "Wyślij mimo to",
+ "warnings_title": "Wysłać tę wiadomość mimo to?",
+ "warning_subject": "Brak tematu",
+ "warning_text": "Brak treści",
+ "warning_attachment": "Wspomina „{keyword}”, ale nie dołączono pliku",
+ "warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:",
+ "warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}",
+ "empty_text_title": "Wysłać bez treści?",
+ "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?"
+ },
"org_templates": {
"section_personal": "Moje szablony",
"section_organisation": "Organizacja",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 7a094b8ea..a10b6f2b4 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Voltar à edição",
+ "send_anyway": "Enviar mesmo assim",
+ "warnings_title": "Enviar esta mensagem mesmo assim?",
+ "warning_subject": "Sem assunto",
+ "warning_text": "Sem texto",
+ "warning_attachment": "Menciona \"{keyword}\", mas nenhum arquivo foi anexado",
+ "warning_placeholders": "Texto de espaço reservado que os destinatários verão:",
+ "warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}",
+ "empty_text_title": "Enviar sem texto?",
+ "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?"
+ },
"org_templates": {
"section_personal": "Meus modelos",
"section_organisation": "Organização",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 1c3d817ce..8a994a38f 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Înapoi la editare",
+ "send_anyway": "Trimite oricum",
+ "warnings_title": "Trimiteți totuși acest mesaj?",
+ "warning_subject": "Fără subiect",
+ "warning_text": "Fără text",
+ "warning_attachment": "Menționează „{keyword}”, dar niciun fișier nu este atașat",
+ "warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:",
+ "warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}",
+ "empty_text_title": "Trimiteți fără text?",
+ "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?"
+ },
"org_templates": {
"section_personal": "Șabloanele mele",
"section_organisation": "Organizație",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index c5c112ea2..7fa2dfdb0 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Вернуться к редактированию",
+ "send_anyway": "Отправить всё равно",
+ "warnings_title": "Всё равно отправить это письмо?",
+ "warning_subject": "Нет темы",
+ "warning_text": "Нет текста",
+ "warning_attachment": "Упоминается «{keyword}», но файл не прикреплён",
+ "warning_placeholders": "Текст-заполнитель, который увидят получатели:",
+ "warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}",
+ "empty_text_title": "Отправить без текста?",
+ "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?"
+ },
"org_templates": {
"section_personal": "Мои шаблоны",
"section_organisation": "Организация",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 81d50e05d..14519e009 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Späť na úpravu",
+ "send_anyway": "Odoslať aj tak",
+ "warnings_title": "Odoslať túto správu aj tak?",
+ "warning_subject": "Bez predmetu",
+ "warning_text": "Bez textu",
+ "warning_attachment": "Spomína „{keyword}“, ale nie je pripojený žiadny súbor",
+ "warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:",
+ "warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}",
+ "empty_text_title": "Odoslať bez textu?",
+ "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?"
+ },
"org_templates": {
"section_personal": "Moje šablóny",
"section_organisation": "Organizácia",
diff --git a/locales/th/common.json b/locales/th/common.json
index c34c371f1..2aa2198ea 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1147,6 +1147,18 @@
}
}
},
+ "compose_send": {
+ "back": "กลับไปแก้ไข",
+ "send_anyway": "ส่งต่อไป",
+ "warnings_title": "ส่งข้อความนี้ต่อไปหรือไม่",
+ "warning_subject": "ไม่มีหัวเรื่อง",
+ "warning_text": "ไม่มีข้อความ",
+ "warning_attachment": "มีคำว่า \"{keyword}\" แต่ไม่ได้แนบไฟล์",
+ "warning_placeholders": "ข้อความตัวยึดตำแหน่งที่ผู้รับจะเห็น:",
+ "warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}",
+ "empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่",
+ "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่"
+ },
"org_templates": {
"section_personal": "แม่แบบของฉัน",
"section_organisation": "องค์กร",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 43380e45c..12f69457d 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Düzenlemeye dön",
+ "send_anyway": "Yine de gönder",
+ "warnings_title": "Bu ileti yine de gönderilsin mi?",
+ "warning_subject": "Konu yok",
+ "warning_text": "Metin yok",
+ "warning_attachment": "\"{keyword}\" geçiyor ama hiç dosya eklenmemiş",
+ "warning_placeholders": "Alıcıların göreceği yer tutucu metin:",
+ "warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}",
+ "empty_text_title": "Metin olmadan gönderilsin mi?",
+ "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?"
+ },
"org_templates": {
"section_personal": "Şablonlarım",
"section_organisation": "Kuruluş",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index d8d7ecf49..d7933e370 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "Назад до редагування",
+ "send_anyway": "Усе одно надіслати",
+ "warnings_title": "Усе одно надіслати цей лист?",
+ "warning_subject": "Без теми",
+ "warning_text": "Без тексту",
+ "warning_attachment": "Згадується «{keyword}», але файл не прикріплено",
+ "warning_placeholders": "Текст-заповнювач, який побачать одержувачі:",
+ "warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}",
+ "empty_text_title": "Надіслати без тексту?",
+ "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?"
+ },
"org_templates": {
"section_personal": "Мої шаблони",
"section_organisation": "Організація",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index ccb97537d..2bdd89e99 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -3908,6 +3908,18 @@
}
}
},
+ "compose_send": {
+ "back": "Quay lại chỉnh sửa",
+ "send_anyway": "Vẫn gửi",
+ "warnings_title": "Vẫn gửi thư này?",
+ "warning_subject": "Không có tiêu đề",
+ "warning_text": "Không có nội dung",
+ "warning_attachment": "Có nhắc đến \"{keyword}\" nhưng chưa đính kèm tệp nào",
+ "warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:",
+ "warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}",
+ "empty_text_title": "Gửi mà không có nội dung?",
+ "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?"
+ },
"org_templates": {
"section_personal": "Mẫu của tôi",
"section_organisation": "Tổ chức",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 3bc47f02b..8650ef97c 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "繼續編輯",
+ "send_anyway": "仍然傳送",
+ "warnings_title": "仍然傳送這封郵件?",
+ "warning_subject": "沒有主旨",
+ "warning_text": "沒有內文",
+ "warning_attachment": "提到了「{keyword}」,但沒有附加檔案",
+ "warning_placeholders": "收件者會看到的預留位置文字:",
+ "warning_proofreading": "未處理的拼字或文法問題:{count}",
+ "empty_text_title": "不寫內文就傳送?",
+ "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?"
+ },
"org_templates": {
"section_personal": "我的範本",
"section_organisation": "組織",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index d4646cf11..500507321 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3896,6 +3896,18 @@
}
}
},
+ "compose_send": {
+ "back": "返回编辑",
+ "send_anyway": "仍然发送",
+ "warnings_title": "仍然发送这封邮件?",
+ "warning_subject": "没有主题",
+ "warning_text": "没有正文",
+ "warning_attachment": "提到了“{keyword}”,但没有附加文件",
+ "warning_placeholders": "收件人会看到的占位文本:",
+ "warning_proofreading": "未处理的拼写或语法问题:{count}",
+ "empty_text_title": "不写正文就发送?",
+ "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?"
+ },
"org_templates": {
"section_personal": "我的模板",
"section_organisation": "组织",
From 007b007302dccafd55206077c9a6f7b131729c17 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:22:53 +0100
Subject: [PATCH 2/5] feat: open the schedule dialog from Send's arrow and say
when a scheduled message goes
---
.../composer-plugin-surfaces.test.tsx | 15 +++++++++++++++
components/email/email-composer.tsx | 19 +++++++++++++------
.../mail/__tests__/undo-send-toast.test.tsx | 12 ++++++++++++
components/mail/mail-app.tsx | 6 ++++--
locales/ar/common.json | 3 ++-
locales/ca/common.json | 3 ++-
locales/cs/common.json | 3 ++-
locales/da/common.json | 3 ++-
locales/de/common.json | 3 ++-
locales/en/common.json | 3 ++-
locales/es/common.json | 3 ++-
locales/et/common.json | 3 ++-
locales/fa/common.json | 3 ++-
locales/fr/common.json | 3 ++-
locales/he/common.json | 3 ++-
locales/hu/common.json | 3 ++-
locales/it/common.json | 3 ++-
locales/ja/common.json | 3 ++-
locales/ko/common.json | 3 ++-
locales/lv/common.json | 3 ++-
locales/mn/common.json | 3 ++-
locales/nb/common.json | 3 ++-
locales/nl/common.json | 3 ++-
locales/pl/common.json | 3 ++-
locales/pt/common.json | 3 ++-
locales/ro/common.json | 3 ++-
locales/ru/common.json | 3 ++-
locales/sk/common.json | 3 ++-
locales/th/common.json | 3 ++-
locales/tr/common.json | 3 ++-
locales/uk/common.json | 3 ++-
locales/vi/common.json | 3 ++-
locales/zh-TW/common.json | 3 ++-
locales/zh/common.json | 3 ++-
34 files changed, 104 insertions(+), 38 deletions(-)
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 787389c18..51927e1d5 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -329,6 +329,21 @@ describe('composer-send-menu', () => {
expect(follows(schedule, plugin)).toBe(true);
});
+ // 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', () => {
+ useAuthStore.setState({
+ client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
+ });
+ render( );
+
+ const arrow = screen.getAllByRole('button', { name: 'schedule_send' })[0];
+ expect(arrow).toHaveAttribute('aria-haspopup', 'dialog');
+ fireEvent.click(arrow);
+
+ expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
+ expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
+ });
+
it('keeps a plain Send button without plugin entries or scheduled send', () => {
render( );
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index b4d9104a1..95948a852 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2793,6 +2793,13 @@ export function EmailComposer({
// Plugin entries make the Send and Attach buttons menus even where the app
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
+ // With nothing but "Schedule send" in Send's menu, the arrow opens the
+ // schedule dialog itself: a menu with one item was one click too many.
+ const sendArrowSchedules = !hasSendMenuActions && !!composerClient?.hasDelayedSend();
+ const handleSendArrow = (e: React.MouseEvent) => {
+ if (sendArrowSchedules) openScheduleDialog();
+ else toggleSendMenu(e);
+ };
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
// Ticked plugin options count in the Options button's badge like the app's own.
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
@@ -4850,14 +4857,14 @@ export function EmailComposer({
{ if (showSendMenu) e.stopPropagation(); }}
- onClick={toggleSendMenu}
+ onClick={handleSendArrow}
disabled={!canSend || isSending || isSavingAndClosing}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
size="sm"
className="h-11 min-w-11 rounded-s-none px-2"
- aria-haspopup="menu"
- aria-expanded={showSendMenu}
+ aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
+ aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
>
@@ -5717,13 +5724,13 @@ export function EmailComposer({
{ if (showSendMenu) e.stopPropagation(); }}
- onClick={toggleSendMenu}
+ onClick={handleSendArrow}
disabled={!canSend || isSending || isSavingAndClosing}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
className="rounded-s-none px-2"
- aria-haspopup="menu"
- aria-expanded={showSendMenu}
+ aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
+ aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
>
diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx
index 17f92fa53..92d7e83e3 100644
--- a/components/mail/__tests__/undo-send-toast.test.tsx
+++ b/components/mail/__tests__/undo-send-toast.test.tsx
@@ -119,6 +119,18 @@ describe('undo send toast', () => {
expect(toast.pauseOnHover).toBe(false);
});
+ // compose-23: "Email scheduled for sending" with "Undo send" said neither
+ // when it goes nor that the button cancels it.
+ it('says when a scheduled message goes, with Cancel and Send now', async () => {
+ render( );
+ sendDelayedBy(3 * 86_400_000);
+
+ await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.title === 'compose_send.scheduled_for')).toBe(true));
+ const toast = useToastStore.getState().toasts.find((t) => t.title === 'compose_send.scheduled_for')!;
+ expect(toast.action?.label).toBe('cancel');
+ expect(toast.secondaryAction?.label).toBe('email_viewer.send_now');
+ });
+
it('says so when the undo comes too late', async () => {
cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.'));
vi.spyOn(console, 'error').mockImplementation(() => {});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 11d4246c0..bbf7e7833 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -2941,9 +2941,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? Math.max(remainingMs - 1000, 1000)
: Math.max(sendDelaySeconds, 8) * 1000;
+ // A message scheduled for later says when it goes, and offers Cancel
+ // where a send-delay window offers Undo send.
toast.success(isUndoWindow
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
- : t('email_viewer.scheduled_send_created'), {
+ : t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
duration: undoDurationMs,
pauseOnHover: false,
secondaryAction: (pending.emailId && pending.identityId)
@@ -2985,7 +2987,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
: undefined,
action: {
- label: t('email_viewer.undo_send'),
+ label: isUndoWindow ? t('email_viewer.undo_send') : tCommon('cancel'),
onClick: () => {
if (!undoClient) return refuseSignedOut();
void (async () => {
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 053453a2a..5ba9c4823 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "نص مؤقت سيراه المستلمون:",
"warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}",
"empty_text_title": "هل تريد الإرسال بدون نص؟",
- "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟"
+ "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟",
+ "scheduled_for": "مجدولة في {when}"
},
"org_templates": {
"section_personal": "قوالبي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 7f4471792..7fb01c5d9 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Text de mostra que veuran els destinataris:",
"warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}",
"empty_text_title": "Vols enviar-lo sense text?",
- "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?"
+ "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?",
+ "scheduled_for": "Programat per a {when}"
},
"org_templates": {
"section_personal": "Les meves plantilles",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index c20188847..219e9da67 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Zástupný text, který příjemci uvidí:",
"warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}",
"empty_text_title": "Odeslat bez textu?",
- "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?"
+ "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?",
+ "scheduled_for": "Naplánováno na {when}"
},
"org_templates": {
"section_personal": "Moje šablony",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9c99fd35c..9fdc94b90 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Pladsholdertekst, som modtagerne vil se:",
"warning_proofreading": "Åbne stave- eller grammatikfejl: {count}",
"empty_text_title": "Send uden tekst?",
- "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?"
+ "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?",
+ "scheduled_for": "Planlagt til {when}"
},
"org_templates": {
"section_personal": "Mine skabeloner",
diff --git a/locales/de/common.json b/locales/de/common.json
index a45ec38e1..2e72aacef 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Platzhaltertext, den die Empfänger sehen:",
"warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}",
"empty_text_title": "Ohne Text senden?",
- "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?"
+ "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?",
+ "scheduled_for": "Geplant für {when}"
},
"org_templates": {
"section_personal": "Meine Vorlagen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 26fa9449a..f8e008234 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Placeholder text that recipients will see:",
"warning_proofreading": "Open spelling or grammar issues: {count}",
"empty_text_title": "Send without text?",
- "empty_text_message": "This message has no text and no attachment. Send it anyway?"
+ "empty_text_message": "This message has no text and no attachment. Send it anyway?",
+ "scheduled_for": "Scheduled for {when}"
},
"org_templates": {
"section_personal": "My templates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 3fb63b84d..1b6a20894 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Texto de marcador que verán los destinatarios:",
"warning_proofreading": "Errores de ortografía o gramática pendientes: {count}",
"empty_text_title": "¿Enviar sin texto?",
- "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?"
+ "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?",
+ "scheduled_for": "Programado para {when}"
},
"org_templates": {
"section_personal": "Mis plantillas",
diff --git a/locales/et/common.json b/locales/et/common.json
index 76ac8af66..411dda041 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Kohatäitetekst, mida saajad näevad:",
"warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}",
"empty_text_title": "Kas saata ilma tekstita?",
- "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?"
+ "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?",
+ "scheduled_for": "Ajastatud: {when}"
},
"org_templates": {
"section_personal": "Minu mallid",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 40122d82c..f99b675ef 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:",
"warning_proofreading": "خطاهای املایی یا دستوری باز: {count}",
"empty_text_title": "بدون متن ارسال شود؟",
- "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟"
+ "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟",
+ "scheduled_for": "زمانبندیشده برای {when}"
},
"org_templates": {
"section_personal": "قالبهای من",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index fb6acde3b..56f599aed 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Texte de remplacement que les destinataires verront :",
"warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}",
"empty_text_title": "Envoyer sans texte ?",
- "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?"
+ "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?",
+ "scheduled_for": "Programmé pour {when}"
},
"org_templates": {
"section_personal": "Mes modèles",
diff --git a/locales/he/common.json b/locales/he/common.json
index 411fd5d51..95ccc1e67 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3803,7 +3803,8 @@
"warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:",
"warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}",
"empty_text_title": "לשלוח ללא טקסט?",
- "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?"
+ "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?",
+ "scheduled_for": "מתוזמן ל-{when}"
},
"org_templates": {
"section_personal": "התבניות שלי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index ec24d247c..35741dc7e 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:",
"warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}",
"empty_text_title": "Küldés szöveg nélkül?",
- "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?"
+ "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?",
+ "scheduled_for": "Ütemezve: {when}"
},
"org_templates": {
"section_personal": "Saját sablonok",
diff --git a/locales/it/common.json b/locales/it/common.json
index 6198c7e9a..061922c51 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Testo segnaposto che i destinatari vedranno:",
"warning_proofreading": "Errori di ortografia o grammatica aperti: {count}",
"empty_text_title": "Inviare senza testo?",
- "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?"
+ "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?",
+ "scheduled_for": "Programmato per {when}"
},
"org_templates": {
"section_personal": "I miei modelli",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 374b5b7e2..aacaa9fa1 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "受信者に表示されるプレースホルダー:",
"warning_proofreading": "未解決のスペル・文法の問題: {count}",
"empty_text_title": "本文なしで送信しますか?",
- "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?"
+ "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?",
+ "scheduled_for": "{when} に送信予定"
},
"org_templates": {
"section_personal": "マイテンプレート",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index cf92bbb07..62961153b 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "받는 사람에게 보이는 자리 표시 텍스트:",
"warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}",
"empty_text_title": "본문 없이 보낼까요?",
- "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?"
+ "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?",
+ "scheduled_for": "{when}에 보내기 예약됨"
},
"org_templates": {
"section_personal": "내 템플릿",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index ce05b0de3..47ba99e8b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:",
"warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}",
"empty_text_title": "Sūtīt bez teksta?",
- "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?"
+ "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?",
+ "scheduled_for": "Ieplānots: {when}"
},
"org_templates": {
"section_personal": "Manas veidnes",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 8c6d231f2..2ceac09b1 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Хүлээн авагчид харах орлуулах текст:",
"warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}",
"empty_text_title": "Текстгүй илгээх үү?",
- "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?"
+ "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?",
+ "scheduled_for": "{when}-д илгээхээр товлосон"
},
"org_templates": {
"section_personal": "Миний загварууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index ab96e67a2..89d1f352a 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Plassholdertekst som mottakerne vil se:",
"warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}",
"empty_text_title": "Sende uten tekst?",
- "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?"
+ "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?",
+ "scheduled_for": "Planlagt til {when}"
},
"org_templates": {
"section_personal": "Mine maler",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index bccdc4a88..9d2cac016 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Tijdelijke tekst die ontvangers zien:",
"warning_proofreading": "Openstaande spel- of grammaticafouten: {count}",
"empty_text_title": "Verzenden zonder tekst?",
- "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?"
+ "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?",
+ "scheduled_for": "Gepland voor {when}"
},
"org_templates": {
"section_personal": "Mijn sjablonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index c011ba2f6..2a90864c5 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:",
"warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}",
"empty_text_title": "Wysłać bez treści?",
- "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?"
+ "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?",
+ "scheduled_for": "Zaplanowano na {when}"
},
"org_templates": {
"section_personal": "Moje szablony",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a10b6f2b4..5988e60e6 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Texto de espaço reservado que os destinatários verão:",
"warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}",
"empty_text_title": "Enviar sem texto?",
- "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?"
+ "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?",
+ "scheduled_for": "Agendado para {when}"
},
"org_templates": {
"section_personal": "Meus modelos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 8a994a38f..8f7064f48 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:",
"warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}",
"empty_text_title": "Trimiteți fără text?",
- "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?"
+ "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?",
+ "scheduled_for": "Programat pentru {when}"
},
"org_templates": {
"section_personal": "Șabloanele mele",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 7fa2dfdb0..f21e430b1 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Текст-заполнитель, который увидят получатели:",
"warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}",
"empty_text_title": "Отправить без текста?",
- "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?"
+ "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?",
+ "scheduled_for": "Запланировано на {when}"
},
"org_templates": {
"section_personal": "Мои шаблоны",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 14519e009..e24d849f5 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:",
"warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}",
"empty_text_title": "Odoslať bez textu?",
- "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?"
+ "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?",
+ "scheduled_for": "Naplánované na {when}"
},
"org_templates": {
"section_personal": "Moje šablóny",
diff --git a/locales/th/common.json b/locales/th/common.json
index 2aa2198ea..b6d400ff4 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1157,7 +1157,8 @@
"warning_placeholders": "ข้อความตัวยึดตำแหน่งที่ผู้รับจะเห็น:",
"warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}",
"empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่",
- "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่"
+ "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่",
+ "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}"
},
"org_templates": {
"section_personal": "แม่แบบของฉัน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 12f69457d..6aff55f44 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Alıcıların göreceği yer tutucu metin:",
"warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}",
"empty_text_title": "Metin olmadan gönderilsin mi?",
- "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?"
+ "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?",
+ "scheduled_for": "{when} için zamanlandı"
},
"org_templates": {
"section_personal": "Şablonlarım",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index d7933e370..5d4fc1d4e 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "Текст-заповнювач, який побачать одержувачі:",
"warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}",
"empty_text_title": "Надіслати без тексту?",
- "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?"
+ "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?",
+ "scheduled_for": "Заплановано на {when}"
},
"org_templates": {
"section_personal": "Мої шаблони",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 2bdd89e99..48fcabf42 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -3918,7 +3918,8 @@
"warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:",
"warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}",
"empty_text_title": "Gửi mà không có nội dung?",
- "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?"
+ "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?",
+ "scheduled_for": "Đã lên lịch gửi lúc {when}"
},
"org_templates": {
"section_personal": "Mẫu của tôi",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 8650ef97c..5e71acdc7 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "收件者會看到的預留位置文字:",
"warning_proofreading": "未處理的拼字或文法問題:{count}",
"empty_text_title": "不寫內文就傳送?",
- "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?"
+ "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?",
+ "scheduled_for": "已排定於 {when} 傳送"
},
"org_templates": {
"section_personal": "我的範本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 500507321..4a013b35d 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3906,7 +3906,8 @@
"warning_placeholders": "收件人会看到的占位文本:",
"warning_proofreading": "未处理的拼写或语法问题:{count}",
"empty_text_title": "不写正文就发送?",
- "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?"
+ "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?",
+ "scheduled_for": "已安排在 {when} 发送"
},
"org_templates": {
"section_personal": "我的模板",
From 781013cc4410f1ae06ad936aed82a2719ecbac17 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:41:51 +0100
Subject: [PATCH 3/5] feat: close the composer as soon as Send is pressed and
show Sending, then Sent with View
---
.../__tests__/composer-send-failed.test.tsx | 238 ++++++++++++++++++
components/email/email-composer.tsx | 18 ++
.../__tests__/send-closes-composer.test.tsx | 156 ++++++++++++
components/mail/mail-app.tsx | 36 ++-
lib/outgoing/send-progress-toast.ts | 48 ++++
locales/ar/common.json | 5 +-
locales/ca/common.json | 5 +-
locales/cs/common.json | 5 +-
locales/da/common.json | 5 +-
locales/de/common.json | 5 +-
locales/en/common.json | 5 +-
locales/es/common.json | 5 +-
locales/et/common.json | 5 +-
locales/fa/common.json | 5 +-
locales/fr/common.json | 5 +-
locales/he/common.json | 5 +-
locales/hu/common.json | 5 +-
locales/it/common.json | 5 +-
locales/ja/common.json | 5 +-
locales/ko/common.json | 5 +-
locales/lv/common.json | 5 +-
locales/mn/common.json | 5 +-
locales/nb/common.json | 5 +-
locales/nl/common.json | 5 +-
locales/pl/common.json | 5 +-
locales/pt/common.json | 5 +-
locales/ro/common.json | 5 +-
locales/ru/common.json | 5 +-
locales/sk/common.json | 5 +-
locales/th/common.json | 5 +-
locales/tr/common.json | 5 +-
locales/uk/common.json | 5 +-
locales/vi/common.json | 5 +-
locales/zh-TW/common.json | 5 +-
locales/zh/common.json | 5 +-
.../email-store-send-list-loading.test.ts | 43 ++++
stores/email-store.ts | 12 +-
37 files changed, 658 insertions(+), 43 deletions(-)
create mode 100644 components/email/__tests__/composer-send-failed.test.tsx
create mode 100644 components/mail/__tests__/send-closes-composer.test.tsx
create mode 100644 lib/outgoing/send-progress-toast.ts
create mode 100644 stores/__tests__/email-store-send-list-loading.test.ts
diff --git a/components/email/__tests__/composer-send-failed.test.tsx b/components/email/__tests__/composer-send-failed.test.tsx
new file mode 100644
index 000000000..aec0f1b79
--- /dev/null
+++ b/components/email/__tests__/composer-send-failed.test.tsx
@@ -0,0 +1,238 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+
+// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
+
+const editorKeyDown = vi.hoisted(() => vi.fn());
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', {
+ 'data-testid': 'rich-text-editor',
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (!event.defaultPrevented) editorKeyDown();
+ },
+ }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+// compose-24: the host closes the composer as soon as the send starts. A send
+// that fails must still hand the message back, or it would vanish.
+
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const READY = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Quarterly numbers',
+ body: 'Here they are.
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: 'draft-3',
+};
+
+const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
+
+describe('a send that fails after the host closed the composer', () => {
+ it('hands the message back to the host', async () => {
+ const onSend = vi.fn().mockRejectedValue(new Error('refused'));
+ const onSendFailed = vi.fn();
+ render( );
+
+ fireEvent.click(sendButton());
+
+ await waitFor(() => expect(onSendFailed).toHaveBeenCalledTimes(1));
+ expect(onSendFailed.mock.calls[0][0]).toMatchObject({
+ to: 'bob@example.com',
+ subject: 'Quarterly numbers',
+ body: 'Here they are.
',
+ draftId: 'draft-3',
+ });
+ });
+
+ it('says nothing to the host when the message went out', async () => {
+ const onSend = vi.fn().mockResolvedValue(undefined);
+ const onSendFailed = vi.fn();
+ render( );
+
+ fireEvent.click(sendButton());
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(onSendFailed).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 95948a852..22ea0db2b 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -368,6 +368,11 @@ interface EmailComposerProps {
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise;
+ /**
+ * onSend failed. A host that closes the composer as soon as onSend starts
+ * gets the message back here, to open it again: it must never vanish.
+ */
+ onSendFailed?: (state: ComposerDraftData) => void;
onScheduledSendCreated?: () => void | Promise;
onClose?: () => void;
/**
@@ -572,6 +577,7 @@ const OFFLINE_SEND_ERRORS: Record = {
export function EmailComposer({
onSend,
+ onSendFailed,
onScheduledSendCreated,
onClose,
requestCloseRef,
@@ -4052,6 +4058,9 @@ export function EmailComposer({
const lastStage = (html: string | undefined, text: string) =>
finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last');
const inlineAttachments = rewritten?.attachments ?? [];
+ // The message as it is now, once onSend has it: the host may close the
+ // composer before the send is through.
+ let handedToHost: ComposerDraftData | null = null;
try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
@@ -4251,6 +4260,13 @@ export function EmailComposer({
// Read when the close is reported: the host tears the composer down
// while onSend is still running. Reset below if the send fails.
sentRef.current = true;
+ handedToHost = {
+ ...snapshotStateRef.current(),
+ // The save before the send may have moved the draft to a new id.
+ draftId: draftIdRef.current,
+ draftAccountId: draftAccountIdRef.current,
+ draftLocalAccountId: draftLocalAccountIdRef.current,
+ };
const outcome = await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
@@ -4326,6 +4342,8 @@ export function EmailComposer({
explicitCloseRef.current = false;
sentRef.current = false;
debug.error('Failed to send email:', err);
+ // A host that closed the composer at Send opens it again with the text.
+ if (handedToHost) onSendFailed?.(handedToHost);
if (isLabelRequiredError(err)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
diff --git a/components/mail/__tests__/send-closes-composer.test.tsx b/components/mail/__tests__/send-closes-composer.test.tsx
new file mode 100644
index 000000000..aa257cc5a
--- /dev/null
+++ b/components/mail/__tests__/send-closes-composer.test.tsx
@@ -0,0 +1,156 @@
+import React, { Suspense, lazy } from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// compose-24: after Send the composer stayed on screen, greyed and without a
+// word, until the server answered. It closes at once now, "Sending…" and then
+// "Sent · View" say what happens, and a send that fails brings the text back.
+vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => {
+ const Component = lazy(async () => ({ default: await load() }));
+ return function Dynamic(props: Record) {
+ return ;
+ };
+} }));
+vi.mock('@/components/email/email-composer', () => ({
+ EmailComposer: ({ onSend, onSendFailed, initialData }: {
+ onSend?: (data: unknown) => Promise;
+ onSendFailed?: (state: unknown) => void;
+ initialData?: { subject?: string } | null;
+ }) => (
+
+ stub subject: {initialData?.subject ?? ''}
+ {
+ // As the composer does: what it handed over comes back on a failure.
+ onSend?.({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Hello' })
+ ?.catch(() => onSendFailed?.({ to: 'bob@example.com', cc: '', bcc: '', subject: 'Hi', body: 'Hello', mode: 'compose' }));
+ }}>
+ stub send
+
+
+ ),
+}));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({
+ MobileHeader: ({ onOpenSearch }: { onOpenSearch?: () => void }) => stub open search ,
+}));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => false,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
+const sendEmail = vi.fn();
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'me@example.com',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never);
+ sendEmail.mockReset().mockResolvedValue({});
+ useToastStore.setState({ toasts: [] });
+ useEmailStore.setState({
+ emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
+ selectedEmailKeys: new Set(),
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ sendEmail,
+ } as never);
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const toastTitled = (title: string) => useToastStore.getState().toasts.find((t) => t.title === title);
+
+async function openComposer() {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
+ await screen.findByText('stub send', {}, { timeout: 5000 });
+}
+
+describe('sending', () => {
+ it('closes the composer at once and says "Sending…", then "Sent" with View', async () => {
+ let finish: (result: unknown) => void = () => {};
+ sendEmail.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
+ await openComposer();
+
+ fireEvent.click(screen.getByText('stub send'));
+
+ // The server hasn't answered yet.
+ await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument());
+ expect(sendEmail).toHaveBeenCalled();
+ expect(toastTitled('compose_send.sending')).toBeDefined();
+
+ await act(async () => { finish({ scheduled: false, emailId: 'sent-1' }); });
+
+ await waitFor(() => expect(toastTitled('compose_send.sent')).toBeDefined());
+ expect(toastTitled('compose_send.sent')!.action?.label).toBe('compose_send.view');
+ expect(toastTitled('compose_send.sending')).toBeUndefined();
+ });
+
+ it('opens the composer again with the text when the send fails', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ sendEmail.mockRejectedValue(new Error('refused'));
+ await openComposer();
+
+ fireEvent.click(screen.getByText('stub send'));
+
+ expect(await screen.findByText('stub subject: Hi', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(toastTitled('compose_send.sending')).toBeUndefined();
+ expect(toastTitled('compose_send.sent')).toBeUndefined();
+ });
+
+ it('leaves a delayed send to the undo toast', async () => {
+ sendEmail.mockResolvedValue({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() });
+ await openComposer();
+
+ fireEvent.click(screen.getByText('stub send'));
+
+ await waitFor(() => expect(sendEmail).toHaveBeenCalled());
+ await waitFor(() => expect(toastTitled('compose_send.sending')).toBeUndefined());
+ expect(toastTitled('compose_send.sent')).toBeUndefined();
+ expect(screen.queryByText('stub send')).not.toBeInTheDocument();
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index bbf7e7833..68922ddfd 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -44,6 +44,7 @@ import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-
import { toast } from "@/stores/toast-store";
import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
+import { startSendProgress } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
@@ -2547,6 +2548,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// the user's message without so much as a toast (#702).
let submitted = false;
+ // The composer closes at once and "Sending…" shows meanwhile. A send that
+ // fails comes back through onSendFailed, which opens the composer again.
+ setShowComposer(false);
+ setPendingDraft(null);
+ if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list');
+ const progress = startSendProgress({
+ sending: t('compose_send.sending'),
+ sent: t('compose_send.sent'),
+ view: t('compose_send.view'),
+ });
+
try {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = answeredEmailId;
@@ -2554,7 +2566,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
submitted = true;
announceSent(result, data);
- setShowComposer(false);
+ // "Sent" with View. A delayed or scheduled send gets the undo toast, and
+ // a filing error, a refused recipient or the offline outbox say so themselves.
+ if (result.scheduled || result.filingError || result.rejectedRecipients?.length || result.queued) {
+ progress.dismiss();
+ } else {
+ const sentId = result.emailId;
+ const ownerAccountId = result.emailAccountId && result.emailAccountId !== sendClient.getAccountId() ? result.emailAccountId : undefined;
+ progress.sent(sentId && sendClient === client
+ ? () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId })
+ : undefined);
+ }
// Sending an edited draft destroys it server-side - and every autosave
// before that already replaced it under a fresh id (createDraft is a
// destroy+create). If the email on display is the draft this session
@@ -2639,11 +2661,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return;
}
- // Delayed sends get the undo toast instead; a filing error or a refused
- // recipient already warned.
- // A message kept in the offline outbox said so itself (#523).
- if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent'));
-
// Refresh the current mailbox to update the UI
if (!isScheduledView) {
await refreshCurrentMailbox(client);
@@ -2651,6 +2668,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
} catch (error) {
console.error("Failed to send email:", error);
+ if (submitted) progress.sent();
+ else progress.dismiss();
// Post-send bookkeeping - refreshing Sent, flagging the original as
// answered, re-reading the thread - is cosmetic once the mail is out.
if (!submitted) throw error;
@@ -5250,6 +5269,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setComposerPrefill(null);
return outcome;
}}
+ onSendFailed={(state) => {
+ setPendingDraft(state);
+ setShowComposer(true);
+ if (isMobile) setActiveView('viewer');
+ }}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
diff --git a/lib/outgoing/send-progress-toast.ts b/lib/outgoing/send-progress-toast.ts
new file mode 100644
index 000000000..b94e8753f
--- /dev/null
+++ b/lib/outgoing/send-progress-toast.ts
@@ -0,0 +1,48 @@
+/**
+ * The toast of a send from the moment the composer closes: "Sending…" while
+ * the server takes the message, then "Sent" with View. The composer used to
+ * stay on screen, greyed and without a word, until the server answered.
+ */
+
+import { toast } from '@/stores/toast-store';
+import { announce } from '@/lib/announce';
+import { effectiveToastDuration } from '@/lib/toast-duration';
+
+export interface SendProgressLabels {
+ sending: string;
+ sent: string;
+ view: string;
+}
+
+export interface SendProgress {
+ /** The message is out. `view` opens it; without it there is no View. */
+ sent(view?: () => void): void;
+ /** Another toast says what happened (undo, scheduled, failed, kept offline). */
+ dismiss(): void;
+}
+
+export function startSendProgress(labels: SendProgressLabels): SendProgress {
+ const id = toast.progress(labels.sending, { progress: null });
+ let settled = false;
+ return {
+ sent(view) {
+ if (settled) return;
+ settled = true;
+ const action = view ? { label: labels.view, onClick: view } : undefined;
+ toast.update(id, {
+ type: 'success',
+ title: labels.sent,
+ progress: undefined,
+ action,
+ duration: effectiveToastDuration({ type: 'success', action }),
+ });
+ // An update isn't read out as a new toast is.
+ announce(labels.sent);
+ },
+ dismiss() {
+ if (settled) return;
+ settled = true;
+ toast.dismiss(id);
+ },
+ };
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 5ba9c4823..e16ef0ab3 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}",
"empty_text_title": "هل تريد الإرسال بدون نص؟",
"empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟",
- "scheduled_for": "مجدولة في {when}"
+ "scheduled_for": "مجدولة في {when}",
+ "sending": "جارٍ الإرسال…",
+ "sent": "تم الإرسال",
+ "view": "عرض"
},
"org_templates": {
"section_personal": "قوالبي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 7fb01c5d9..a49fe1c5f 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}",
"empty_text_title": "Vols enviar-lo sense text?",
"empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?",
- "scheduled_for": "Programat per a {when}"
+ "scheduled_for": "Programat per a {when}",
+ "sending": "S'està enviant…",
+ "sent": "Enviat",
+ "view": "Mostra"
},
"org_templates": {
"section_personal": "Les meves plantilles",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 219e9da67..f6253092f 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}",
"empty_text_title": "Odeslat bez textu?",
"empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?",
- "scheduled_for": "Naplánováno na {when}"
+ "scheduled_for": "Naplánováno na {when}",
+ "sending": "Odesílání…",
+ "sent": "Odesláno",
+ "view": "Zobrazit"
},
"org_templates": {
"section_personal": "Moje šablony",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9fdc94b90..9df578928 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Åbne stave- eller grammatikfejl: {count}",
"empty_text_title": "Send uden tekst?",
"empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?",
- "scheduled_for": "Planlagt til {when}"
+ "scheduled_for": "Planlagt til {when}",
+ "sending": "Sender…",
+ "sent": "Sendt",
+ "view": "Vis"
},
"org_templates": {
"section_personal": "Mine skabeloner",
diff --git a/locales/de/common.json b/locales/de/common.json
index 2e72aacef..716e74114 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}",
"empty_text_title": "Ohne Text senden?",
"empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?",
- "scheduled_for": "Geplant für {when}"
+ "scheduled_for": "Geplant für {when}",
+ "sending": "Wird gesendet…",
+ "sent": "Gesendet",
+ "view": "Anzeigen"
},
"org_templates": {
"section_personal": "Meine Vorlagen",
diff --git a/locales/en/common.json b/locales/en/common.json
index f8e008234..48c763384 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Open spelling or grammar issues: {count}",
"empty_text_title": "Send without text?",
"empty_text_message": "This message has no text and no attachment. Send it anyway?",
- "scheduled_for": "Scheduled for {when}"
+ "scheduled_for": "Scheduled for {when}",
+ "sending": "Sending…",
+ "sent": "Sent",
+ "view": "View"
},
"org_templates": {
"section_personal": "My templates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 1b6a20894..0a734bd11 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Errores de ortografía o gramática pendientes: {count}",
"empty_text_title": "¿Enviar sin texto?",
"empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?",
- "scheduled_for": "Programado para {when}"
+ "scheduled_for": "Programado para {when}",
+ "sending": "Enviando…",
+ "sent": "Enviado",
+ "view": "Ver"
},
"org_templates": {
"section_personal": "Mis plantillas",
diff --git a/locales/et/common.json b/locales/et/common.json
index 411dda041..2a329b7f9 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}",
"empty_text_title": "Kas saata ilma tekstita?",
"empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?",
- "scheduled_for": "Ajastatud: {when}"
+ "scheduled_for": "Ajastatud: {when}",
+ "sending": "Saatmine…",
+ "sent": "Saadetud",
+ "view": "Vaata"
},
"org_templates": {
"section_personal": "Minu mallid",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index f99b675ef..ae6a86694 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "خطاهای املایی یا دستوری باز: {count}",
"empty_text_title": "بدون متن ارسال شود؟",
"empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟",
- "scheduled_for": "زمانبندیشده برای {when}"
+ "scheduled_for": "زمانبندیشده برای {when}",
+ "sending": "در حال ارسال…",
+ "sent": "ارسال شد",
+ "view": "مشاهده"
},
"org_templates": {
"section_personal": "قالبهای من",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 56f599aed..857a4208b 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}",
"empty_text_title": "Envoyer sans texte ?",
"empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?",
- "scheduled_for": "Programmé pour {when}"
+ "scheduled_for": "Programmé pour {when}",
+ "sending": "Envoi…",
+ "sent": "Envoyé",
+ "view": "Afficher"
},
"org_templates": {
"section_personal": "Mes modèles",
diff --git a/locales/he/common.json b/locales/he/common.json
index 95ccc1e67..84da00998 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3804,7 +3804,10 @@
"warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}",
"empty_text_title": "לשלוח ללא טקסט?",
"empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?",
- "scheduled_for": "מתוזמן ל-{when}"
+ "scheduled_for": "מתוזמן ל-{when}",
+ "sending": "שולח…",
+ "sent": "נשלח",
+ "view": "הצג"
},
"org_templates": {
"section_personal": "התבניות שלי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 35741dc7e..f119f53b3 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}",
"empty_text_title": "Küldés szöveg nélkül?",
"empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?",
- "scheduled_for": "Ütemezve: {when}"
+ "scheduled_for": "Ütemezve: {when}",
+ "sending": "Küldés…",
+ "sent": "Elküldve",
+ "view": "Megtekintés"
},
"org_templates": {
"section_personal": "Saját sablonok",
diff --git a/locales/it/common.json b/locales/it/common.json
index 061922c51..1b2b07908 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Errori di ortografia o grammatica aperti: {count}",
"empty_text_title": "Inviare senza testo?",
"empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?",
- "scheduled_for": "Programmato per {when}"
+ "scheduled_for": "Programmato per {when}",
+ "sending": "Invio in corso…",
+ "sent": "Inviato",
+ "view": "Visualizza"
},
"org_templates": {
"section_personal": "I miei modelli",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index aacaa9fa1..1c080bd35 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "未解決のスペル・文法の問題: {count}",
"empty_text_title": "本文なしで送信しますか?",
"empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?",
- "scheduled_for": "{when} に送信予定"
+ "scheduled_for": "{when} に送信予定",
+ "sending": "送信中…",
+ "sent": "送信しました",
+ "view": "表示"
},
"org_templates": {
"section_personal": "マイテンプレート",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 62961153b..ae2a92c38 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}",
"empty_text_title": "본문 없이 보낼까요?",
"empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?",
- "scheduled_for": "{when}에 보내기 예약됨"
+ "scheduled_for": "{when}에 보내기 예약됨",
+ "sending": "보내는 중…",
+ "sent": "보냈어요",
+ "view": "보기"
},
"org_templates": {
"section_personal": "내 템플릿",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 47ba99e8b..b264be861 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}",
"empty_text_title": "Sūtīt bez teksta?",
"empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?",
- "scheduled_for": "Ieplānots: {when}"
+ "scheduled_for": "Ieplānots: {when}",
+ "sending": "Sūta…",
+ "sent": "Nosūtīts",
+ "view": "Skatīt"
},
"org_templates": {
"section_personal": "Manas veidnes",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 2ceac09b1..a9a4e523d 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}",
"empty_text_title": "Текстгүй илгээх үү?",
"empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?",
- "scheduled_for": "{when}-д илгээхээр товлосон"
+ "scheduled_for": "{when}-д илгээхээр товлосон",
+ "sending": "Илгээж байна…",
+ "sent": "Илгээгдлээ",
+ "view": "Харах"
},
"org_templates": {
"section_personal": "Миний загварууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 89d1f352a..bb2e3880a 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}",
"empty_text_title": "Sende uten tekst?",
"empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?",
- "scheduled_for": "Planlagt til {when}"
+ "scheduled_for": "Planlagt til {when}",
+ "sending": "Sender…",
+ "sent": "Sendt",
+ "view": "Vis"
},
"org_templates": {
"section_personal": "Mine maler",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 9d2cac016..a9941ecf0 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Openstaande spel- of grammaticafouten: {count}",
"empty_text_title": "Verzenden zonder tekst?",
"empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?",
- "scheduled_for": "Gepland voor {when}"
+ "scheduled_for": "Gepland voor {when}",
+ "sending": "Bezig met verzenden…",
+ "sent": "Verzonden",
+ "view": "Bekijken"
},
"org_templates": {
"section_personal": "Mijn sjablonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 2a90864c5..9766bb92d 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}",
"empty_text_title": "Wysłać bez treści?",
"empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?",
- "scheduled_for": "Zaplanowano na {when}"
+ "scheduled_for": "Zaplanowano na {when}",
+ "sending": "Wysyłanie…",
+ "sent": "Wysłano",
+ "view": "Pokaż"
},
"org_templates": {
"section_personal": "Moje szablony",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 5988e60e6..55b28a608 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}",
"empty_text_title": "Enviar sem texto?",
"empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?",
- "scheduled_for": "Agendado para {when}"
+ "scheduled_for": "Agendado para {when}",
+ "sending": "Enviando…",
+ "sent": "Enviado",
+ "view": "Ver"
},
"org_templates": {
"section_personal": "Meus modelos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 8f7064f48..318bf1d4f 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}",
"empty_text_title": "Trimiteți fără text?",
"empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?",
- "scheduled_for": "Programat pentru {when}"
+ "scheduled_for": "Programat pentru {when}",
+ "sending": "Se trimite…",
+ "sent": "Trimis",
+ "view": "Vezi"
},
"org_templates": {
"section_personal": "Șabloanele mele",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index f21e430b1..c8f382676 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}",
"empty_text_title": "Отправить без текста?",
"empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?",
- "scheduled_for": "Запланировано на {when}"
+ "scheduled_for": "Запланировано на {when}",
+ "sending": "Отправка…",
+ "sent": "Отправлено",
+ "view": "Открыть"
},
"org_templates": {
"section_personal": "Мои шаблоны",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index e24d849f5..aefbf6012 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}",
"empty_text_title": "Odoslať bez textu?",
"empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?",
- "scheduled_for": "Naplánované na {when}"
+ "scheduled_for": "Naplánované na {when}",
+ "sending": "Odosielanie…",
+ "sent": "Odoslané",
+ "view": "Zobraziť"
},
"org_templates": {
"section_personal": "Moje šablóny",
diff --git a/locales/th/common.json b/locales/th/common.json
index b6d400ff4..fcad4fef7 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1158,7 +1158,10 @@
"warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}",
"empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่",
"empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่",
- "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}"
+ "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}",
+ "sending": "กำลังส่ง…",
+ "sent": "ส่งแล้ว",
+ "view": "ดู"
},
"org_templates": {
"section_personal": "แม่แบบของฉัน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 6aff55f44..71a097b10 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}",
"empty_text_title": "Metin olmadan gönderilsin mi?",
"empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?",
- "scheduled_for": "{when} için zamanlandı"
+ "scheduled_for": "{when} için zamanlandı",
+ "sending": "Gönderiliyor…",
+ "sent": "Gönderildi",
+ "view": "Görüntüle"
},
"org_templates": {
"section_personal": "Şablonlarım",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 5d4fc1d4e..c0c04b84f 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}",
"empty_text_title": "Надіслати без тексту?",
"empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?",
- "scheduled_for": "Заплановано на {when}"
+ "scheduled_for": "Заплановано на {when}",
+ "sending": "Надсилання…",
+ "sent": "Надіслано",
+ "view": "Переглянути"
},
"org_templates": {
"section_personal": "Мої шаблони",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 48fcabf42..e0132525a 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -3919,7 +3919,10 @@
"warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}",
"empty_text_title": "Gửi mà không có nội dung?",
"empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?",
- "scheduled_for": "Đã lên lịch gửi lúc {when}"
+ "scheduled_for": "Đã lên lịch gửi lúc {when}",
+ "sending": "Đang gửi…",
+ "sent": "Đã gửi",
+ "view": "Xem"
},
"org_templates": {
"section_personal": "Mẫu của tôi",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 5e71acdc7..b0d322bed 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "未處理的拼字或文法問題:{count}",
"empty_text_title": "不寫內文就傳送?",
"empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?",
- "scheduled_for": "已排定於 {when} 傳送"
+ "scheduled_for": "已排定於 {when} 傳送",
+ "sending": "正在傳送…",
+ "sent": "已傳送",
+ "view": "檢視"
},
"org_templates": {
"section_personal": "我的範本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 4a013b35d..83dc1a395 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3907,7 +3907,10 @@
"warning_proofreading": "未处理的拼写或语法问题:{count}",
"empty_text_title": "不写正文就发送?",
"empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?",
- "scheduled_for": "已安排在 {when} 发送"
+ "scheduled_for": "已安排在 {when} 发送",
+ "sending": "正在发送…",
+ "sent": "已发送",
+ "view": "查看"
},
"org_templates": {
"section_personal": "我的模板",
diff --git a/stores/__tests__/email-store-send-list-loading.test.ts b/stores/__tests__/email-store-send-list-loading.test.ts
new file mode 100644
index 000000000..72d867a0a
--- /dev/null
+++ b/stores/__tests__/email-store-send-list-loading.test.ts
@@ -0,0 +1,43 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// isLoading is the message list's: while it is set, the list fades under
+// "Loading emails…". A send set it too, so every send reloaded the list on
+// screen until the message was out (compose-24).
+
+describe('the message list during a send', () => {
+ let client: IJMAPClient;
+ let finishSend: (value: unknown) => void;
+
+ beforeEach(() => {
+ client = {
+ getAccountId: () => 'acc-1',
+ pruneEmailSubmissions: vi.fn().mockResolvedValue(0),
+ sendEmail: vi.fn(() => new Promise((resolve) => { finishSend = resolve; })),
+ getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [], states: {} }),
+ } as unknown as IJMAPClient;
+ useAuthStore.setState({ client: null });
+ useEmailStore.setState({ isLoading: false, pendingUndoSend: null });
+ });
+
+ it('is not put in its loading state while the message goes out', async () => {
+ const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
+
+ expect(useEmailStore.getState().isLoading).toBe(false);
+
+ finishSend({ scheduled: false, emailId: 'sent-1' });
+ await sending;
+ expect(useEmailStore.getState().isLoading).toBe(false);
+ });
+
+ it('keeps loading a folder that a send finished in the middle of', async () => {
+ useEmailStore.setState({ isLoading: true });
+ const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
+ finishSend({ scheduled: false, emailId: 'sent-1' });
+ await sending;
+
+ expect(useEmailStore.getState().isLoading).toBe(true);
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 96d37325e..0ac3db403 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -4399,7 +4399,9 @@ export const useEmailStore = create((set, get) => ({
const outbox = options?.outbox ? getOutboxHooks() : null;
const queueArgs = () => [to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options];
if (outbox?.shouldQueue(client)) return outbox.queueEmail(client, queueArgs());
- set({ isLoading: true, error: null });
+ // Not isLoading: that is the message list's own, and a send put the list
+ // under "Loading emails…" until the message was out.
+ set({ error: null });
try {
// Files attached while offline are uploaded now that the server answers.
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
@@ -4414,7 +4416,6 @@ export const useEmailStore = create((set, get) => ({
if (useAuthStore.getState().client === client) void get().fetchMailboxes(client);
// Refresh handled by UI layer for immediate feedback
set({
- isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? {
submissionId: result.emailSubmissionId,
@@ -4435,12 +4436,10 @@ export const useEmailStore = create((set, get) => ({
// (the client says so: SendNotStartedError): keep it. Any other
// failure may have come after the server took the message.
if (outbox && error instanceof Error && error.name === 'SendNotStartedError' && outbox.shouldQueue(client)) {
- set({ isLoading: false });
return outbox.queueEmail(client, queueArgs());
}
set({
error: error instanceof Error ? error.message : "Failed to send email",
- isLoading: false
});
throw error;
}
@@ -4451,7 +4450,8 @@ export const useEmailStore = create((set, get) => ({
// encrypted with the user present, so the finished message can wait.
const outbox = options?.outbox ? getOutboxHooks() : null;
if (outbox?.shouldQueue(client)) return outbox.queueRaw(client, rawMimeBlob, { identityId, delayedUntil, envelopeRecipients });
- set({ isLoading: true, error: null });
+ // Not isLoading, as in sendEmail.
+ set({ error: null });
try {
const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -4460,7 +4460,6 @@ export const useEmailStore = create((set, get) => ({
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
void client.pruneEmailSubmissions();
set({
- isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true, submissionAccountId: result.submissionAccountId }
: get().pendingUndoSend,
@@ -4469,7 +4468,6 @@ export const useEmailStore = create((set, get) => ({
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
- isLoading: false,
});
throw error;
}
From 0ced5482d5624662da4ec8cc19e6d062256e2165 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 07:03:15 +0100
Subject: [PATCH 4/5] feat: list your contacts in the composer's recipient
picker, with the directory on its own tab
---
.../directory-picker-dialog.test.tsx | 73 +++++++++++-
components/contacts/directory-browser.tsx | 43 ++++---
.../contacts/directory-picker-dialog.tsx | 111 ++++++++++++++++--
.../email/__tests__/composer-mention.test.tsx | 17 +++
components/email/email-composer.tsx | 9 +-
locales/ar/common.json | 9 +-
locales/ca/common.json | 9 +-
locales/cs/common.json | 9 +-
locales/da/common.json | 9 +-
locales/de/common.json | 9 +-
locales/en/common.json | 9 +-
locales/es/common.json | 9 +-
locales/et/common.json | 9 +-
locales/fa/common.json | 9 +-
locales/fr/common.json | 9 +-
locales/he/common.json | 9 +-
locales/hu/common.json | 9 +-
locales/it/common.json | 9 +-
locales/ja/common.json | 9 +-
locales/ko/common.json | 9 +-
locales/lv/common.json | 9 +-
locales/mn/common.json | 9 +-
locales/nb/common.json | 9 +-
locales/nl/common.json | 9 +-
locales/pl/common.json | 9 +-
locales/pt/common.json | 9 +-
locales/ro/common.json | 9 +-
locales/ru/common.json | 9 +-
locales/sk/common.json | 9 +-
locales/th/common.json | 9 +-
locales/tr/common.json | 9 +-
locales/uk/common.json | 9 +-
locales/vi/common.json | 9 +-
locales/zh-TW/common.json | 9 +-
locales/zh/common.json | 9 +-
35 files changed, 462 insertions(+), 61 deletions(-)
diff --git a/components/contacts/__tests__/directory-picker-dialog.test.tsx b/components/contacts/__tests__/directory-picker-dialog.test.tsx
index 192f8dc4b..8f41baf9d 100644
--- a/components/contacts/__tests__/directory-picker-dialog.test.tsx
+++ b/components/contacts/__tests__/directory-picker-dialog.test.tsx
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DirectoryPickerDialog } from '../directory-picker-dialog';
import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { ContactCard } from '@/lib/jmap/types';
// next-intl is mocked globally in vitest.setup (t returns the key).
@@ -25,7 +26,11 @@ function rows() {
}
describe('DirectoryPickerDialog', () => {
- beforeEach(() => vi.clearAllMocks());
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // No contacts of the user's own: the picker opens on the directory.
+ useContactStore.setState({ contacts: [] });
+ });
it('lists the directory before any search, one page at a time', () => {
setDirectory(people(60), 'ready');
@@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => {
expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument();
});
});
+
+// compose-17: the picker listed only the server's directory, not the
+// contacts the To field's suggestions offer.
+describe('DirectoryPickerDialog with contacts', () => {
+ function contact(id: string, name: string, ...emails: string[]): ContactCard {
+ return {
+ id, addressBookIds: { book: true },
+ name: { full: name },
+ emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])),
+ } as unknown as ContactCard;
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready');
+ useContactStore.setState({
+ contacts: [
+ contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'),
+ contact('c2', 'Anna Becker', 'anna@example.com'),
+ { id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard,
+ ],
+ });
+ });
+
+ it('opens on the contacts, one row per address, without groups', () => {
+ render( );
+
+ expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true');
+ expect(rows().map((r) => r.textContent)).toEqual([
+ expect.stringContaining('anna@example.com'),
+ expect.stringContaining('zoe.f@work.example'),
+ expect.stringContaining('zoe@example.com'),
+ ]);
+ expect(screen.queryByText('Server Account')).not.toBeInTheDocument();
+ });
+
+ it('searches the contacts', () => {
+ render( );
+ fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } });
+ expect(rows()).toHaveLength(1);
+ fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } });
+ expect(screen.getByText('contacts_no_match')).toBeInTheDocument();
+ });
+
+ it('adds what was checked on both tabs', () => {
+ const onAdd = vi.fn();
+ render( );
+ fireEvent.click(rows()[0]);
+ fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' }));
+ fireEvent.click(rows()[0]);
+ fireEvent.click(screen.getByText('add_to'));
+
+ expect(onAdd).toHaveBeenCalledWith('to', [
+ expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }),
+ expect.objectContaining({ email: 'server@example.org' }),
+ ]);
+ });
+
+ it('shows the contacts alone where the server has no directory', () => {
+ const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient;
+ render( );
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
+ expect(rows()).toHaveLength(3);
+ });
+});
+
diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx
index 5f5f89d8b..1998b702b 100644
--- a/components/contacts/directory-browser.tsx
+++ b/components/contacts/directory-browser.tsx
@@ -51,6 +51,13 @@ interface DirectoryBrowserProps {
/** Set while the list is being printed. */
printing?: boolean;
className?: string;
+ /**
+ * Browse these instead of the server's directory (the recipient picker's
+ * Contacts tab): nothing is fetched and there is nothing to refresh.
+ */
+ entries?: DirectoryEntry[];
+ /** With `entries`: the list's name and what an empty list or search says. */
+ labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string };
}
/**
@@ -69,13 +76,17 @@ export function DirectoryBrowser({
onPrint,
printing = false,
className,
+ entries: ownEntries,
+ labels,
}: DirectoryBrowserProps) {
const t = useTranslations("contacts.directory");
const tContacts = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
- const entries = useContactStore((s) => s.directoryPrincipals);
- const status = useContactStore((s) => s.directoryStatus);
+ const directoryEntries = useContactStore((s) => s.directoryPrincipals);
+ const directoryStatus = useContactStore((s) => s.directoryStatus);
+ const entries = ownEntries ?? directoryEntries;
+ const status = ownEntries ? "ready" : directoryStatus;
const fetchDirectory = useContactStore((s) => s.fetchDirectory);
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
@@ -83,8 +94,8 @@ export function DirectoryBrowser({
// The directory loads at sign-in; fetch here only if that never ran.
useEffect(() => {
- if (client && status === "idle") void fetchDirectory(client);
- }, [client, status, fetchDirectory]);
+ if (!ownEntries && client && status === "idle") void fetchDirectory(client);
+ }, [ownEntries, client, status, fetchDirectory]);
useEffect(() => {
if (autoFocusSearch) searchRef.current?.focus();
@@ -117,7 +128,9 @@ export function DirectoryBrowser({
};
let placeholder: React.ReactNode = null;
- if (entries.length === 0) {
+ if (entries.length === 0 && ownEntries) {
+ placeholder = } title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />;
+ } else if (entries.length === 0) {
if (loading) {
placeholder = (
} title={t("loading")} />
@@ -132,7 +145,7 @@ export function DirectoryBrowser({
placeholder = } title={t("empty_title")} hint={t("empty_hint")} />;
}
} else if (filtered.length === 0) {
- placeholder = } title={t("no_match")} />;
+ placeholder = } title={labels?.noMatch ?? t("no_match")} />;
}
return (
@@ -163,13 +176,15 @@ export function DirectoryBrowser({
onClear={() => { setQuery(""); setPage(0); }}
clearLabel={tContacts("clear_search")}
/>
-
-
-
+ {!ownEntries && (
+
+
+
+ )}
{onPrint && (
@@ -186,7 +201,7 @@ export function DirectoryBrowser({
)}
-
+
{placeholder ?? pageEntries.map((entry) => {
const key = directoryKey(entry);
const checked = selected.has(key);
diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx
index 493a84e3c..ca031d91c 100644
--- a/components/contacts/directory-picker-dialog.tsx
+++ b/components/contacts/directory-picker-dialog.tsx
@@ -1,36 +1,94 @@
"use client";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { cn } from "@/lib/utils";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
-import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
+import type { ContactCard } from "@/lib/jmap/types";
+import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store";
import { DirectoryBrowser, directoryKey } from "./directory-browser";
export type RecipientField = "to" | "cc" | "bcc";
+type PickerTab = "contacts" | "directory";
+
interface DirectoryPickerDialogProps {
client: IJMAPClient | null;
onClose: () => void;
- /** Called with the checked entries in directory order; the dialog closes afterwards. */
+ /** Called with the checked entries in list order; the dialog closes afterwards. */
onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void;
}
-/** The composer's "Select from directory" dialog. */
+/** Every address of the user's own contacts, one row each; groups are left out. */
+export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] {
+ const seen = new Set
();
+ const entries: DirectoryEntry[] = [];
+ for (const contact of contacts) {
+ if (contact.kind === "group") continue;
+ const name = getContactDisplayName(contact);
+ for (const email of Object.values(contact.emails ?? {})) {
+ const address = email?.address?.trim();
+ if (!address) continue;
+ const entry = { id: `${contact.id}:${address}`, name: name || address, email: address };
+ if (seen.has(directoryKey(entry))) continue;
+ seen.add(directoryKey(entry));
+ entries.push(entry);
+ }
+ }
+ return entries;
+}
+
+/**
+ * The composer's recipient picker: the user's own contacts, and the server's
+ * directory (other accounts) on a tab of its own where the server has one.
+ * It used to list only the directory, without the contacts autocomplete offers.
+ */
export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) {
const t = useTranslations("contacts.directory");
+ const tSend = useTranslations("compose_send");
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
+ const contacts = useContactStore((s) => s.contacts);
+ const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]);
+ const hasDirectory = !!client?.supportsPrincipals?.();
+ // Contacts first; straight to the directory for someone who has none.
+ const [tab, setTab] = useState(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts"));
const [selected, setSelected] = useState>(() => new Set());
const add = (field: RecipientField) => {
- const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e)));
+ // Checked on either tab, each address once.
+ const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals];
+ const seen = new Set();
+ const entries = pool.filter((e) => {
+ const key = directoryKey(e);
+ if (!selected.has(key) || seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
if (entries.length === 0) return;
onAdd(field, entries);
onClose();
};
+ const tabButton = (value: PickerTab, label: string) => (
+ setTab(value)}
+ className={cn(
+ "px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
+ tab === value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {label}
+
+ );
+
return (
- {t("picker_title")}
+ {tSend("picker_title")}
-
+ {hasDirectory && (
+
+ {tabButton("contacts", tSend("tab_contacts"))}
+ {tabButton("directory", tSend("tab_directory"))}
+
+ )}
+
+
+
+
diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx
index d50d46ad5..42c312b12 100644
--- a/components/email/__tests__/composer-mention.test.tsx
+++ b/components/email/__tests__/composer-mention.test.tsx
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { useAuthStore } from '@/stores/auth-store';
+import { useContactStore } from '@/stores/contact-store';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
@@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => {
expect(to.value).toBe('Sarah Kim ');
});
+ // compose-17: the button was there only with a server directory, so
+ // contacts alone were never offered.
+ it('offers the recipient picker for the contacts when the server has no directory', async () => {
+ const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState();
+ contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }];
+ try {
+ await renderComposer();
+ const button = screen.getByTestId('composer-directory-picker');
+ expect(button).toHaveAccessibleName('picker_button');
+ fireEvent.click(button);
+ expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument();
+ } finally {
+ contactState.contacts = [];
+ }
+ });
+
// The directory picker is drawn inside the composer itself, not portaled.
it('lets Escape in the directory picker leave the composer alone', async () => {
// Every other capability answers nothing.
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 22ea0db2b..5f4a9ab85 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1775,6 +1775,9 @@ export function EmailComposer({
// Whether a Sent mailbox is known so the on-demand server search is worth
// offering (falls back to hiding the "search the server" row otherwise).
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
+ // The recipient picker: the user's contacts, and the server's directory.
+ const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0);
+ const tSend = useTranslations('compose_send');
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -5091,15 +5094,15 @@ export function EmailComposer({
onMoveChip={handleMoveChip}
/>
- {activeClient?.supportsPrincipals?.() && (
+ {(hasContacts || activeClient?.supportsPrincipals?.()) && (
setShowDirectoryPicker(true)}
className="h-7 w-7 p-0"
- title={t('select_from_directory')}
- aria-label={t('select_from_directory')}
+ title={tSend('picker_button')}
+ aria-label={tSend('picker_button')}
data-testid="composer-directory-picker"
>
diff --git a/locales/ar/common.json b/locales/ar/common.json
index e16ef0ab3..8fc060a58 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "مجدولة في {when}",
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال",
- "view": "عرض"
+ "view": "عرض",
+ "picker_button": "إضافة من جهات الاتصال",
+ "picker_title": "إضافة مستلمين",
+ "tab_contacts": "جهات الاتصال",
+ "tab_directory": "الدليل",
+ "contacts_empty_title": "لا توجد جهات اتصال بعد",
+ "contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.",
+ "contacts_no_match": "لا توجد جهة اتصال تطابق بحثك"
},
"org_templates": {
"section_personal": "قوالبي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index a49fe1c5f..ea9846760 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Programat per a {when}",
"sending": "S'està enviant…",
"sent": "Enviat",
- "view": "Mostra"
+ "view": "Mostra",
+ "picker_button": "Afegeix des dels contactes",
+ "picker_title": "Afegeix destinataris",
+ "tab_contacts": "Contactes",
+ "tab_directory": "Directori",
+ "contacts_empty_title": "Encara no hi ha contactes",
+ "contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.",
+ "contacts_no_match": "Cap contacte coincideix amb la cerca"
},
"org_templates": {
"section_personal": "Les meves plantilles",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index f6253092f..211878338 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Naplánováno na {when}",
"sending": "Odesílání…",
"sent": "Odesláno",
- "view": "Zobrazit"
+ "view": "Zobrazit",
+ "picker_button": "Přidat z kontaktů",
+ "picker_title": "Přidat příjemce",
+ "tab_contacts": "Kontakty",
+ "tab_directory": "Adresář organizace",
+ "contacts_empty_title": "Zatím žádné kontakty",
+ "contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.",
+ "contacts_no_match": "Hledání neodpovídá žádný kontakt"
},
"org_templates": {
"section_personal": "Moje šablony",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9df578928..3b27a738f 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Planlagt til {when}",
"sending": "Sender…",
"sent": "Sendt",
- "view": "Vis"
+ "view": "Vis",
+ "picker_button": "Tilføj fra kontakter",
+ "picker_title": "Tilføj modtagere",
+ "tab_contacts": "Kontakter",
+ "tab_directory": "Adressefortegnelse",
+ "contacts_empty_title": "Ingen kontakter endnu",
+ "contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.",
+ "contacts_no_match": "Ingen kontakter matcher din søgning"
},
"org_templates": {
"section_personal": "Mine skabeloner",
diff --git a/locales/de/common.json b/locales/de/common.json
index 716e74114..ae5039e3e 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Geplant für {when}",
"sending": "Wird gesendet…",
"sent": "Gesendet",
- "view": "Anzeigen"
+ "view": "Anzeigen",
+ "picker_button": "Aus Kontakten hinzufügen",
+ "picker_title": "Empfänger hinzufügen",
+ "tab_contacts": "Kontakte",
+ "tab_directory": "Verzeichnis",
+ "contacts_empty_title": "Noch keine Kontakte",
+ "contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.",
+ "contacts_no_match": "Kein Kontakt passt zu Ihrer Suche"
},
"org_templates": {
"section_personal": "Meine Vorlagen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 48c763384..dbef12f39 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Scheduled for {when}",
"sending": "Sending…",
"sent": "Sent",
- "view": "View"
+ "view": "View",
+ "picker_button": "Add from contacts",
+ "picker_title": "Add recipients",
+ "tab_contacts": "Contacts",
+ "tab_directory": "Directory",
+ "contacts_empty_title": "No contacts yet",
+ "contacts_empty_hint": "People you save in Contacts show up here.",
+ "contacts_no_match": "No contact matches your search"
},
"org_templates": {
"section_personal": "My templates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 0a734bd11..44b012eb1 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Programado para {when}",
"sending": "Enviando…",
"sent": "Enviado",
- "view": "Ver"
+ "view": "Ver",
+ "picker_button": "Añadir desde contactos",
+ "picker_title": "Añadir destinatarios",
+ "tab_contacts": "Contactos",
+ "tab_directory": "Directorio",
+ "contacts_empty_title": "Aún no hay contactos",
+ "contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.",
+ "contacts_no_match": "Ningún contacto coincide con la búsqueda"
},
"org_templates": {
"section_personal": "Mis plantillas",
diff --git a/locales/et/common.json b/locales/et/common.json
index 2a329b7f9..fed4d3fa0 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Ajastatud: {when}",
"sending": "Saatmine…",
"sent": "Saadetud",
- "view": "Vaata"
+ "view": "Vaata",
+ "picker_button": "Lisa kontaktidest",
+ "picker_title": "Lisa saajaid",
+ "tab_contacts": "Kontaktid",
+ "tab_directory": "Kataloog",
+ "contacts_empty_title": "Kontakte veel pole",
+ "contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.",
+ "contacts_no_match": "Otsingule ei vasta ükski kontakt"
},
"org_templates": {
"section_personal": "Minu mallid",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index ae6a86694..6ce99c57a 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "زمانبندیشده برای {when}",
"sending": "در حال ارسال…",
"sent": "ارسال شد",
- "view": "مشاهده"
+ "view": "مشاهده",
+ "picker_button": "افزودن از مخاطبین",
+ "picker_title": "افزودن گیرندگان",
+ "tab_contacts": "مخاطبین",
+ "tab_directory": "فهرست سازمانی",
+ "contacts_empty_title": "هنوز مخاطبی وجود ندارد",
+ "contacts_empty_hint": "افرادی که در مخاطبین ذخیره میکنید اینجا نمایش داده میشوند.",
+ "contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد"
},
"org_templates": {
"section_personal": "قالبهای من",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 857a4208b..18783b5e7 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Programmé pour {when}",
"sending": "Envoi…",
"sent": "Envoyé",
- "view": "Afficher"
+ "view": "Afficher",
+ "picker_button": "Ajouter depuis les contacts",
+ "picker_title": "Ajouter des destinataires",
+ "tab_contacts": "Contacts",
+ "tab_directory": "Annuaire",
+ "contacts_empty_title": "Aucun contact pour l'instant",
+ "contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.",
+ "contacts_no_match": "Aucun contact ne correspond à votre recherche"
},
"org_templates": {
"section_personal": "Mes modèles",
diff --git a/locales/he/common.json b/locales/he/common.json
index 84da00998..1735dee2a 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3807,7 +3807,14 @@
"scheduled_for": "מתוזמן ל-{when}",
"sending": "שולח…",
"sent": "נשלח",
- "view": "הצג"
+ "view": "הצג",
+ "picker_button": "הוסף מאנשי הקשר",
+ "picker_title": "הוספת נמענים",
+ "tab_contacts": "אנשי קשר",
+ "tab_directory": "ספרייה",
+ "contacts_empty_title": "אין עדיין אנשי קשר",
+ "contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.",
+ "contacts_no_match": "אין איש קשר שתואם לחיפוש"
},
"org_templates": {
"section_personal": "התבניות שלי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index f119f53b3..74a817551 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Ütemezve: {when}",
"sending": "Küldés…",
"sent": "Elküldve",
- "view": "Megtekintés"
+ "view": "Megtekintés",
+ "picker_button": "Hozzáadás a névjegyekből",
+ "picker_title": "Címzettek hozzáadása",
+ "tab_contacts": "Névjegyek",
+ "tab_directory": "Címtár",
+ "contacts_empty_title": "Még nincsenek névjegyek",
+ "contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.",
+ "contacts_no_match": "Egy névjegy sem felel meg a keresésnek"
},
"org_templates": {
"section_personal": "Saját sablonok",
diff --git a/locales/it/common.json b/locales/it/common.json
index 1b2b07908..5b254ce9c 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Programmato per {when}",
"sending": "Invio in corso…",
"sent": "Inviato",
- "view": "Visualizza"
+ "view": "Visualizza",
+ "picker_button": "Aggiungi dai contatti",
+ "picker_title": "Aggiungi destinatari",
+ "tab_contacts": "Contatti",
+ "tab_directory": "Rubrica aziendale",
+ "contacts_empty_title": "Ancora nessun contatto",
+ "contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.",
+ "contacts_no_match": "Nessun contatto corrisponde alla ricerca"
},
"org_templates": {
"section_personal": "I miei modelli",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 1c080bd35..73cf4eb8e 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "{when} に送信予定",
"sending": "送信中…",
"sent": "送信しました",
- "view": "表示"
+ "view": "表示",
+ "picker_button": "連絡先から追加",
+ "picker_title": "宛先を追加",
+ "tab_contacts": "連絡先",
+ "tab_directory": "ディレクトリ",
+ "contacts_empty_title": "連絡先はまだありません",
+ "contacts_empty_hint": "連絡先に保存した人がここに表示されます。",
+ "contacts_no_match": "検索に一致する連絡先はありません"
},
"org_templates": {
"section_personal": "マイテンプレート",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index ae2a92c38..827cb61f0 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "{when}에 보내기 예약됨",
"sending": "보내는 중…",
"sent": "보냈어요",
- "view": "보기"
+ "view": "보기",
+ "picker_button": "연락처에서 추가",
+ "picker_title": "받는 사람 추가",
+ "tab_contacts": "연락처",
+ "tab_directory": "디렉터리",
+ "contacts_empty_title": "아직 연락처가 없어요",
+ "contacts_empty_hint": "연락처에 저장한 사람이 여기에 표시돼요.",
+ "contacts_no_match": "검색과 일치하는 연락처가 없어요"
},
"org_templates": {
"section_personal": "내 템플릿",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index b264be861..5ceb5f684 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Ieplānots: {when}",
"sending": "Sūta…",
"sent": "Nosūtīts",
- "view": "Skatīt"
+ "view": "Skatīt",
+ "picker_button": "Pievienot no kontaktiem",
+ "picker_title": "Pievienot saņēmējus",
+ "tab_contacts": "Kontakti",
+ "tab_directory": "Katalogs",
+ "contacts_empty_title": "Vēl nav kontaktu",
+ "contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.",
+ "contacts_no_match": "Meklēšanai neatbilst neviens kontakts"
},
"org_templates": {
"section_personal": "Manas veidnes",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index a9a4e523d..37458cb71 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "{when}-д илгээхээр товлосон",
"sending": "Илгээж байна…",
"sent": "Илгээгдлээ",
- "view": "Харах"
+ "view": "Харах",
+ "picker_button": "Харилцагчдаас нэмэх",
+ "picker_title": "Хүлээн авагч нэмэх",
+ "tab_contacts": "Харилцагчид",
+ "tab_directory": "Лавлах",
+ "contacts_empty_title": "Одоогоор харилцагч алга",
+ "contacts_empty_hint": "Харилцагчдад хадгалсан хүмүүс энд харагдана.",
+ "contacts_no_match": "Хайлтад тохирох харилцагч алга"
},
"org_templates": {
"section_personal": "Миний загварууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index bb2e3880a..4a8f8f941 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Planlagt til {when}",
"sending": "Sender…",
"sent": "Sendt",
- "view": "Vis"
+ "view": "Vis",
+ "picker_button": "Legg til fra kontakter",
+ "picker_title": "Legg til mottakere",
+ "tab_contacts": "Kontakter",
+ "tab_directory": "Katalog",
+ "contacts_empty_title": "Ingen kontakter ennå",
+ "contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.",
+ "contacts_no_match": "Ingen kontakter samsvarer med søket"
},
"org_templates": {
"section_personal": "Mine maler",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index a9941ecf0..75d1a276d 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Gepland voor {when}",
"sending": "Bezig met verzenden…",
"sent": "Verzonden",
- "view": "Bekijken"
+ "view": "Bekijken",
+ "picker_button": "Toevoegen uit contacten",
+ "picker_title": "Ontvangers toevoegen",
+ "tab_contacts": "Contacten",
+ "tab_directory": "Adresboek van de organisatie",
+ "contacts_empty_title": "Nog geen contacten",
+ "contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.",
+ "contacts_no_match": "Geen contact komt overeen met uw zoekopdracht"
},
"org_templates": {
"section_personal": "Mijn sjablonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 9766bb92d..f728e9e79 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Zaplanowano na {when}",
"sending": "Wysyłanie…",
"sent": "Wysłano",
- "view": "Pokaż"
+ "view": "Pokaż",
+ "picker_button": "Dodaj z kontaktów",
+ "picker_title": "Dodaj odbiorców",
+ "tab_contacts": "Kontakty",
+ "tab_directory": "Katalog",
+ "contacts_empty_title": "Brak kontaktów",
+ "contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.",
+ "contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania"
},
"org_templates": {
"section_personal": "Moje szablony",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 55b28a608..58f9dbdeb 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Agendado para {when}",
"sending": "Enviando…",
"sent": "Enviado",
- "view": "Ver"
+ "view": "Ver",
+ "picker_button": "Adicionar dos contatos",
+ "picker_title": "Adicionar destinatários",
+ "tab_contacts": "Contatos",
+ "tab_directory": "Diretório",
+ "contacts_empty_title": "Nenhum contato ainda",
+ "contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.",
+ "contacts_no_match": "Nenhum contato corresponde à sua pesquisa"
},
"org_templates": {
"section_personal": "Meus modelos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 318bf1d4f..3f006f780 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Programat pentru {when}",
"sending": "Se trimite…",
"sent": "Trimis",
- "view": "Vezi"
+ "view": "Vezi",
+ "picker_button": "Adaugă din contacte",
+ "picker_title": "Adaugă destinatari",
+ "tab_contacts": "Contacte",
+ "tab_directory": "Director",
+ "contacts_empty_title": "Încă nu există contacte",
+ "contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.",
+ "contacts_no_match": "Niciun contact nu corespunde căutării"
},
"org_templates": {
"section_personal": "Șabloanele mele",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index c8f382676..afa5dc6c8 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Запланировано на {when}",
"sending": "Отправка…",
"sent": "Отправлено",
- "view": "Открыть"
+ "view": "Открыть",
+ "picker_button": "Добавить из контактов",
+ "picker_title": "Добавить получателей",
+ "tab_contacts": "Контакты",
+ "tab_directory": "Справочник",
+ "contacts_empty_title": "Контактов пока нет",
+ "contacts_empty_hint": "Здесь появятся люди, которых вы сохраните в контактах.",
+ "contacts_no_match": "Нет контактов, подходящих под запрос"
},
"org_templates": {
"section_personal": "Мои шаблоны",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index aefbf6012..901a328b8 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Naplánované na {when}",
"sending": "Odosielanie…",
"sent": "Odoslané",
- "view": "Zobraziť"
+ "view": "Zobraziť",
+ "picker_button": "Pridať z kontaktov",
+ "picker_title": "Pridať príjemcov",
+ "tab_contacts": "Kontakty",
+ "tab_directory": "Adresár organizácie",
+ "contacts_empty_title": "Zatiaľ žiadne kontakty",
+ "contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.",
+ "contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt"
},
"org_templates": {
"section_personal": "Moje šablóny",
diff --git a/locales/th/common.json b/locales/th/common.json
index fcad4fef7..7b86f34b3 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1161,7 +1161,14 @@
"scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}",
"sending": "กำลังส่ง…",
"sent": "ส่งแล้ว",
- "view": "ดู"
+ "view": "ดู",
+ "picker_button": "เพิ่มจากผู้ติดต่อ",
+ "picker_title": "เพิ่มผู้รับ",
+ "tab_contacts": "ผู้ติดต่อ",
+ "tab_directory": "ไดเรกทอรีกลาง",
+ "contacts_empty_title": "ยังไม่มีผู้ติดต่อ",
+ "contacts_empty_hint": "คนที่คุณบันทึกไว้ในผู้ติดต่อจะแสดงที่นี่",
+ "contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา"
},
"org_templates": {
"section_personal": "แม่แบบของฉัน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 71a097b10..6a02a84e8 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "{when} için zamanlandı",
"sending": "Gönderiliyor…",
"sent": "Gönderildi",
- "view": "Görüntüle"
+ "view": "Görüntüle",
+ "picker_button": "Kişilerden ekle",
+ "picker_title": "Alıcı ekle",
+ "tab_contacts": "Kişiler",
+ "tab_directory": "Dizin",
+ "contacts_empty_title": "Henüz kişi yok",
+ "contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.",
+ "contacts_no_match": "Aramanızla eşleşen kişi yok"
},
"org_templates": {
"section_personal": "Şablonlarım",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index c0c04b84f..9cdeb4bd7 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "Заплановано на {when}",
"sending": "Надсилання…",
"sent": "Надіслано",
- "view": "Переглянути"
+ "view": "Переглянути",
+ "picker_button": "Додати з контактів",
+ "picker_title": "Додати одержувачів",
+ "tab_contacts": "Контакти",
+ "tab_directory": "Довідник",
+ "contacts_empty_title": "Контактів ще немає",
+ "contacts_empty_hint": "Тут з’являться люди, яких ви збережете в контактах.",
+ "contacts_no_match": "Немає контактів, що відповідають пошуку"
},
"org_templates": {
"section_personal": "Мої шаблони",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index e0132525a..459eb3aea 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -3922,7 +3922,14 @@
"scheduled_for": "Đã lên lịch gửi lúc {when}",
"sending": "Đang gửi…",
"sent": "Đã gửi",
- "view": "Xem"
+ "view": "Xem",
+ "picker_button": "Thêm từ danh bạ",
+ "picker_title": "Thêm người nhận",
+ "tab_contacts": "Danh bạ",
+ "tab_directory": "Danh bạ tổ chức",
+ "contacts_empty_title": "Chưa có liên hệ nào",
+ "contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.",
+ "contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm"
},
"org_templates": {
"section_personal": "Mẫu của tôi",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index b0d322bed..cda397e2b 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "已排定於 {when} 傳送",
"sending": "正在傳送…",
"sent": "已傳送",
- "view": "檢視"
+ "view": "檢視",
+ "picker_button": "從聯絡人新增",
+ "picker_title": "新增收件者",
+ "tab_contacts": "聯絡人",
+ "tab_directory": "通訊錄目錄",
+ "contacts_empty_title": "還沒有聯絡人",
+ "contacts_empty_hint": "你在聯絡人中儲存的人會顯示在這裡。",
+ "contacts_no_match": "沒有符合搜尋的聯絡人"
},
"org_templates": {
"section_personal": "我的範本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 83dc1a395..ede1e67b7 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3910,7 +3910,14 @@
"scheduled_for": "已安排在 {when} 发送",
"sending": "正在发送…",
"sent": "已发送",
- "view": "查看"
+ "view": "查看",
+ "picker_button": "从联系人添加",
+ "picker_title": "添加收件人",
+ "tab_contacts": "联系人",
+ "tab_directory": "通讯录目录",
+ "contacts_empty_title": "还没有联系人",
+ "contacts_empty_hint": "你在联系人中保存的人会显示在这里。",
+ "contacts_no_match": "没有符合搜索的联系人"
},
"org_templates": {
"section_personal": "我的模板",
From 165c94996073d97e3131edca9561c233a08acdf4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 07:13:05 +0100
Subject: [PATCH 5/5] feat: save a message as a template with just a name, and
show each template's name once
---
.../composer-template-reply.test.tsx | 43 ++++++++-
components/email/email-composer.tsx | 20 ++++-
.../template-picker-headings.test.tsx | 49 +++++++++++
.../templates/quick-save-template-dialog.tsx | 87 +++++++++++++++++++
components/templates/template-form.tsx | 4 +-
components/templates/template-picker.tsx | 17 ++--
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 +-
36 files changed, 298 insertions(+), 42 deletions(-)
create mode 100644 components/templates/__tests__/template-picker-headings.test.tsx
create mode 100644 components/templates/quick-save-template-dialog.tsx
diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx
index db74b7ee8..d2d5abc3e 100644
--- a/components/email/__tests__/composer-template-reply.test.tsx
+++ b/components/email/__tests__/composer-template-reply.test.tsx
@@ -1,9 +1,10 @@
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
+import { useTemplateStore } from '@/stores/template-store';
// The real composer around the real editor: applying a template to a reply
// the user has already started (#911, follow-up to #540).
@@ -30,9 +31,9 @@ vi.mock('@/components/templates/template-picker', () => ({
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
}));
// Save as template: what the composer hands the form.
-const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } }));
+const savedForm = vi.hoisted(() => ({ initialData: null as null | { name?: string; body?: string; isHTML?: boolean } }));
vi.mock('@/components/templates/template-form', () => ({
- TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => {
+ TemplateForm: (props: { initialData?: { name?: string; body?: string; isHTML?: boolean } }) => {
savedForm.initialData = props.initialData ?? null;
return null;
},
@@ -158,6 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
+ onTemplateCreate: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
@@ -308,13 +310,46 @@ describe('applying a template to a reply (#911)', () => {
savedForm.initialData = null;
});
- async function openSaveAsTemplate() {
+ async function openNameDialog() {
fireEvent.click(await screen.findByTestId('composer-templates-menu'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' }));
+ return screen.findByTestId('quick-save-template');
+ }
+
+ // The full editor is behind "Edit details…" (compose-18).
+ async function openSaveAsTemplate() {
+ fireEvent.click(within(await openNameDialog()).getByText('template_edit_details'));
await waitFor(() => expect(savedForm.initialData).not.toBeNull());
return savedForm.initialData!;
}
+ // compose-18: Save as template opened an editor with nine fields.
+ it('asks for a name only, starting from the subject, and saves the message', async () => {
+ useTemplateStore.setState({ templates: [] });
+ await openReply();
+ const subject = (screen.getByTestId('composer-subject') as HTMLInputElement).value;
+ const dialog = await openNameDialog();
+ const name = within(dialog).getByLabelText('template_name') as HTMLInputElement;
+ expect(name.value).toBe(subject);
+ expect(savedForm.initialData).toBeNull();
+
+ fireEvent.change(name, { target: { value: 'Quick answer' } });
+ fireEvent.click(within(dialog).getByText('save'));
+
+ await waitFor(() => expect(useTemplateStore.getState().templates).toHaveLength(1));
+ expect(useTemplateStore.getState().templates[0]).toMatchObject({ name: 'Quick answer', subject, isHTML: true });
+ expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument();
+ });
+
+ it('carries the name over into the full editor', async () => {
+ await openReply();
+ const dialog = await openNameDialog();
+ fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } });
+ fireEvent.click(within(dialog).getByText('template_edit_details'));
+
+ await waitFor(() => expect(savedForm.initialData).toMatchObject({ name: 'Quick answer' }));
+ });
+
it('marks a rich text draft as HTML', async () => {
await openReply();
const data = await openSaveAsTemplate();
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 5f4a9ab85..64e965452 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -53,6 +53,7 @@ import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { ScheduleSendDialog } from "./schedule-send-dialog";
import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog";
+import { QuickSaveTemplateDialog } from "@/components/templates/quick-save-template-dialog";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
@@ -1194,6 +1195,9 @@ export function EmailComposer({
const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null);
const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
+ // Save as template asks for a name only; "Edit details…" opens the full
+ // editor with the name typed so far (null: the name dialog).
+ const [saveTemplateDetails, setSaveTemplateDetails] = useState(null);
const [showCloseDialog, setShowCloseDialog] = useState(false);
// The formatting toolbar is folded away on a phone until asked for.
const [showFormattingOnMobile, setShowFormattingOnMobile] = useState(false);
@@ -1232,6 +1236,7 @@ export function EmailComposer({
if (!(await confirmDiscardTemplate(saveTemplateDirty))) return;
setSaveTemplateDirty(false);
setShowSaveAsTemplate(false);
+ setSaveTemplateDetails(null);
};
const { client: activeClient } = useAuthStore();
@@ -5797,7 +5802,18 @@ export function EmailComposer({
/>
)}
- {showSaveAsTemplate && (
+ {showSaveAsTemplate && saveTemplateDetails === null && (
+ setShowSaveAsTemplate(false)}
+ onEditDetails={(name) => setSaveTemplateDetails(name)}
+ onSave={(name) => {
+ addTemplate({ name, subject, body, isHTML: !plainTextMode, category: '', isFavorite: false });
+ setShowSaveAsTemplate(false);
+ }}
+ />
+ )}
+ {showSaveAsTemplate && saveTemplateDetails !== null && (
void closeSaveAsTemplate()}
@@ -5810,6 +5826,7 @@ export function EmailComposer({
void closeSaveAsTemplate()}
onDirtyChange={setSaveTemplateDirty}
diff --git a/components/templates/__tests__/template-picker-headings.test.tsx b/components/templates/__tests__/template-picker-headings.test.tsx
new file mode 100644
index 000000000..b93b8bd1f
--- /dev/null
+++ b/components/templates/__tests__/template-picker-headings.test.tsx
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { TemplatePicker } from '../template-picker';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useTemplateStore } from '@/stores/template-store';
+import { useAccountTemplatesStore } from '@/stores/account-templates-store';
+import type { EmailTemplate } from '@/lib/template-types';
+
+// compose-18: a template saved from the composer showed its name twice
+// ("Meeting follow-up / Meeting follow-up") under the heading "Other", with
+// no other heading for it to stand apart from. The next-intl mock returns keys.
+
+const own = (id: string, name: string, extra: Partial = {}): EmailTemplate => ({
+ id, name, subject: name, body: `${name} body`, category: '', isFavorite: false,
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
+});
+
+beforeEach(() => {
+ const subject = { email: 'alice@example.org', groups: [] };
+ usePolicyStore.setState({ rawPolicy: DEFAULT_POLICY, policy: resolvePolicy(DEFAULT_POLICY, subject).policy, subject });
+ useTemplateStore.setState({ templates: [own('t1', 'Meeting follow-up')], recentTemplateIds: [], deletedTemplateIds: {} });
+ useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
+});
+
+describe('the template picker', () => {
+ it('shows a name once, and no "Other" heading without categories', () => {
+ render( {}} onSelect={() => {}} />);
+
+ expect(screen.getAllByText('Meeting follow-up')).toHaveLength(1);
+ expect(screen.queryByText('section_uncategorized')).not.toBeInTheDocument();
+ });
+
+ it('keeps the subject when it says more than the name', () => {
+ useTemplateStore.setState({ templates: [own('t1', 'Follow-up', { subject: 'Thanks for meeting today' })] });
+ render( {}} onSelect={() => {}} />);
+
+ expect(screen.getByText('Thanks for meeting today')).toBeInTheDocument();
+ });
+
+ it('keeps "Other" apart from the categories', () => {
+ useTemplateStore.setState({ templates: [own('t1', 'Follow-up'), own('t2', 'Invoice', { category: 'Billing' })] });
+ render( {}} onSelect={() => {}} />);
+
+ expect(screen.getByText('Billing', { selector: 'h3' })).toBeInTheDocument();
+ expect(screen.getByText('section_uncategorized')).toBeInTheDocument();
+ });
+});
diff --git a/components/templates/quick-save-template-dialog.tsx b/components/templates/quick-save-template-dialog.tsx
new file mode 100644
index 000000000..04bb5295d
--- /dev/null
+++ b/components/templates/quick-save-template-dialog.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { useId, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/button';
+import { Dialog } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
+import { validateTemplateName } from '@/lib/template-utils';
+
+interface QuickSaveTemplateDialogProps {
+ /** The name to start with: the message's subject. */
+ defaultName: string;
+ onSave: (name: string) => void;
+ /** "Edit details…": the full template editor, with the name typed so far. */
+ onEditDetails: (name: string) => void;
+ onClose: () => void;
+}
+
+/**
+ * "Save as template" from the composer: a name and Save. The full editor
+ * (category, recipients, sending identity, favourite) is one click further,
+ * behind "Edit details…"; it used to open straight away with nine fields.
+ */
+export function QuickSaveTemplateDialog({ defaultName, onSave, onEditDetails, onClose }: QuickSaveTemplateDialogProps) {
+ const tComposer = useTranslations('email_composer');
+ const tSend = useTranslations('compose_send');
+ const tSettings = useTranslations('settings.templates');
+ const tCommon = useTranslations('common');
+ const fieldId = useId();
+ const [name, setName] = useState(defaultName);
+ const [error, setError] = useState(null);
+
+ const save = () => {
+ const problem = validateTemplateName(name);
+ if (problem) {
+ setError(problem);
+ return;
+ }
+ onSave(name.trim());
+ };
+
+ return (
+ {
+ e.preventDefault();
+ save();
+ }}
+ footer={
+ <>
+ onEditDetails(name)}>
+ {tSend('template_edit_details')}
+
+ {tCommon('cancel')}
+ {tCommon('save')}
+ >
+ }
+ >
+
+
{tSend('template_name')}
+
{
+ setName(e.target.value);
+ if (error) setError(null);
+ }}
+ className={cn('mt-1', error && 'border-destructive')}
+ aria-invalid={error ? true : undefined}
+ aria-describedby={error ? `${fieldId}-error` : undefined}
+ autoFocus
+ />
+ {error && (
+
+ {tSettings(`validation.${error}`)}
+
+ )}
+
+
+ );
+}
diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx
index 5109d4a2e..7f6b78fbc 100644
--- a/components/templates/template-form.tsx
+++ b/components/templates/template-form.tsx
@@ -22,6 +22,8 @@ import type { Editor } from '@tiptap/react';
interface TemplateFormProps {
template?: EmailTemplate;
initialData?: {
+ /** A name typed before the full editor opened (Save as template). */
+ name?: string;
subject?: string;
body?: string;
/** The body is the composer's HTML, not plain text (#1506). */
@@ -57,7 +59,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
const browserList = useTemplateStore((s) => s.templates);
const templates = categorySource ?? browserList;
- const [name, setName] = useState(template?.name || '');
+ const [name, setName] = useState(template?.name || initialData?.name || '');
const [category, setCategory] = useState(template?.category || '');
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
const [body, setBody] = useState(template?.body || initialData?.body || '');
diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx
index a8cbd72b3..f84990342 100644
--- a/components/templates/template-picker.tsx
+++ b/components/templates/template-picker.tsx
@@ -114,7 +114,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
)}
- {template.subject && (
+ {/* Not the name a second time ("Meeting follow-up / Meeting follow-up"). */}
+ {template.subject && template.subject.trim() !== template.name.trim() && (
{template.subject}
@@ -131,13 +132,15 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
);
- const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
+ const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource, withHeading = true) => {
if (items.length === 0) return null;
return (
-
- {title}
-
+ {withHeading && (
+
+ {title}
+
+ )}
{items.map((item) => renderTemplateItem(item, source))}
);
@@ -171,7 +174,9 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
{Object.entries(byCategory)
.filter(([cat]) => cat !== '')
.map(([cat, items]) => renderSection(cat, items, source))}
- {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
+ {/* "Other" only sets them apart from favourites, recent or categories. */}
+ {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source,
+ favorites.length > 0 || recent.length > 0 || Object.keys(byCategory).some((cat) => cat !== ''))}
>
);
if (!withHeading) return {body}
;
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 8fc060a58..24d70a5f5 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "الدليل",
"contacts_empty_title": "لا توجد جهات اتصال بعد",
"contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.",
- "contacts_no_match": "لا توجد جهة اتصال تطابق بحثك"
+ "contacts_no_match": "لا توجد جهة اتصال تطابق بحثك",
+ "template_name": "اسم القالب",
+ "template_edit_details": "تعديل التفاصيل…"
},
"org_templates": {
"section_personal": "قوالبي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index ea9846760..660791f93 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Directori",
"contacts_empty_title": "Encara no hi ha contactes",
"contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.",
- "contacts_no_match": "Cap contacte coincideix amb la cerca"
+ "contacts_no_match": "Cap contacte coincideix amb la cerca",
+ "template_name": "Nom de la plantilla",
+ "template_edit_details": "Edita els detalls…"
},
"org_templates": {
"section_personal": "Les meves plantilles",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 211878338..28032fd09 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Adresář organizace",
"contacts_empty_title": "Zatím žádné kontakty",
"contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.",
- "contacts_no_match": "Hledání neodpovídá žádný kontakt"
+ "contacts_no_match": "Hledání neodpovídá žádný kontakt",
+ "template_name": "Název šablony",
+ "template_edit_details": "Upravit podrobnosti…"
},
"org_templates": {
"section_personal": "Moje šablony",
diff --git a/locales/da/common.json b/locales/da/common.json
index 3b27a738f..a1e3dac0f 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Adressefortegnelse",
"contacts_empty_title": "Ingen kontakter endnu",
"contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.",
- "contacts_no_match": "Ingen kontakter matcher din søgning"
+ "contacts_no_match": "Ingen kontakter matcher din søgning",
+ "template_name": "Skabelonens navn",
+ "template_edit_details": "Rediger detaljer…"
},
"org_templates": {
"section_personal": "Mine skabeloner",
diff --git a/locales/de/common.json b/locales/de/common.json
index ae5039e3e..c2976faf0 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Verzeichnis",
"contacts_empty_title": "Noch keine Kontakte",
"contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.",
- "contacts_no_match": "Kein Kontakt passt zu Ihrer Suche"
+ "contacts_no_match": "Kein Kontakt passt zu Ihrer Suche",
+ "template_name": "Name der Vorlage",
+ "template_edit_details": "Details bearbeiten…"
},
"org_templates": {
"section_personal": "Meine Vorlagen",
diff --git a/locales/en/common.json b/locales/en/common.json
index dbef12f39..3af2a2f74 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Directory",
"contacts_empty_title": "No contacts yet",
"contacts_empty_hint": "People you save in Contacts show up here.",
- "contacts_no_match": "No contact matches your search"
+ "contacts_no_match": "No contact matches your search",
+ "template_name": "Template name",
+ "template_edit_details": "Edit details…"
},
"org_templates": {
"section_personal": "My templates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 44b012eb1..0509e004c 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Directorio",
"contacts_empty_title": "Aún no hay contactos",
"contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.",
- "contacts_no_match": "Ningún contacto coincide con la búsqueda"
+ "contacts_no_match": "Ningún contacto coincide con la búsqueda",
+ "template_name": "Nombre de la plantilla",
+ "template_edit_details": "Editar detalles…"
},
"org_templates": {
"section_personal": "Mis plantillas",
diff --git a/locales/et/common.json b/locales/et/common.json
index fed4d3fa0..0f7f020db 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Kataloog",
"contacts_empty_title": "Kontakte veel pole",
"contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.",
- "contacts_no_match": "Otsingule ei vasta ükski kontakt"
+ "contacts_no_match": "Otsingule ei vasta ükski kontakt",
+ "template_name": "Malli nimi",
+ "template_edit_details": "Muuda üksikasju…"
},
"org_templates": {
"section_personal": "Minu mallid",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 6ce99c57a..e109ff7b6 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "فهرست سازمانی",
"contacts_empty_title": "هنوز مخاطبی وجود ندارد",
"contacts_empty_hint": "افرادی که در مخاطبین ذخیره میکنید اینجا نمایش داده میشوند.",
- "contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد"
+ "contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد",
+ "template_name": "نام قالب",
+ "template_edit_details": "ویرایش جزئیات…"
},
"org_templates": {
"section_personal": "قالبهای من",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 18783b5e7..4956fb6ad 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Annuaire",
"contacts_empty_title": "Aucun contact pour l'instant",
"contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.",
- "contacts_no_match": "Aucun contact ne correspond à votre recherche"
+ "contacts_no_match": "Aucun contact ne correspond à votre recherche",
+ "template_name": "Nom du modèle",
+ "template_edit_details": "Modifier les détails…"
},
"org_templates": {
"section_personal": "Mes modèles",
diff --git a/locales/he/common.json b/locales/he/common.json
index 1735dee2a..a9f5fda38 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3814,7 +3814,9 @@
"tab_directory": "ספרייה",
"contacts_empty_title": "אין עדיין אנשי קשר",
"contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.",
- "contacts_no_match": "אין איש קשר שתואם לחיפוש"
+ "contacts_no_match": "אין איש קשר שתואם לחיפוש",
+ "template_name": "שם התבנית",
+ "template_edit_details": "עריכת פרטים…"
},
"org_templates": {
"section_personal": "התבניות שלי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 74a817551..c66311018 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Címtár",
"contacts_empty_title": "Még nincsenek névjegyek",
"contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.",
- "contacts_no_match": "Egy névjegy sem felel meg a keresésnek"
+ "contacts_no_match": "Egy névjegy sem felel meg a keresésnek",
+ "template_name": "Sablon neve",
+ "template_edit_details": "Részletek szerkesztése…"
},
"org_templates": {
"section_personal": "Saját sablonok",
diff --git a/locales/it/common.json b/locales/it/common.json
index 5b254ce9c..90b2ef2e9 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Rubrica aziendale",
"contacts_empty_title": "Ancora nessun contatto",
"contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.",
- "contacts_no_match": "Nessun contatto corrisponde alla ricerca"
+ "contacts_no_match": "Nessun contatto corrisponde alla ricerca",
+ "template_name": "Nome del modello",
+ "template_edit_details": "Modifica dettagli…"
},
"org_templates": {
"section_personal": "I miei modelli",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 73cf4eb8e..79317ef5d 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "ディレクトリ",
"contacts_empty_title": "連絡先はまだありません",
"contacts_empty_hint": "連絡先に保存した人がここに表示されます。",
- "contacts_no_match": "検索に一致する連絡先はありません"
+ "contacts_no_match": "検索に一致する連絡先はありません",
+ "template_name": "テンプレート名",
+ "template_edit_details": "詳細を編集…"
},
"org_templates": {
"section_personal": "マイテンプレート",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 827cb61f0..e2eab9c80 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "디렉터리",
"contacts_empty_title": "아직 연락처가 없어요",
"contacts_empty_hint": "연락처에 저장한 사람이 여기에 표시돼요.",
- "contacts_no_match": "검색과 일치하는 연락처가 없어요"
+ "contacts_no_match": "검색과 일치하는 연락처가 없어요",
+ "template_name": "템플릿 이름",
+ "template_edit_details": "세부 정보 편집…"
},
"org_templates": {
"section_personal": "내 템플릿",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 5ceb5f684..ace10bc3f 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Katalogs",
"contacts_empty_title": "Vēl nav kontaktu",
"contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.",
- "contacts_no_match": "Meklēšanai neatbilst neviens kontakts"
+ "contacts_no_match": "Meklēšanai neatbilst neviens kontakts",
+ "template_name": "Veidnes nosaukums",
+ "template_edit_details": "Rediģēt detaļas…"
},
"org_templates": {
"section_personal": "Manas veidnes",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 37458cb71..ba80232aa 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Лавлах",
"contacts_empty_title": "Одоогоор харилцагч алга",
"contacts_empty_hint": "Харилцагчдад хадгалсан хүмүүс энд харагдана.",
- "contacts_no_match": "Хайлтад тохирох харилцагч алга"
+ "contacts_no_match": "Хайлтад тохирох харилцагч алга",
+ "template_name": "Загварын нэр",
+ "template_edit_details": "Дэлгэрэнгүйг засах…"
},
"org_templates": {
"section_personal": "Миний загварууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 4a8f8f941..84f0718f6 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Katalog",
"contacts_empty_title": "Ingen kontakter ennå",
"contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.",
- "contacts_no_match": "Ingen kontakter samsvarer med søket"
+ "contacts_no_match": "Ingen kontakter samsvarer med søket",
+ "template_name": "Navn på mal",
+ "template_edit_details": "Rediger detaljer…"
},
"org_templates": {
"section_personal": "Mine maler",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 75d1a276d..bd8b6deda 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Adresboek van de organisatie",
"contacts_empty_title": "Nog geen contacten",
"contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.",
- "contacts_no_match": "Geen contact komt overeen met uw zoekopdracht"
+ "contacts_no_match": "Geen contact komt overeen met uw zoekopdracht",
+ "template_name": "Naam van sjabloon",
+ "template_edit_details": "Details bewerken…"
},
"org_templates": {
"section_personal": "Mijn sjablonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index f728e9e79..87d6f7bdb 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Katalog",
"contacts_empty_title": "Brak kontaktów",
"contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.",
- "contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania"
+ "contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania",
+ "template_name": "Nazwa szablonu",
+ "template_edit_details": "Edytuj szczegóły…"
},
"org_templates": {
"section_personal": "Moje szablony",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 58f9dbdeb..d153f9f63 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Diretório",
"contacts_empty_title": "Nenhum contato ainda",
"contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.",
- "contacts_no_match": "Nenhum contato corresponde à sua pesquisa"
+ "contacts_no_match": "Nenhum contato corresponde à sua pesquisa",
+ "template_name": "Nome do modelo",
+ "template_edit_details": "Editar detalhes…"
},
"org_templates": {
"section_personal": "Meus modelos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 3f006f780..d69744bf6 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Director",
"contacts_empty_title": "Încă nu există contacte",
"contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.",
- "contacts_no_match": "Niciun contact nu corespunde căutării"
+ "contacts_no_match": "Niciun contact nu corespunde căutării",
+ "template_name": "Numele șablonului",
+ "template_edit_details": "Editează detaliile…"
},
"org_templates": {
"section_personal": "Șabloanele mele",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index afa5dc6c8..c82e2d783 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Справочник",
"contacts_empty_title": "Контактов пока нет",
"contacts_empty_hint": "Здесь появятся люди, которых вы сохраните в контактах.",
- "contacts_no_match": "Нет контактов, подходящих под запрос"
+ "contacts_no_match": "Нет контактов, подходящих под запрос",
+ "template_name": "Название шаблона",
+ "template_edit_details": "Изменить подробности…"
},
"org_templates": {
"section_personal": "Мои шаблоны",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 901a328b8..73fc1a0eb 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Adresár organizácie",
"contacts_empty_title": "Zatiaľ žiadne kontakty",
"contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.",
- "contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt"
+ "contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt",
+ "template_name": "Názov šablóny",
+ "template_edit_details": "Upraviť podrobnosti…"
},
"org_templates": {
"section_personal": "Moje šablóny",
diff --git a/locales/th/common.json b/locales/th/common.json
index 7b86f34b3..76af0639e 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1168,7 +1168,9 @@
"tab_directory": "ไดเรกทอรีกลาง",
"contacts_empty_title": "ยังไม่มีผู้ติดต่อ",
"contacts_empty_hint": "คนที่คุณบันทึกไว้ในผู้ติดต่อจะแสดงที่นี่",
- "contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา"
+ "contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา",
+ "template_name": "ชื่อแม่แบบ",
+ "template_edit_details": "แก้ไขรายละเอียด…"
},
"org_templates": {
"section_personal": "แม่แบบของฉัน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 6a02a84e8..8fc0c1d0e 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Dizin",
"contacts_empty_title": "Henüz kişi yok",
"contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.",
- "contacts_no_match": "Aramanızla eşleşen kişi yok"
+ "contacts_no_match": "Aramanızla eşleşen kişi yok",
+ "template_name": "Şablon adı",
+ "template_edit_details": "Ayrıntıları düzenle…"
},
"org_templates": {
"section_personal": "Şablonlarım",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 9cdeb4bd7..9a12ae296 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "Довідник",
"contacts_empty_title": "Контактів ще немає",
"contacts_empty_hint": "Тут з’являться люди, яких ви збережете в контактах.",
- "contacts_no_match": "Немає контактів, що відповідають пошуку"
+ "contacts_no_match": "Немає контактів, що відповідають пошуку",
+ "template_name": "Назва шаблону",
+ "template_edit_details": "Редагувати подробиці…"
},
"org_templates": {
"section_personal": "Мої шаблони",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 459eb3aea..d120c2103 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -3929,7 +3929,9 @@
"tab_directory": "Danh bạ tổ chức",
"contacts_empty_title": "Chưa có liên hệ nào",
"contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.",
- "contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm"
+ "contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm",
+ "template_name": "Tên mẫu",
+ "template_edit_details": "Sửa chi tiết…"
},
"org_templates": {
"section_personal": "Mẫu của tôi",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index cda397e2b..c0b50d357 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "通訊錄目錄",
"contacts_empty_title": "還沒有聯絡人",
"contacts_empty_hint": "你在聯絡人中儲存的人會顯示在這裡。",
- "contacts_no_match": "沒有符合搜尋的聯絡人"
+ "contacts_no_match": "沒有符合搜尋的聯絡人",
+ "template_name": "範本名稱",
+ "template_edit_details": "編輯詳細資料…"
},
"org_templates": {
"section_personal": "我的範本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index ede1e67b7..78f0c9f09 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3917,7 +3917,9 @@
"tab_directory": "通讯录目录",
"contacts_empty_title": "还没有联系人",
"contacts_empty_hint": "你在联系人中保存的人会显示在这里。",
- "contacts_no_match": "没有符合搜索的联系人"
+ "contacts_no_match": "没有符合搜索的联系人",
+ "template_name": "模板名称",
+ "template_edit_details": "编辑详细信息…"
},
"org_templates": {
"section_personal": "我的模板",