fix: mark quotes and list items in the plain-text part of sent mail

This commit is contained in:
Linus Rath
2026-10-11 20:00:47 +02:00
parent e9a37e14bd
commit 46e4f8385c
3 changed files with 58 additions and 2 deletions
+2 -1
View File
@@ -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 <br> tags. Paragraph spacing is on so
* <p> 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 });
}
/**
+24
View File
@@ -60,4 +60,28 @@ describe('htmlToPlainText', () => {
// recognise as the start of a signature.
expect(htmlToPlainText('<p>Hi</p><p>-- </p><p>User A</p>')).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 = '<p>Sure.</p><p>On Sun, B wrote:</p><div data-quoted-html=""><p>Hi A,</p><p>can you?</p></div>';
expect(htmlToPlainText(html, mail)).toBe('Sure.\n\nOn Sun, B wrote:\n\n> Hi A,\n>\n> can you?');
});
it('nests quotes', () => {
expect(htmlToPlainText('<blockquote><p>a</p><blockquote><p>b</p></blockquote></blockquote>', mail)).toBe('> a\n>\n> > b');
});
it('marks list items', () => {
expect(htmlToPlainText('<ul><li>Revenue</li><li>Costs</li></ul><ol start="3"><li>x</li><li>y</li></ol>', mail))
.toBe('- Revenue\n- Costs\n\n3. x\n4. y');
});
it('leaves the default conversion unchanged', () => {
expect(htmlToPlainText('<blockquote>a</blockquote><ul><li>b</li></ul>')).toBe('a\nb');
});
});
});
+32 -1
View File
@@ -55,6 +55,12 @@ export interface HtmlToPlainTextOptions {
* are visually spaced; leave off for compact output like signatures.
*/
paragraphSpacing?: boolean;
/**
* Mark quoted text (<blockquote>, 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);
}