From 9188a8bcd2f71cf3cbb77b8e9de2d35a516c7f50 Mon Sep 17 00:00:00 2001 From: the-djmaze <> Date: Thu, 10 Feb 2022 10:25:21 +0100 Subject: [PATCH] Improved cleanHtml and the handling of removeColors for dark mode --- dev/Common/Html.js | 291 ++++++++++++++++++++++--------------------- dev/Model/Message.js | 11 +- 2 files changed, 151 insertions(+), 151 deletions(-) diff --git a/dev/Common/Html.js b/dev/Common/Html.js index e6e099184..379ab8e90 100644 --- a/dev/Common/Html.js +++ b/dev/Common/Html.js @@ -13,6 +13,8 @@ const "'": ''' }, + replaceWithChildren = node => node.replaceWith(...[...node.childNodes]), + // Strip utm_* tracking stripTracking = text => text.replace(/(\?|&|&)utm_[a-z]+=[^&?#]*/si, '$1'); @@ -29,8 +31,9 @@ export const * @param {string} text * @returns {string} */ - clearHtml = (html, contentLocationUrls) => { - const debug = false, // Config()->Get('debug', 'enable', false); + cleanHtml = (html, contentLocationUrls, removeColors) => { + const + debug = false, // Config()->Get('debug', 'enable', false); useProxy = !!SettingsGet('UseLocalProxyForExternalImages'), detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), @@ -38,7 +41,72 @@ export const hasExternals: false, foundCIDs: [], 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 .replace(/(
]*>)([\s\S]*?)(<\/pre>)/gi, aMatches => {
return (aMatches[1] + aMatches[2].trim() + aMatches[3].trim()).replace(/\r?\n/g, '
');
@@ -47,13 +115,13 @@ export const
.replace(//g, '')
// \MailSo\Base\HtmlUtils::ClearTags()
// 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
.replace(' ', '')
.replace('', '')
.replace(' ', '')
// https://github.com/the-djmaze/snappymail/issues/187
- .replace(/]*)?>((?![\s\S]*<\/a)[\s\S]*?]*)?>)/gi, '$1')
+// .replace(/]*)?>((?![\s\S]*<\/a)[\s\S]*?]*)?>)/gi, '$1')
// \MailSo\Base\HtmlUtils::ClearFastTags
.replace(/]*><\/p>/i, '')
.replace(/]*>/i, '')
@@ -61,73 +129,13 @@ export const
.trim();
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 => {
- const name = oElement.tagName.toUpperCase(),
+ const name = oElement.tagName,
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)
|| 'none' == oStyle.display
@@ -143,19 +151,36 @@ export const
if ('BODY' === name) {
forEachObjectEntry(tasks, (name, cb) => {
- if (oElement.hasAttribute(name)) {
+ if (hasAttribute(name)) {
cb(getAttribute(name), oElement);
- oElement.removeAttribute(name);
+ delAttribute(name);
}
});
}
- if ('TABLE' === name && oElement.hasAttribute('width')) {
- let value = getAttribute('width');
- oElement.removeAttribute('width');
+ else if ('TABLE' === name && hasAttribute('width')) {
+ value = getAttribute('width');
+ delAttribute('width');
oStyle.maxWidth = value + (/^[0-9]+$/.test(value) ? 'px' : '');
oStyle.removeProperty('width');
- oStyle.removeProperty('min-width');
+ }
+
+ else if ('A' === name) {
+ value = oElement.href;
+ // https://github.com/the-djmaze/snappymail/issues/187 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 = [];
@@ -171,39 +196,23 @@ export const
// || sAttrName.includes(':')
|| disallowedAttributes.includes(sAttrName))
{
- oElement.removeAttribute(sAttrName);
+ delAttribute(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
/*
- if (oElement.hasAttribute('xlink:href')) {
- oElement.removeAttribute('xlink:href');
+ if (hasAttribute('xlink:href')) {
+ delAttribute('xlink:href');
}
*/
- if ('A' === name) {
- oElement.setAttribute('tabindex', '-1');
- oElement.setAttribute('target', '_blank');
- }
-
let skipStyle = false;
- if (oElement.hasAttribute('src')) {
- let sSrc = getAttribute('src');
- oElement.removeAttribute('src');
+ if (hasAttribute('src')) {
+ value = getAttribute('src');
+ delAttribute('src');
if (detectHiddenImages
&& 'IMG' === name
@@ -214,51 +223,50 @@ export const
'github.com/notifications/beacon/',
'mandrillapp.com/track/open',
'list-manage.com/track/open'
- ].filter(uri => sSrc.toLowerCase().includes(uri)).length
+ ].filter(uri => value.toLowerCase().includes(uri)).length
)) {
skipStyle = true;
- oElement.setAttribute('style', 'display:none');
- oElement.setAttribute('data-x-hidden-src', sSrc);
+ setAttribute('style', 'display:none');
+ setAttribute('data-x-hidden-src', value);
}
- else if (contentLocationUrls[sSrc])
+ else if (contentLocationUrls[value])
{
- oElement.setAttribute('data-x-src-location', sSrc);
- result.foundContentLocationUrls.push(sSrc);
+ setAttribute('data-x-src-location', value);
+ 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));
- result.foundCIDs.push(sSrc.slice(4));
+ setAttribute('data-x-src-cid', value.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;
}
- else if ('data:image/' === sSrc.slice(0, 11))
+ else if ('data:image/' === value.slice(0, 11))
{
- oElement.setAttribute('src', sSrc);
+ setAttribute('src', value);
}
else
{
- oElement.setAttribute('data-x-broken-src', sSrc);
+ setAttribute('data-x-broken-src', value);
}
}
- if (oElement.hasAttribute('background')) {
- let sBackground = getAttribute('background');
- if (sBackground) {
- oStyle.backgroundImage = 'url("' + sBackground + '")';
- }
- oElement.removeAttribute('background');
+ if (hasAttribute('background')) {
+ oStyle.backgroundImage = 'url("' + getAttribute('background') + '")';
+ delAttribute('background');
}
- if (oElement.hasAttribute('bgcolor')) {
- let sBackgroundColor = getAttribute('bgcolor');
- if (sBackgroundColor) {
- oStyle.backgroundColor = sBackgroundColor;
- }
- oElement.removeAttribute('bgcolor');
+ if (hasAttribute('bgcolor')) {
+ oStyle.backgroundColor = getAttribute('bgcolor');
+ delAttribute('bgcolor');
+ }
+
+ if (hasAttribute('color')) {
+ oStyle.color = getAttribute('color');
+ delAttribute('color');
}
if (!skipStyle) {
@@ -269,6 +277,7 @@ export const
*/
oStyle.removeProperty('behavior');
oStyle.removeProperty('cursor');
+ oStyle.removeProperty('min-width');
const urls = {
cid: [], // 'data-x-style-cid'
@@ -302,22 +311,31 @@ export const
// oStyle.removeProperty('list-style-image');
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) {
- oElement.setAttribute('data-x-style-url', JSON.stringify(urls.remote));
+ setAttribute('data-x-style-url', JSON.stringify(urls.remote));
}
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)) {
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) {
- oElement.setAttribute('data-removed-attrs', aAttrsForRemove.join(', '));
+ if (debug && aAttrsForRemove.length) {
+ setAttribute('data-removed-attrs', aAttrsForRemove.join(', '));
}
});
@@ -326,21 +344,6 @@ export const
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
* @returns {string}
@@ -356,7 +359,7 @@ export const
blockquotes(bq);
// Convert blockquote
bq.innerHTML = '\n' + ('\n' + bq.innerHTML.replace(/\n{3,}/gm, '\n\n').trim() + '\n').replace(/^/gm, '> ');
- bq.replaceWith(...[...bq.childNodes]);
+ replaceWithChildren(bq);
}
};
diff --git a/dev/Model/Message.js b/dev/Model/Message.js
index f96a97d9e..a4aa575cb 100644
--- a/dev/Model/Message.js
+++ b/dev/Model/Message.js
@@ -4,7 +4,7 @@ import { MessagePriority } from 'Common/EnumsUser';
import { i18n } from 'Common/Translator';
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 { serverRequestRaw } from 'Common/Links';
@@ -380,20 +380,17 @@ export class MessageModel extends AbstractModel {
viewHtml() {
const body = this.body;
if (body && this.html()) {
- let html = this.html();
- if (SettingsUserStore.removeColors()) {
- html = removeColors(html);
- }
-
const contentLocationUrls = {},
oAttachments = this.attachments();
+
+ // Get contentLocationUrls
oAttachments.forEach(oAttachment => {
if (oAttachment.cid && 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.hasInternals = result.foundCIDs.length || result.foundContentLocationUrls.length;
this.hasImages(body.rlHasImages = !!result.hasExternals);