fix: mark quotes, number lists and keep the signature delimiter in the text part of sent mail

This commit is contained in:
Linus Rath
2026-10-31 03:40:56 +01:00
parent 60d0e98fc7
commit 0647cbf171
4 changed files with 102 additions and 5 deletions
+27
View File
@@ -98,6 +98,33 @@ describe('htmlToPlainText', () => {
it('decodes   to space', () => { it('decodes   to space', () => {
expect(htmlToPlainText('hello world')).toBe('hello world'); expect(htmlToPlainText('hello world')).toBe('hello world');
}); });
it('keeps the "-- " signature delimiter', () => {
expect(htmlToPlainText('<p>Thanks</p><p data-signature-block="separator">-- </p><p>Jane</p>')).toBe('Thanks\n\n-- \n\nJane');
expect(htmlToPlainText('<p>Thanks</p><p>--&nbsp;</p><p>Jane</p>')).toBe('Thanks\n\n-- \n\nJane');
});
});
describe('htmlToPlainText with mail markup (the sent text part)', () => {
const mail = (html: string) => htmlToPlainText(html, { mailMarkup: true });
it('marks quoted text with "> ", nested quotes twice', () => {
expect(mail('<p>My reply</p><div data-quoted-html="true">On Monday, Ann wrote:<br><blockquote style="x">Hello<br>there<blockquote>older</blockquote></blockquote></div>'))
.toBe('My reply\n\nOn Monday, Ann wrote:\n\n> Hello\n> there\n>\n> > older');
});
it('numbers ordered lists from their start and dashes bullet lists', () => {
expect(mail('<ol><li>one</li><li>two</li></ol><ul><li>a</li></ul><ol start="4"><li>four</li></ol>'))
.toBe('1. one\n2. two\n- a\n4. four');
});
it('numbers a list nested in a bullet item on its own', () => {
expect(mail('<ul><li>a<ol><li>x</li><li>y</li></ol></li><li>b</li></ul>')).toBe('- a\n1. x\n2. y\n\n- b');
});
it('keeps the signature delimiter', () => {
expect(mail('<p>Hi</p><p data-signature-block="separator">-- </p><p>Jane</p>')).toBe('Hi\n\n-- \n\nJane');
});
}); });
describe('rewriteInlineImages', () => { describe('rewriteInlineImages', () => {
@@ -12,4 +12,9 @@ describe('ComposeScreen wiring', () => {
const sheet = source.slice(source.indexOf('<IdentitySheet')); const sheet = source.slice(source.indexOf('<IdentitySheet'));
expect(sheet.slice(0, 400)).toMatch(/onPick=\{\(identity\) => \{[^}]*setIdentityPickedByUser\(true\)/); expect(sheet.slice(0, 400)).toMatch(/onPick=\{\(identity\) => \{[^}]*setIdentityPickedByUser\(true\)/);
}); });
it('writes the text part of a rich message with mail markup', () => {
const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version'));
expect(build).toMatch(/textBody = htmlToPlainText\(safeHtml, \{ mailMarkup: true \}\);/);
});
}); });
+67 -4
View File
@@ -80,14 +80,48 @@ export function buildInitialHtml(
return `<p><br></p>${QUOTED_BLOCK_START}${quoted}</div>`; return `<p><br></p>${QUOTED_BLOCK_START}${quoted}</div>`;
} }
export interface HtmlToPlainTextOptions {
/**
* Mark quoted text (<blockquote>) with "> " and list items with "- " or
* "1. ", as plain-text mail does (webmail lib/html-to-text.ts mailMarkup).
* Without them the text part of a reply could not tell the reply from the
* quote. Use for the text/plain part of a sent message.
*/
mailMarkup?: boolean;
}
// Private-use characters that stand in for converted quotes until the end.
const QUOTE_TOKEN_OPEN = '\u0003';
const QUOTE_TOKEN_CLOSE = '\u0004';
const QUOTE_TOKEN_RE = /\u0003(\d+)\u0004/g;
// An innermost blockquote: one with no other blockquote opening inside.
const INNERMOST_BLOCKQUOTE_RE = /<blockquote\b[^>]*>((?:(?!<blockquote\b)[\s\S])*?)<\/blockquote>/i;
/** /**
* Strip HTML tags and decode common entities for a plain-text fallback body. * Strip HTML tags and decode common entities for a plain-text fallback body.
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't * Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
* have DOMParser in the RN runtime. Links keep their target as `text <href>` * have DOMParser in the RN runtime. Links keep their target as `text <href>`
* so a pasted link survives the text/plain alternative. * so a pasted link survives the text/plain alternative.
*/ */
export function htmlToPlainText(html: string): string { export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string {
if (!html) return ''; if (!html) return '';
if (!options.mailMarkup) return blocksToPlainText(html, false);
// Quotes from the innermost out: each becomes its text with "> " in front
// of every line, so a quote in a quote reads "> > ".
const quotes: string[] = [];
const expand = (text: string) => text.replace(QUOTE_TOKEN_RE, (_m, i: string) => quotes[Number(i)]);
let s = html;
for (let m = INNERMOST_BLOCKQUOTE_RE.exec(s); m; m = INNERMOST_BLOCKQUOTE_RE.exec(s)) {
const inner = expand(blocksToPlainText(m[1], true));
const token = inner
? `\n\n${QUOTE_TOKEN_OPEN}${quotes.push(inner.split('\n').map((line) => (line ? `> ${line}` : '>')).join('\n')) - 1}${QUOTE_TOKEN_CLOSE}\n\n`
: '';
s = s.slice(0, m.index) + token + s.slice(m.index + m[0].length);
}
return expand(blocksToPlainText(s, true));
}
function blocksToPlainText(html: string, mailMarkup: boolean): string {
let s = html; let s = html;
// Drop non-content blocks entirely. // Drop non-content blocks entirely.
s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, ''); s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
@@ -109,7 +143,30 @@ export function htmlToPlainText(html: string): string {
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n'); s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n'); s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
s = s.replace(/<\/(td|th)>/gi, '\t'); s = s.replace(/<\/(td|th)>/gi, '\t');
s = s.replace(/<li[^>]*>/gi, '• '); if (mailMarkup) {
// "- " in a bullet list, "1. " (from the list's start) in a numbered one.
const lists: Array<{ ordered: boolean; next: number }> = [];
s = s.replace(/<(\/?)(ol|ul|li)\b([^>]*)>/gi, (tag, close: string, name: string, attrs: string) => {
const kind = name.toLowerCase();
if (kind === 'li') {
if (close) return tag;
const list = lists[lists.length - 1];
if (!list?.ordered) return '- ';
return `${list.next++}. `;
}
if (close) {
lists.pop();
return tag;
}
// A list inside an item starts on its own line.
const nested = lists.length > 0;
const start = Number(attrs.match(/\sstart\s*=\s*["']?(-?\d+)/i)?.[1] ?? 1);
lists.push({ ordered: kind === 'ol', next: Number.isFinite(start) ? start : 1 });
return nested ? `\n${tag}` : tag;
});
} else {
s = s.replace(/<li[^>]*>/gi, '• ');
}
s = s.replace(/<[^>]+>/g, ''); s = s.replace(/<[^>]+>/g, '');
s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>'); s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>');
// Decode the most common entities; leave the rest as-is. // Decode the most common entities; leave the rest as-is.
@@ -121,8 +178,14 @@ export function htmlToPlainText(html: string): string {
.replace(/&#39;/gi, "'") .replace(/&#39;/gi, "'")
.replace(/&#x27;/gi, "'") .replace(/&#x27;/gi, "'")
.replace(/&amp;/gi, '&'); .replace(/&amp;/gi, '&');
// Collapse runs of >2 newlines and trim trailing whitespace per line. // Collapse runs of >2 newlines and trim trailing whitespace per line, but
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n'); // keep the space that makes "-- " the RFC 3676 signature delimiter, so the
// text part still marks the signature (webmail lib/html-to-text.ts).
s = s
.split('\n')
.map((line) => (/^--[ \t\u00a0]*$/.test(line) ? '-- ' : line.replace(/[ \t]+$/, '')))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
return s.trim(); return s.trim();
} }
+3 -1
View File
@@ -1306,7 +1306,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
// can preserve pasted <script>/<style>/etc. Strip them before sending. // can preserve pasted <script>/<style>/etc. Strip them before sending.
const safeHtml = stripDangerousTags(opts.forDraft ? rewritten.html : stripEditorMarkers(rewritten.html)); const safeHtml = stripDangerousTags(opts.forDraft ? rewritten.html : stripEditorMarkers(rewritten.html));
htmlBody = `<div>${safeHtml}</div>`; htmlBody = `<div>${safeHtml}</div>`;
textBody = htmlToPlainText(safeHtml); // The text part marks the quote with "> ", numbers ordered lists and
// keeps the "-- " signature delimiter, as plain-text mail does.
textBody = htmlToPlainText(safeHtml, { mailMarkup: true });
} }
const inlineFromBody = usedCids const inlineFromBody = usedCids