From 2b5110e0e462b787569c6ffede896c191ae6c421 Mon Sep 17 00:00:00 2001
From: dealerweb
Date: Wed, 30 Sep 2026 19:26:20 +0200
Subject: [PATCH] feat: mention a recipient by first name with @ in the message
body
Typing @ at the start of a word in the rich-text body offers the To and
Cc recipients (contact groups expanded) and inserts the chosen one's
first name as plain text. The name comes from the display name; with a
firstname.lastname@ address, the address tells which word of the name is
the first name, and without a name the address supplies it. A bare
initial keeps the mailbox name (@m.mustermann), and recipients who share
a first name get their full name. Bcc recipients are never offered -
naming one in the text would disclose them to everyone else.
The list only opens while a recipient matches, so Enter and Escape
otherwise keep their usual meaning. While it is open, Escape closes the
list rather than the composer, which handles Escape in the capture phase,
and the list closes when the editor loses focus.
Built on @tiptap/suggestion, at the version of the other TipTap packages.
---
.../email/__tests__/composer-mention.test.tsx | 327 ++++++++++++++++++
.../__tests__/recipient-mention.test.tsx | 294 ++++++++++++++++
.../email/__tests__/reply-addressing.test.tsx | 38 +-
components/email/email-composer.tsx | 10 +
components/email/recipient-mention.tsx | 198 +++++++++++
components/email/rich-text-editor.tsx | 37 ++
lib/__tests__/recipient-mentions.test.ts | 124 +++++++
lib/recipient-mentions.ts | 159 +++++++++
locales/ar/common.json | 1 +
locales/ca/common.json | 1 +
locales/cs/common.json | 1 +
locales/da/common.json | 1 +
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es/common.json | 1 +
locales/fa/common.json | 1 +
locales/fr/common.json | 1 +
locales/he/common.json | 1 +
locales/hu/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/ko/common.json | 1 +
locales/lv/common.json | 1 +
locales/mn/common.json | 1 +
locales/nb/common.json | 1 +
locales/nl/common.json | 1 +
locales/pl/common.json | 1 +
locales/pt/common.json | 1 +
locales/ro/common.json | 1 +
locales/ru/common.json | 1 +
locales/sk/common.json | 1 +
locales/tr/common.json | 1 +
locales/uk/common.json | 1 +
locales/zh-TW/common.json | 1 +
locales/zh/common.json | 1 +
package-lock.json | 22 +-
package.json | 1 +
37 files changed, 1231 insertions(+), 6 deletions(-)
create mode 100644 components/email/__tests__/composer-mention.test.tsx
create mode 100644 components/email/__tests__/recipient-mention.test.tsx
create mode 100644 components/email/recipient-mention.tsx
create mode 100644 lib/__tests__/recipient-mentions.test.ts
create mode 100644 lib/recipient-mentions.ts
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 ca7d7b11f..b2c096f6e 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";
@@ -1114,6 +1116,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
@@ -2658,6 +2664,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;
@@ -3116,6 +3125,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 950a1789a..b913249f5 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -718,6 +718,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 ecb402b91..fa421fb04 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -718,6 +718,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 3664d0b43..b7d166b50 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -718,6 +718,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 3caa14583..f72f6495c 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -718,6 +718,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 770435414..1b62d8337 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -718,6 +718,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 b4f9be364..8d90b5b4b 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -718,6 +718,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 0d945c826..e76fd79ce 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -718,6 +718,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 2d4f04da2..7843a3c2b 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -718,6 +718,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 524bb55f0..d9226833c 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -718,6 +718,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 5f30eca55..73e5448e0 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -683,6 +683,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 e57c534c7..9158b2756 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -718,6 +718,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 9f05e4ec5..6c5c4c85d 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -718,6 +718,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 0a865dd83..2c54df037 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -718,6 +718,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 35cac66e2..de50978f4 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -718,6 +718,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 fae6716d7..320eac22b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -718,6 +718,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 a40bc852e..9f8c66052 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -718,6 +718,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 257e9656e..8c00939b6 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -718,6 +718,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 bf526848c..27d71a459 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -718,6 +718,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 f1e882522..ecd4f2e15 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -718,6 +718,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 9eda58464..b66a4ff90 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -718,6 +718,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 b84e6383f..529e2f25e 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -718,6 +718,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 d28eb40d9..5796b0cbf 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -718,6 +718,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 685434051..12492ed5d 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -718,6 +718,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 af7411d2e..0e591f4b8 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -718,6 +718,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 e5bcc0854..a8e2c3e85 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -718,6 +718,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 6e6fcde07..50d9c8c10 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -718,6 +718,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 6e8be267a..97765b1b1 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -718,6 +718,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",