mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: mark quotes, number lists and keep the signature delimiter in the text part of sent mail
This commit is contained in:
@@ -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>-- </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
@@ -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(/'/gi, "'")
|
.replace(/'/gi, "'")
|
||||||
.replace(/'/gi, "'")
|
.replace(/'/gi, "'")
|
||||||
.replace(/&/gi, '&');
|
.replace(/&/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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user