diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 572f6f2a0..15ef3d85b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -84,9 +84,10 @@ import { toUnicodeEmail } from "@/lib/idn"; * 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). + * Quotes get "> " and list items their markers, like any plain-text mail. */ function htmlToPlainText(html: string): string { - return htmlToPlainTextShared(html, { paragraphSpacing: true }); + return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true }); } /** diff --git a/lib/__tests__/html-to-text.test.ts b/lib/__tests__/html-to-text.test.ts index 8473fcdaf..134becd82 100644 --- a/lib/__tests__/html-to-text.test.ts +++ b/lib/__tests__/html-to-text.test.ts @@ -60,4 +60,28 @@ describe('htmlToPlainText', () => { // recognise as the start of a signature. expect(htmlToPlainText('

Hi

--

User A

')).toBe('Hi\n-- \nUser A'); }); + + describe('mailMarkup', () => { + // The text part of a reply showed the quoted original as ordinary text, + // and list items without their bullets. + const mail = { paragraphSpacing: true, mailMarkup: true }; + + it('prefixes quoted text with "> "', () => { + const html = '

Sure.

On Sun, B wrote:

Hi A,

can you?

'; + expect(htmlToPlainText(html, mail)).toBe('Sure.\n\nOn Sun, B wrote:\n\n> Hi A,\n>\n> can you?'); + }); + + it('nests quotes', () => { + expect(htmlToPlainText('

a

b

', mail)).toBe('> a\n>\n> > b'); + }); + + it('marks list items', () => { + expect(htmlToPlainText('
  1. x
  2. y
', mail)) + .toBe('- Revenue\n- Costs\n\n3. x\n4. y'); + }); + + it('leaves the default conversion unchanged', () => { + expect(htmlToPlainText('
a
')).toBe('a\nb'); + }); + }); }); diff --git a/lib/html-to-text.ts b/lib/html-to-text.ts index 64dfb9a3a..bd0ed7e75 100644 --- a/lib/html-to-text.ts +++ b/lib/html-to-text.ts @@ -55,6 +55,12 @@ export interface HtmlToPlainTextOptions { * are visually spaced; leave off for compact output like signatures. */ paragraphSpacing?: boolean; + /** + * Mark quoted text (
, the composer's quoted original) with "> " + * and list items with "- " or "1. ", as plain-text mail does. Without them + * the text part of a reply could not tell the reply from the quote. + */ + mailMarkup?: boolean; } function normalizeLineBreaks(value: string): string { @@ -76,7 +82,7 @@ function normalizeLineBreaks(value: string): string { * 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 { paragraphSpacing = false, mailMarkup = false } = options; const document = parseHtmlSafely(html); const chunks: string[] = []; @@ -152,6 +158,31 @@ export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = const separator = paragraphSpacing && PARAGRAPH_TAGS.has(tagName) ? 2 : 1; const isBlock = BLOCK_TAGS.has(tagName); + if (mailMarkup && (tagName === 'blockquote' || element.hasAttribute('data-quoted-html'))) { + const quoted = htmlToPlainText(element.innerHTML, options); + if (quoted) { + ensureNewlines(separator); + chunks.push(quoted.split('\n').map((line) => (line ? `> ${line}` : '>')).join('\n')); + ensureNewlines(separator); + } + return; + } + + if (mailMarkup && tagName === 'li') { + const list = element.parentElement; + let marker = '- '; + if (list?.tagName.toLowerCase() === 'ol') { + const start = Number(list.getAttribute('start') ?? 1) || 1; + const position = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === 'li').indexOf(element); + marker = `${start + position}. `; + } + ensureNewlines(1); + chunks.push(marker); + Array.from(element.childNodes).forEach(walk); + ensureNewlines(1); + return; + } + if (isBlock) { ensureNewlines(separator); }