Improved cleanHtml and the handling of removeColors for dark mode

This commit is contained in:
the-djmaze 2022-02-10 10:25:21 +01:00
parent 2c5322129c
commit 9188a8bcd2
2 changed files with 151 additions and 151 deletions

View file

@ -13,6 +13,8 @@ const
"'": ''' "'": '''
}, },
replaceWithChildren = node => node.replaceWith(...[...node.childNodes]),
// Strip utm_* tracking // Strip utm_* tracking
stripTracking = text => text.replace(/(\?|&|&)utm_[a-z]+=[^&?#]*/si, '$1'); stripTracking = text => text.replace(/(\?|&|&)utm_[a-z]+=[^&?#]*/si, '$1');
@ -29,8 +31,9 @@ export const
* @param {string} text * @param {string} text
* @returns {string} * @returns {string}
*/ */
clearHtml = (html, contentLocationUrls) => { cleanHtml = (html, contentLocationUrls, removeColors) => {
const debug = false, // Config()->Get('debug', 'enable', false); const
debug = false, // Config()->Get('debug', 'enable', false);
useProxy = !!SettingsGet('UseLocalProxyForExternalImages'), useProxy = !!SettingsGet('UseLocalProxyForExternalImages'),
detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'),
@ -38,7 +41,72 @@ export const
hasExternals: false, hasExternals: false,
foundCIDs: [], foundCIDs: [],
foundContentLocationUrls: [] foundContentLocationUrls: []
},
// convert body attributes to CSS
tasks = {
link: value => {
if (/^#[a-fA-Z0-9]{3,6}$/.test(value)) {
tpl.content.querySelectorAll('a').forEach(node => node.style.color = value)
}
},
text: (value, node) => node.style.color = value,
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
bottommargin: (value, node) => node.style.marginBottom = pInt(value) + 'px',
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
}; };
// if (static::Config()->Get('labs', 'strict_html_parser', true))
let
value,
allowedAttributes = [
// defaults
'name',
'dir', 'lang', 'style', 'title',
'background', 'bgcolor', 'alt', 'height', 'width', 'src', 'href',
'border', 'bordercolor', 'charset', 'direction', 'language',
// a
'coords', 'download', 'hreflang', 'shape',
// body
'alink', 'bgproperties', 'bottommargin', 'leftmargin', 'link', 'rightmargin', 'text', 'topmargin', 'vlink',
'marginwidth', 'marginheight', 'offset',
// button,
'disabled', 'type', 'value',
// col
'align', 'valign',
// font
'color', 'face', 'size',
// form
'novalidate',
// hr
'noshade',
// img
'hspace', 'sizes', 'srcset', 'vspace', 'usemap',
// input, textarea
'checked', 'max', 'min', 'maxlength', 'multiple', 'pattern', 'placeholder', 'readonly',
'required', 'step', 'wrap',
// label
'for',
// meter
'low', 'high', 'optimum',
// ol
'reversed', 'start',
// option
'selected', 'label',
// table
'cols', 'rows', 'frame', 'rules', 'summary', 'cellpadding', 'cellspacing',
// th
'abbr', 'scope',
// td
'axis', 'colspan', 'rowspan', 'headers', 'nowrap'
],
disallowedAttributes = [
'id', 'class', 'contenteditable', 'designmode', 'formaction', 'manifest', 'action',
'data-bind', 'data-reactid', 'xmlns', 'srcset',
'fscommand', 'seeksegmenttime'
];
tpl.innerHTML = html tpl.innerHTML = html
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, aMatches => { .replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, aMatches => {
return (aMatches[1] + aMatches[2].trim() + aMatches[3].trim()).replace(/\r?\n/g, '<br>'); return (aMatches[1] + aMatches[2].trim() + aMatches[3].trim()).replace(/\r?\n/g, '<br>');
@ -47,13 +115,13 @@ export const
.replace(/<!--[\s\S]*?-->/g, '') .replace(/<!--[\s\S]*?-->/g, '')
// \MailSo\Base\HtmlUtils::ClearTags() // \MailSo\Base\HtmlUtils::ClearTags()
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
.replace(/<\/?(link|form|center|base|meta|bgsound|keygen|source|object|embed|applet|mocha|i?frame|frameset|video|audio|area|map)(\s[\s\S]*?)?>/gi, '') .replace(/<\/?(link|form|input|output|select|button|textarea|center|base|meta|bgsound|keygen|source|object|embed|applet|mocha|i?frame|frameset|video|audio|area|map)(\s[\s\S]*?)?>/gi, '')
// GetDomFromText // GetDomFromText
.replace('<o:p></o:p>', '') .replace('<o:p></o:p>', '')
.replace('<o:p>', '<span>') .replace('<o:p>', '<span>')
.replace('</o:p>', '</span>') .replace('</o:p>', '</span>')
// https://github.com/the-djmaze/snappymail/issues/187 // https://github.com/the-djmaze/snappymail/issues/187
.replace(/<a(?:\s[^>]*)?>((?![\s\S]*<\/a)[\s\S]*?<a(\s[^>]*)?>)/gi, '$1') // .replace(/<a(?:\s[^>]*)?>((?![\s\S]*<\/a)[\s\S]*?<a(\s[^>]*)?>)/gi, '$1')
// \MailSo\Base\HtmlUtils::ClearFastTags // \MailSo\Base\HtmlUtils::ClearFastTags
.replace(/<p[^>]*><\/p>/i, '') .replace(/<p[^>]*><\/p>/i, '')
.replace(/<!doctype[^>]*>/i, '') .replace(/<!doctype[^>]*>/i, '')
@ -61,73 +129,13 @@ export const
.trim(); .trim();
html = ''; html = '';
// convert body attributes to CSS
const tasks = {
link: value => {
if (/^#[a-fA-Z0-9]{3,6}$/.test(value)) {
tpl.content.querySelectorAll('a').forEach(node => node.style.color = value)
}
},
text: (value, node) => node.style.color = value,
topmargin: (value, node) => node.style.marginTop = pInt(value) + 'px',
leftmargin: (value, node) => node.style.marginLeft = pInt(value) + 'px',
bottommargin: (value, node) => node.style.marginBottom = pInt(value) + 'px',
rightmargin: (value, node) => node.style.marginRight = pInt(value) + 'px'
};
// if (static::Config()->Get('labs', 'strict_html_parser', true))
let allowedAttributes = [
// defaults
'name',
'dir', 'lang', 'style', 'title',
'background', 'bgcolor', 'alt', 'height', 'width', 'src', 'href',
'border', 'bordercolor', 'charset', 'direction', 'language',
// a
'coords', 'download', 'hreflang', 'shape',
// body
'alink', 'bgproperties', 'bottommargin', 'leftmargin', 'link', 'rightmargin', 'text', 'topmargin', 'vlink',
'marginwidth', 'marginheight', 'offset',
// button,
'disabled', 'type', 'value',
// col
'align', 'valign',
// font
'color', 'face', 'size',
// form
'novalidate',
// hr
'noshade',
// img
'hspace', 'sizes', 'srcset', 'vspace', 'usemap',
// input, textarea
'checked', 'max', 'min', 'maxlength', 'multiple', 'pattern', 'placeholder', 'readonly',
'required', 'step', 'wrap',
// label
'for',
// meter
'low', 'high', 'optimum',
// ol
'reversed', 'start',
// option
'selected', 'label',
// table
'cols', 'rows', 'frame', 'rules', 'summary', 'cellpadding', 'cellspacing',
// th
'abbr', 'scope',
// td
'axis', 'colspan', 'rowspan', 'headers', 'nowrap'
];
let disallowedAttributes = [
'id', 'class', 'contenteditable', 'designmode', 'formaction', 'manifest', 'action',
'data-bind', 'data-reactid', 'xmlns', 'srcset',
'fscommand', 'seeksegmenttime'
];
tpl.content.querySelectorAll('*').forEach(oElement => { tpl.content.querySelectorAll('*').forEach(oElement => {
const name = oElement.tagName.toUpperCase(), const name = oElement.tagName,
oStyle = oElement.style, oStyle = oElement.style,
getAttribute = name => oElement.hasAttribute(name) ? oElement.getAttribute(name).trim() : ''; hasAttribute = name => oElement.hasAttribute(name),
getAttribute = name => hasAttribute(name) ? oElement.getAttribute(name).trim() : '',
setAttribute = (name, value) => oElement.setAttribute(name, value),
delAttribute = name => oElement.removeAttribute(name);
if (['HEAD','STYLE','SVG','SCRIPT','TITLE','INPUT','BUTTON','TEXTAREA','SELECT'].includes(name) if (['HEAD','STYLE','SVG','SCRIPT','TITLE','INPUT','BUTTON','TEXTAREA','SELECT'].includes(name)
|| 'none' == oStyle.display || 'none' == oStyle.display
@ -143,19 +151,36 @@ export const
if ('BODY' === name) { if ('BODY' === name) {
forEachObjectEntry(tasks, (name, cb) => { forEachObjectEntry(tasks, (name, cb) => {
if (oElement.hasAttribute(name)) { if (hasAttribute(name)) {
cb(getAttribute(name), oElement); cb(getAttribute(name), oElement);
oElement.removeAttribute(name); delAttribute(name);
} }
}); });
} }
if ('TABLE' === name && oElement.hasAttribute('width')) { else if ('TABLE' === name && hasAttribute('width')) {
let value = getAttribute('width'); value = getAttribute('width');
oElement.removeAttribute('width'); delAttribute('width');
oStyle.maxWidth = value + (/^[0-9]+$/.test(value) ? 'px' : ''); oStyle.maxWidth = value + (/^[0-9]+$/.test(value) ? 'px' : '');
oStyle.removeProperty('width'); oStyle.removeProperty('width');
oStyle.removeProperty('min-width'); }
else if ('A' === name) {
value = oElement.href;
// https://github.com/the-djmaze/snappymail/issues/187 <a> can't have block element inside
if (!value || oElement.querySelector('td,div,p,li')) {
replaceWithChildren(oElement);
return;
}
value = stripTracking(value);
if (!/^([a-z]+):/i.test(value) && '//' !== value.slice(0, 2)) {
setAttribute('data-x-broken-href', value);
delAttribute('href');
} else {
setAttribute('target', '_blank');
setAttribute('rel', 'external nofollow noopener noreferrer');
}
setAttribute('tabindex', '-1');
} }
const aAttrsForRemove = []; const aAttrsForRemove = [];
@ -171,39 +196,23 @@ export const
// || sAttrName.includes(':') // || sAttrName.includes(':')
|| disallowedAttributes.includes(sAttrName)) || disallowedAttributes.includes(sAttrName))
{ {
oElement.removeAttribute(sAttrName); delAttribute(sAttrName);
aAttrsForRemove.push(sAttrName); aAttrsForRemove.push(sAttrName);
} }
} }
} }
if (oElement.hasAttribute('href')) {
let sHref = stripTracking(getAttribute('href'));
if (!/^([a-z]+):/i.test(sHref) && '//' !== sHref.slice(0, 2)) {
oElement.setAttribute('data-x-broken-href', sHref);
oElement.removeAttribute('href');
}
if ('A' === name) {
oElement.setAttribute('rel', 'external nofollow noopener noreferrer');
}
}
// SVG xlink:href // SVG xlink:href
/* /*
if (oElement.hasAttribute('xlink:href')) { if (hasAttribute('xlink:href')) {
oElement.removeAttribute('xlink:href'); delAttribute('xlink:href');
} }
*/ */
if ('A' === name) {
oElement.setAttribute('tabindex', '-1');
oElement.setAttribute('target', '_blank');
}
let skipStyle = false; let skipStyle = false;
if (oElement.hasAttribute('src')) { if (hasAttribute('src')) {
let sSrc = getAttribute('src'); value = getAttribute('src');
oElement.removeAttribute('src'); delAttribute('src');
if (detectHiddenImages if (detectHiddenImages
&& 'IMG' === name && 'IMG' === name
@ -214,51 +223,50 @@ export const
'github.com/notifications/beacon/', 'github.com/notifications/beacon/',
'mandrillapp.com/track/open', 'mandrillapp.com/track/open',
'list-manage.com/track/open' 'list-manage.com/track/open'
].filter(uri => sSrc.toLowerCase().includes(uri)).length ].filter(uri => value.toLowerCase().includes(uri)).length
)) { )) {
skipStyle = true; skipStyle = true;
oElement.setAttribute('style', 'display:none'); setAttribute('style', 'display:none');
oElement.setAttribute('data-x-hidden-src', sSrc); setAttribute('data-x-hidden-src', value);
} }
else if (contentLocationUrls[sSrc]) else if (contentLocationUrls[value])
{ {
oElement.setAttribute('data-x-src-location', sSrc); setAttribute('data-x-src-location', value);
result.foundContentLocationUrls.push(sSrc); result.foundContentLocationUrls.push(value);
} }
else if ('cid:' === sSrc.slice(0, 4)) else if ('cid:' === value.slice(0, 4))
{ {
oElement.setAttribute('data-x-src-cid', sSrc.slice(4)); setAttribute('data-x-src-cid', value.slice(4));
result.foundCIDs.push(sSrc.slice(4)); result.foundCIDs.push(value.slice(4));
} }
else if (/^https?:\/\//i.test(sSrc) || '//' === sSrc.slice(0, 2)) else if (/^https?:\/\//i.test(value) || '//' === value.slice(0, 2))
{ {
oElement.setAttribute('data-x-src', useProxy ? proxy(sSrc) : sSrc); setAttribute('data-x-src', useProxy ? proxy(value) : value);
result.hasExternals = true; result.hasExternals = true;
} }
else if ('data:image/' === sSrc.slice(0, 11)) else if ('data:image/' === value.slice(0, 11))
{ {
oElement.setAttribute('src', sSrc); setAttribute('src', value);
} }
else else
{ {
oElement.setAttribute('data-x-broken-src', sSrc); setAttribute('data-x-broken-src', value);
} }
} }
if (oElement.hasAttribute('background')) { if (hasAttribute('background')) {
let sBackground = getAttribute('background'); oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
if (sBackground) { delAttribute('background');
oStyle.backgroundImage = 'url("' + sBackground + '")';
}
oElement.removeAttribute('background');
} }
if (oElement.hasAttribute('bgcolor')) { if (hasAttribute('bgcolor')) {
let sBackgroundColor = getAttribute('bgcolor'); oStyle.backgroundColor = getAttribute('bgcolor');
if (sBackgroundColor) { delAttribute('bgcolor');
oStyle.backgroundColor = sBackgroundColor; }
}
oElement.removeAttribute('bgcolor'); if (hasAttribute('color')) {
oStyle.color = getAttribute('color');
delAttribute('color');
} }
if (!skipStyle) { if (!skipStyle) {
@ -269,6 +277,7 @@ export const
*/ */
oStyle.removeProperty('behavior'); oStyle.removeProperty('behavior');
oStyle.removeProperty('cursor'); oStyle.removeProperty('cursor');
oStyle.removeProperty('min-width');
const urls = { const urls = {
cid: [], // 'data-x-style-cid' cid: [], // 'data-x-style-cid'
@ -302,22 +311,31 @@ export const
// oStyle.removeProperty('list-style-image'); // oStyle.removeProperty('list-style-image');
if (urls.cid.length) { if (urls.cid.length) {
oElement.setAttribute('data-x-style-cid', JSON.stringify(urls.cid)); setAttribute('data-x-style-cid', JSON.stringify(urls.cid));
} }
if (urls.remote.length) { if (urls.remote.length) {
oElement.setAttribute('data-x-style-url', JSON.stringify(urls.remote)); setAttribute('data-x-style-url', JSON.stringify(urls.remote));
} }
if (urls.broken.length) { if (urls.broken.length) {
oElement.setAttribute('data-x-style-broken-urls', JSON.stringify(urls.broken)); setAttribute('data-x-style-broken-urls', JSON.stringify(urls.broken));
} }
if (11 < pInt(oStyle.fontSize)) { if (11 < pInt(oStyle.fontSize)) {
oStyle.removeProperty('font-size'); oStyle.removeProperty('font-size');
} }
// Removes background and color
// Many e-mails incorrectly only define one, not both
// And in dark theme mode this kills the readability
if (removeColors) {
oStyle.removeProperty('background-color');
oStyle.removeProperty('background-image');
oStyle.removeProperty('color');
}
} }
if (debug && aAttrsForRemove) { if (debug && aAttrsForRemove.length) {
oElement.setAttribute('data-removed-attrs', aAttrsForRemove.join(', ')); setAttribute('data-removed-attrs', aAttrsForRemove.join(', '));
} }
}); });
@ -326,21 +344,6 @@ export const
return result; return result;
}, },
// Removes background and color
// Many e-mails incorrectly only define one, not both
// And in dark theme mode this kills the readability
removeColors = html => {
let l;
do {
l = html.length;
html = html
.replace(/(<[^>]+[;"'])\s*background(-[a-z]+)?\s*:[^;"']+/gi, '$1')
.replace(/(<[^>]+[;"'])\s*color\s*:[^;"']+/gi, '$1')
.replace(/(<[^>]+)\s(bg)?color=("[^"]+"|'[^']+')/gi, '$1');
} while (l != html.length)
return html;
},
/** /**
* @param {string} html * @param {string} html
* @returns {string} * @returns {string}
@ -356,7 +359,7 @@ export const
blockquotes(bq); blockquotes(bq);
// Convert blockquote // Convert blockquote
bq.innerHTML = '\n' + ('\n' + bq.innerHTML.replace(/\n{3,}/gm, '\n\n').trim() + '\n').replace(/^/gm, '&gt; '); bq.innerHTML = '\n' + ('\n' + bq.innerHTML.replace(/\n{3,}/gm, '\n\n').trim() + '\n').replace(/^/gm, '&gt; ');
bq.replaceWith(...[...bq.childNodes]); replaceWithChildren(bq);
} }
}; };

View file

@ -4,7 +4,7 @@ import { MessagePriority } from 'Common/EnumsUser';
import { i18n } from 'Common/Translator'; import { i18n } from 'Common/Translator';
import { doc } from 'Common/Globals'; import { doc } from 'Common/Globals';
import { encodeHtml, removeColors, plainToHtml, clearHtml } from 'Common/Html'; import { encodeHtml, plainToHtml, cleanHtml } from 'Common/Html';
import { isArray, arrayLength, forEachObjectEntry } from 'Common/Utils'; import { isArray, arrayLength, forEachObjectEntry } from 'Common/Utils';
import { serverRequestRaw } from 'Common/Links'; import { serverRequestRaw } from 'Common/Links';
@ -380,20 +380,17 @@ export class MessageModel extends AbstractModel {
viewHtml() { viewHtml() {
const body = this.body; const body = this.body;
if (body && this.html()) { if (body && this.html()) {
let html = this.html();
if (SettingsUserStore.removeColors()) {
html = removeColors(html);
}
const contentLocationUrls = {}, const contentLocationUrls = {},
oAttachments = this.attachments(); oAttachments = this.attachments();
// Get contentLocationUrls
oAttachments.forEach(oAttachment => { oAttachments.forEach(oAttachment => {
if (oAttachment.cid && oAttachment.contentLocation) { if (oAttachment.cid && oAttachment.contentLocation) {
contentLocationUrls[oAttachment.contentId()] = oAttachment.contentLocation; contentLocationUrls[oAttachment.contentId()] = oAttachment.contentLocation;
} }
}); });
let result = clearHtml(html, contentLocationUrls); let result = cleanHtml(this.html(), contentLocationUrls, SettingsUserStore.removeColors());
this.hasExternals(result.hasExternals); this.hasExternals(result.hasExternals);
// this.hasInternals = result.foundCIDs.length || result.foundContentLocationUrls.length; // this.hasInternals = result.foundCIDs.length || result.foundContentLocationUrls.length;
this.hasImages(body.rlHasImages = !!result.hasExternals); this.hasImages(body.rlHasImages = !!result.hasExternals);