feat: warn before sending a message with unfilled placeholders

This commit is contained in:
Linus Rath
2026-10-27 22:30:33 +01:00
parent f2ed1597f7
commit 011ff0e93a
4 changed files with 484 additions and 0 deletions
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>See you at [TIME] in TODO.</p>',
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
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(
<EmailComposer
initialData={{ ...DRAFT_WITH_PLACEHOLDER, subject: 'Hi {{name}}', body: '<p>Hello</p>' }}
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
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 = '<p>Thanks!</p>'
+ '<div data-signature-block-node="">[COMPANY]</div>'
+ '<blockquote><p>Meet at [TIME]? TODO</p></blockquote>';
render(<EmailComposer initialData={{ ...DRAFT_WITH_PLACEHOLDER, body }} onSend={onSend} />);
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(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
fireEvent.click(sendButton());
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
});
});
+138
View File
@@ -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<string | undefined>();
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState<string[]>([]);
const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState<string | undefined>();
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<Set<string>>(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({
</div>
)}
{showPlaceholderWarning && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowPlaceholderWarning(false)}
>
<div
ref={placeholderWarningRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="placeholder-warning-title"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 id="placeholder-warning-title" className="text-lg font-semibold text-foreground">{t('placeholder_warning.title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('placeholder_warning.message')}</p>
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t('placeholder_warning.found')}>
{placeholderWarningMatches.slice(0, 5).map((p) => (
<li key={p} className="max-w-full truncate rounded bg-muted px-2 py-0.5 font-mono text-xs text-foreground">{p}</li>
))}
{placeholderWarningMatches.length > 5 && (
<li className="text-xs text-muted-foreground">
{t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
</li>
)}
</ul>
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={placeholderDontAskAgain}
onChange={(e) => setPlaceholderDontAskAgain(e.target.checked)}
className="rounded border-input"
/>
{t('placeholder_warning.dont_ask_again')}
</label>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button
variant="outline"
onClick={() => {
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')}
</Button>
<Button
onClick={() => {
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')}
</Button>
</div>
</div>
</div>
)}
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
+9
View File
@@ -803,6 +803,15 @@
"send_anyway": "Send anyway",
"back": "Back to editing"
},
"placeholder_warning": {
"title": "Check placeholders?",
"message": "Your message has placeholder text that recipients will see. Do you want to edit it before sending?",
"found": "Placeholders found",
"more": "and {count} more",
"dont_ask_again": "Don't ask again",
"send_anyway": "Send anyway",
"back": "Back to editing"
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_preset": {
+5
View File
@@ -682,6 +682,9 @@ interface SettingsState {
// Ask for confirmation when sending a message with an empty subject
emptySubjectWarningEnabled: boolean;
// Ask for confirmation when sending a message with unfilled placeholders
placeholderWarningEnabled: boolean;
// "@" in the message body offers the recipients and inserts a first name
recipientMentionsEnabled: boolean;
// Where an "@" puts a picked contact who isn't a recipient yet; 'off'
@@ -975,6 +978,7 @@ const DEFAULT_SETTINGS = {
] as string[],
emptySubjectWarningEnabled: true,
placeholderWarningEnabled: true,
recipientMentionsEnabled: true,
mentionAddsRecipientTo: 'to' as MentionAddsRecipientTo,
@@ -1275,6 +1279,7 @@ export const useSettingsStore = create<SettingsState>()(
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
emptySubjectWarningEnabled: state.emptySubjectWarningEnabled,
placeholderWarningEnabled: state.placeholderWarningEnabled,
recipientMentionsEnabled: state.recipientMentionsEnabled,
mentionAddsRecipientTo: state.mentionAddsRecipientTo,
hideInlineImageAttachments: state.hideInlineImageAttachments,