import { createElement } from 'Common/Globals'; import { forEachObjectEntry, pInt } from 'Common/Utils'; import { SettingsUserStore } from 'Stores/User/Settings'; const tpl = createElement('template'), htmlre = /[&<>"']/g, httpre = /^(https?:)?\/\//i, htmlmap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }, replaceWithChildren = node => node.replaceWith(...[...node.childNodes]), // Strip tracking urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url, base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')), stripTracking = text => text .replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/^.+awstrack\.me\/.+(https:%2F%2F[^/]+)/i, (...m) => decodeURIComponent(m[1])) .replace(/^.+(www\.google|safelinks\.protection\.outlook\.com).+$/i, () => urlGetParam(text, 'url')) .replace(/^.+go\.dhlparcel\.nl.+\/([^/]+)$/i, (...m) => base64Url(m[1])) // Mandrill .replace(/^.+\/track\/click\/.+\?p=.+$/i, () => { let d = urlGetParam(text, 'p'); try { d = JSON.parse(base64Url(d)); if (d?.p) { d = JSON.parse(d.p); } } catch (e) { console.error(e); } return d?.url || text; }) .replace(/([?&])utm_[a-z]+=[^&?#]*/gi, '$1') // Urchin Tracking Module .replace(/([?&])ec_[a-z]+=[^&?#]*/gi, '$1') // Sitecore /** TODO: implement other url strippers like from * https://www.bleepingcomputer.com/news/security/new-firefox-privacy-feature-strips-urls-of-tracking-parameters/ * https://github.com/newhouse/url-tracking-stripper * https://github.com/svenjacobs/leon * https://maxchadwick.xyz/tracking-query-params-registry/ * https://github.com/M66B/FairEmail/blob/master/app/src/main/java/eu/faircode/email/UriHelper.java */ .replace(/([?&])&+/g, '$1'); export const /** * @param {string} text * @returns {string} */ encodeHtml = text => (text?.toString?.() || '' + text).replace(htmlre, m => htmlmap[m]), /** * Clears the Message Html for viewing * @param {string} text * @returns {string} */ cleanHtml = (html, oAttachments) => { const debug = false, // Config()->Get('debug', 'enable', false); detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), result = { hasExternals: false }, findAttachmentByCid = cid => oAttachments.findByCid(cid), findLocationByCid = cid => { const attachment = findAttachmentByCid(cid); return attachment?.contentLocation ? attachment : 0; }, // 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' }, allowedAttributes = [ // defaults 'name', 'dir', 'lang', 'style', 'title', 'background', 'bgcolor', 'alt', 'height', 'width', 'src', 'href', 'border', 'bordercolor', 'charset', 'direction', // a 'download', 'hreflang', // body 'alink', 'bottommargin', 'leftmargin', 'link', 'rightmargin', 'text', 'topmargin', 'vlink', // col 'align', 'valign', // font 'color', 'face', 'size', // hr 'noshade', // img 'hspace', 'sizes', 'srcset', 'vspace', // meter 'low', 'high', 'optimum', 'value', // ol 'reversed', 'start', // table 'cols', 'rows', 'frame', 'rules', 'summary', 'cellpadding', 'cellspacing', // th 'abbr', 'scope', // td 'colspan', 'rowspan', 'headers' ], disallowedTags = [ 'HEAD','STYLE','SVG','SCRIPT','TITLE','LINK','BASE','META', 'INPUT','OUTPUT','SELECT','BUTTON','TEXTAREA', 'BGSOUND','KEYGEN','SOURCE','OBJECT','EMBED','APPLET','IFRAME','FRAME','FRAMESET','VIDEO','AUDIO','AREA','MAP' ], nonEmptyTags = [ 'A','B','EM','I','SPAN','STRONG' ]; tpl.innerHTML = html // .replace(/
]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n
')) .replace(/]*>/gi, '') .replace(/<\?xml[^>]*\?>/gi, '') // Not supported by element .replace(/<(\/?)body(\s[^>]*)?>/gi, '<$1div class="mail-body"$2>') .replace(/<\/?(html|head)[^>]*>/gi, '') // Fix Reddit https://github.com/the-djmaze/snappymail/issues/540 .replace(//, '') .trim(); html = ''; // \MailSo\Base\HtmlUtils::ClearComments() // https://github.com/the-djmaze/snappymail/issues/187 const nodeIterator = document.createNodeIterator(tpl.content, NodeFilter.SHOW_COMMENT); while (nodeIterator.nextNode()) { nodeIterator.referenceNode.remove(); } tpl.content.querySelectorAll('*').forEach(oElement => { const name = oElement.tagName, oStyle = oElement.style; // \MailSo\Base\HtmlUtils::ClearTags() if (disallowedTags.includes(name) || 'none' == oStyle.display || 'hidden' == oStyle.visibility // || (oStyle.lineHeight && 1 > parseFloat(oStyle.lineHeight) // || (oStyle.maxHeight && 1 > parseFloat(oStyle.maxHeight) // || (oStyle.maxWidth && 1 > parseFloat(oStyle.maxWidth) // || ('0' === oStyle.opacity ) { oElement.remove(); return; } // if (['CENTER','FORM'].includes(name)) { if ('FORM' === name || 'O:P' === name || (nonEmptyTags.includes(name) && ('' == oElement.textContent.trim()))) { ('A' !== name || !oElement.querySelector('IMG')) && replaceWithChildren(oElement); return; } /* // Idea to allow CSS if ('STYLE' === name) { msgId = '#rl-msg-061eb4d647771be4185943ce91f0039d'; oElement.textContent = oElement.textContent .replace(/[^{}]+{/g, m => msgId + ' ' + m.replace(',', ', '+msgId+' ')) .replace(/(background-)color:[^};]+/g, ''); return; } */ const aAttrsForRemove = [], 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 ('mail-body' === oElement.className) { forEachObjectEntry(tasks, (name, cb) => { if (hasAttribute(name)) { cb(getAttribute(name), oElement); delAttribute(name); } }); } if (oElement.hasAttributes()) { let i = oElement.attributes.length; while (i--) { let sAttrName = oElement.attributes[i].name.toLowerCase(); if (!allowedAttributes.includes(sAttrName)) { delAttribute(sAttrName); aAttrsForRemove.push(sAttrName); } } } let value; // if ('TABLE' === name || 'TD' === name || 'TH' === name) { if (!oStyle.backgroundImage) { if (hasAttribute('width')) { value = getAttribute('width'); oStyle.width = value.includes('%') ? value : value + 'px'; delAttribute('width'); } value = oStyle.width; if (value && !value.includes('%')) { oStyle.maxWidth = value; if ('TD' !== name && 'TH' !== name) { oStyle.width = '100%'; } } if (hasAttribute('height')) { value = getAttribute('height'); oStyle.height = value.includes('%') ? value : value + 'px'; delAttribute('height'); } value = oStyle.removeProperty('height'); if (value && !value.includes('%')) { oStyle.maxHeight = value; } } // } else if ('A' === name) { value = oElement.href; if (!/^([a-z]+):/i.test(value)) { setAttribute('data-x-broken-href', value); delAttribute('href'); } else { oElement.href = stripTracking(value); setAttribute('target', '_blank'); // setAttribute('rel', 'external nofollow noopener noreferrer'); } setAttribute('tabindex', '-1'); } // SVG xlink:href /* if (hasAttribute('xlink:href')) { delAttribute('xlink:href'); } */ let skipStyle = false; if (hasAttribute('src')) { value = getAttribute('src'); delAttribute('src'); if ('IMG' === name) { oElement.loading = 'lazy'; let attachment; if (detectHiddenImages && (('' != getAttribute('height') && 3 > pInt(getAttribute('height'))) || ('' != getAttribute('width') && 3 > pInt(getAttribute('width'))) || [ 'email.microsoftemail.com/open', 'github.com/notifications/beacon/', 'mandrillapp.com/track/open', 'list-manage.com/track/open' ].filter(uri => value.toLowerCase().includes(uri)).length )) { skipStyle = true; setAttribute('style', 'display:none'); setAttribute('data-x-src-hidden', value); } else if ((attachment = findLocationByCid(value))) { if (attachment.download) { oElement.src = attachment.linkPreview(); attachment.isLinked(true); } } else if ('cid:' === value.slice(0, 4)) { value = value.slice(4); setAttribute('data-x-src-cid', value); attachment = findAttachmentByCid(value); if (attachment?.download) { oElement.src = attachment.linkPreview(); attachment.isInline(true); attachment.isLinked(true); } } else if (httpre.test(value)) { setAttribute('data-x-src', value); result.hasExternals = true; } else if ('data:image/' === value.slice(0, 11)) { oElement.src = value; } else { setAttribute('data-x-src-broken', value); } } else { setAttribute('data-x-src-broken', value); } } if (hasAttribute('background')) { oStyle.backgroundImage = 'url("' + getAttribute('background') + '")'; delAttribute('background'); } if (hasAttribute('bgcolor')) { oStyle.backgroundColor = getAttribute('bgcolor'); delAttribute('bgcolor'); } if (hasAttribute('color')) { oStyle.color = getAttribute('color'); delAttribute('color'); } if (!skipStyle) { /* if ('fixed' === oStyle.position) { oStyle.position = 'absolute'; } */ oStyle.removeProperty('behavior'); oStyle.removeProperty('cursor'); oStyle.removeProperty('min-width'); const urls_remote = [], // 'data-x-style-url' urls_broken = []; // 'data-x-broken-style-src' ['backgroundImage', 'listStyleImage', 'content'].forEach(property => { if (oStyle[property]) { let value = oStyle[property], found = value.match(/url\s*\(([^)]+)\)/i); if (found) { oStyle[property] = null; found = found[1].replace(/^["'\s]+|["'\s]+$/g, ''); let lowerUrl = found.toLowerCase(); if ('cid:' === lowerUrl.slice(0, 4)) { const attachment = findAttachmentByCid(found); if (attachment?.linkPreview && name) { oStyle[property] = "url('" + attachment.linkPreview() + "')"; attachment.isInline(true); attachment.isLinked(true); } } else if (httpre.test(lowerUrl)) { result.hasExternals = true; urls_remote.push([property, found]); } else if ('data:image/' === lowerUrl.slice(0, 11)) { oStyle[property] = value; } else { urls_broken.push([property, found]); } } } }); // oStyle.removeProperty('background-image'); // oStyle.removeProperty('list-style-image'); if (urls_remote.length) { setAttribute('data-x-style-url', JSON.stringify(urls_remote)); } if (urls_broken.length) { 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 (SettingsUserStore.removeColors()) { oStyle.removeProperty('background-color'); oStyle.removeProperty('background-image'); oStyle.removeProperty('color'); } // Drop Microsoft Office style properties // oStyle.cssText = oStyle.cssText.replace(/mso-[^:;]+:[^;]+/gi, ''); } if (debug && aAttrsForRemove.length) { setAttribute('data-removed-attrs', aAttrsForRemove.join(', ')); } }); // return tpl.content.firstChild; result.html = tpl.innerHTML.trim(); return result; }, /** * @param {string} html * @returns {string} */ htmlToPlain = html => { const hr = '⎯'.repeat(64), forEach = (selector, fn) => tpl.content.querySelectorAll(selector).forEach(fn), blockquotes = node => { let bq; while ((bq = node.querySelector('blockquote'))) { // Convert child blockquote first blockquotes(bq); // Convert blockquote // bq.innerHTML = '\n' + ('\n' + bq.innerHTML.replace(/\n{3,}/gm, '\n\n').trim() + '\n').replace(/^/gm, '> '); // replaceWithChildren(bq); bq.replaceWith( '\n' + ('\n' + bq.textContent.replace(/\n{3,}/g, '\n\n').trim() + '\n').replace(/^/gm, '> ') ); } }; html = html .replace(/]*>([\s\S]*?)<\/pre>/gim, (...args) => 1 < args.length ? args[1].toString().replace(/\n/g, '
') : '') .replace(/\r?\n/g, '') .replace(/\s+/gm, ' '); while (/<(div|tr)[\s>]/i.test(html)) { html = html.replace(/\n*<(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n'); } while (/<\/(div|tr)[\s>]/i.test(html)) { html = html.replace(/\n*<\/(div|tr)(\s[\s\S]*?)?>\n*/gi, '\n'); } tpl.innerHTML = html .replace(//gi, '\t') .replace(/<\/tr(\s[\s\S]*?)?>/gi, '\n'); // lines forEach('hr', node => node.replaceWith(`\n\n${hr}\n\n`)); // headings forEach('h1,h2,h3,h4,h5,h6', h => h.replaceWith(`\n\n${'#'.repeat(h.tagName[1])} ${h.textContent}\n\n`)); // paragraphs forEach('p', node => { node.prepend('\n\n'); if ('' == node.textContent.trim()) { node.remove(); } else { node.after('\n\n'); } }); // proper indenting and numbering of (un)ordered lists forEach('ol,ul', node => { let prefix = '', parent = node, ordered = 'OL' == node.tagName, i = 0; while ((parent = parent?.parentNode?.closest('ol,ul'))) { prefix = ' ' + prefix; } node.querySelectorAll(':scope > li').forEach(li => { li.prepend('\n' + prefix + (ordered ? `${++i}. ` : ' * ')); }); node.prepend('\n\n'); node.after('\n\n'); }); // Convert anchors forEach('a', a => { let txt = a.textContent, href = a.href; return a.replaceWith( txt.trim() == href || href.includes('mailto:') ? txt : txt + ' ' + href + ' ' ); }); // Bold forEach('b,strong', b => b.replaceWith(`**${b.textContent}**`)); // Italic forEach('i,em', i => i.replaceWith(`*${i.textContent}*`)); // Convert line-breaks tpl.innerHTML = tpl.innerHTML .replace(/\n{3,}/gm, '\n\n') .replace(/\n
]*>/g, '\n') .replace(/
]*>\n/g, '\n'); forEach('br', br => br.replaceWith('\n')); // Blockquotes must be last blockquotes(tpl.content); return (tpl.content.textContent || '').trim(); }, /** * @param {string} plain * @param {boolean} findEmailAndLinksInText = false * @returns {string} */ plainToHtml = plain => { plain = stripTracking(plain) .toString() .replace(/\r/g, '') .replace(/^>[> ]>+/gm, ([match]) => (match ? match.replace(/[ ]+/g, '') : match)); let bIn = false, bDo = true, bStart = true, aNextText = [], aText = plain.split('\n'); do { bDo = false; aNextText = []; aText.forEach(sLine => { bStart = '>' === sLine.slice(0, 1); if (bStart && !bIn) { bDo = true; bIn = true; aNextText.push('~~~blockquote~~~'); aNextText.push(sLine.slice(1)); } else if (!bStart && bIn) { if (sLine) { bIn = false; aNextText.push('~~~/blockquote~~~'); aNextText.push(sLine); } else { aNextText.push(sLine); } } else if (bStart && bIn) { aNextText.push(sLine.slice(1)); } else { aNextText.push(sLine); } }); if (bIn) { bIn = false; aNextText.push('~~~/blockquote~~~'); } aText = aNextText; } while (bDo); return aText.join('\n') // .replace(/~~~\/blockquote~~~\n~~~blockquote~~~/g, '\n') .replace(/&/g, '&') .replace(/>/g, '>') .replace(/') .replace(/\s*~~~\/blockquote~~~/g, '') .replace(/\n/g, '
'); }; export class HtmlEditor { /** * @param {Object} element * @param {Function=} onBlur * @param {Function=} onReady * @param {Function=} onModeChange */ constructor(element, onBlur = null, onReady = null, onModeChange = null) { this.blurTimer = 0; this.onBlur = onBlur; this.onModeChange = onModeChange; if (element) { let editor; onReady = onReady ? [onReady] : []; this.onReady = fn => onReady.push(fn); const readyCallback = () => { this.editor = editor; this.onReady = fn => fn(); onReady.forEach(fn => fn()); }; if (rl.createWYSIWYG) { editor = rl.createWYSIWYG(element, readyCallback); } if (!editor) { editor = new SquireUI(element); setTimeout(readyCallback, 1); } editor.on('blur', () => this.blurTrigger()); editor.on('focus', () => this.blurTimer && clearTimeout(this.blurTimer)); editor.on('mode', () => { this.blurTrigger(); this.onModeChange?.(!this.isPlain()); }); } } blurTrigger() { if (this.onBlur) { clearTimeout(this.blurTimer); this.blurTimer = setTimeout(() => this.onBlur?.(), 200); } } /** * @returns {boolean} */ isHtml() { return this.editor ? !this.isPlain() : false; } /** * @returns {boolean} */ isPlain() { return this.editor ? 'plain' === this.editor.mode : false; } /** * @returns {void} */ clearCachedSignature() { this.onReady(() => this.editor.execCommand('insertSignature', { clearCache: true })); } /** * @param {string} signature * @param {bool} html * @param {bool} insertBefore * @returns {void} */ setSignature(signature, html, insertBefore = false) { this.onReady(() => this.editor.execCommand('insertSignature', { isHtml: html, insertBefore: insertBefore, signature: signature })); } /** * @param {boolean=} wrapIsHtml = false * @returns {string} */ getData() { let result = ''; if (this.editor) { try { if (this.isPlain() && this.editor.plugins.plain && this.editor.__plain) { result = this.editor.__plain.getRawData(); } else { result = this.editor.getData(); } } catch (e) {} // eslint-disable-line no-empty } return result; } /** * @returns {string} */ getDataWithHtmlMark() { return (this.isHtml() ? ':HTML:' : '') + this.getData(); } modeWysiwyg() { this.onReady(() => this.editor.setMode('wysiwyg')); } modePlain() { this.onReady(() => this.editor.setMode('plain')); } setHtmlOrPlain(text) { if (':HTML:' === text.slice(0, 6)) { this.setHtml(text.slice(6)); } else { this.setPlain(text); } } setData(mode, data) { this.onReady(() => { const editor = this.editor; this.clearCachedSignature(); try { editor.setMode(mode); if (this.isPlain() && editor.plugins.plain && editor.__plain) { editor.__plain.setRawData(data); } else { editor.setData(data); } } catch (e) { console.error(e); } }); } setHtml(html) { this.setData('wysiwyg', html/*.replace(/]*><\/p>/gi, '')*/); } setPlain(txt) { this.setData('plain', txt); } focus() { this.onReady(() => this.editor.focus()); } hasFocus() { try { return !!this.editor?.focusManager.hasFocus; } catch (e) { return false; } } blur() { this.onReady(() => this.editor.focusManager.blur(true)); } clear() { this.onReady(() => this.isPlain() ? this.setPlain('') : this.setHtml('')); } } rl.Utils = { htmlToPlain: htmlToPlain, plainToHtml: plainToHtml };