diff --git a/components/email/__tests__/placeholder-warning.test.tsx b/components/email/__tests__/placeholder-warning.test.tsx
new file mode 100644
index 000000000..0cda741ea
--- /dev/null
+++ b/components/email/__tests__/placeholder-warning.test.tsx
@@ -0,0 +1,332 @@
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useSettingsStore } from '@/stores/settings-store';
+
+// ─── Heavy component mocks (mirrors empty-subject-warning.test.tsx) ──────────
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+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();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ placeholderWarningEnabled: 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: () => '',
+ plainTextBodyWithoutSignature: (body: string) => body,
+}));
+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 ────────────────────────────────────────────────────────────────────
+
+/** A ready-to-send draft whose body still has a fill-in. */
+const DRAFT_WITH_PLACEHOLDER = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Lunch',
+ body: 'See you at [TIME] in TODO.
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
+
+describe('composer placeholder warning', () => {
+ beforeEach(() => {
+ updateSetting.mockClear();
+ });
+
+ afterEach(() => {
+ useSettingsStore.setState({
+ placeholderWarningEnabled: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ } as never);
+ });
+
+ it('asks before sending and lists the placeholders', async () => {
+ const onSend = vi.fn();
+ render();
+
+ fireEvent.click(sendButton());
+
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('placeholder_warning.title')).toBeInTheDocument();
+ const found = within(dialog).getAllByRole('listitem').map((li) => li.textContent);
+ expect(found).toEqual(['[TIME]', 'TODO']);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it('sends once confirmed', async () => {
+ const onSend = vi.fn();
+ render();
+
+ fireEvent.click(sendButton());
+ fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('turns the warning off when "don\'t ask again" is checked', async () => {
+ const onSend = vi.fn();
+ render();
+
+ fireEvent.click(sendButton());
+ fireEvent.click(await screen.findByText('placeholder_warning.dont_ask_again'));
+ fireEvent.click(screen.getByText('placeholder_warning.send_anyway'));
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(updateSetting).toHaveBeenCalledWith('placeholderWarningEnabled', false);
+ });
+
+ it('goes back to editing with the placeholder selected', async () => {
+ const onSend = vi.fn();
+ render(
+ Hello
' }}
+ onSend={onSend}
+ />
+ );
+
+ fireEvent.click(sendButton());
+ fireEvent.click(await screen.findByText('placeholder_warning.back'));
+
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ const subject = screen.getByDisplayValue('Hi {{name}}') as HTMLInputElement;
+ await waitFor(() => expect(document.activeElement).toBe(subject));
+ expect([subject.selectionStart, subject.selectionEnd]).toEqual([3, 11]);
+ 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();
+
+ 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());
+
+ fireEvent.click(sendButton());
+
+ expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
+ 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);
+ 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());
+
+ fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } });
+ fireEvent.click(sendButton());
+
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('placeholder_warning.title')).toBeInTheDocument();
+ expect(within(dialog).getByText('{{name}}')).toBeInTheDocument();
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it('ignores placeholders in quoted text and in the signature', async () => {
+ const onSend = vi.fn();
+ const body = 'Thanks!
'
+ + '[COMPANY]
'
+ + 'Meet at [TIME]? TODO
';
+ render();
+
+ fireEvent.click(sendButton());
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
+ });
+
+ it('sends without asking when the warning is off', async () => {
+ useSettingsStore.setState({ placeholderWarningEnabled: false } as never);
+ const onSend = vi.fn();
+ render();
+
+ fireEvent.click(sendButton());
+
+ await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
+ expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 81495cf59..1b538ec45 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -89,6 +89,7 @@ import { usePluginSignature } from "@/hooks/use-plugin-signature";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
+import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
@@ -478,6 +479,7 @@ export function EmailComposer({
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const emptySubjectWarningEnabled = useSettingsStore((state) => state.emptySubjectWarningEnabled);
+ const placeholderWarningEnabled = useSettingsStore((state) => state.placeholderWarningEnabled);
const recipientMentionsEnabled = useSettingsStore((state) => state.recipientMentionsEnabled);
const mentionAddsRecipientTo = useSettingsStore((state) => state.mentionAddsRecipientTo);
const updateSetting = useSettingsStore((state) => state.updateSetting);
@@ -966,6 +968,10 @@ export function EmailComposer({
const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false);
const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState();
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
+ const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
+ const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState([]);
+ const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState();
+ const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleValue, setScheduleValue] = useState('');
const [scheduleError, setScheduleError] = useState('');
@@ -997,6 +1003,12 @@ export function EmailComposer({
restoreFocus: true,
});
+ const placeholderWarningRef = useFocusTrap({
+ isActive: showPlaceholderWarning,
+ onEscape: () => setShowPlaceholderWarning(false),
+ restoreFocus: true,
+ });
+
const { client: activeClient } = useAuthStore();
const composerOwnClientRef = useRef(activeClient);
if (onComposerAccount && activeClient) composerOwnClientRef.current = activeClient;
@@ -3093,6 +3105,9 @@ export function EmailComposer({
const sendCancelledRef = useRef(false);
// The user confirmed sending without a subject for this message.
const emptySubjectConfirmedRef = 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());
// Toasts sit in the bottom corner, right over the composer's Send button.
// The attribute lifts them clear while a composer is open (see toast.tsx).
@@ -3107,6 +3122,41 @@ export function EmailComposer({
};
}, []);
+ // "Back to editing" from the placeholder warning selects the first
+ // placeholder, so the user can type over it.
+ const selectPlaceholder = (placeholder: string) => {
+ const subjectInput = subjectInputRef.current;
+ const subjectIndex = subjectInput?.value.indexOf(placeholder) ?? -1;
+ if (subjectInput && subjectIndex >= 0) {
+ subjectInput.focus();
+ subjectInput.setSelectionRange(subjectIndex, subjectIndex + placeholder.length);
+ return;
+ }
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ if (!textarea) return;
+ const index = textarea.value.indexOf(placeholder);
+ textarea.focus();
+ if (index >= 0) textarea.setSelectionRange(index, index + placeholder.length);
+ return;
+ }
+ const editor = editorRef.current;
+ if (!editor) return;
+ const range = { from: -1, to: -1 };
+ editor.state.doc.descendants((node, pos) => {
+ if (range.from >= 0) return false;
+ const index = node.isText && node.text ? node.text.indexOf(placeholder) : -1;
+ if (index >= 0) {
+ range.from = pos + index;
+ range.to = range.from + placeholder.length;
+ }
+ });
+ // A placeholder split across formatting has no single text node; then
+ // the caret just goes back into the body.
+ if (range.from >= 0) editor.chain().focus().setTextSelection(range).scrollIntoView().run();
+ else editor.commands.focus();
+ };
+
const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: {
skipAttachmentCheck?: boolean;
skipSubjectCheck?: boolean;
@@ -3176,6 +3226,27 @@ export function EmailComposer({
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'),
+ });
+ const found = findUnfilledPlaceholders(subject, bodyText);
+ if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
+ setPlaceholderWarningMatches(found);
+ setPlaceholderDontAskAgain(false);
+ setPlaceholderWarningDelayedUntil(delayedUntil);
+ setShowPlaceholderWarning(true);
+ return;
+ }
+ }
+
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
@@ -3688,6 +3759,7 @@ export function EmailComposer({
showScheduleDialog ||
showAttachmentWarning ||
showEmptySubjectWarning ||
+ showPlaceholderWarning ||
showCloseDialog
) return;
@@ -4824,6 +4896,72 @@ export function EmailComposer({
)}
+ {showPlaceholderWarning && (
+ setShowPlaceholderWarning(false)}
+ >
+
e.stopPropagation()}
+ className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
+ >
+
+
{t('placeholder_warning.title')}
+
{t('placeholder_warning.message')}
+
+ {placeholderWarningMatches.slice(0, 5).map((p) => (
+ - {p}
+ ))}
+ {placeholderWarningMatches.length > 5 && (
+ -
+ {t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
+
+ )}
+
+
+
+
+
+
+
+
+
+ )}
+
{showCloseDialog && (
()(
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
emptySubjectWarningEnabled: state.emptySubjectWarningEnabled,
+ placeholderWarningEnabled: state.placeholderWarningEnabled,
recipientMentionsEnabled: state.recipientMentionsEnabled,
mentionAddsRecipientTo: state.mentionAddsRecipientTo,
hideInlineImageAttachments: state.hideInlineImageAttachments,