{t("oauth_completing")}
-{t("common.loading")}
-page
{authError}
{t('loading')}
page
'); }); // The shell's own names come from the translations, and the tab says @@ -59,7 +66,10 @@ describe('AdminShell', () => { it('hides the session links until the check says which kind of admin this is', () => { const { rerender } = render(shell({ authenticated: false, isStalwartAutoLogin: false })); - expect(screen.queryByText('Sign out of admin')).toBeNull(); + // Only their shapes hold the place while the check runs. + expect(screen.queryByRole('button', { name: 'Sign out of admin' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Change password' })).toBeNull(); + expect(screen.getByText('Sign out of admin').closest('[aria-hidden="true"]')).not.toBeNull(); rerender(shell({ authenticated: true, isStalwartAutoLogin: true })); expect(screen.queryByText('Sign out of admin')).toBeNull(); rerender(shell({ authenticated: true, isStalwartAutoLogin: false })); diff --git a/components/admin/admin-page-icons.ts b/components/admin/admin-page-icons.ts new file mode 100644 index 000000000..e8d6903e9 --- /dev/null +++ b/components/admin/admin-page-icons.ts @@ -0,0 +1,66 @@ +/** + * The admin sidebar's icons and group headings. A plain module (no + * "use client"), so the admin shell and its page skeleton, which the server + * renders, draw the same list. + */ +import { + Activity, + ArrowUpCircle, + Bell, + Building2, + CalendarDays, + HeartRateMonitor, + LayoutDashboard, + LayoutTemplate, + Palette, + PenLine, + Puzzle, + Scale, + ScrollText, + Server, + Share2, + Shield, + ShieldCheck, + Smartphone, + Store, + SwatchBook, + Tag, + SpellCheck, + type AppIcon, +} from '@/components/icons'; +import type { AdminPageDef, AdminPageId } from '@/lib/admin/admin-nav'; + +export const ADMIN_PAGE_ICONS: Record+ No proofreading plugin is installed. Install one, such as Proofreading (LanguageTool), under Marketplace or Plugins. +
+ )} + + {plugins?.map((p) => { + const r = draft.restriction(pluginPolicyKey(p.id)); + const state = pluginProofreadingState(r.value); + return ( ++ + + Proofreading is required, but no proofreading plugin is forced on, so users can turn the plugin off under + Settings > Add-ons and the check with it. Forcing a plugin on applies to everyone, not only this scope. + +
+{forceError}
} ++ For a fully mandatory setup: force the plugin on, set Proofreading to "On, required" and, in the plugin's + settings, turn off users changing its language and rules. A plugin that reaches a server on your network needs that + address in PLUGIN_PROXY_PRIVATE_ORIGINS. +
+Agenda:
Budget & plans
Bring notes
'; + +function htmlEvent(): CalendarEvent { + return { + id: 'evt-1', uid: 'u1', '@type': 'Event', calendarIds: { 'cal-1': true }, title: 'Review', + description: HTML, descriptionContentType: 'text/html', + start: '2026-11-20T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + participants: null, alerts: null, useDefaultAlerts: false, recurrenceRules: null, + } as unknown as CalendarEvent; +} + +const TEXT = 'Agenda:\nBudget & plans\n\nBring notes'; + +describe('HTML event descriptions', () => { + it('shows the text in the detail popover', () => { + render( +');
+ expect(text).not.toContain('&');
+ expect(text).toContain('Budget & plans');
+ });
+
+ it('shows the text in the editor, read and edit', () => {
+ const { container } = render(
+
');
+ expect(container.textContent).toContain('Budget & plans');
+ fireEvent.click(screen.getByText('events.edit'));
+ expect((screen.getByPlaceholderText('form.description') as HTMLTextAreaElement).value).toBe(TEXT);
+ });
+
+ it('prints the text', () => {
+ const { container } = render(
+
{event.description}
+{description}
-
-
Thanks
original', identity, { separator: true }); - expect(text).toBe('Thanks\n\noriginal'); + expect(text).toBe('Thanks\n\n> original'); expect(plainTextBodyHasSignature(text, identity)).toBe(false); }); + it('keeps the quoted original of a rich reply marked with "> "', () => { + const html = `
Thanks
On Monday Bob wrote:
${buildQuotedHtmlBlock('Line one
Line two
Para two
')}`; + const text = htmlComposeBodyToPlainText(html, identity, { separator: true }); + expect(text).toBe('Thanks\n\nOn Monday Bob wrote:\n\n> Line one\n> Line two\n>\n> Para two'); + // The attachment reminder and plugin edits only see the user's text. + expect(extractUserAuthoredText(text, { plainTextMode: true })).not.toContain('Line one'); + }); + + it('keeps list markers and image alt text', () => { + const html = '
Hi
${buildEmbeddedSignatureHtml(identity, { embed: true, separator: false })}`; expect(htmlComposeBodyToPlainText(html, identity, { separator: false })).toBe('Hi\n\nAlice\nAcme'); @@ -69,10 +85,27 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => { it('keeps a body without signature signature-free', () => { const html = plainComposeBodyToHtml('Thanks\n\n> original', identity, { separator: true }); - expect(html).toBe('Thanks
> original
'); + expect(html).toBe('Thanks
'); expect(containsEmbeddedSignature(html)).toBe(false); }); + it('turns "> " lines back into a quote, one level per marker', () => { + const text = 'Thanks\n\nOn Monday Bob wrote:\n> Line one\n> Line two\n>\n> > older\nnot quoted'; + expect(plainComposeBodyToHtml(text, identity, { separator: true })).toBe( + 'original
Thanks
On Monday Bob wrote:
' + + '' + + 'Line one
Line twoolder
not quoted
', + ); + }); + + it('round-trips a quoted reply through plain text and back', () => { + const start = `Thanks
On Monday Bob wrote:
${buildQuotedHtmlBlock('Line one
')}`; + const text = htmlComposeBodyToPlainText(start, identity, { separator: true }); + const back = plainComposeBodyToHtml(text, identity, { separator: true }); + expect(back).toBe('older
Thanks
On Monday Bob wrote:
'); + expect(htmlComposeBodyToPlainText(back, identity, { separator: true })).toBe(text); + }); + it('gives an untouched signature-only body an empty paragraph to type into', () => { const html = plainComposeBodyToHtml('\n\n-- \nAlice\nAcme', identity, { separator: true }); expect(html.startsWith('')).toBe(true); diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index 3a9d3f39f..e69492e89 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -49,6 +49,8 @@ const settings = vi.hoisted(() => ({ emptySubjectWarningEnabled: false, sendDelaySeconds: 0, signaturePosition: 'below_quote', + signatureNewMailEnabled: true, + signatureReplyForwardEnabled: true, signatureSeparatorEnabled: false, requestReadReceiptDefault: false, sendConfirmation: false, diff --git a/components/email/__tests__/composer-external-attachment-format.test.tsx b/components/email/__tests__/composer-external-attachment-format.test.tsx new file mode 100644 index 000000000..3af09d8c9 --- /dev/null +++ b/components/email/__tests__/composer-external-attachment-format.test.tsx @@ -0,0 +1,293 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +// A plugin can offload a large attachment (onBeforeBlobUpload) and hand back +// a link for the body. While it works, the user can switch the message +// between rich text and plain text. The link must go in in the format the +// body has when it arrives, not the one it had when the file was added. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +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: PartialLine one
older
See the file
', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const toggle = () => { + if (!screen.queryByTestId('composer-format-toggle')) fireEvent.click(screen.getByTestId('composer-options-menu')); + return screen.getByTestId('composer-format-toggle') as HTMLButtonElement; +}; +const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement | null; + +async function attachAndWaitForPlugin() { + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'], 'report.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function finishOffload() { + await act(async () => { + offload.finish(LINK); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +describe('composer: cloud attachment link after a format switch', () => { + afterEach(() => { + useAuthStore.setState({ client: null }); + useSettingsStore.setState({ plainTextMode: false }); + vi.clearAllMocks(); + }); + + function mockClient() { + useAuthStore.setState({ + client: { + uploadBlob: vi.fn().mockResolvedValue({ blobId: 'blob-1' }), + hasDelayedSend: () => false, + getMaxDelayedSend: () => 0, + } as never, + }); + } + + it('puts the plain-text link in when switched to plain text meanwhile', async () => { + mockClient(); + render(--
')).toBe(true); + }); + + it('leaves an empty line above a delimiter that arrives with late identities', async () => { + useSettingsStore.setState({ signaturePosition: 'above_quote' }); + setIdentities([]); + const props = { mode: 'reply' as const, replyTo: TO_ME }; + const { rerender } = render(Your apartment
Cheers, Me
' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: PartialDear reader
', isHTML: true }); + try { + render(${text}
`, + }); + editors.push(editor); + return { editor, onOpen }; +} + +/** The range of `word` (first occurrence) in a one-paragraph document. */ +function range(editor: Editor, word: string, key = word): TextIssueRange { + const text = editor.state.doc.textContent; + const from = 1 + text.indexOf(word); + return { key, from, to: from + word.length, kind: 'grammar', text: word }; +} + +const type = (editor: Editor, pos: number, text: string) => editor.view.dispatch(editor.state.tr.insertText(text, pos)); + +describe('text check underlines', () => { + it('draws issues whose text matches and drops those that do not', () => { + const { editor } = setup('Ich habe dem Brief geschrieben'); + setTextIssues(editor, [range(editor, 'dem'), { ...range(editor, 'Brief', 'stale'), text: 'Briefe' }]); + expect(drawnIssues(editor.state).map((r) => r.key)).toEqual(['dem']); + expect(editor.view.dom.querySelector('[data-text-issue="dem"]')?.className).toContain('text-issue-grammar'); + // Screen readers hear it as they do the browser's own markers. + expect(editor.view.dom.querySelector('[data-text-issue="dem"]')?.getAttribute('aria-invalid')).toBe('grammar'); + }); + + it('moves with typing before it, keeps still for typing after it, and goes when its text is edited', () => { + const { editor } = setup('Ich habe dem Brief geschrieben'); + const dem = range(editor, 'dem'); + setTextIssues(editor, [dem]); + type(editor, 1, 'Ja, '); + expect(drawnIssues(editor.state)[0]).toMatchObject({ from: dem.from + 4, to: dem.to + 4 }); + type(editor, editor.state.doc.content.size - 1, ' heute'); + expect(drawnIssues(editor.state)[0]).toMatchObject({ from: dem.from + 4 }); + // Typing right next to it leaves it. + type(editor, dem.to + 4, '!'); + expect(drawnIssues(editor.state)).toHaveLength(1); + // Typing inside it drops it. + type(editor, dem.from + 5, 'x'); + expect(drawnIssues(editor.state)).toHaveLength(0); + }); + + it('applies a replacement as one undo step', () => { + const { editor } = setup('Ich habe dem Brief geschrieben'); + setTextIssues(editor, [range(editor, 'dem')]); + expect(applyIssueReplacement(editor, 'dem', 'den')).toBe(true); + expect(editor.state.doc.textContent).toBe('Ich habe den Brief geschrieben'); + // Typing right after the fix is a step of its own. + type(editor, editor.state.selection.from, 'x'); + undo(editor.state, editor.view.dispatch); + expect(editor.state.doc.textContent).toBe('Ich habe den Brief geschrieben'); + undo(editor.state, editor.view.dispatch); + expect(editor.state.doc.textContent).toBe('Ich habe dem Brief geschrieben'); + }); + + it('keeps the replacement apart from typing just before it', () => { + const { editor } = setup('Ich habe dem Brief'); + setTextIssues(editor, [range(editor, 'dem')]); + type(editor, editor.state.doc.content.size - 1, ' heute'); + applyIssueReplacement(editor, 'dem', 'den'); + undo(editor.state, editor.view.dispatch); + expect(editor.state.doc.textContent).toBe('Ich habe dem Brief heute'); + }); + + it('refuses a replacement when the text is not what the checker saw', () => { + const { editor } = setup('Ich habe dem Brief'); + setTextIssues(editor, [range(editor, 'dem')]); + expect(applyIssueReplacement(editor, 'unknown', 'x')).toBe(false); + // The underlined word changed (here from another edit than typing). + const dem = range(editor, 'dem'); + editor.view.dispatch(editor.state.tr.insertText('D', dem.from, dem.from + 1).setMeta('addToHistory', false)); + expect(editor.state.doc.textContent).toBe('Ich habe Dem Brief'); + expect(applyIssueReplacement(editor, 'dem', 'den')).toBe(false); + }); + + it('steps through issues from the caret and selects them', () => { + const { editor } = setup('teh cat and teh dog'); + const text = editor.state.doc.textContent; + const first = { key: 'a', from: 1, to: 4, kind: 'spelling' as const, text: 'teh' }; + const second = { key: 'b', from: 1 + text.lastIndexOf('teh'), to: 4 + text.lastIndexOf('teh'), kind: 'spelling' as const, text: 'teh' }; + setTextIssues(editor, [first, second]); + editor.commands.setTextSelection(1); + expect(selectNextIssue(editor, 1)).toBe('a'); + expect(selectNextIssue(editor, 1)).toBe('b'); + expect(selectNextIssue(editor, 1)).toBe('a'); + expect(selectNextIssue(editor, -1)).toBe('b'); + expect(editor.state.selection).toMatchObject({ from: second.from, to: second.to }); + }); + + it('opens the popup on a right-click on an underline instead of the browser menu', () => { + const { editor, onOpen } = setup('Ich habe dem Brief'); + setTextIssues(editor, [range(editor, 'dem')]); + const el = editor.view.dom.querySelector('[data-text-issue="dem"]')!; + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }); + el.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(onOpen).toHaveBeenCalledWith('dem', expect.anything(), 'contextmenu'); + const plain = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }); + editor.view.dom.querySelector('p')!.dispatchEvent(plain); + expect(plain.defaultPrevented).toBe(false); + }); +}); diff --git a/components/email/__tests__/viewer-print-plain-text.test.tsx b/components/email/__tests__/viewer-print-plain-text.test.tsx new file mode 100644 index 000000000..58e01b048 --- /dev/null +++ b/components/email/__tests__/viewer-print-plain-text.test.tsx @@ -0,0 +1,94 @@ +// Printing a plain-text mail wrote its escaped text into the print window +// with no white-space rule, so every line break collapsed into one run-on +// paragraph, and a trailing ">" quote printed as the collapsed "•••" toggle +// with the quoted text left out. +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsMobile: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const TEXT = 'Hi Bob,\n\nline one\nline two\n\nOn Monday you wrote:\n> the old question\n> second quoted line\n'; + +function plainMail(): Email { + return { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hi', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: TEXT, isTruncated: false } }, + } as unknown as Email; +} + +function printToDocument() { + const popupDoc = document.implementation.createHTMLDocument('print'); + let written = ''; + const popup = { + opener: window as Window | null, + document: { + write: (html: string) => { written += html; }, + close: () => { popupDoc.documentElement.innerHTML = written; }, + }, + focus: vi.fn(), + print: vi.fn(), + }; + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window); + render((one +// level per ">"), so a quote stays a quote in the rich editor. +function plainTextWithQuotesToHtml(text: string): string { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + if (!lines.some((line) => QUOTE_LINE.test(line))) return plainTextToComposerBody(text); + const parts: string[] = []; + let i = 0; + while (i < lines.length) { + const quoted = QUOTE_LINE.test(lines[i]); + const run: string[] = []; + while (i < lines.length && QUOTE_LINE.test(lines[i]) === quoted) run.push(lines[i++]); + if (quoted) { + parts.push(`${plainTextWithQuotesToHtml(run.map((line) => line.replace(/^> ?/, '')).join('\n')) || ''}`); + } else { + parts.push(plainTextToComposerBody(run.join('\n').replace(/^\n+|\n+$/g, ''))); + } + } + return parts.join(''); +} + /** * Convert a plain-text body to editor HTML: paragraphs per blank line,
- * per newline. A trailing plain-text signature is swapped for the embedded - * (marker-bracketed) HTML signature so identity switches and the send path - * keep working as in a body that started out as HTML. + * per newline, "> " lines as a quote. A trailing plain-text signature is + * swapped for the embedded (marker-bracketed) HTML signature so identity + * switches and the send path keep working as in a body that started out as + * HTML. */ export function plainComposeBodyToHtml( text: string, @@ -215,7 +253,7 @@ export function plainComposeBodyToHtml( ): string { const hadSignature = plainTextBodyHasSignature(text, identity); const userText = hadSignature ? plainTextBodyWithoutSignature(text, identity) : text; - const html = plainTextToComposerBody(userText); + const html = plainTextWithQuotesToHtml(userText); if (!hadSignature) return html; const embedded = buildEmbeddedSignatureHtml(identity, { embed: true, separator: options.separator }); return `${html || ''}${embedded}`; diff --git a/components/email/composer-selection-menu.tsx b/components/email/composer-selection-menu.tsx index ea17d8e74..a9475a9c6 100644 --- a/components/email/composer-selection-menu.tsx +++ b/components/email/composer-selection-menu.tsx @@ -6,8 +6,15 @@ // its spelling suggestions. Cut and Copy are kept, since the menu replaces // the one that had them. A click on a plugin entry carries the composer id // and the selected text. +// +// It opens only for text the user had selected before the right-click. +// Chrome (a misspelled word, any word on a Mac) and Safari select the word +// under the pointer as the button goes down; that word belongs to the +// browser's menu and its spelling suggestions, not to ours. A right-click on +// a proofreading underline opens that issue's popup instead (the editor +// handles it first). -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; @@ -39,16 +46,40 @@ export function selectionMenuPoint( return rect ? { x: rect.left + 16, y: rect.top + 16 } : { x: 0, y: 0 }; } +/** + * The selected text as the browser has it right now: ProseMirror's own + * selection catches up only on the next selectionchange, after the + * contextmenu event. + */ +export function liveSelectionText(): string { + const el = document.activeElement; + if (el instanceof HTMLTextAreaElement) return el.value.slice(el.selectionStart ?? 0, el.selectionEnd ?? 0); + return window.getSelection()?.toString() ?? ""; +} + /** The menu's state and the contextmenu handler for the message text. */ export function useComposerSelectionMenu() { const actions = usePluginActions(COMPOSER_SELECTION_SURFACE); const menu = useContextMenu(); const { openContextMenu } = menu; const enabled = actions.length > 0; + // The selection when the right button went down, before the browser moved it. + const beforeRef = useRef<{ text: string; at: number } | null>(null); + const onMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 2) return; + beforeRef.current = { text: liveSelectionText(), at: Date.now() }; + }, []); const onContextMenu = useCallback((e: React.MouseEvent, read: () => string) => { - if (!enabled) return; + if (!enabled || e.defaultPrevented) return; const text = read(); if (!text.trim()) return; + const before = beforeRef.current; + beforeRef.current = null; + // From the mouse: only when that selection was there already and the + // press didn't change it. From the keyboard (Shift+F10, the Menu key) + // there is no press, and the selection is the user's. + const fromMouse = !!before && Date.now() - before.at < 2000; + if (fromMouse && (!before.text.trim() || before.text !== liveSelectionText())) return; const point = selectionMenuPoint(e); openContextMenu({ preventDefault: () => e.preventDefault(), @@ -57,7 +88,7 @@ export function useComposerSelectionMenu() { clientY: point.y, } as React.MouseEvent, { text }); }, [enabled, openContextMenu]); - return { ...menu, onContextMenu, enabled }; + return { ...menu, onContextMenu, onMouseDown, enabled }; } export function ComposerSelectionMenu({ diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 76fdaf5b2..20f04d211 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useMvComposeWindow } from "@/components/email/mv-compose-context"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Button, TOUCH_HIT_AREA } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog } from "@/components/ui/dialog"; @@ -103,7 +103,7 @@ import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview" import "@/lib/org/outgoing"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; -import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils"; +import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages } from "@/lib/template-utils"; import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check"; import enMessages from "@/locales/en/common.json"; import { TemplatePicker } from "@/components/templates/template-picker"; @@ -155,6 +155,11 @@ import { announce as announceStatus } from "@/lib/announce"; import { isReportPart, normalizeCid } from "@/lib/attachment-visibility"; import { unnamedAttachmentName } from "@/lib/attachment-name"; import { RichTextEditor } from "@/components/email/rich-text-editor"; +import { useProofreading } from "@/components/email/proofreading/use-proofreading"; +import { IssuePopup } from "@/components/email/proofreading/issue-popup"; +import { ProofreadingStatus } from "@/components/email/proofreading/proofreading-status"; +import { ProofreadingNotice } from "@/components/email/proofreading/proofreading-notice"; +import { issueRect } from "@/components/email/proofreading/text-check-extension"; import { isRecipientMentionActive } from "@/components/email/recipient-mention"; import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; @@ -499,6 +504,7 @@ export function EmailComposer({ const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); + const tProofreading = useTranslations('proofreading'); const tOffline = useTranslations('offline'); const tSendGuards = useTranslations('send_guards'); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -509,6 +515,10 @@ export function EmailComposer({ const [plainTextMode, setPlainTextMode] = useState ( () => initialDataProp?.plainTextMode ?? plainTextModeDefault ); + // The format right now, for async work that writes into the body after an + // await: the user may have switched it meanwhile. + const plainTextModeRef = useRef(plainTextMode); + plainTextModeRef.current = plainTextMode; // A plugin's onBeforeCompose prefill (plain text body) folded into what the // composer starts from: over `initialData` when the host hands one in (a // mailto:, a re-opened draft), otherwise over the fields built below. @@ -547,6 +557,13 @@ export function EmailComposer({ const updateSetting = useSettingsStore((state) => state.updateSetting); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); + const signatureNewMailEnabled = useSettingsStore((state) => state.signatureNewMailEnabled); + const signatureReplyForwardEnabled = useSettingsStore((state) => state.signatureReplyForwardEnabled); + // Whether automatic signature insertion is on for this composer mode: new + // mail (and re-opened drafts, which open in compose mode) follow the + // new-mail toggle, replies and forwards the reply/forward one. What the + // body already carries is never stripped - only the insert/append is gated. + const autoSignatureEnabled = mode === 'compose' ? signatureNewMailEnabled : signatureReplyForwardEnabled; const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); // A draft belongs to the account the composer was opened in. Switching @@ -622,12 +639,13 @@ export function EmailComposer({ const shouldEmbedSignatureAboveQuote = (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote' && + signatureReplyForwardEnabled && hasInitialSignature; // New-mail composes always embed the signature into the editor body so it's // editable/removable (the previous read-only preview below the editor was // never spec-correct - see #329). Compose mode also ignores any leftover // `replyTo` from a still-selected email; getInitialBody short-circuits below. - const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature; + const shouldEmbedSignatureInNewMail = mode === 'compose' && signatureNewMailEnabled && hasInitialSignature; // Format a single EmailAddress for display in the composer input const toRecipient = (r: { name?: string; email?: string }): Recipient => @@ -771,10 +789,13 @@ export function EmailComposer({ : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); + // Like a new mail, an empty line above the signature gives the cursor a + // place for the reply. Without it the reply started on the "-- " line (#1503). const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: shouldEmbedSignatureAboveQuote, separator: signatureSeparatorEnabled, }); + const head = `${prefix || (signatureBlock ? '' : '')}${signatureBlock}`; // Whether the quoted original may show its remote images in the editor: // the viewer's rule, minus the per-message "load" click it doesn't know. @@ -798,7 +819,7 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false }, ); - return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${island}`; + return `${head}
${replyHeader(replyTo.quoteHeaderHtml)}${island}`; } const escaped = replyTo.body ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') @@ -807,7 +828,7 @@ export function EmailComposer({ const bodyHtml = wrap ? `${escaped}` : escaped; - return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`; + return `${head}
${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`; } // Build quoted content as HTML @@ -825,15 +846,15 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed }, ); - return `${prefix}${signatureBlock}
${replyHeader(`${quoteHeader}`)}${island}`; + return `${head}
${replyHeader(`${quoteHeader}`)}${island}`; } if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { - return `${prefix}${signatureBlock}
${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}
${escapedOriginal}`; + return `${head}
${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}
${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}
${replyHeader(`${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
`)}${escapedOriginal}`; + return `${head}
${replyHeader(`${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
`)}${escapedOriginal}`; } } return prefix; @@ -1087,6 +1108,9 @@ export function EmailComposer({ const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState(); const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false); const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false); + // A proofreading checker asked to warn before sending with open issues. + const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(null); + const proofreadingConfirmedRef = useRef(false); const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState ([]); const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState (); const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false); @@ -1317,6 +1341,9 @@ export function EmailComposer({ const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward'; if (!isReplyLike && mode !== 'compose') return; if (isReplyLike && signaturePosition !== 'above_quote') return; + // With automatic insertion off the body never got a signature spliced + // in, so there is nothing to swap. + if (!autoSignatureEnabled) return; if (plainTextMode) { // A new plain-text message carries the signature at the end of the @@ -1358,7 +1385,7 @@ export function EmailComposer({ // would re-run on unrelated identity-field changes and re-splice the // signature into the live editor. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]); + }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]); const openScheduleDialog = useCallback(() => { setShowScheduleDialog(true); @@ -1928,12 +1955,12 @@ export function EmailComposer({ const editor = editorRef.current; const html = editor && !editor.isDestroyed ? serializeEditorContent(editor) : body; editorRef.current = null; - setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator })); + setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator, imageAlt: imageAltText })); setPlainTextMode(true); // Keyed on the signature fields, not the identity object (see the // signature effects above for the same rationale). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled]); + }, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, imageAltText]); // Move a chip from one recipient field to another. `toIndex`, when given, // inserts at that position in the destination (drag-and-drop reordering, @@ -2103,18 +2130,55 @@ export function EmailComposer({ } }; + const handleImageUpload = useCallback(async ( + file: File, + ): Promise<{ src: string; cid: string } | null> => { + if (!client) return null; + try { + const readAsDataUrl = new Promise ((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve((e.target?.result as string) ?? null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); + const [{ blobId }, dataUrl] = await Promise.all([ + (composerClientRef.current ?? client).uploadBlob(file), + readAsDataUrl, + ]); + if (!dataUrl) throw new Error('Failed to read image as data URL'); + const cid = `${generateUUID()}@webmail`; + inlineImagesRef.current.push({ + cid, + blobId, + type: file.type || 'application/octet-stream', + name: file.name, + size: file.size, + dataUrl, + uploadedAt: Date.now(), + }); + return { src: dataUrl, cid }; + } catch (error) { + debug.error(`Failed to upload inline image ${file.name}:`, error); + toast.error(t('upload_failed', { filename: file.name })); + return null; + } + }, [client, t]); + const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record ) => { let filledSubject = Object.keys(filledValues).length > 0 ? substitutePlaceholders(template.subject, filledValues) : template.subject; const filledBody = Object.keys(filledValues).length > 0 - ? substitutePlaceholders(template.body, filledValues) + ? substitutePlaceholders(template.body, filledValues, { html: !!template.isHTML }) : template.body; - // In plain text mode, use template body as-is; otherwise convert to HTML - let bodyContent = plainTextMode || template.isHTML - ? filledBody - : ` ${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
`; + // Match the template to the draft's format: an HTML template goes into a + // plain-text draft as its text, not its markup (#1506). + let bodyContent = plainTextMode + ? (template.isHTML ? htmlToPlainText(filledBody) : filledBody) + : template.isHTML + ? filledBody + : `
')}${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
`; // Plugins may rewrite the template before it goes in (onTemplateRender). const rendered = await renderTemplateWithPlugins(template.id, { subject: filledSubject, @@ -2125,6 +2189,8 @@ export function EmailComposer({ filledSubject = rendered.subject; bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html); } + // Its data: images go out as cid: parts, like pasted ones (#1490). + if (!plainTextMode) bodyContent = await embedTemplateImages(bodyContent, handleImageUpload); if (mode === 'compose') { // An empty template subject must not wipe one the user typed (#540). @@ -2210,7 +2276,7 @@ export function EmailComposer({ setShowTemplatePicker(false); notifyTemplateApplied(template); - }, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]); + }, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled, handleImageUpload]); useEffect(() => { const handleTemplateKey = (e: KeyboardEvent) => { @@ -2320,7 +2386,9 @@ export function EmailComposer({ // offloading it - don't drop a link for a file they cancelled. if (controller?.signal.aborted) continue; setAttachments(prev => prev.filter(att => att.file !== file)); - if (plainTextMode) { + // The ref, not the closure: the user may have switched format + // while the handler was offloading the file. + if (plainTextModeRef.current) { setBody(previous => previous && !previous.endsWith('\n') ? `${previous}\n${transformed.text}` : previous + transformed.text ); @@ -2391,40 +2459,6 @@ export function EmailComposer({ ); } } - }, [client, t, plainTextMode]); - - const handleImageUpload = useCallback(async ( - file: File, - ): Promise<{ src: string; cid: string } | null> => { - if (!client) return null; - try { - const readAsDataUrl = new Promise
')}((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => resolve((e.target?.result as string) ?? null); - reader.onerror = () => resolve(null); - reader.readAsDataURL(file); - }); - const [{ blobId }, dataUrl] = await Promise.all([ - (composerClientRef.current ?? client).uploadBlob(file), - readAsDataUrl, - ]); - if (!dataUrl) throw new Error('Failed to read image as data URL'); - const cid = `${generateUUID()}@webmail`; - inlineImagesRef.current.push({ - cid, - blobId, - type: file.type || 'application/octet-stream', - name: file.name, - size: file.size, - dataUrl, - uploadedAt: Date.now(), - }); - return { src: dataUrl, cid }; - } catch (error) { - debug.error(`Failed to upload inline image ${file.name}:`, error); - toast.error(t('upload_failed', { filename: file.name })); - return null; - } }, [client, t]); const handleFileSelect = async (event: React.ChangeEvent ) => { @@ -2658,7 +2692,7 @@ export function EmailComposer({ // below the editor - was saved without it (#823). Embed the *marked-up* // form so re-opening the draft round-trips the signature as one block and // signatureAlreadyInBody sees it instead of appending a second copy. - const draftSignatureHtml = signatureAlreadyInBody + const draftSignatureHtml = signatureAlreadyInBody || !autoSignatureEnabled ? '' : buildEmbeddedSignatureHtml(signatureIdentity, { embed: true, @@ -2666,7 +2700,7 @@ export function EmailComposer({ }); const draftHtmlBody = plainTextMode ? undefined : `${body}${draftSignatureHtml}`; const draftTextBody = plainTextMode - ? (signatureAlreadyInBody + ? (signatureAlreadyInBody || !autoSignatureEnabled ? body : appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled })) : htmlToPlainText(draftHtmlBody!, imageAltText); @@ -2929,8 +2963,6 @@ export function EmailComposer({ // catalogue): plain text at the caret, replacing the selection, so a // rewrite of a selected paragraph is one call and Ctrl+Z takes it back in // the rich editor. Stable, so the slot iframes don't get new props. - const plainTextModeRef = useRef(plainTextMode); - plainTextModeRef.current = plainTextMode; const pluginInsertText = useCallback((value: unknown) => { if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PLUGIN_INSERT_LENGTH) return false; if (plainTextModeRef.current) { @@ -3114,6 +3146,21 @@ export function EmailComposer({ }; }, [wantsPluginSelection, pluginEditor, plainTextMode]); + // Proofreading: checker plugins underline mistakes while the user types + // (components/email/proofreading). The browser's own spell check steps + // back while one checks spelling, and the text carries its language. + const uiLocale = useLocale(); + const writingLanguage = useSettingsStore((s) => s.writingLanguage); + const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale; + const proofreading = useProofreading({ + composerId: pluginComposerId, + sessionKey: effectiveSessionKey, + editor: pluginEditor, + textareaRef: bodyRef, + plainTextMode, + ownOptions: pluginEditOptsRef, + }); + const pluginDraftProps = useMemo( () => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), [pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId], @@ -3510,6 +3557,16 @@ export function EmailComposer({ } } + // Open proofreading issues, when a checker asked for the warning (the + // LanguageTool plugin's "warn before sending" option). Once per message. + if (!proofreadingConfirmedRef.current) { + const open = proofreading.sendWarningIssues().length; + if (open > 0) { + setProofreadingWarning({ count: open, delayedUntil }); + return; + } + } + // Checks other features register (lib/outgoing/send-pipeline.ts), such // as the external-recipient confirm. They may ask the user, so a second // click meanwhile does nothing. @@ -3611,9 +3668,14 @@ export function EmailComposer({ // above-quote replies, a re-opened draft) `signatureAlreadyInBody` is set // and the trailing append below is skipped so we don't duplicate it. + // Whether automatic signature insertion is on for this composer mode + // (component-level `autoSignatureEnabled` - see signatureAlreadyInBody). + // What the body already carries is never stripped - only the append is + // gated. + // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { - if (signatureAlreadyInBody) return ''; + if (signatureAlreadyInBody || !autoSignatureEnabled) return ''; const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `
--
` : `
`; if (signatureIdentity?.htmlSignature) { return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`; @@ -3630,8 +3692,8 @@ export function EmailComposer({ // In plain text mode, send text/plain only (no HTML body) const signatureOpts = { separator: signatureSeparatorEnabled }; const builtTextBody = plainTextMode - ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) - : (signatureAlreadyInBody ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts)); + ? (signatureAlreadyInBody || !autoSignatureEnabled ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) + : (signatureAlreadyInBody || !autoSignatureEnabled ? htmlToPlainText(body, imageAltText) : appendPlainTextSignature(htmlToPlainText(body, imageAltText), signatureIdentity, signatureOpts)); // Before any blobId is read: forwarded parts may still sit on the account // that holds the original message. @@ -4172,6 +4234,8 @@ export function EmailComposer({ // 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; + // And for proofreading's popup and panel: Escape closes only them. + if (proofreading.popup || proofreading.panel) return; e.preventDefault(); handleClose(); return; @@ -4734,6 +4798,23 @@ export function EmailComposer({)} {/* Body */} + + {proofreading.popup && !plainTextMode && pluginEditor && (() => { + const issue = proofreading.issues.find((i) => i.key === proofreading.popup!.key); + return issue ? ( + issueRect(pluginEditor, issue.key)} + onApply={proofreading.apply} + onDismiss={proofreading.dismiss} + onClose={proofreading.closePopup} + onNavigate={proofreading.navigate} + textRoot={() => (pluginEditor.isDestroyed ? null : pluginEditor.view.dom)} + container={(pluginEditor.isDestroyed ? null : pluginEditor.view.dom.closest ('[aria-modal="true"]')) ?? document.body} + /> + ) : null; + })()} {plainTextMode ? ( <> @@ -4754,10 +4835,13 @@ export function EmailComposer({ aria-invalid={validationErrors.body || undefined} aria-describedby={validationErrors.body ? `${fieldIdBase}-body-error` : undefined} onContextMenu={onBodyContextMenu} + onMouseDown={selectionMenu.onMouseDown} + spellCheck={proofreading.browserSpellcheck} + lang={textLang} /> > ) : ( - +)} @@ -4790,8 +4877,10 @@ export function EmailComposer({ {/* Hide the visual signature preview when the signature has already been embedded into the body (compose, above-quote replies, re-opened - drafts) - it would otherwise read as a second signature. */} - {signatureAlreadyInBody ? null + drafts) - it would otherwise read as a second signature. Also when + automatic insertion is off: nothing will be appended at send time, + so a preview would promise a signature that never goes out. */} + {signatureAlreadyInBody || !autoSignatureEnabled ? null : plainTextMode ? ( getPlainTextSignature(signatureIdentity) ? ({ @@ -4778,6 +4862,9 @@ export function EmailComposer({ findMentionContacts={recipientMentionsEnabled && mentionAddsRecipientTo !== 'off' ? findMentionContacts : undefined} mentionAddsTo={mentionAddsRecipientTo === 'cc' ? 'cc' : 'to'} onMentionAdd={addMentionedRecipient} + spellcheck={proofreading.browserSpellcheck} + lang={textLang} + textCheck={proofreading.editorHandlers} /> @@ -5122,6 +5211,7 @@ export function EmailComposer({ >}{/* Right side - Discard + Send (desktop) */} @@ -5231,6 +5321,7 @@ export function EmailComposer({ initialData={{ subject, body, + isHTML: !plainTextMode, to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)), cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)), bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)), @@ -5394,6 +5485,40 @@ export function EmailComposer({ ++