import { createElement } from 'Common/Globals'; import { forEachObjectEntry, isArray, pInt } from 'Common/Utils'; import { SettingsUserStore } from 'Stores/User/Settings'; const tmpl = createElement('template'), turndown = new TurndownService(), htmlre = /[&<>"']/g, httpre = /^(https?:)?\/\//i, htmlmap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }, keepTagContent = 'form,button,data', // font allowedTags = [ // Structural Elements: 'blockquote','br','div','figcaption','figure','h1','h2','h3','h4','h5','h6','hgroup','hr','p','wbr', 'article','aside','header','footer','main','section', 'details','summary','nav', // List Elements 'dd','dl','dt','li','ol','ul', // Text Formatting Elements 'a','abbr','address','b','bdi','bdo','cite','code','del','dfn', 'em','i','ins','kbd','mark','pre','q','rp','rt','ruby','s','samp','small', 'span','strong','sub','sup','time','u','var', // Deprecated by HTML Standard 'acronym','big','center','dir','font','marquee', 'nobr','plaintext','rb','rtc','strike','tt', // Media Elements 'img',//'picture','source', // Table Elements 'caption','col','colgroup','table','tbody','td','tfoot','th','thead','tr', // Disallowed but converted later 'style','xmp' ].join(','), nonEmptyTags = [ 'A','B','EM','I','SPAN','STRONG' ], blockquoteSwitcher = () => { SettingsUserStore.collapseBlockquotes() && // tmpl.content.querySelectorAll('blockquote').forEach(node => { [...tmpl.content.querySelectorAll('blockquote')].reverse().forEach(node => { const el = createElement('details', {class:'sm-bq-switcher'}); el.innerHTML = '•••'; node.replaceWith(el); el.append(node); }); }, replaceWithChildren = node => node.replaceWith(...[...node.childNodes]), urlRegExp = /https?:\/\/[^\p{C}\p{Z}]+[^\p{C}\p{Z}.]/gu, // eslint-disable-next-line max-len email = /(^|\r|\n|\p{C}\p{Z})((?:[^"(),.:;<>@[\]\\\p{C}\p{Z}]+(?:\.[^"(),.:;<>@[\]\\\p{C}\p{Z}]+)*|"(?:\\?[^"\\\p{C}\p{Z}])*")@[^@\p{C}\p{Z}]+[^@\p{C}\p{Z}.])/gui, // rfc3966 tel = /(tel:(\+[0-9().-]+|[0-9*#().-]+(;phone-context=\+[0-9+().-]+)?))/g, // Strip tracking /** 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 */ // eslint-disable-next-line max-len stripParams = /^(utm_|ec_|fbclid|mc_eid|mkt_tok|_hsenc|vero_id|oly_enc_id|oly_anon_id|__s|Referrer|mailing|elq|bch|trc|ref|correlation_id|pd_|pf_|email_hash)$/i, urlGetParam = (url, name) => new URL(url).searchParams.get(name) || url, base64Url = data => atob(data.replace(/_/g,'/').replace(/-/g,'+')), decode = decodeURIComponent, stripTracking = url => { try { let nurl = url // Copernica .replace(/^.+\/(https%253A[^/?&]+).*$/i, (...m) => decode(decode(m[1]))) .replace(/tracking\.(printabout\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/(zalando\.nl[^?]+)\?.*/i, (...m) => m[1]) .replace(/^.+(awstrack\.me|redditmail\.com)\/.+(https:%2F%2F[^/]+).*/i, (...m) => decode(m[2])) .replace(/^.+(www\.google|safelinks\.protection\.outlook\.com|mailchimp\.com).+url=.+$/i, () => urlGetParam(url, 'url')) .replace(/^.+click\.godaddy\.com.+$/i, () => urlGetParam(url, 'redir')) .replace(/^.+delivery-status\.com.+$/i, () => urlGetParam(url, 'fb')) .replace(/^.+go\.dhlparcel\.nl.+\/([A-Za-z0-9_-]+)$/i, (...m) => base64Url(m[1])) .replace(/^(.+mopinion\.com.+)\?.*$/i, (...m) => m[1]) .replace(/^.+sellercentral\.amazon\.com\/nms\/redirect.+$/i, () => base64Url(urlGetParam(url, 'u'))) .replace(/^.+amazon\.com\/gp\/r\.html.+$/i, () => urlGetParam(url, 'U')) // Mandrill .replace(/^.+\/track\/click\/.+\?p=.+$/i, () => { let d = urlGetParam(url, 'p'); try { d = JSON.parse(base64Url(d)); if (d?.p) { d = JSON.parse(d.p); } } catch (e) { console.error(e); } return d?.url || url; }) // Remove invalid URL characters .replace(/[\s<>]+/gi, ''); nurl = new URL(nurl); let s = nurl.searchParams; [...s.keys()].forEach(key => stripParams.test(key) && s.delete(key)); return nurl.toString(); } catch (e) { console.dir({ error:e, url:url }); } return url; }, cleanCSS = source => source.trim() .replace(/;\s*-[^;]+/g, '') .replace(/^\s*-[^;]+(;|$)/g, '') .replace(/white-space[^;]+/g, '') // Drop Microsoft Office style properties // .replace(/mso-[^:;]+:[^;]+/gi, '') , /* Parses given css string, and returns css object keys as selectors and values are css rules eliminates all css comments before parsing @param source css string to be parsed @return object css */ parseCSS = source => { const css = []; css.toString = () => css.reduce( (ret, tmp) => ret + tmp.selector + ' {\n' + (tmp.type === 'media' ? tmp.subStyles.toString() : tmp.rules) + '}\n' , '' ); /** * Given css array, parses it and then for every selector, * prepends namespace to prevent css collision issues */ css.applyNamespace = namespace => css.forEach(obj => { if (obj.type === 'media') { obj.subStyles.applyNamespace(namespace); } else { obj.selector = obj.selector.split(',').map(selector => (namespace + ' .mail-body ' + selector.replace(/\./g, '.msg-')) .replace(/\sbody/gi, '') ).join(','); } }); if (source) { source = source // strip comments .replace(/\/\*[\s\S]*?\*\//gi, '') // strip MS Word comments .replace(//gi, '') // strip HTML, as < is no CSS combinator anyway .replace(/<[\s\S]*/gi, ''); // .replace(/<\/?[a-z][\s\S]*?>/gi, ''); // unified regex to match css & media queries together let unified = /(?:(\s*?@(?:media)[\s\S]*?){([\s\S]*?)}\s*?})|(?:([\s\S]*?){([\s\S]*?)})/gi, arr; while (true) { arr = unified.exec(source); if (arr === null) { break; } let selector = arr[arr[2] === undefined ? 3 : 1].split('\r\n').join('\n').trim() // Never have more than a single line break in a row .replace(/\n+/, "\n") // Remove :root and html .split(/\s+/g).map(item => item.replace(/^(:root|html)$/, '')).join(' ').trim(); // determine the type if (selector.includes('@media')) { // we have a media query css.push({ selector: selector, type: 'media', subStyles: parseCSS(arr[2] + '\n}') //recursively parse media query inner css }); } else if (selector && !selector.includes('@')) { // we have standard css // ignores @import, @keyframe, @font-face statements css.push({ selector: selector, rules: cleanCSS(arr[4]) }); } } } return css; }; 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, msgId) => { let aColor; const debug = false, // Config()->Get('debug', 'enable', false); detectHiddenImages = true, // !!SettingsGet('try_to_detect_hidden_images'), bqLevel = parseInt(SettingsUserStore.maxBlockquotesLevel()), result = { hasExternals: false, tracking: false, linkedData: [] }, isMsg = !!msgId, findAttachmentByCid = cId => oAttachments.findByCid(cId), findLocationByCid = cId => { const attachment = findAttachmentByCid(cId); return attachment?.contentLocation ? attachment : 0; }, // convert body attributes to CSS tasks = { link: value => aColor = 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' // others //'class', 'id', 'target' ]; if (SettingsUserStore.allowStyles()) { allowedAttributes.push('class'); } else { msgId = 0; } tmpl.innerHTML = html // .replace(/]*>[\s\S]*?<\/pre>/gi, pre => pre.replace(/\n/g, '\n
')) // Not supported by