From 0b9fe5451f9ccfd0bda36c52d176583be252f25f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:15:04 +0200 Subject: [PATCH] fix: preserve line breaks in generated text/plain alternative #421 --- components/email/email-composer.tsx | 10 +- lib/__tests__/html-to-text.test.ts | 57 ++++++++++ lib/html-to-text.ts | 164 ++++++++++++++++++++++++++++ lib/signature-utils.ts | 85 +------------- 4 files changed, 229 insertions(+), 87 deletions(-) create mode 100644 lib/__tests__/html-to-text.test.ts create mode 100644 lib/html-to-text.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 12314cb5..4d74944f 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -50,11 +50,15 @@ import { } from "@/lib/email-composer-utils"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; +import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; -/** Strip HTML tags and decode entities to get a plain-text version */ +/** + * Derives the text/plain alternative from the composer's HTML body, preserving + * line structure from block elements and
tags. Paragraph spacing is on so + *

blocks are separated by a blank line, matching their visual rendering (#421). + */ function htmlToPlainText(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent || ''; + return htmlToPlainTextShared(html, { paragraphSpacing: true }); } /** diff --git a/lib/__tests__/html-to-text.test.ts b/lib/__tests__/html-to-text.test.ts new file mode 100644 index 00000000..4f3bcee2 --- /dev/null +++ b/lib/__tests__/html-to-text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { htmlToPlainText } from '../html-to-text'; + +describe('htmlToPlainText', () => { + it('preserves line breaks from
within a paragraph', () => { + expect(htmlToPlainText('

Line 1.
Line 2.

')).toBe('Line 1.\nLine 2.'); + }); + + it('separates block elements with newlines instead of running them together (#421)', () => { + // The previous textContent-based implementation produced "Line 1.Line 2.Paragraph 2." + expect(htmlToPlainText('
Line 1.
Line 2.
Paragraph 2.
')).toBe( + 'Line 1.\nLine 2.\nParagraph 2.' + ); + }); + + it('with paragraphSpacing, separates paragraphs with a blank line', () => { + expect( + htmlToPlainText('

Line 1.
Line 2.

Paragraph 2.

', { paragraphSpacing: true }) + ).toBe('Line 1.\nLine 2.\n\nParagraph 2.'); + }); + + it('without paragraphSpacing, separates paragraphs with a single newline', () => { + expect(htmlToPlainText('

A

B

')).toBe('A\nB'); + }); + + it('renders links as text when the text equals the href', () => { + expect( + htmlToPlainText('

alice@example.com

') + ).toBe('alice@example.com'); + }); + + it('renders links as "text " when they differ', () => { + expect(htmlToPlainText('

Visit our site

')).toBe( + 'Visit our site ' + ); + }); + + it('collapses excess whitespace and trims the result', () => { + expect(htmlToPlainText('

Hello world

')).toBe('Hello world'); + }); + + it('caps consecutive blank lines at one', () => { + expect(htmlToPlainText('

A




B

', { paragraphSpacing: true })).toBe( + 'A\n\nB' + ); + }); + + it('returns an empty string for empty or whitespace-only HTML', () => { + expect(htmlToPlainText('')).toBe(''); + expect(htmlToPlainText('

')).toBe(''); + }); + + it('handles nested lists as separate lines', () => { + expect(htmlToPlainText('')).toBe('One\nTwo'); + }); +}); diff --git a/lib/html-to-text.ts b/lib/html-to-text.ts new file mode 100644 index 00000000..941bbbcb --- /dev/null +++ b/lib/html-to-text.ts @@ -0,0 +1,164 @@ +import { parseHtmlSafely } from '@/lib/email-sanitization'; + +// Block-level tags whose boundaries become line breaks in the plain-text +// rendering. Without this, the DOM's textContent would run every block +// together on a single line (#421). +const BLOCK_TAGS = new Set([ + 'address', + 'article', + 'aside', + 'blockquote', + 'div', + 'dd', + 'dl', + 'dt', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'li', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'table', + 'tr', + 'ul', +]); + +// Paragraph-level blocks that, with `paragraphSpacing` enabled, are separated by +// a blank line rather than a single newline - matching how they render visually. +const PARAGRAPH_TAGS = new Set([ + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ol', + 'p', + 'pre', + 'table', + 'ul', +]); + +export interface HtmlToPlainTextOptions { + /** + * Separate paragraph-level blocks (

, headings, lists, ...) with a blank + * line instead of a single newline. Use for email bodies where paragraphs + * are visually spaced; leave off for compact output like signatures. + */ + paragraphSpacing?: boolean; +} + +function normalizeLineBreaks(value: string): string { + return value + .replace(/\r\n?/g, '\n') + .replace(new RegExp(String.fromCharCode(160), 'g'), ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Converts HTML into readable plain text, preserving line structure from block + * elements and
tags. Links render as `text ` unless the text already + * is the href. Used to derive the text/plain alternative of an HTML email. + */ +export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string { + const { paragraphSpacing = false } = options; + const document = parseHtmlSafely(html); + const chunks: string[] = []; + + const appendText = (value: string) => { + if (!value) return; + const normalized = value.replace(/\s+/g, ' '); + if (!normalized.trim()) return; + const previous = chunks[chunks.length - 1]; + if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) { + chunks.push(' '); + } + chunks.push(normalized); + }; + + // Number of newline characters already trailing the accumulated output. + const trailingNewlines = (): number => { + let count = 0; + for (let i = chunks.length - 1; i >= 0; i--) { + const chunk = chunks[i]; + let j = chunk.length - 1; + let inner = 0; + while (j >= 0 && chunk[j] === '\n') { + inner++; + j--; + } + count += inner; + if (j >= 0) break; // chunk had non-newline content, stop counting + } + return count; + }; + + // Ensures at least `min` trailing newlines, without emitting leading ones. + const ensureNewlines = (min: number) => { + if (chunks.length === 0) return; + for (let current = trailingNewlines(); current < min; current++) { + chunks.push('\n'); + } + }; + + const walk = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + appendText(node.textContent || ''); + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + if (tagName === 'br') { + // Additive so consecutive
can form a blank line (capped by normalize). + if (chunks.length > 0) chunks.push('\n'); + return; + } + + if (tagName === 'a') { + const text = element.textContent?.replace(/\s+/g, ' ').trim() || ''; + const href = element.getAttribute('href')?.trim() || ''; + const normalizedHref = href.replace(/^mailto:/i, ''); + if (text && normalizedHref && text === normalizedHref) { + appendText(text); + return; + } + if (text && href && text !== href) { + appendText(`${text} <${href}>`); + return; + } + } + + const separator = paragraphSpacing && PARAGRAPH_TAGS.has(tagName) ? 2 : 1; + const isBlock = BLOCK_TAGS.has(tagName); + + if (isBlock) { + ensureNewlines(separator); + } + + Array.from(element.childNodes).forEach(walk); + + if (isBlock) { + ensureNewlines(separator); + } + }; + + Array.from(document.body.childNodes).forEach(walk); + return normalizeLineBreaks(chunks.join('')); +} diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index 678fa461..b707b49e 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -1,25 +1,11 @@ import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization'; +import { htmlToPlainText } from '@/lib/html-to-text'; type SignatureSource = { textSignature?: string; htmlSignature?: string; }; -const BLOCK_TAGS = new Set([ - 'address', - 'article', - 'aside', - 'blockquote', - 'div', - 'footer', - 'header', - 'li', - 'nav', - 'p', - 'section', - 'tr', -]); - function normalizeSignatureLineBreaks(value: string): string { return value .replace(/\r\n?/g, '\n') @@ -29,75 +15,6 @@ function normalizeSignatureLineBreaks(value: string): string { .trim(); } -function htmlToPlainText(html: string): string { - const document = parseHtmlSafely(html); - const chunks: string[] = []; - - const appendText = (value: string) => { - if (!value) return; - const normalized = value.replace(/\s+/g, ' '); - if (!normalized.trim()) return; - const previous = chunks[chunks.length - 1]; - if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) { - chunks.push(' '); - } - chunks.push(normalized); - }; - - const appendNewline = () => { - const previous = chunks[chunks.length - 1]; - if (previous === '\n') return; - if (previous?.endsWith('\n')) return; - chunks.push('\n'); - }; - - const walk = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - appendText(node.textContent || ''); - return; - } - - if (node.nodeType !== Node.ELEMENT_NODE) { - return; - } - - const element = node as HTMLElement; - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'br') { - appendNewline(); - return; - } - - if (tagName === 'a') { - const text = element.textContent?.replace(/\s+/g, ' ').trim() || ''; - const href = element.getAttribute('href')?.trim() || ''; - const normalizedHref = href.replace(/^mailto:/i, ''); - if (text && normalizedHref && text === normalizedHref) { - appendText(text); - return; - } - if (text && href && text !== href) { - appendText(`${text} <${href}>`); - return; - } - } - - if (BLOCK_TAGS.has(tagName) && chunks.length > 0) { - appendNewline(); - } - - Array.from(element.childNodes).forEach(walk); - - if (BLOCK_TAGS.has(tagName)) { - appendNewline(); - } - }; - - Array.from(document.body.childNodes).forEach(walk); - return normalizeSignatureLineBreaks(chunks.join('')); -} - export function getPlainTextSignature(signature?: SignatureSource | null): string { if (signature?.textSignature?.trim()) { return normalizeSignatureLineBreaks(signature.textSignature);