diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx
new file mode 100644
index 000000000..b51668969
--- /dev/null
+++ b/components/email/__tests__/composer-mention.test.tsx
@@ -0,0 +1,327 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+import { EmailComposer } from '../email-composer';
+
+// The real composer around the real editor and its "@" recipient list: the
+// two share the keyboard, and the composer handles Escape (close) and
+// Ctrl+Enter (send) in the capture phase, before the editor sees them.
+
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+
+// ─── Composer dependency mocks (as in composer-close-guard.test.tsx) ──────────
+
+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,
+ 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', () => ({
+ 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 },
+ onComposeSend: { intercept: async () => true },
+ 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('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const DRAFT = {
+ to: 'Bob Builder , carol.smith@example.com',
+ cc: '',
+ bcc: 'Geheim ',
+ subject: 'Half-written',
+ body: 'Hallo
',
+ showCc: false,
+ showBcc: true,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+/** Lets the suggestion's async item lookup finish. */
+const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+
+/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
+const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
+
+async function renderComposer() {
+ const onClose = vi.fn();
+ render( );
+ // An edit makes the draft dirty, so closing asks first instead of closing.
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ // TipTap keeps its editor on the editable element.
+ const dom = await waitFor(() => {
+ const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
+ expect(el?.editor).toBeTruthy();
+ return el!;
+ });
+ const editor = dom.editor!;
+ // A new message focuses the To field 100ms after opening. Let that happen
+ // first: arriving later, it would blur the editor and close the list.
+ const to = within(screen.getByTestId('composer-to')).getByRole('combobox');
+ await waitFor(() => expect(document.activeElement).toBe(to));
+ act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 1); });
+ return { editor, onClose };
+}
+
+async function typeInto(editor: Editor, text: string) {
+ act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); });
+ await settle();
+}
+
+const press = (editor: Editor, key: string, init: KeyboardEventInit = {}) =>
+ fireEvent.keyDown(editor.view.dom, { key, ...init });
+
+const mentionList = () => screen.queryByRole('listbox', { name: 'mention_recipients' });
+
+describe('composer with the @ recipient list', () => {
+ // jsdom has no layout. After an insertion the editor focuses a frame later
+ // and ProseMirror measures a Range to scroll the cursor into view.
+ const range = Range.prototype as unknown as Record;
+ beforeAll(() => {
+ range.getClientRects = () => [];
+ range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
+ });
+ afterAll(() => {
+ delete range.getClientRects;
+ delete range.getBoundingClientRect;
+ });
+
+ it('lists To recipients only - never Bcc', async () => {
+ const { editor } = await renderComposer();
+ await typeInto(editor, ' @');
+ const options = within(await screen.findByRole('listbox', { name: 'mention_recipients' })).getAllByRole('option');
+ expect(options.map((o) => o.textContent)).toEqual(['Bob Builderbob@example.com', 'Carolcarol.smith@example.com']);
+ });
+
+ it('lets Escape close the open list, not the composer; the next Escape asks to close', async () => {
+ const { editor, onClose } = await renderComposer();
+ await typeInto(editor, ' @');
+ await screen.findByRole('listbox', { name: 'mention_recipients' });
+
+ press(editor, 'Escape');
+ await waitFor(() => expect(mentionList()).toBeNull());
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+
+ press(editor, 'Escape');
+ expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
+ });
+
+ it('keeps Ctrl+Enter as the send shortcut while the list is open', async () => {
+ const { editor } = await renderComposer();
+ await typeInto(editor, ' @');
+ await screen.findByRole('listbox', { name: 'mention_recipients' });
+ // The composer takes it first; the editor then leaves it alone - no
+ // recipient picked, no line break.
+ expect(press(editor, 'Enter', { ctrlKey: true })).toBe(false);
+ expect(editor.getText()).toBe('Hallo @');
+ });
+
+ it('picks with the arrows and Enter, or with Tab, instead of breaking the line', async () => {
+ const { editor } = await renderComposer();
+ await typeInto(editor, ' @');
+ await screen.findByRole('listbox', { name: 'mention_recipients' });
+ press(editor, 'ArrowDown');
+ press(editor, 'Enter');
+ await nextFrame();
+ expect(editor.getText()).toBe('Hallo @Carol ');
+
+ await typeInto(editor, '@b');
+ await screen.findByRole('listbox', { name: 'mention_recipients' });
+ press(editor, 'Tab');
+ await nextFrame();
+ expect(editor.getText()).toBe('Hallo @Carol @Bob ');
+ expect(editor.state.doc.childCount).toBe(1);
+ expect(mentionList()).toBeNull();
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ });
+
+ it('closes the list when the focus moves to another field, which gets Escape back', async () => {
+ const { editor } = await renderComposer();
+ act(() => { editor.view.focus(); });
+ expect(document.activeElement).toBe(editor.view.dom);
+ await typeInto(editor, ' @');
+ await screen.findByRole('listbox', { name: 'mention_recipients' });
+
+ // Shift+Tab, say: the subject field takes the focus.
+ const subject = screen.getByDisplayValue('Half-written more');
+ act(() => { subject.focus(); });
+ await waitFor(() => expect(mentionList()).toBeNull());
+
+ fireEvent.keyDown(subject, { key: 'Escape' });
+ expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
+ });
+
+ it('closes the list on a click elsewhere, but not on a click on the list itself', async () => {
+ const { editor } = await renderComposer();
+ await typeInto(editor, ' @');
+ const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
+
+ // Its scrollbar or padding: the editor keeps the focus, the list stays.
+ expect(fireEvent.mouseDown(list)).toBe(false);
+ fireEvent.pointerDown(list);
+ expect(mentionList()).not.toBeNull();
+
+ fireEvent.pointerDown(screen.getByDisplayValue('Half-written more'));
+ await waitFor(() => expect(mentionList()).toBeNull());
+ });
+
+ it('inserts a clicked recipient', async () => {
+ const { editor } = await renderComposer();
+ await typeInto(editor, ' @');
+ const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
+ fireEvent.mouseDown(within(list).getAllByRole('option')[0]);
+ await nextFrame();
+ expect(editor.getText()).toBe('Hallo @Bob ');
+ await waitFor(() => expect(mentionList()).toBeNull());
+ });
+});
diff --git a/components/email/__tests__/recipient-mention.test.tsx b/components/email/__tests__/recipient-mention.test.tsx
new file mode 100644
index 000000000..7f1f7c14c
--- /dev/null
+++ b/components/email/__tests__/recipient-mention.test.tsx
@@ -0,0 +1,294 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { Editor, type AnyExtension } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { Table } from '@tiptap/extension-table';
+import { TableRow } from '@tiptap/extension-table-row';
+import { TableHeader } from '@tiptap/extension-table-header';
+import { TableCell } from '@tiptap/extension-table-cell';
+import type { SuggestionProps } from '@tiptap/suggestion';
+
+import { RichTextEditor } from '../rich-text-editor';
+import { RecipientMention, insertMention } from '../recipient-mention';
+import { buildMentionCandidates, type MentionCandidate } from '@/lib/recipient-mentions';
+
+// The avatar resolves app config and favicons; neither matters here.
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+
+const PEOPLE = buildMentionCandidates(
+ [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }],
+ [],
+);
+
+/** Lets the suggestion's async item lookup finish. */
+const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+
+/** Lets the editor's delayed focus (and scroll to the cursor) run inside the test. */
+const nextFrame = () => act(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
+
+const type = (editor: Editor, text: string) => act(() => {
+ editor.view.dispatch(editor.state.tr.insertText(text));
+});
+
+const press = (editor: Editor, key: string) => {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ act(() => { editor.view.dom.dispatchEvent(event); });
+ return event;
+};
+
+/** An editor whose suggestion list picks the first item on Enter or Tab. */
+function setup(content: string, candidates: MentionCandidate[] = PEOPLE, extensions: AnyExtension[] = [StarterKit]) {
+ const suggestion: { props: SuggestionProps | null } = { props: null };
+ const editor = new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ ...extensions,
+ RecipientMention.configure({
+ getCandidates: () => candidates,
+ render: () => ({
+ onStart: (props) => { suggestion.props = props; },
+ onUpdate: (props) => { if (!props.loading) suggestion.props = props; },
+ onKeyDown: ({ event }) => {
+ if ((event.key !== 'Enter' && event.key !== 'Tab') || !suggestion.props?.items.length) return false;
+ suggestion.props.command(suggestion.props.items[0]);
+ return true;
+ },
+ onExit: () => { suggestion.props = null; },
+ }),
+ }),
+ ],
+ content,
+ });
+ editor.commands.setTextSelection(endOfText(editor));
+ return { editor, suggestion };
+}
+
+/** Position after the document's last text (or inside an empty first block). */
+function endOfText(editor: Editor): number {
+ let end = 1;
+ editor.state.doc.descendants((node, pos) => {
+ if (node.isText) end = pos + node.nodeSize;
+ });
+ return end;
+}
+
+const labels = (suggestion: { props: SuggestionProps | null }) =>
+ suggestion.props?.items.map((c) => c.label) ?? null;
+
+describe('RecipientMention trigger', () => {
+ it('offers the recipients after an @ that starts a word', async () => {
+ const { editor, suggestion } = setup('Hallo
');
+ await type(editor, ' @');
+ await settle();
+ expect(labels(suggestion)).toEqual(['Max', 'Eva']);
+ await type(editor, 'ev');
+ await settle();
+ expect(labels(suggestion)).toEqual(['Eva']);
+ editor.destroy();
+ });
+
+ it('also triggers after a non-breaking space', async () => {
+ const { editor, suggestion } = setup('Hallo
');
+ await type(editor, '@');
+ await settle();
+ expect(labels(suggestion)).toEqual(['Max', 'Eva']);
+ editor.destroy();
+ });
+
+ it('never triggers inside a word, so typing an address stays untouched', async () => {
+ const { editor, suggestion } = setup('info
');
+ await type(editor, '@');
+ await settle();
+ expect(suggestion.props).toBeNull();
+ editor.destroy();
+ });
+
+ it('stays inactive while nobody matches, leaving Enter and Escape to the editor', async () => {
+ const { editor, suggestion } = setup('Hallo
');
+ await type(editor, ' @xy');
+ await settle();
+ expect(suggestion.props).toBeNull();
+ expect(press(editor, 'Escape').defaultPrevented).toBe(false);
+ editor.destroy();
+ });
+
+ it('is off without recipients (e.g. the vacation reply editor)', async () => {
+ const { editor, suggestion } = setup('Hallo
', []);
+ await type(editor, ' @');
+ await settle();
+ expect(suggestion.props).toBeNull();
+ editor.destroy();
+ });
+
+ it('is off in code blocks and inline code', async () => {
+ // The cursor lands right after the @ inside the code.
+ for (const content of ['x @ ', 'x @
']) {
+ const { editor, suggestion } = setup(content);
+ await settle();
+ expect(suggestion.props).toBeNull();
+ editor.destroy();
+ }
+ // The same text outside code does offer the list.
+ const { editor, suggestion } = setup('x @
');
+ await settle();
+ expect(labels(suggestion)).toEqual(['Max', 'Eva']);
+ editor.destroy();
+ });
+});
+
+describe('RecipientMention insertion', () => {
+ it('replaces the typed @query with the first name and a space', async () => {
+ const { editor, suggestion } = setup('Hallo
');
+ await type(editor, ' @ma');
+ await settle();
+ const enter = press(editor, 'Enter');
+ expect(enter.defaultPrevented).toBe(true);
+ expect(editor.getHTML()).toBe('Hallo @Max
');
+ expect(suggestion.props).toBeNull();
+ // The cursor sits after the space, ready to go on typing.
+ await type(editor, 'bitte');
+ expect(editor.getHTML()).toBe('Hallo @Max bitte
');
+ editor.destroy();
+ });
+
+ it('reuses a space that already follows the cursor', async () => {
+ const { editor, suggestion } = setup('Hallo @ma welt
');
+ act(() => { editor.commands.setTextSelection(1 + 'Hallo @ma'.length); });
+ await settle();
+ expect(labels(suggestion)).toEqual(['Max']);
+ press(editor, 'Enter');
+ expect(editor.getHTML()).toBe('Hallo @Max welt
');
+ editor.destroy();
+ });
+
+ it('inserts the label as text, never as markup', () => {
+ const { editor } = setup('
');
+ insertMention(editor, { from: 1, to: 1 }, ' ');
+ expect(editor.getHTML()).toBe('@<img src=x onerror=alert(1)>
');
+ editor.destroy();
+ });
+
+ it('takes Enter from a list item and Tab from a table cell while the list is open', async () => {
+ const list = setup('');
+ await type(list.editor, ' @ma');
+ await settle();
+ press(list.editor, 'Enter');
+ // Still one item: the Enter did not split it. (StarterKit's trailing
+ // node adds an empty paragraph after the list.)
+ expect(list.editor.getHTML()).toContain('');
+ list.editor.destroy();
+
+ const table = setup(
+ '',
+ PEOPLE,
+ [StarterKit, Table, TableRow, TableHeader, TableCell],
+ );
+ // After the "x" in the first cell: table, row, cell and paragraph open first.
+ act(() => { table.editor.commands.setTextSelection(4 + 'x'.length); });
+ await type(table.editor, ' @ev');
+ await settle();
+ expect(labels(table.suggestion)).toEqual(['Eva']);
+ press(table.editor, 'Tab');
+ expect(table.editor.getText()).toContain('x @Eva ');
+ expect(table.editor.state.selection.$from.parent.textContent).toBe('x @Eva ');
+ table.editor.destroy();
+ });
+
+ it('closes on Escape without letting the key through, and stays closed while typing on', async () => {
+ const { editor, suggestion } = setup('Hallo
');
+ await type(editor, ' @ma');
+ await settle();
+ expect(press(editor, 'Escape').defaultPrevented).toBe(true);
+ expect(suggestion.props).toBeNull();
+ await type(editor, 'x');
+ await settle();
+ expect(suggestion.props).toBeNull();
+ expect(editor.getHTML()).toBe('Hallo @max
');
+ editor.destroy();
+ });
+});
+
+describe('RichTextEditor recipient list', () => {
+ // jsdom has no layout. After an insertion the editor focuses a frame later
+ // and ProseMirror measures a Range to scroll the cursor into view.
+ const range = Range.prototype as unknown as Record;
+ beforeAll(() => {
+ range.getClientRects = () => [];
+ range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
+ });
+ afterAll(() => {
+ delete range.getClientRects;
+ delete range.getBoundingClientRect;
+ });
+
+ async function renderEditor(onChange = vi.fn()) {
+ let editor: Editor | null = null;
+ render(
+ { editor = ed; }}
+ />
+ );
+ await waitFor(() => expect(editor).not.toBeNull());
+ const ed = editor as unknown as Editor;
+ act(() => { ed.commands.setTextSelection(1); });
+ return { editor: ed, onChange };
+ }
+
+ it('lists the recipients, moves with the arrows and inserts on Enter', async () => {
+ const { editor, onChange } = await renderEditor();
+ await type(editor, '@');
+ await settle();
+ const list = await screen.findByRole('listbox', { name: 'mention_recipients' });
+ const options = within(list).getAllByRole('option');
+ expect(options.map((o) => o.textContent)).toEqual([
+ 'Max Mustermannmax.mustermann@dornig.de',
+ 'Evaeva.weber@dornig.de',
+ ]);
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+
+ press(editor, 'ArrowDown');
+ expect(within(list).getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true');
+
+ press(editor, 'Enter');
+ await nextFrame();
+ expect(editor.getText()).toBe('@Eva ');
+ expect(onChange.mock.lastCall?.[0]).toContain('>@Eva
');
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
+ });
+
+ it('does not pick a recipient on Ctrl+Enter, the composer\'s send shortcut', async () => {
+ const { editor } = await renderEditor();
+ await type(editor, '@');
+ await settle();
+ await screen.findByRole('listbox');
+ // Standing alone, the editor's own keymap makes it a line break.
+ act(() => { editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true })); });
+ expect(editor.getText()).not.toMatch(/@(Max|Eva)/);
+ });
+
+ it('inserts the clicked recipient', async () => {
+ const { editor } = await renderEditor();
+ await type(editor, '@');
+ await settle();
+ const list = await screen.findByRole('listbox');
+ fireEvent.mouseDown(within(list).getAllByRole('option')[0]);
+ await nextFrame();
+ expect(editor.getText()).toBe('@Max ');
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
+ });
+
+ it('narrows the list as the name is typed', async () => {
+ const { editor } = await renderEditor();
+ await type(editor, '@');
+ await settle();
+ await screen.findByRole('listbox');
+ await type(editor, 'e');
+ await settle();
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1));
+ expect(screen.getByRole('option')).toHaveTextContent('Eva');
+ });
+});
diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx
index cdabf000f..4b53e1608 100644
--- a/components/email/__tests__/reply-addressing.test.tsx
+++ b/components/email/__tests__/reply-addressing.test.tsx
@@ -8,8 +8,13 @@ import { useAccountStore } from '@/stores/account-store';
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
+const editorProps = vi.hoisted(() => ({ mentionCandidates: [] as Array<{ label: string; email: string }> }));
+
vi.mock('@/components/email/rich-text-editor', () => ({
- RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+ RichTextEditor: (props: { mentionCandidates?: Array<{ label: string; email: string }> }) => {
+ editorProps.mentionCandidates = props.mentionCandidates ?? [];
+ return React.createElement('div', { 'data-testid': 'rich-text-editor' });
+ },
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
@@ -457,3 +462,34 @@ describe('composer reply addressing', () => {
});
});
});
+
+describe('composer @-mention candidates', () => {
+ const offered = () => editorProps.mentionCandidates.map((c) => `${c.label} <${c.email}>`);
+
+ it('offers everyone a reply-all addresses, by first name', () => {
+ render( );
+ expect(offered()).toEqual(['Bob ', 'Carol ', 'Dave ']);
+ });
+
+ // Naming a blind-copied recipient in the text would disclose them.
+ it('never offers Bcc recipients', () => {
+ render(
+ ',
+ cc: 'eva.weber@example.com',
+ bcc: 'Geheim ',
+ subject: '',
+ body: '',
+ showCc: true,
+ showBcc: true,
+ selectedIdentityId: null,
+ subAddressTag: '',
+ mode: 'compose',
+ draftId: null,
+ }}
+ />
+ );
+ expect(offered()).toEqual(['Max ', 'Eva ']);
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index e38eca129..5ce9068ba 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -65,7 +65,9 @@ import {
ICON_MAP,
} from "@/lib/email-composer-utils";
import { isValidEmail } from "@/lib/validation";
+import { buildMentionCandidates } from "@/lib/recipient-mentions";
import { RichTextEditor } from "@/components/email/rich-text-editor";
+import { isRecipientMentionActive } from "@/components/email/recipient-mention";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
@@ -1170,6 +1172,10 @@ export function EmailComposer({
const ccStr = formatRecipientList(withInput(cc, ccInput));
const bccStr = formatRecipientList(withInput(bcc, bccInput));
+ // Who an "@" in the body offers. Never Bcc: naming a blind-copied
+ // recipient in the text would disclose them to everyone else.
+ const mentionCandidates = useMemo(() => buildMentionCandidates(to, cc), [to, cc]);
+
// Uploaded/hydrated attachments in ComposerDraftData shape, so a state
// snapshot (pro tab move, unmount save) carries them across a remount
// instead of silently dropping them (#849). In-flight/failed uploads have
@@ -2741,6 +2747,9 @@ export function EmailComposer({
if (isPlainEscape) {
if (activeAutoField) return;
+ // Same for the "@" recipient list in the body: this handler runs in the
+ // capture phase, before the editor could close the list itself.
+ if (isRecipientMentionActive(editorRef.current)) return;
e.preventDefault();
handleClose();
return;
@@ -3199,6 +3208,7 @@ export function EmailComposer({
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
onEditorReady={(ed) => { editorRef.current = ed; }}
+ mentionCandidates={mentionCandidates}
/>
)}
diff --git a/components/email/recipient-mention.tsx b/components/email/recipient-mention.tsx
new file mode 100644
index 000000000..d12f8ddfa
--- /dev/null
+++ b/components/email/recipient-mention.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import React, { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
+import { Extension, type Editor, type Range } from "@tiptap/core";
+import { PluginKey, TextSelection } from "@tiptap/pm/state";
+import { Suggestion, exitSuggestion, type SuggestionMount, type SuggestionOptions } from "@tiptap/suggestion";
+import { useTranslations } from "next-intl";
+import { Avatar } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+import { filterMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
+
+export const recipientMentionPluginKey = new PluginKey<{ active: boolean }>("recipientMention");
+
+/**
+ * Whether the "@" list is open in this editor. It owns Escape then, and the
+ * composer, which handles Escape in the capture phase before the editor
+ * sees it, has to let that one through.
+ */
+export function isRecipientMentionActive(editor: Editor | null | undefined): boolean {
+ return !!editor && !editor.isDestroyed && !!recipientMentionPluginKey.getState(editor.state)?.active;
+}
+
+/**
+ * Replaces the typed "@que" with "@Label " as plain text. Never as HTML: the
+ * label comes from recipient display names, which a reply-all takes from the
+ * incoming message.
+ */
+export function insertMention(editor: Editor, range: Range, label: string) {
+ const text = `@${label}`;
+ const after = editor.state.doc.resolve(range.to).nodeAfter;
+ const spaceFollows = !!after?.isText && /^\s/.test(after.text ?? "");
+ editor
+ .chain()
+ .focus()
+ .command(({ tr }) => {
+ tr.insertText(spaceFollows ? text : `${text} `, range.from, range.to);
+ tr.setSelection(TextSelection.create(tr.doc, range.from + text.length + 1));
+ tr.scrollIntoView();
+ return true;
+ })
+ .run();
+}
+
+export interface RecipientMentionOptions {
+ /** The recipients an @ can address right now; read on every keystroke. */
+ getCandidates: () => MentionCandidate[];
+ render?: SuggestionOptions["render"];
+}
+
+/**
+ * "@" in the body offers the message's recipients and inserts the chosen
+ * one's first name as plain text. The suggestion is only active while a
+ * recipient matches what was typed, so an @ nobody matches - or one typed
+ * without recipients - leaves Enter and Escape to the editor and composer.
+ */
+export const RecipientMention = Extension.create({
+ name: "recipientMention",
+ // While the list is open its Enter, Tab and arrows go ahead of the list,
+ // table and core keymaps, which run at the default priority (100).
+ priority: 101,
+
+ addOptions() {
+ return {
+ getCandidates: () => [],
+ render: undefined,
+ };
+ },
+
+ // Focus leaving the editor (Shift+Tab, another field) closes the list. Left
+ // open, it would float over that field and keep Escape from the composer.
+ onBlur() {
+ if (isRecipientMentionActive(this.editor)) exitSuggestion(this.editor.view, recipientMentionPluginKey);
+ },
+
+ addProseMirrorPlugins() {
+ const { getCandidates, render } = this.options;
+ return [
+ Suggestion({
+ editor: this.editor,
+ pluginKey: recipientMentionPluginKey,
+ char: "@",
+ // Only at the start of a word, so info@example.com never triggers.
+ allowedPrefixes: [" ", "\u00a0"],
+ decorationClass: "recipient-mention-query",
+ floatingUi: { strategy: "fixed" },
+ allow: ({ state, range }) => {
+ const $from = state.doc.resolve(range.from);
+ if ($from.parent.type.spec.code) return false;
+ const code = state.schema.marks.code;
+ if (code && state.doc.rangeHasMark(range.from, range.to, code)) return false;
+ const query = state.doc.textBetween(range.from + 1, range.to);
+ return filterMentionCandidates(getCandidates(), query).length > 0;
+ },
+ items: ({ query }) => filterMentionCandidates(getCandidates(), query),
+ command: ({ editor, range, props }) => insertMention(editor, range, props.label),
+ render,
+ }),
+ ];
+ },
+});
+
+export interface RecipientMentionListHandle {
+ onKeyDown: (event: KeyboardEvent) => boolean;
+}
+
+interface RecipientMentionListProps {
+ items: MentionCandidate[];
+ command: (item: MentionCandidate) => void;
+ mount: SuggestionMount;
+}
+
+export const RecipientMentionList = forwardRef(
+ function RecipientMentionList({ items, command, mount }, ref) {
+ const t = useTranslations("email_composer");
+ // Every keystroke brings new items; the highlight starts over with them.
+ const [selection, setSelection] = useState({ items, index: 0 });
+ const selected = selection.items === items ? selection.index : 0;
+ const [position, setPosition] = useState<{ x: number; y: number; strategy: "absolute" | "fixed" } | null>(null);
+ const listRef = useRef(null);
+
+ // Anchored under the typed "@..." by the suggestion's own positioning,
+ // which follows scrolling and resizing and dismisses on outside clicks.
+ useLayoutEffect(() => {
+ const element = listRef.current;
+ if (!element) return;
+ return mount(element, {
+ onPosition: ({ x, y, strategy }) => {
+ // Keep the list on screen in narrow panes.
+ const maxX = window.innerWidth - element.offsetWidth - 8;
+ setPosition({ x: Math.max(8, Math.min(x, maxX)), y, strategy });
+ },
+ });
+ }, [mount]);
+
+ useEffect(() => {
+ listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest" });
+ }, [selected]);
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: (event) => {
+ if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey || items.length === 0) return false;
+ if (event.key === "ArrowDown") {
+ setSelection({ items, index: (selected + 1) % items.length });
+ return true;
+ }
+ if (event.key === "ArrowUp") {
+ setSelection({ items, index: (selected + items.length - 1) % items.length });
+ return true;
+ }
+ if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey) {
+ command(items[selected]);
+ return true;
+ }
+ return false;
+ },
+ }), [items, selected, command]);
+
+ return (
+ e.preventDefault()}
+ style={{
+ position: position?.strategy ?? "fixed",
+ left: position?.x ?? 0,
+ top: position?.y ?? 0,
+ visibility: position ? undefined : "hidden",
+ }}
+ >
+ {items.map((item, i) => (
+
{
+ // Keep the editor focused and the typed "@..." in place.
+ e.preventDefault();
+ command(item);
+ }}
+ >
+
+ {item.name || item.label}
+ {item.email}
+
+ ))}
+
+ );
+ }
+);
diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx
index 5189cec97..e22caf8cc 100644
--- a/components/email/rich-text-editor.tsx
+++ b/components/email/rich-text-editor.tsx
@@ -1,7 +1,9 @@
"use client";
import React, { useEffect, useCallback, useState, useRef } from "react";
+import { createPortal } from "react-dom";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
+import type { SuggestionProps } from "@tiptap/suggestion";
import StarterKit from "@tiptap/starter-kit";
import Paragraph from "@tiptap/extension-paragraph";
import Heading from "@tiptap/extension-heading";
@@ -22,6 +24,8 @@ import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
+import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention";
+import type { MentionCandidate } from "@/lib/recipient-mentions";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useTranslations } from "next-intl";
@@ -83,6 +87,8 @@ interface RichTextEditorProps {
className?: string;
hasError?: boolean;
onEditorReady?: (editor: Editor) => void;
+ /** Recipients an "@" in the text offers to mention; none turns it off. */
+ mentionCandidates?: MentionCandidate[];
}
function ToolbarButton({
@@ -173,6 +179,7 @@ export function RichTextEditor({
className,
hasError,
onEditorReady,
+ mentionCandidates,
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const tComposer = useTranslations("email_composer");
@@ -180,6 +187,10 @@ export function RichTextEditor({
onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
onEditorReadyRef.current = onEditorReady;
+ const mentionCandidatesRef = React.useRef([]);
+ mentionCandidatesRef.current = mentionCandidates ?? [];
+ const [mention, setMention] = useState | null>(null);
+ const mentionListRef = useRef(null);
const editor = useEditor({
extensions: [
@@ -238,6 +249,22 @@ export function RichTextEditor({
SignatureBlock.configure({ editHint: tComposer('signature_edit_hint') }),
TextDirection,
PlainTextPaste,
+ // "@" offers the recipients and inserts a first name (see
+ // recipient-mention.tsx). The editor is built once, so the candidates
+ // and the list are reached through refs and a stable state setter.
+ RecipientMention.configure({
+ getCandidates: () => mentionCandidatesRef.current,
+ render: () => ({
+ onStart: (props) => setMention(props),
+ // Every keystroke first reports a pending lookup with no items;
+ // keeping the last list until the new one is in avoids a flicker.
+ onUpdate: (props) => {
+ if (!props.loading) setMention(props);
+ },
+ onKeyDown: ({ event }) => mentionListRef.current?.onKeyDown(event) ?? false,
+ onExit: () => setMention(null),
+ }),
+ }),
],
// One newline per paragraph when copying out as plain text, the inverse
// of PlainTextPaste: a blank line is an empty paragraph, so the default
@@ -797,6 +824,16 @@ export function RichTextEditor({
{/* Editor */}
+
+ {mention && mention.items.length > 0 && createPortal(
+ ,
+ document.body
+ )}
);
}
diff --git a/lib/__tests__/recipient-mentions.test.ts b/lib/__tests__/recipient-mentions.test.ts
new file mode 100644
index 000000000..d8177ed7c
--- /dev/null
+++ b/lib/__tests__/recipient-mentions.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildMentionCandidates, filterMentionCandidates, mentionLabel } from '../recipient-mentions';
+
+describe('mentionLabel', () => {
+ it('takes the first name from a display name', () => {
+ expect(mentionLabel('Max Mustermann', 'mm@dornig.de')).toBe('Max');
+ expect(mentionLabel('Mustermann, Max', 'mm@dornig.de')).toBe('Max');
+ expect(mentionLabel('Dr. Max Mustermann', 'mm@dornig.de')).toBe('Max');
+ expect(mentionLabel('Prof. Dr.-Ing. Max Mustermann', 'mm@dornig.de')).toBe('Max');
+ expect(mentionLabel('Jürgen Müller', 'jm@dornig.de')).toBe('Jürgen');
+ expect(mentionLabel("'Max Mustermann'", 'mm@dornig.de')).toBe('Max');
+ });
+
+ it('takes the first name from a firstname.lastname address when no name is known', () => {
+ expect(mentionLabel(undefined, 'max.mustermann@dornig.de')).toBe('Max');
+ expect(mentionLabel('', 'anna-lena.schmidt@dornig.de')).toBe('Anna-Lena');
+ expect(mentionLabel(undefined, 'max.mustermann+rechnungen@dornig.de')).toBe('Max');
+ expect(mentionLabel(undefined, 'juergen.mueller@dornig.de')).toBe('Juergen');
+ expect(mentionLabel(undefined, 'info@dornig.de')).toBe('Info');
+ });
+
+ it('lets a firstname.lastname address tell which word of the name is the first name', () => {
+ expect(mentionLabel('Mustermann Max', 'max.mustermann@dornig.de')).toBe('Max');
+ expect(mentionLabel('Müller Jürgen', 'juergen.mueller@dornig.de')).toBe('Jürgen');
+ expect(mentionLabel('Dornig GmbH - Max Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
+ // A one-part mailbox name confirms nothing: it may be the last name.
+ expect(mentionLabel('Max Mustermann', 'mustermann@dornig.de')).toBe('Max');
+ });
+
+ it('treats a display name that is an address or mailbox name like the address', () => {
+ expect(mentionLabel('max.mustermann@dornig.de', 'max.mustermann@dornig.de')).toBe('Max');
+ expect(mentionLabel('max.mustermann', 'mm@dornig.de')).toBe('Max');
+ });
+
+ it('keeps the whole mailbox name when the first name is a bare initial', () => {
+ expect(mentionLabel(undefined, 'm.mustermann@dornig.de')).toBe('m.mustermann');
+ expect(mentionLabel(undefined, 'm.mustermann+news@dornig.de')).toBe('m.mustermann');
+ expect(mentionLabel('M. Mustermann', 'm.mustermann@dornig.de')).toBe('m.mustermann');
+ // The address may know more than the display name.
+ expect(mentionLabel('M. Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
+ });
+
+ it('stays fast on a crafted display name from an incoming message', () => {
+ // Uncapped, trimming this token takes seconds (quadratic backtracking).
+ const crafted = `a${'!'.repeat(100_000)}b`;
+ const started = performance.now();
+ expect(mentionLabel(crafted, 'max.mustermann@dornig.de')).toBe('Max');
+ expect(performance.now() - started).toBeLessThan(1000);
+ });
+});
+
+describe('buildMentionCandidates', () => {
+ it('offers To and Cc recipients in order, each address once', () => {
+ const candidates = buildMentionCandidates(
+ [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'anna.schmidt@dornig.de' }],
+ [{ email: 'MAX.MUSTERMANN@dornig.de' }, { name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
+ );
+ expect(candidates).toEqual([
+ { label: 'Max', name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
+ { label: 'Anna', name: undefined, email: 'anna.schmidt@dornig.de' },
+ { label: 'Eva', name: 'Eva Weber', email: 'eva.weber@dornig.de' },
+ ]);
+ });
+
+ it('expands contact groups into their members and skips entries without an address', () => {
+ const candidates = buildMentionCandidates(
+ [{
+ name: 'Team',
+ email: '',
+ group: { members: [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }] },
+ }],
+ [{ email: 'not-an-address' }],
+ );
+ expect(candidates.map((c) => c.label)).toEqual(['Max', 'Eva']);
+ });
+
+ it('tells apart recipients who would share a first name', () => {
+ const labels = (to: Array<{ name?: string; email: string }>) => buildMentionCandidates(to, []).map((c) => c.label);
+ expect(labels([
+ { name: 'Max Mustermann', email: 'a@dornig.de' },
+ { name: 'Mueller, Max', email: 'b@dornig.de' },
+ { name: 'Eva Weber', email: 'c@dornig.de' },
+ ])).toEqual(['Max Mustermann', 'Max Mueller', 'Eva']);
+ expect(labels([
+ { name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
+ { email: 'max.mueller@dornig.de' },
+ ])).toEqual(['Max Mustermann', 'max.mueller']);
+ expect(labels([
+ { name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
+ { name: 'Max Mustermann', email: 'max.mustermann2@dornig.de' },
+ ])).toEqual(['max.mustermann', 'max.mustermann2']);
+ });
+});
+
+describe('filterMentionCandidates', () => {
+ const candidates = buildMentionCandidates(
+ [
+ { name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
+ { name: 'Jürgen Müller', email: 'juergen.mueller@dornig.de' },
+ { email: 'anna-lena.schmidt@dornig.de' },
+ ],
+ [],
+ );
+ const labels = (query: string) => filterMentionCandidates(candidates, query).map((c) => c.label);
+
+ it('offers everyone for a bare @', () => {
+ expect(labels('')).toEqual(['Max', 'Jürgen', 'Anna-Lena']);
+ });
+
+ it('matches the start of the label, a name word or a mailbox part, ignoring case', () => {
+ expect(labels('ma')).toEqual(['Max']);
+ expect(labels('MUSTER')).toEqual(['Max']);
+ expect(labels('lena')).toEqual(['Anna-Lena']);
+ expect(labels('schm')).toEqual(['Anna-Lena']);
+ expect(labels('xyz')).toEqual([]);
+ });
+
+ it('finds umlaut names typed either way', () => {
+ expect(labels('jü')).toEqual(['Jürgen']);
+ expect(labels('jue')).toEqual(['Jürgen']);
+ expect(labels('mül')).toEqual(['Jürgen']);
+ });
+});
diff --git a/lib/recipient-mentions.ts b/lib/recipient-mentions.ts
new file mode 100644
index 000000000..bf2e87174
--- /dev/null
+++ b/lib/recipient-mentions.ts
@@ -0,0 +1,159 @@
+import { expandRecipients, type Recipient } from '@/lib/email-composer-utils';
+
+/** A recipient an @ in the message body can address. */
+export interface MentionCandidate {
+ /** What goes after the @ in the body. */
+ label: string;
+ name?: string;
+ email: string;
+}
+
+function letterCount(value: string): number {
+ return value.match(/\p{L}/gu)?.length ?? 0;
+}
+
+/** The mailbox part of an address, without a +subaddress tag. */
+function localPartOf(email: string): string {
+ const local = email.slice(0, email.lastIndexOf('@'));
+ return local.split('+')[0] || local;
+}
+
+/** "Mustermann, Max" -> "Max Mustermann"; any other name as is. */
+function naturalOrder(name: string): string {
+ const parts = name.split(',');
+ if (parts.length !== 2 || !parts[1].trim()) return name;
+ return `${parts[1].trim()} ${parts[0].trim()}`;
+}
+
+/**
+ * "max.mustermann" -> "Max", "anna-lena.schmidt" -> "Anna-Lena". Null when
+ * the first part is a bare initial ("m.mustermann") - no name to address
+ * anyone by.
+ */
+function givenNameFromLocalPart(localPart: string): string | null {
+ const first = localPart.split(/[._]/)[0];
+ if (letterCount(first) <= 1) return null;
+ return first.replace(/(^|-)(\p{Ll})/gu, (_, sep: string, letter: string) => sep + letter.toUpperCase());
+}
+
+/** The words of a display name, without the punctuation around them. */
+function nameWords(name: string): string[] {
+ return naturalOrder(name)
+ .split(/\s+/)
+ .map((raw) => raw.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}.]+$/u, ''))
+ .filter(Boolean);
+}
+
+/**
+ * The given name in a display name: "Max Mustermann", "Mustermann, Max" and
+ * "Dr. Max Mustermann" all give "Max". Null when the name opens with a bare
+ * initial ("M. Mustermann").
+ */
+function givenNameFromDisplayName(name: string): string | null {
+ // Some systems put the mailbox name in the display name.
+ if (!/\s/.test(name) && /\p{L}[._]\p{L}/u.test(name)) return givenNameFromLocalPart(name);
+ for (const word of nameWords(name)) {
+ if (letterCount(word) <= 1) return null;
+ // Titles and abbreviations: "Dr.", "Prof.", "Dipl.-Ing."
+ if (word.endsWith('.')) continue;
+ return word;
+ }
+ return null;
+}
+
+const UMLAUT_SPELLINGS: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
+
+/** Whether two names match as an address spells them: "Jürgen" and "juergen". */
+function spelledAlike(a: string, b: string): boolean {
+ const key = (s: string) => fold(s.normalize('NFC').toLowerCase().replace(/[äöüß]/g, (c) => UMLAUT_SPELLINGS[c]));
+ return key(a) === key(b);
+}
+
+/**
+ * A display name worth deriving a label from: not empty, not an address.
+ * Capped because a reply-all takes it from the incoming message and the
+ * trimming in nameWords() is quadratic on a crafted run of punctuation; no
+ * real name comes near the cap.
+ */
+function usableName(name: string | undefined): string | undefined {
+ const trimmed = name?.trim().slice(0, 200).replace(/^(['"])(.*)\1$/, '$2').trim();
+ return trimmed && !trimmed.includes('@') ? trimmed : undefined;
+}
+
+/**
+ * First name to put after the @. A firstname.lastname@ address tells which
+ * word of the display name is the first name, whatever order the name is
+ * written in ("Mustermann Max", "Firma - Max Mustermann"); otherwise it is
+ * the display name's first word, or the address's first part when no name
+ * is known. A bare initial ("m.mustermann@") keeps the whole mailbox name.
+ */
+export function mentionLabel(name: string | undefined, email: string): string {
+ const localPart = localPartOf(email);
+ const fromAddress = givenNameFromLocalPart(localPart);
+ const usable = usableName(name);
+ if (usable) {
+ // Only a two-part mailbox name says which part is the first name.
+ const confirmed = fromAddress && /[._]/.test(localPart)
+ ? nameWords(usable).find((word) => spelledAlike(word, fromAddress))
+ : undefined;
+ const given = confirmed || givenNameFromDisplayName(usable);
+ if (given) return given;
+ }
+ return fromAddress ?? localPart;
+}
+
+function clashingLabels(candidates: MentionCandidate[]): Set {
+ const seen = new Set();
+ const clashes = new Set();
+ for (const { label } of candidates) {
+ const key = label.toLowerCase();
+ if (seen.has(key)) clashes.add(key);
+ seen.add(key);
+ }
+ return clashes;
+}
+
+/**
+ * The recipients an @ in the message body can address: To and Cc, contact
+ * groups expanded into their members, each address once. Bcc is left out on
+ * purpose - naming a blind-copied recipient in the body would disclose them
+ * to everyone else on the message.
+ */
+export function buildMentionCandidates(to: Recipient[], cc: Recipient[]): MentionCandidate[] {
+ const candidates: MentionCandidate[] = expandRecipients([...to, ...cc])
+ .filter((r) => r.email.lastIndexOf('@') > 0)
+ .map((r) => {
+ const name = usableName(r.name);
+ return { label: mentionLabel(name, r.email), name, email: r.email };
+ });
+ // Two recipients who would both be "@Max" are told apart by full name, or
+ // by mailbox name where no name is known - and by mailbox name when even
+ // the full names match.
+ const clashes = clashingLabels(candidates);
+ for (const c of candidates) {
+ if (clashes.has(c.label.toLowerCase())) c.label = c.name ? naturalOrder(c.name) : localPartOf(c.email);
+ }
+ const stillClashing = clashingLabels(candidates);
+ for (const c of candidates) {
+ if (stillClashing.has(c.label.toLowerCase())) c.label = localPartOf(c.email);
+ }
+ return candidates;
+}
+
+function fold(value: string): string {
+ return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
+}
+
+/**
+ * Candidates whose label, a word of their name, their address or a part of
+ * their mailbox name starts with what was typed after the @ - case and
+ * accents ignored, so "jue" finds juergen.mueller@ and "jü" finds Jürgen.
+ */
+export function filterMentionCandidates(candidates: MentionCandidate[], query: string): MentionCandidate[] {
+ const typed = fold(query);
+ if (!typed) return candidates;
+ return candidates.filter((c) =>
+ [c.label, c.email, ...(c.name?.split(/\s+/) ?? []), ...localPartOf(c.email).split(/[._-]/)]
+ .some((term) => fold(term).startsWith(typed)),
+ );
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index b5942aa1c..3378176fe 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "الاسم المعروض",
"autocomplete_search_server": "البحث في الخادم",
"autocomplete_searching": "جارٍ البحث...",
+ "mention_recipients": "المستلمون",
"toolbar": {
"bold": "غامق",
"italic": "مائل",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 7980d9384..99b183e5c 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Nom mostrat",
"autocomplete_search_server": "Cerca al servidor",
"autocomplete_searching": "Cercant...",
+ "mention_recipients": "Destinataris",
"toolbar": {
"bold": "Negreta",
"italic": "Cursiva",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index e75d45a38..20b17bf7c 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Zobrazované jméno",
"autocomplete_search_server": "Hledat na serveru",
"autocomplete_searching": "Hledání...",
+ "mention_recipients": "Příjemci",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
diff --git a/locales/da/common.json b/locales/da/common.json
index 74645b094..e324c495c 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Visningsnavn",
"autocomplete_search_server": "Søg på serveren",
"autocomplete_searching": "Søger...",
+ "mention_recipients": "Modtagere",
"toolbar": {
"bold": "Fed",
"italic": "Kursiv",
diff --git a/locales/de/common.json b/locales/de/common.json
index 2555d7790..ea0f9e06f 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Anzeigename",
"autocomplete_search_server": "Auf dem Server suchen",
"autocomplete_searching": "Suche läuft...",
+ "mention_recipients": "Empfänger",
"toolbar": {
"bold": "Fett",
"italic": "Kursiv",
diff --git a/locales/en/common.json b/locales/en/common.json
index ad2c3f45e..bd6f2d523 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Display name",
"autocomplete_search_server": "Search the server",
"autocomplete_searching": "Searching...",
+ "mention_recipients": "Recipients",
"toolbar": {
"bold": "Bold",
"italic": "Italic",
diff --git a/locales/es/common.json b/locales/es/common.json
index 21a79d162..e5beed216 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Nombre para mostrar",
"autocomplete_search_server": "Buscar en el servidor",
"autocomplete_searching": "Buscando...",
+ "mention_recipients": "Destinatarios",
"toolbar": {
"bold": "Negrita",
"italic": "Cursiva",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 723dfdfd7..6e69abfae 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "نام نمایشی",
"autocomplete_search_server": "جستجو در سرور",
"autocomplete_searching": "در حال جستجو...",
+ "mention_recipients": "گیرندگان",
"toolbar": {
"bold": "پررنگ",
"italic": "کج",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 2d8e737f8..371c3ddd3 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Nom d'affichage",
"autocomplete_search_server": "Rechercher sur le serveur",
"autocomplete_searching": "Recherche en cours...",
+ "mention_recipients": "Destinataires",
"toolbar": {
"bold": "Gras",
"italic": "Italique",
diff --git a/locales/he/common.json b/locales/he/common.json
index 234e6b769..d6e384f08 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -690,6 +690,7 @@
"recipient_name_placeholder": "שם תצוגה",
"autocomplete_search_server": "חיפוש בשרת",
"autocomplete_searching": "מחפש...",
+ "mention_recipients": "נמענים",
"toolbar": {
"bold": "מודגש",
"italic": "נטוי",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 98ac6fcc5..6d810b8fd 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Megjelenített név",
"autocomplete_search_server": "Keresés a kiszolgálón",
"autocomplete_searching": "Keresés...",
+ "mention_recipients": "Címzettek",
"toolbar": {
"bold": "Félkövér",
"italic": "Dőlt",
diff --git a/locales/it/common.json b/locales/it/common.json
index 3f7671ebf..188823fad 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Nome visualizzato",
"autocomplete_search_server": "Cerca nel server",
"autocomplete_searching": "Ricerca in corso...",
+ "mention_recipients": "Destinatari",
"toolbar": {
"bold": "Grassetto",
"italic": "Corsivo",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 299da4724..14e10994d 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "表示名",
"autocomplete_search_server": "サーバーを検索",
"autocomplete_searching": "検索中...",
+ "mention_recipients": "宛先",
"toolbar": {
"bold": "太字",
"italic": "斜体",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 5e4f69096..bb08dc8b1 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "표시 이름",
"autocomplete_search_server": "서버에서 검색",
"autocomplete_searching": "검색 중...",
+ "mention_recipients": "수신자",
"toolbar": {
"bold": "굵게",
"italic": "기울임꼴",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 627ced780..61076ed1b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Parādāmais vārds",
"autocomplete_search_server": "Meklēt serverī",
"autocomplete_searching": "Meklē...",
+ "mention_recipients": "Adresāti",
"toolbar": {
"bold": "Treknraksts",
"italic": "Kursīvs",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index a494a2a8d..f1e0d92d8 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Дэлгэцийн нэр",
"autocomplete_search_server": "Серверээс хайх",
"autocomplete_searching": "Хайж байна...",
+ "mention_recipients": "Хүлээн авагчид",
"toolbar": {
"bold": "Болд",
"italic": "Налуу",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index acdc41371..88615ee59 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Visningsnavn",
"autocomplete_search_server": "Søk på serveren",
"autocomplete_searching": "Søker...",
+ "mention_recipients": "Mottakere",
"toolbar": {
"bold": "Fet skrift",
"italic": "Kursiv",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3470bc713..2d97f0316 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Weergavenaam",
"autocomplete_search_server": "Op de server zoeken",
"autocomplete_searching": "Bezig met zoeken...",
+ "mention_recipients": "Ontvangers",
"toolbar": {
"bold": "Vet",
"italic": "Cursief",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 6e087623d..512d60f0a 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Wyświetlana nazwa",
"autocomplete_search_server": "Szukaj na serwerze",
"autocomplete_searching": "Wyszukiwanie...",
+ "mention_recipients": "Odbiorcy",
"toolbar": {
"bold": "Pogrubienie",
"italic": "Kursywa",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 56ba1e162..bb7166a24 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Nome de exibição",
"autocomplete_search_server": "Pesquisar no servidor",
"autocomplete_searching": "Pesquisando...",
+ "mention_recipients": "Destinatários",
"toolbar": {
"bold": "Negrito",
"italic": "Itálico",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index f00cb6e54..0638da285 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Numele afișat",
"autocomplete_search_server": "Caută pe server",
"autocomplete_searching": "Se caută...",
+ "mention_recipients": "Destinatari",
"toolbar": {
"bold": "Aldin",
"italic": "Cursiv",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index f636e4b19..f1e9e78f2 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Отображаемое имя",
"autocomplete_search_server": "Искать на сервере",
"autocomplete_searching": "Поиск...",
+ "mention_recipients": "Получатели",
"toolbar": {
"bold": "Жирный",
"italic": "Курсив",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 63f90755e..87f8b2140 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Zobrazené meno",
"autocomplete_search_server": "Hľadať na serveri",
"autocomplete_searching": "Hľadanie...",
+ "mention_recipients": "Príjemcovia",
"toolbar": {
"bold": "Tučné",
"italic": "Kurzíva",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 4d35b4958..0ccc51ea5 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Görünen ad",
"autocomplete_search_server": "Sunucuda ara",
"autocomplete_searching": "Aranıyor...",
+ "mention_recipients": "Alıcılar",
"toolbar": {
"bold": "Kalın",
"italic": "İtalik",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 6434bf637..ca97e718c 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "Відображуване ім'я",
"autocomplete_search_server": "Шукати на сервері",
"autocomplete_searching": "Пошук...",
+ "mention_recipients": "Одержувачі",
"toolbar": {
"bold": "Жирний",
"italic": "Курсив",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index a207746ac..9d4ba4046 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "顯示名稱",
"autocomplete_search_server": "在伺服器上搜尋",
"autocomplete_searching": "正在搜尋…",
+ "mention_recipients": "收件者",
"toolbar": {
"bold": "粗體",
"italic": "斜體",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 6983d8e17..2754fb5de 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -725,6 +725,7 @@
"recipient_name_placeholder": "显示名称",
"autocomplete_search_server": "在服务器上搜索",
"autocomplete_searching": "搜索中...",
+ "mention_recipients": "收件人",
"toolbar": {
"bold": "加粗",
"italic": "斜体",
diff --git a/package-lock.json b/package-lock.json
index cb32cc7f7..05761189a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
+ "@tiptap/suggestion": "^3.31.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.12",
@@ -1329,7 +1330,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
- "optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
@@ -1339,7 +1339,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
- "optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
@@ -1349,8 +1348,7 @@
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.2",
@@ -4161,6 +4159,21 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
+ "node_modules/@tiptap/suggestion": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.31.3.tgz",
+ "integrity": "sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "3.31.3",
+ "@tiptap/pm": "3.31.3"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -6231,7 +6244,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
diff --git a/package.json b/package.json
index 13c92681a..9665146b1 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
+ "@tiptap/suggestion": "^3.31.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.12",