From f6ad8a594201aa2d535ff7ec00ef7119f9e36b17 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:34:20 +0200 Subject: [PATCH] feat: add per-message HTML/plain-text toggle #1022 --- .../email/__tests__/compose-format.test.ts | 86 ++++++ .../__tests__/composer-format-toggle.test.tsx | 286 ++++++++++++++++++ components/email/compose-format.ts | 142 +++++++++ components/email/email-composer.tsx | 155 +++++----- components/mail/mail-app.tsx | 5 + locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + 32 files changed, 648 insertions(+), 80 deletions(-) create mode 100644 components/email/__tests__/compose-format.test.ts create mode 100644 components/email/__tests__/composer-format-toggle.test.tsx create mode 100644 components/email/compose-format.ts diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts new file mode 100644 index 000000000..5837505f8 --- /dev/null +++ b/components/email/__tests__/compose-format.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + buildEmbeddedSignatureHtml, + htmlComposeBodyToPlainText, + plainComposeBodyToHtml, + stripEmbeddedSignature, +} from '../compose-format'; +import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block'; +import { plainTextBodyHasSignature } from '@/lib/signature-utils'; + +const identity = { htmlSignature: 'Alice
Acme', textSignature: 'Alice\nAcme' }; +const count = (haystack: string, needle: string) => haystack.split(needle).length - 1; + +describe('compose-format (#1022): HTML <-> plain text switch', () => { + describe('stripEmbeddedSignature', () => { + it('removes the marker-bracketed range and reports it', () => { + const html = `

Hi

${buildEmbeddedSignatureHtml(identity, { embed: true, separator: true })}`; + const out = stripEmbeddedSignature(html); + expect(out.hadSignature).toBe(true); + expect(out.html).toBe('

Hi

'); + }); + + it('stops before the quote when the end marker is missing', () => { + const html = `

Hi

--

Alice

original
`; + const out = stripEmbeddedSignature(html); + expect(out.hadSignature).toBe(true); + expect(out.html).toBe('

Hi

original
'); + }); + + it('leaves a body without markers untouched', () => { + expect(stripEmbeddedSignature('

Hi

')).toEqual({ html: '

Hi

', hadSignature: false }); + }); + }); + + describe('htmlComposeBodyToPlainText', () => { + it('flattens formatting and carries the signature over as plain text', () => { + const html = `

Hello there

Second

${buildEmbeddedSignatureHtml(identity, { embed: true, separator: true })}`; + const text = htmlComposeBodyToPlainText(html, identity, { separator: true }); + expect(text).toBe('Hello there\n\nSecond\n\n-- \nAlice\nAcme'); + expect(plainTextBodyHasSignature(text, identity)).toBe(true); + expect(count(text, 'Alice')).toBe(1); + }); + + it('does not invent a signature for a body that had none (below-quote reply)', () => { + const text = htmlComposeBodyToPlainText('

Thanks

original
', identity, { separator: true }); + expect(text).toBe('Thanks\n\noriginal'); + expect(plainTextBodyHasSignature(text, identity)).toBe(false); + }); + + it('honours the separator setting', () => { + const html = `

Hi

${buildEmbeddedSignatureHtml(identity, { embed: true, separator: false })}`; + expect(htmlComposeBodyToPlainText(html, identity, { separator: false })).toBe('Hi\n\nAlice\nAcme'); + }); + }); + + describe('plainComposeBodyToHtml', () => { + it('wraps paragraphs and re-embeds the signature with markers', () => { + const html = plainComposeBodyToHtml('Hello\nthere\n\nSecond\n\n-- \nAlice\nAcme', identity, { separator: true }); + expect(html.startsWith('

Hello
there

Second

')).toBe(true); + expect(containsEmbeddedSignature(html)).toBe(true); + expect(count(html, SIGNATURE_BLOCK_MARKER)).toBe(1); + expect(html).toContain('Alice'); + // The plain-text copy is gone - only the embedded HTML one remains. + expect(count(html, 'Alice')).toBe(1); + }); + + it('keeps a body without signature signature-free', () => { + const html = plainComposeBodyToHtml('Thanks\n\n> original', identity, { separator: true }); + expect(html).toBe('

Thanks

> original

'); + expect(containsEmbeddedSignature(html)).toBe(false); + }); + + 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); + expect(containsEmbeddedSignature(html)).toBe(true); + }); + }); + + it('round-trips a compose body without duplicating or losing the signature', () => { + const start = `

Draft text

${buildEmbeddedSignatureHtml(identity, { embed: true, separator: true })}`; + const text = htmlComposeBodyToPlainText(start, identity, { separator: true }); + const back = plainComposeBodyToHtml(text, identity, { separator: true }); + expect(back).toBe(start); + }); +}); diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx new file mode 100644 index 000000000..bf91e1886 --- /dev/null +++ b/components/email/__tests__/composer-format-toggle.test.tsx @@ -0,0 +1,286 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useSettingsStore } from '@/stores/settings-store'; +import { SIGNATURE_BLOCK_MARKER } from '../signature-block'; + +// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─ + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice', textSignature: 'Alice' }, + ] as Array>, + 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 }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + // The default position: replies/forwards get the signature appended at + // send time rather than embedded above the quote. + signaturePosition: 'below_quote', + signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + 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, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +/** + * #1022: the HTML / plain-text choice used to live only in settings. The + * composer toolbar now carries a per-message toggle. Switching converts the + * body in place and keeps the signature invariant (exactly one copy, in the + * target format) that the send path relies on. + */ + +const DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Hello', + body: '

Draft text

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const toggle = () => screen.getByTestId('composer-format-toggle') as HTMLButtonElement; +const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement; +const textarea = () => document.querySelector('textarea') as HTMLTextAreaElement | null; +const countOf = (haystack: string, needle: string) => haystack.split(needle).length - 1; + +async function send(onSend: ReturnType) { + fireEvent.click(sendButton()); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + return onSend.mock.calls[0][0] as { body: string; htmlBody?: string }; +} + +describe('composer HTML / plain-text toggle (#1022)', () => { + afterEach(() => { + useSettingsStore.setState({ plainTextMode: false }); + vi.clearAllMocks(); + }); + + it('starts in the rich-text editor and switches to a plain-text textarea', () => { + render(); + expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument(); + expect(textarea()).toBeNull(); + expect(toggle()).toHaveAttribute('aria-pressed', 'false'); + expect(toggle()).toHaveAttribute('title', 'format_plain_text'); + + fireEvent.click(toggle()); + + expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument(); + expect(toggle()).toHaveAttribute('aria-pressed', 'true'); + expect(toggle()).toHaveAttribute('title', 'format_rich_text'); + // Formatting flattened, signature carried over as plain text - once. + expect(textarea()!.value).toBe('Draft text\n\n-- \nAlice'); + }); + + it('sends text/plain only after switching, with exactly one signature', async () => { + const onSend = vi.fn(); + render(); + fireEvent.click(toggle()); + + const sent = await send(onSend); + expect(sent.htmlBody).toBeUndefined(); + expect(sent.body).toBe('Draft text\n\n-- \nAlice'); + expect(countOf(sent.body, 'Alice')).toBe(1); + }); + + it('switches back to HTML and sends an embedded signature exactly once', async () => { + const onSend = vi.fn(); + render(); + fireEvent.click(toggle()); + fireEvent.change(textarea()!, { target: { value: 'Edited\n\nSecond\n\n-- \nAlice' } }); + fireEvent.click(toggle()); + + expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument(); + expect(textarea()).toBeNull(); + + const sent = await send(onSend); + expect(sent.htmlBody).toContain('

Edited

Second

'); + expect(countOf(sent.htmlBody!, SIGNATURE_BLOCK_MARKER)).toBe(1); + expect(countOf(sent.htmlBody!, 'Alice')).toBe(1); + expect(countOf(sent.body, 'Alice')).toBe(1); + }); + + it('honours the "plain text only" setting as the default and still lets the user switch', () => { + useSettingsStore.setState({ plainTextMode: true }); + render(); + expect(textarea()).not.toBeNull(); + expect(toggle()).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(toggle()); + expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument(); + }); + + it('re-opens a draft in the format it was written in', () => { + render(); + expect(textarea()).not.toBeNull(); + expect(textarea()!.value).toContain('Plain draft'); + }); + + it('stashes the chosen format with the draft on unmount', () => { + const onSaveState = vi.fn(); + const { unmount } = render(); + fireEvent.click(toggle()); + fireEvent.change(textarea()!, { target: { value: 'changed' } }); + unmount(); + expect(onSaveState).toHaveBeenCalledTimes(1); + expect(onSaveState.mock.calls[0][0]).toMatchObject({ plainTextMode: true, body: 'changed' }); + }); +}); diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts new file mode 100644 index 000000000..4d4e918a6 --- /dev/null +++ b/components/email/compose-format.ts @@ -0,0 +1,142 @@ +import { sanitizeSignatureHtml } from "@/lib/email-sanitization"; +import { htmlToPlainText } from "@/lib/html-to-text"; +import { plainTextToComposerBody } from "@/lib/email-composer-utils"; +import { + appendPlainTextSignature, + plainTextBodyHasSignature, + plainTextBodyWithoutSignature, +} from "@/lib/signature-utils"; +import { buildSignatureBlock, SIGNATURE_RANGE_MARKER } from "@/components/email/signature-block"; + +/** + * Helpers for the composer's per-message HTML <-> plain-text switch (#1022). + * + * Both directions preserve the one invariant the send and draft-save paths + * rely on: whether the body *carries* the signature. An HTML body carries it + * as a marker-bracketed range (`containsEmbeddedSignature`), a plain-text + * body by ending with the signature text (`plainTextBodyHasSignature`). The + * converters strip the signature in the source format and re-append it in + * the target format, so a switch never produces a doubled or lost signature. + */ + +export type SignatureIdentityLike = { + htmlSignature?: string; + textSignature?: string; +} | null | undefined; + +// Render the embedded signature. Bracketed with `data-signature-block` marker +// paragraphs so we can swap the inner content when the user switches identity +// without losing the surrounding draft or quoted message. The markers are +// preserved through TipTap by the StyledParagraph extension. The HTML +// signature itself is wrapped in a SignatureBlock atom node so its inline +// styling survives the editor (see signature-block.ts) instead of being +// flattened by the schema. +export function buildEmbeddedSignatureHtml( + identity: SignatureIdentityLike, + options: { embed: boolean; separator: boolean } +): string { + if (!options.embed) return ''; + const startMarker = options.separator + ? `

--

` + : `

`; + const endMarker = `

`; + if (identity?.htmlSignature) { + return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`; + } + if (identity?.textSignature) { + const escaped = identity.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${startMarker}

${escaped}

${endMarker}`; + } + return ''; +} + +function isQuoteBoundary(node: Node | null): boolean { + if (!node || node.nodeType !== 1) return false; + const el = node as Element; + return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html'); +} + +/** + * Remove the embedded signature range [start/separator marker … end marker] + * from a parsed body. Without an end marker the removal stops before the next + * quote boundary (a legacy
or the QuotedHtml island) so it never + * eats into the quoted message. Returns the removed nodes' former position so + * a caller can splice a replacement in, or null when the body has no range. + */ +export function removeSignatureRange( + doc: Document, +): { parent: Node; insertBefore: Node | null } | null { + const startEl = doc.querySelector( + `[${SIGNATURE_RANGE_MARKER}="separator"], [${SIGNATURE_RANGE_MARKER}="start"]` + ); + if (!startEl) return null; + const parent = startEl.parentNode; + if (!parent) return null; + const endEl = doc.querySelector(`[${SIGNATURE_RANGE_MARKER}="end"]`); + const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; + + const toRemove: Node[] = []; + let cursor: Node | null = startEl; + while (cursor) { + toRemove.push(cursor); + if (cursor === removeUntil) break; + const next: Node | null = cursor.nextSibling; + if (!removeUntil && isQuoteBoundary(next)) break; + cursor = next; + } + const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; + toRemove.forEach((node) => parent.removeChild(node)); + return { parent, insertBefore }; +} + +/** + * The HTML body with its embedded signature range removed, plus whether one + * was there. A bare signature atom without range markers (a body whose marker + * paragraphs were dropped) is left in place - it has no defined extent. + */ +export function stripEmbeddedSignature(html: string): { html: string; hadSignature: boolean } { + if (!html.includes(SIGNATURE_RANGE_MARKER)) return { html, hadSignature: false }; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const removed = removeSignatureRange(doc); + if (!removed) return { html, hadSignature: false }; + return { html: doc.body.innerHTML, hadSignature: true }; +} + +/** + * Convert the rich-text editor's body to a plain-text body. Formatting is + * dropped (that is the point of plain text); an embedded signature comes back + * as the identity's plain-text signature so the body still "carries" it. + */ +export function htmlComposeBodyToPlainText( + html: string, + identity: SignatureIdentityLike, + options: { separator: boolean }, +): string { + const stripped = stripEmbeddedSignature(html); + const text = htmlToPlainText(stripped.html, { paragraphSpacing: true }); + if (!stripped.hadSignature) return text; + return appendPlainTextSignature(text, identity, { separator: options.separator }); +} + +/** + * 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. + */ +export function plainComposeBodyToHtml( + text: string, + identity: SignatureIdentityLike, + options: { separator: boolean }, +): string { + const hadSignature = plainTextBodyHasSignature(text, identity); + const userText = hadSignature ? plainTextBodyWithoutSignature(text, identity) : text; + const html = plainTextToComposerBody(userText); + if (!hadSignature) return html; + const embedded = buildEmbeddedSignatureHtml(identity, { embed: true, separator: options.separator }); + return `${html || '

'}${embedded}`; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 04d63e49a..899f62d01 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PackageCheck, LockKeyhole, Type } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -16,7 +16,13 @@ import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { isEditableEventTarget } from "@/lib/keyboard"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; -import { buildSignatureBlock, containsEmbeddedSignature, SIGNATURE_RANGE_MARKER } from "@/components/email/signature-block"; +import { containsEmbeddedSignature } from "@/components/email/signature-block"; +import { + buildEmbeddedSignatureHtml, + htmlComposeBodyToPlainText, + plainComposeBodyToHtml, + removeSignatureRange, +} from "@/components/email/compose-format"; import { emailHooks, contactHooks, isExternalAttachmentResult } from "@/lib/plugin-hooks"; import { onUploadProgress } from "@/lib/upload-progress"; import type { AlmostSavedDraft, OutgoingEmail, PluginAttachmentUpload, RecipientSuggestion } from "@/lib/plugin-types"; @@ -129,6 +135,12 @@ export interface ComposerDraftData { fromOverrideEmail?: string; fromOverrideName?: string; fromOverrideEnabled?: boolean; + /** + * Format the body is in. Absent, the "plain text only" setting decides - + * set for a stashed compose or a re-opened draft so it comes back in the + * format it was written in (#1022). + */ + plainTextMode?: boolean; } interface EmailComposerProps { @@ -244,41 +256,6 @@ type ComposerAttachment = { fromDraftPart?: boolean; }; -type SignatureIdentityLike = { - htmlSignature?: string; - textSignature?: string; -} | null | undefined; - -// Render the embedded signature. Bracketed with `data-signature-block` marker -// paragraphs so we can swap the inner content when the user switches identity -// without losing the surrounding draft or quoted message. The markers are -// preserved through TipTap by the StyledParagraph extension. The HTML -// signature itself is wrapped in a SignatureBlock atom node so its inline -// styling survives the editor (see signature-block.ts) instead of being -// flattened by the schema. -function buildEmbeddedSignatureHtml( - identity: SignatureIdentityLike, - options: { embed: boolean; separator: boolean } -): string { - if (!options.embed) return ''; - const startMarker = options.separator - ? `

--

` - : `

`; - const endMarker = `

`; - if (identity?.htmlSignature) { - return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`; - } - if (identity?.textSignature) { - const escaped = identity.textSignature - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - return `${startMarker}

${escaped}

${endMarker}`; - } - return ''; -} - function formatLocalDateTimeInput(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; @@ -309,7 +286,13 @@ export function EmailComposer({ const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); const timeFormat = useSettingsStore((state) => state.timeFormat); - const plainTextMode = useSettingsStore((state) => state.plainTextMode); + const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); + // Per-message format (#1022). Seeded from the "plain text only" setting - + // or the format a re-opened / stashed draft was written in - and switchable + // from the toolbar for just this message. + const [plainTextMode, setPlainTextMode] = useState( + () => initialData?.plainTextMode ?? plainTextModeDefault + ); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch); @@ -747,50 +730,22 @@ export function EmailComposer({ // body isn't lost during the signature splice + setContent round-trip. const currentHtml = serializeEditorContent(editor); const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); - const startEl = doc.querySelector( - `[${SIGNATURE_RANGE_MARKER}="separator"], [${SIGNATURE_RANGE_MARKER}="start"]` - ); - if (!startEl) return; - const endEl = doc.querySelector(`[${SIGNATURE_RANGE_MARKER}="end"]`); - const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, { embed: true, separator: signatureSeparatorEnabled, }); if (!newSignature) return; - // Build a temporary container holding the replacement nodes so we can - // splice them in without re-serializing/parsing twice. + // Remove the existing signature range (bounded so it never eats into the + // quoted body - see removeSignatureRange) and splice the new one in at the + // same position, without re-serializing/parsing twice. + const removed = removeSignatureRange(doc); + if (!removed) return; const replacementHost = doc.createElement('div'); replacementHost.innerHTML = newSignature; - const replacementNodes = Array.from(replacementHost.childNodes); - - const parent = startEl.parentNode; - if (!parent) return; - - // Remove the existing signature range [startEl … endEl] inclusive, or - // from startEl to the next quote boundary if no end marker is present. - // The quote boundary is either a legacy
or the QuotedHtml - // island wrapper (
) - stop before either so the - // signature splice never eats into the quoted body. - const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; - const isQuoteBoundary = (n: Node | null): boolean => { - if (!n || n.nodeType !== 1) return false; - const el = n as Element; - return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html'); - }; - const toRemove: Node[] = []; - let cursor: Node | null = startEl; - while (cursor) { - toRemove.push(cursor); - if (cursor === removeUntil) break; - const next: Node | null = cursor.nextSibling; - if (!removeUntil && isQuoteBoundary(next)) break; - cursor = next; - } - const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; - toRemove.forEach((node) => parent.removeChild(node)); - replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore)); + Array.from(replacementHost.childNodes).forEach((node) => + removed.parent.insertBefore(node, removed.insertBefore) + ); const nextHtml = doc.body.innerHTML; if (nextHtml !== currentHtml) { @@ -1102,8 +1057,8 @@ export function EmailComposer({ })); // Keep a ref to current state for the unmount save - const stateRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName, attachments: snapshotAttachments() }); - stateRef.current = { to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName, attachments: snapshotAttachments() }; + const stateRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName, attachments: snapshotAttachments(), plainTextMode }); + stateRef.current = { to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName, attachments: snapshotAttachments(), plainTextMode }; // Track initial values for dirty detection (captured once on first render) const initialValuesRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, attachmentCount: attachments.length }); @@ -1192,6 +1147,31 @@ export function EmailComposer({ } }, [plainTextMode]); + // Switch this one message between rich text and plain text (#1022). The + // body is converted in place and the embedded signature survives in the + // target format (compose-format.ts). Formatting is dropped going to plain + // text - that is what plain text means - and does not come back on a + // second switch. + const togglePlainTextMode = useCallback(() => { + const separator = signatureSeparatorEnabled; + if (plainTextMode) { + setBody((prev) => plainComposeBodyToHtml(prev, signatureIdentity, { separator })); + setPlainTextMode(false); + return; + } + // serializeEditorContent (not the React `body` mirror) so a QuotedHtml + // island's verbatim body is what gets converted. The editor unmounts with + // the switch - drop the ref so nothing talks to a destroyed instance. + const editor = editorRef.current; + const html = editor && !editor.isDestroyed ? serializeEditorContent(editor) : body; + editorRef.current = null; + setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator })); + 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]); + // Move a chip from one recipient field to another. `toIndex`, when given, // inserts at that position in the destination (drag-and-drop reordering, // #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button). @@ -2390,7 +2370,7 @@ export function EmailComposer({ setScheduleValue(''); setScheduleError(''); // Clear ref so unmount effect doesn't re-save - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [] }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; } catch (err) { debug.error('Failed to send email:', err); // A timeout is not a clean failure: the submission may have reached the @@ -2449,7 +2429,7 @@ export function EmailComposer({ if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [] }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; emitClose(); }; @@ -2474,7 +2454,7 @@ export function EmailComposer({ toast.error(t('save_failed')); explicitCloseRef.current = false; } else { - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [] }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; } } finally { emitClose(); @@ -2491,7 +2471,7 @@ export function EmailComposer({ if (draftId && onDiscardDraft) { onDiscardDraft(draftId); } - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [] }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; emitClose(); }; @@ -3109,6 +3089,21 @@ export function EmailComposer({ > + {templatesEnabled && <>