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.
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('') + ).toBe('alice@example.com'); + }); + + it('renders links as "textVisit our site
')).toBe( + 'Visit our siteHello 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('
, 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
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);